Skip to content

Commit

Permalink
Flanges deep breadcrumb fixes
Browse files Browse the repository at this point in the history
  • Loading branch information
Ioannis committed Sep 23, 2025
1 parent 99520df commit eba8522
Show file tree
Hide file tree
Showing 7 changed files with 272 additions and 63 deletions.
98 changes: 90 additions & 8 deletions app/src/Controller/AppController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
102 changes: 77 additions & 25 deletions app/src/Controller/Component/BreadcrumbComponent.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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}");
Expand Down Expand Up @@ -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
*
Expand Down
4 changes: 2 additions & 2 deletions app/src/Controller/StandardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
53 changes: 46 additions & 7 deletions app/src/Controller/StandardPluginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down
Loading

0 comments on commit eba8522

Please sign in to comment.