diff --git a/app/src/Controller/AppController.php b/app/src/Controller/AppController.php index b4549e98a..ce8781e9c 100644 --- a/app/src/Controller/AppController.php +++ b/app/src/Controller/AppController.php @@ -36,12 +36,12 @@ use App\Lib\Util\StringUtilities; use Cake\Controller\Controller; use Cake\Datasource\Exception\RecordNotFoundException; -use Cake\Http\Exception\UnauthorizedException; use Cake\Event\EventManager; +use Cake\Http\Exception\UnauthorizedException; use Cake\ORM\TableRegistry; +use Cake\Routing\Router; use Cake\Utility\Hash; - /** * @property \App\Controller\Component\RegistryAuthComponent $RegistryAuth * @property \App\Controller\Component\BreadcrumbComponent $Breadcrumb @@ -273,16 +273,98 @@ public function getCOID(): ?int { */ public function getCurrentTable(): \Cake\ORM\Table { - /** @var string $modelsName */ - $modelsName = $this->getName(); + return $this->fetchTable($this->getQualifiedName()); + } + + /** + * Get the fully-qualified controller/model name with plugin prefix when present. + * + * Examples: + * - Core controller "Users" => "Users" + * - Plugin controller "MyPlugin.Users" => "MyPlugin.Users" + * + * @since COmanage Registry v5.2.0 + * @return string + */ + public function getQualifiedName(): string + { + $name = $this->getName(); + $plugin = $this->getPlugin(); + + return $plugin !== null && $plugin !== '' + ? $plugin . '.' . $name + : $name; + } - $alias = $this->getPlugin() !== null - ? $this->getPlugin() . '.' . $modelsName - : $modelsName; - return $this->fetchTable($alias); + /** + * Check if a given route/URL target is valid and can be resolved. + * + * @param array $target Route parameters to validate + * @return bool True if route is valid and can be resolved + * @since COmanage Registry v5.2.0 + */ + public function isValidRoute(array $target): bool + { + try { + // Normalize plugin nulls and query args + $params = $target; + if (array_key_exists('plugin', $params) && $params['plugin'] === null) { + unset($params['plugin']); + } + if (isset($params['?'])) { + $params['query'] = $params['?']; + unset($params['?']); + } + + Router::url($params, true); // will throw if unresolvable + return true; + } catch (\Throwable $e) { + return false; + } } + /** + * Check if target corresponds to an active application route (is routable). + * + * @param array $target e.g. ['plugin'=>'PipelineToolkit','controller'=>'IdentifierMappers','action'=>'index','?'=>['flange_id'=>3]] + * @return bool + * @since COmanage Registry v5.2.0 + */ + public function isActiveRoute(array $target): bool + { + try { + // Normalize params for Router::match + $params = $target; + + // Query params should not affect route matching + if (isset($params['?'])) { + unset($params['?']); + } + if (isset($params['query'])) { + unset($params['query']); + } + + // Default request method and extension are not required for match() + // Build URL first to ensure consistency (optional) + Router::url($target, false); + + // Router::match expects a request-style array; provide defaults + $params += [ + 'plugin' => $params['plugin'] ?? null, + 'prefix' => $params['prefix'] ?? null, + 'controller' => $params['controller'] ?? null, + 'action' => $params['action'] ?? null, + 'pass' => array_values(array_filter($params, 'is_int', ARRAY_FILTER_USE_KEY)) ?: [], + ]; + + // If a matching route exists, match() returns an array; otherwise it throws + Router::getRouteCollection()->match($params, []); + return true; + } catch (\Throwable $e) { + return false; + } + } /** * @param string $potentialPrimaryLink diff --git a/app/src/Controller/Component/BreadcrumbComponent.php b/app/src/Controller/Component/BreadcrumbComponent.php index 71bf699c5..c4a9e437d 100644 --- a/app/src/Controller/Component/BreadcrumbComponent.php +++ b/app/src/Controller/Component/BreadcrumbComponent.php @@ -31,8 +31,7 @@ use \Cake\Controller\Component; use \Cake\Event\EventInterface; -use \Cake\ORM\TableRegistry; -use \Cake\Utility\Inflector; +use \Cake\Routing\Router; use \App\Lib\Util\StringUtilities; class BreadcrumbComponent extends Component { @@ -57,6 +56,9 @@ class BreadcrumbComponent extends Component { protected $skipConfigPaths = []; // Don't render the parent links protected $skipParentPaths = []; + // Skip target index/entity links for paths where the target has no view + protected $skipNoViewPaths = []; + // Inject parent links (these render before the index link, if set) // The parent links are constructed as part of the injectPrimaryLink function, as well as in the StandardController and // in the StandardPluginController, MVEAController, ProvisioningHistoryRecordController, etc. These controllers are @@ -250,21 +252,41 @@ public function injectPrimaryLink(object $link, bool $index = true, string $link if ($index && method_exists($linkTable, 'findPrimaryLink')) { $parentLink = $linkTable->findPrimaryLink($linkedEntity->id); - $this->injectParents[strtolower($linkModelFqn) . ':index'] = [ - 'target' => [ - 'plugin' => $parentLink->plugin ?? StringUtilities::blankToNull(StringUtilities::pluginPlugin($linkModelFqn)) ?? null, - 'controller' => StringUtilities::pluginModel($linkModelFqn), - 'action' => 'index', - '?' => [ - $parentLink->attr => $parentLink->value - ] - ], - 'label' => StringUtilities::localizeController( - controllerName: $linkModelFqn, - pluginName: $link->plugin ?? null, - plural: true - ) + $targetIndexRoute = [ + 'plugin' => $parentLink->plugin ?? StringUtilities::blankToNull(StringUtilities::pluginPlugin($linkModelFqn)) ?? null, + 'controller' => StringUtilities::pluginModel($linkModelFqn), + 'action' => 'index', + '?' => [ + $parentLink->attr => $parentLink->value + ] ]; + + // Build absolute URL string for regex matching against injected route + $targetIndexUrl = Router::url($targetIndexRoute, false); + + // If the injected route matches any configured "no view" patterns, skip injecting it + $skipInjectedIndex = false; + foreach ($this->skipNoViewPaths as $pattern) { + if (preg_match($pattern, $targetIndexUrl)) { + $skipInjectedIndex = true; + break; + } + } + + if($controller->isValidRoute($targetIndexRoute) && $controller->isActiveRoute($targetIndexRoute)) { + if(!$skipInjectedIndex) { + $this->injectParents[strtolower($linkModelFqn) . ':index'] = [ + 'target' => $targetIndexRoute, + 'label' => StringUtilities::localizeController( + controllerName: $linkModelFqn, + pluginName: $link->plugin ?? null, + plural: true + ) + ]; + } + } else { + $this->llog('debug', "Breadcrumbs: invalid route for $linkModelFqn {$parentLink->attr}={$parentLink->value}"); + } } // Determine target action for entity link @@ -279,16 +301,34 @@ public function injectPrimaryLink(object $link, bool $index = true, string $link $title = StringUtilities::stripActionPrefix($title); - // Inject the entity breadcrumb (unique per table:id) - $this->injectParents[$this->composeEntityKey($linkTable->getTable(), (int)$linkedEntity->id)] = [ - 'target' => [ - 'plugin' => $parentLink->plugin ?? StringUtilities::blankToNull(StringUtilities::pluginPlugin($linkModelFqn)) ?? null, - 'controller' => StringUtilities::pluginModel($linkModelFqn), - 'action' => $breadcrumbAction, - (int)$linkedEntity->id - ], - 'label' => $linkLabel ?? $title, + $targetRoute = [ + 'plugin' => $parentLink->plugin ?? StringUtilities::blankToNull(StringUtilities::pluginPlugin($linkModelFqn)) ?? null, + 'controller' => StringUtilities::pluginModel($linkModelFqn), + 'action' => $breadcrumbAction, + (int)$linkedEntity->id ]; + + // Build absolute URL for entity link and check skip + $entityUrl = Router::url($targetRoute, false); + $skipInjectedEntity = false; + foreach ($this->skipNoViewPaths as $pattern) { + if (preg_match($pattern, $entityUrl)) { + $skipInjectedEntity = true; + break; + } + } + + if($controller->isValidRoute($targetIndexRoute) && $controller->isActiveRoute($targetIndexRoute)) { + if(!$skipInjectedEntity) { + // Inject the entity breadcrumb (unique per table:id) + $this->injectParents[$this->composeEntityKey($linkTable->getTable(), (int)$linkedEntity->id)] = [ + 'target' => $targetRoute, + 'label' => $linkLabel ?? $title, + ]; + } + } else { + $this->llog('debug', "Breadcrumbs: invalid route for $linkModelFqn {$parentLink->attr}={$parentLink->value}"); + } } catch (\Cake\Datasource\Exception\RecordNotFoundException $e) { $this->llog('error', "Breadcrumbs: linked entity not found for $linkModelFqn {$link->attr}={$link->value}"); @@ -369,6 +409,18 @@ public function skipParents(array $skipPaths): void $this->skipParentPaths = $skipPaths; } + /** + * Set the set of paths where the target entity has no dedicated view. + * For these paths, skip generating index/entity breadcrumb links to non-existent views. + * + * @since COmanage Registry v5.2.0 + * @param array $skipPaths Array of regular expressions describing paths + */ + public function skipTargetsWithoutView(array $skipPaths): void + { + $this->skipNoViewPaths = $skipPaths; + } + /** * Resolves the contain list for a given table and mapped action * diff --git a/app/src/Controller/StandardController.php b/app/src/Controller/StandardController.php index b80d637d4..e921eb2c4 100644 --- a/app/src/Controller/StandardController.php +++ b/app/src/Controller/StandardController.php @@ -635,7 +635,7 @@ protected function getRequiredFields() { public function index() { /** var string $modelsName */ - $modelsName = $this->getName(); + $modelsName = $this->getQualifiedName(); /** var Cake\ORM\Table $table */ $table = $this->getCurrentTable(); // $tableName = models @@ -671,7 +671,7 @@ public function index() { $this->set('vv_permission_set', $this->RegistryAuth->calculatePermissionsForResultSet($resultSet)); // Default index view title is model name - [$title, , ] = StringUtilities::entityAndActionToTitle($resultSet, $modelsName, 'index'); + [$title, , ] = StringUtilities::entityAndActionToTitle(null, $modelsName, 'index'); $this->set('vv_title', $title); // Let the view render diff --git a/app/src/Controller/StandardPluginController.php b/app/src/Controller/StandardPluginController.php index 5e18af44d..972e19ab2 100644 --- a/app/src/Controller/StandardPluginController.php +++ b/app/src/Controller/StandardPluginController.php @@ -35,10 +35,31 @@ use \App\Lib\Util\StringUtilities; use \App\Lib\Enum\SuspendableStatusEnum; +use \PipelineToolkit\Model\Table\IdentifierMappersTable; +use Symfony\Component\Filesystem\Exception\RuntimeException; class StandardPluginController extends StandardController { use \App\Lib\Traits\BreadcrumbsTrait; + /** + * Perform Controller initialization. + * + * @since COmanage Registry v5.2.0 + */ + + public function initialize(): void { + parent::initialize(); + + // Configure breadcrumb rendering + // https://comanage.org/registry-pe/pipeline-toolkit/identifier-mappers?flange_id=3 + // https://comanage.org/registry-pe/pipeline-toolkit/person-role-mappers?flange_id=3 + // This is wrong because the view does not exist. + $this->Breadcrumb->skipTargetsWithoutView([ + '#^(.*?)/pipeline-toolkit/identifier-mappers(?:\?.*)?$#', + '#^(.*?)/pipeline-toolkit/person-role-mappers(?:\?.*)?$#', + ]); + } + /** * Callback run prior to the request action. * @@ -94,6 +115,7 @@ public function beforeFilter(\Cake\Event\EventInterface $event) { public function beforeRender(\Cake\Event\EventInterface $event) { $link = $this->getPrimaryLink(true); + $request = $this->getRequest(); if(!empty($link->value) && !empty($link->model_name)) { // This might be a plugin table in Plugin.Model notation @@ -114,15 +136,32 @@ public function beforeRender(\Cake\Event\EventInterface $event) { } // Check for missing Breadcrumbs - if ($this->viewBuilder()->getVar('vv_obj')) { - // Check if this is a PipelineToolkit Mapper controller - if (str_ends_with($this->getName(), 'Mappers') - && $this->getPlugin() == 'PipelineToolkit') { + // For the Pipeline Toolkit + if($this->getPlugin() == 'PipelineToolkit') { + $identifierMapperTable = $this->fetchTable(IdentifierMappersTable::class); + // Edit/View + if ($this->viewBuilder()->getVar('vv_obj') && $request->getParam('action') != 'add') { $obj = $this->viewBuilder()->getVar('vv_obj'); - $breadcrumbs = $this->buildPipelineBreadcrumbs($obj); - $vv_bc_parents = (array)$this->viewBuilder()->getVar('vv_bc_parents'); - $this->set('vv_bc_parents', [...$breadcrumbs, ...$vv_bc_parents]); + // Load IdentifierMapper if identifier_mapper_id is set + if (empty($obj->identifier_mapper_id) && $obj->getSource() !== 'PipelineToolkit.IdentifierMappers') { + throw new \RuntimeException('Identifier Mapper ID is not set'); + } + $identifierMapperEntity = empty($obj->identifier_mapper_id) ? $obj : $identifierMapperTable->get($obj->identifier_mapper_id); + } else if ($this->request->getQuery('identifier_mapper_id')) { + $identifierMapperEntity = $identifierMapperTable->get($this->request->getQuery('identifier_mapper_id')); + } else if ($this->request->getQuery('flange_id')) { + $identifierMapperEntity = $identifierMapperTable->find() + ->where(['flange_id' => $this->request->getQuery('flange_id')]) + ->firstOrFail(); + } + + if(empty($identifierMapperEntity)) { + throw new \RuntimeException('Identifier Mapper Record not found'); } + $this->set('vv_identifier_mapper', $identifierMapperEntity); + $breadcrumbs = $this->buildPipelineBreadcrumbs($identifierMapperEntity); + $vv_bc_parents = (array)$this->viewBuilder()->getVar('vv_bc_parents'); + $this->set('vv_bc_parents', [...$breadcrumbs, ...$vv_bc_parents]); } return parent::beforeRender($event); diff --git a/app/src/Lib/Traits/BreadcrumbsTrait.php b/app/src/Lib/Traits/BreadcrumbsTrait.php index c0b2d27a7..c90902bda 100644 --- a/app/src/Lib/Traits/BreadcrumbsTrait.php +++ b/app/src/Lib/Traits/BreadcrumbsTrait.php @@ -196,13 +196,21 @@ protected function buildPersonBreadcrumbs(int $personId, bool $includePeopleInde */ protected function buildPipelineBreadcrumbs(EntityInterface $entity): array { - // 1) Get the flange id - $flangeId = $entity->get('flange')->id - ?? $entity->get('flange_id') - ?? null; + // 0) If the given entity is itself a Flange, skip flangeId calculations + if ($entity instanceof \App\Model\Entity\Flange) { + $flangeId = (int)$entity->get('id'); + if ($flangeId <= 0) { + return []; + } + } else { + // 1) Get the flange id + $flangeId = $entity->get('flange')->id + ?? $entity->get('flange_id') + ?? null; - if (!$flangeId) { - return []; + if (!$flangeId) { + return []; + } } $coId = method_exists($this, 'getCOID') ? (int)$this->getCOID() : 0; @@ -252,6 +260,36 @@ protected function buildPipelineBreadcrumbs(EntityInterface $entity): array ], ]; } + + // 5) Flanges index for this pipeline + $flangesIndexKey = 'flanges:index'; + if (empty($vv_bc_parents[$flangesIndexKey])) { + $parents[$flangesIndexKey] = [ + 'label' => __('Flanges'), + 'target' => [ + 'plugin' => null, + 'controller' => 'Flanges', + 'action' => 'index', + '?' => ['pipeline_id' => $pipelineId], + ], + ]; + } + + // 6) Current Flange + $flangeKey = 'flanges:' . $flangeId; + if (empty($vv_bc_parents[$flangeKey])) { + $parents[$flangeKey] = [ + 'label' => method_exists($Flanges, 'generateDisplayField') + ? $Flanges->generateDisplayField($flange) + : ($flange->{$Flanges->getDisplayField()} ?? ('Flange #' . $flangeId)), + 'target' => [ + 'plugin' => null, + 'controller' => 'Flanges', + 'action' => 'edit', + $flangeId, + ], + ]; + } } catch (\Throwable $e) { // Fail-soft: no pipeline → no crumbs return []; diff --git a/app/src/Lib/Util/StringUtilities.php b/app/src/Lib/Util/StringUtilities.php index 60b9f5227..090e70cda 100644 --- a/app/src/Lib/Util/StringUtilities.php +++ b/app/src/Lib/Util/StringUtilities.php @@ -259,13 +259,15 @@ public static function entityAndActionToTitle($entity, [$plugin, $modelsName] = explode('.', $modelPath, 2); } - if($entity == null && !empty($plugin)) { - $count = $action == 'index' ? 99 : 1; - return [__d($domain, "controller.$modelsName", [$count]), '', '']; - } elseif($entity === null) { - $count = $action == 'index' ? 99 : 1; - return [__d($domain, "{$modelPath}.{$action}", [$count]), '', '']; + // 3) Index view → use the controller plural form (token 99 convention) + if($action === 'index') { + if(!empty($plugin)) { + $domain = StringUtilities::pluginToTextDomain($plugin); + return [__d($domain, "controller.$modelsName", [99]), '', '']; + } + return [__d('controller', $modelsName, [99]), '', '']; } + // Base table and default message IDs for translation $linkTable = TableRegistry::getTableLocator()->get($modelPath); $msgId = "{$action}.a"; // eg: "edit.a" @@ -293,14 +295,6 @@ public static function entityAndActionToTitle($entity, return [__d('controller', $modelsName), '', '']; } - // 3) Index view → use the controller plural form (token 99 convention) - if($action === 'index') { - if(!empty($plugin)) { - return [__d($domain, "controller.$modelsName", [99]), '', '']; - } - return [__d('controller', $modelsName, [99]), '', '']; - } - // Add/Edit/View // The MVEA Models have an entityId. The one from the parent model. // We need to have a condition for this and exclude it. diff --git a/app/src/Model/Table/FlangesTable.php b/app/src/Model/Table/FlangesTable.php index 3bda87ded..ee5a12592 100644 --- a/app/src/Model/Table/FlangesTable.php +++ b/app/src/Model/Table/FlangesTable.php @@ -43,9 +43,10 @@ class FlangesTable extends Table { use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PluggableModelTrait; use \App\Lib\Traits\PrimaryLinkTrait; + use \App\Lib\Traits\QueryModificationTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; - + /** * Perform Cake Model initialization. * @@ -72,6 +73,9 @@ public function initialize(array $config): void { $this->setPrimaryLink('pipeline_id'); $this->setRequiresCO(true); + $this->setEditContains(['Pipelines']); + $this->setViewContains(['Pipelines']); + $this->setAutoViewVars([ 'plugins' => [ 'type' => 'plugin',