From 60bfd8bfb9692128b4197d8297dd4c467123ec4d Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Tue, 16 Sep 2025 18:18:30 +0300 Subject: [PATCH] CFM-274_breadcrumb_improvements_fixes (#338) * Breadcrumb improvements * remove action prefix * Do not load duplicate breadcrumbs * Fix view title * reinstate add and configure prefix * Remove unused code. Add comment. --- .../src/Controller/SqlSourcesController.php | 23 ++ .../src/Model/Table/SqlSourcesTable.php | 18 ++ .../src/Controller/SshKeysController.php | 7 +- .../templates/SshKeyAuthenticators/fields.inc | 4 + app/resources/locales/en_US/field.po | 4 +- app/src/Controller/AppController.php | 2 +- .../Component/BreadcrumbComponent.php | 288 ++++++++++++------ .../Component/RegistryAuthComponent.php | 17 +- app/src/Controller/DashboardsController.php | 8 +- app/src/Controller/MVEAController.php | 86 ++++-- .../MultipleAuthenticatorController.php | 84 ++++- app/src/Controller/StandardController.php | 36 +-- .../Controller/StandardPluginController.php | 6 +- app/src/Lib/Traits/BreadcrumbsTrait.php | 84 +++++ app/src/Lib/Util/StringUtilities.php | 174 ++++++++--- app/src/Lib/Util/TableUtilities.php | 32 +- app/templates/element/breadcrumbs.php | 6 +- 17 files changed, 664 insertions(+), 215 deletions(-) create mode 100644 app/src/Lib/Traits/BreadcrumbsTrait.php diff --git a/app/availableplugins/SqlConnector/src/Controller/SqlSourcesController.php b/app/availableplugins/SqlConnector/src/Controller/SqlSourcesController.php index 895dd2d54..903f05490 100644 --- a/app/availableplugins/SqlConnector/src/Controller/SqlSourcesController.php +++ b/app/availableplugins/SqlConnector/src/Controller/SqlSourcesController.php @@ -30,6 +30,8 @@ namespace SqlConnector\Controller; use App\Controller\StandardPluginController; +use Cake\Event\EventInterface; +use Cake\Http\Response; class SqlSourcesController extends StandardPluginController { protected array $paginate = [ @@ -37,4 +39,25 @@ class SqlSourcesController extends StandardPluginController { 'SqlSources.id' => 'asc' ] ]; + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @return Response|void + * @since COmanage Registry v5.2.0 + */ + + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->SqlSources->ExternalIdentitySources->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->SqlSources->ExternalIdentitySources->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->SqlSources->ExternalIdentitySources->getPrimaryKey()); + } + + return parent::beforeRender($event); + } } diff --git a/app/availableplugins/SqlConnector/src/Model/Table/SqlSourcesTable.php b/app/availableplugins/SqlConnector/src/Model/Table/SqlSourcesTable.php index ab9e4c979..a0eb81ff4 100644 --- a/app/availableplugins/SqlConnector/src/Model/Table/SqlSourcesTable.php +++ b/app/availableplugins/SqlConnector/src/Model/Table/SqlSourcesTable.php @@ -45,6 +45,7 @@ class SqlSourcesTable extends Table { use \App\Lib\Traits\LabeledLogTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; + use \App\Lib\Traits\TabTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; @@ -145,6 +146,23 @@ public function initialize(array $config): void { ] ]); + // All the tabs share the same configuration in the ModelTable file + $this->setTabsConfig( + [ + // Ordered list of Tabs + 'tabs' => ['ExternalIdentitySources', 'SqlConnector.SqlSources', 'ExternalIdentitySources@action.search'], + // What actions will include the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'ExternalIdentitySources' => ['edit', 'view', 'search'], + 'SqlConnector.SqlSources' => ['edit'], + 'ExternalIdentitySources@action.search' => [], + ], + ] + ); + $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) 'entity' => [ diff --git a/app/plugins/SshKeyAuthenticator/src/Controller/SshKeysController.php b/app/plugins/SshKeyAuthenticator/src/Controller/SshKeysController.php index fcea1043e..d02e776f9 100644 --- a/app/plugins/SshKeyAuthenticator/src/Controller/SshKeysController.php +++ b/app/plugins/SshKeyAuthenticator/src/Controller/SshKeysController.php @@ -77,7 +77,12 @@ public function add() { $this->set('vv_obj', $obj); // Default title is add new object - [$title, $supertitle, $subtitle] = StringUtilities::entityAndActionToTitle($obj, 'SshKeys', 'add'); + [$title, $supertitle, $subtitle] = StringUtilities::entityAndActionToTitle( + $obj, + 'SshKeys', + 'add', + 'ssh_key_authenticator' + ); $this->set('vv_title', $title); $this->set('vv_supertitle', $supertitle); $this->set('vv_subtitle', $subtitle); diff --git a/app/plugins/SshKeyAuthenticator/templates/SshKeyAuthenticators/fields.inc b/app/plugins/SshKeyAuthenticator/templates/SshKeyAuthenticators/fields.inc index aeb267672..a99b02ce2 100644 --- a/app/plugins/SshKeyAuthenticator/templates/SshKeyAuthenticators/fields.inc +++ b/app/plugins/SshKeyAuthenticator/templates/SshKeyAuthenticators/fields.inc @@ -25,5 +25,9 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ +declare(strict_types = 1); + +$this->Field->disableFormEditMode(); + // There are currently no configurable options for the SSH Key Authenticator print $this->element('notify/banner', ['info' => __d('information', 'plugin.config.none')]); diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po index 02ff23c97..b3270a15e 100644 --- a/app/resources/locales/en_US/field.po +++ b/app/resources/locales/en_US/field.po @@ -358,7 +358,7 @@ msgid "ApiUsers.privileged.desc" msgstr "A privileged API user has full access to the CO. Unprivileged API users may be granted specific permissions where supported." msgid "ApiUsers.remote_ip.desc" -msgstr "If specified, a regular expression describing the IP address(es) from which this API User may connect. Be sure to escape dots (eg: "/10\\.0\\.1\\.150/")." +msgstr "If specified, a regular expression describing the IP address(es) from which this API User may connect. Be sure to escape dots (eg: \"/10\\.0\\.1\\.150/\")." msgid "ApiUsers.username.desc" msgstr "The API User Name must be prefixed with the string \"co_#.\"" @@ -929,7 +929,7 @@ msgstr "If ticked, uniqueness checks for this Identifier Type will be case insen msgid "Types.edupersonaffiliation.desc" # XXX update link to PE wiki? -msgstr "Map the extended affiliation to this eduPersonAffiliation, see eduPersonAffiliation and Extended Affiliations" +msgstr "Map the extended affiliation to this eduPersonAffiliation, see eduPersonAffiliation and Extended Affiliations" msgid "Types.status.desc" msgstr "Suspending a Type will prevent it from being assigned to new attributes, but will not remove it from existing attributes" diff --git a/app/src/Controller/AppController.php b/app/src/Controller/AppController.php index 4e42987f4..6f8b31988 100644 --- a/app/src/Controller/AppController.php +++ b/app/src/Controller/AppController.php @@ -271,7 +271,7 @@ public function getCOID(): ?int { * @since COmanage Registry v5.2.0 * @return \Cake\ORM\Table */ - protected function getCurrentTable(): \Cake\ORM\Table + public function getCurrentTable(): \Cake\ORM\Table { /** @var string $modelsName */ $modelsName = $this->getName(); diff --git a/app/src/Controller/Component/BreadcrumbComponent.php b/app/src/Controller/Component/BreadcrumbComponent.php index 9cbc4d82d..903c47898 100644 --- a/app/src/Controller/Component/BreadcrumbComponent.php +++ b/app/src/Controller/Component/BreadcrumbComponent.php @@ -82,8 +82,6 @@ public function beforeRender(EventInterface $event) { return; } - $modelsName = $controller->getName(); - // Determine the request target, but strip off query params $requestTarget = $request->getRequestTarget(false); @@ -110,12 +108,13 @@ public function beforeRender(EventInterface $event) { $controller->set('vv_bc_skip_config', $skipConfig); + $table = $controller->getCurrentTable(); + // Do we have a target model, and if so is it a configuration // model (eg: ApiUsers) or an object model (eg: CoPeople)? - if(\is_object($controller->fetchTable($modelsName)) - && method_exists($controller->fetchTable($modelsName), "isConfigurationTable") + if(method_exists($table, "isConfigurationTable") ) { - $controller->set('vv_bc_configuration_link', $controller->fetchTable($modelsName)->isConfigurationTable()); + $controller->set('vv_bc_configuration_link', $table->isConfigurationTable()); } else { $controller->set('vv_bc_configuration_link', false); } @@ -141,7 +140,7 @@ public function beforeRender(EventInterface $event) { if($action != 'index') { $target = [ 'plugin' => $primaryLink->plugin ?? null, - 'controller' => $modelsName, + 'controller' => $controller->getName(), 'action' => 'index' ]; @@ -149,9 +148,11 @@ public function beforeRender(EventInterface $event) { $target['?'] = [$primaryLink->attr => $primaryLink->value]; } - $label = (!empty($primaryLink->plugin) - ? __d(Inflector::underscore($primaryLink->plugin), 'controller.'.$modelsName, [99]) - : __d('controller', $modelsName, [99])); + $label = StringUtilities::localizeController( + $controller->getName(), + $primaryLink->plugin ?? null, + true + ); $parents[] = [ 'label' => $label, @@ -175,114 +176,128 @@ public function beforeRender(EventInterface $event) { *@since COmanage Registry v5.0.0 */ - public function injectPrimaryLink(object $link, bool $index=true, string $linkLabel=null): void + public function injectPrimaryLink(object $link, bool $index = true, string $linkLabel = null): void { - try { - // eg: "People" - $modelsName = StringUtilities::foreignKeyToClassName($link->attr); - if(!empty($this->getController()->viewBuilder()->getVar('vv_primary_link_model'))) { - // $link doesn't seem to handle table aliases (eg "Groups" instead of "RecipientGroups" for - // Notifications)). This may also return a plugin qualified path (eg SshKeyAuthenticator.SshKeyAuthenticators). - $modelsName = $this->getController()->viewBuilder()->getVar('vv_primary_link_model'); - } - $modelPath = $modelsName; + $controller = $this->getController(); + $request = $controller->getRequest(); - if(!empty($link->plugin) && !str_starts_with($modelsName, $link->plugin . '.')) { - // eg: "CoreEnroller.AttributeCollectors", however check first since we may have the - // path from vv_primary_link_model. - $modelPath = $link->plugin . '.' . $modelsName; - } + // Ensure null stays null; don’t coerce to 0 + $idParam = $request->getParam('pass.0'); + $id = $idParam !== null ? (int)$idParam : null; + + // Determine link model name, optionally overridden by the view + $linkModelName = $controller->viewBuilder()->getVar('vv_primary_link_model') + ?? StringUtilities::foreignKeyToClassName($link->attr); - // Construct the getContains function name - $requestAction = $this->getController()->getRequest()->getParam('action'); - $mappedRequestAction = $requestAction; - // In the case we are dealing with non-standard actions we need to fallback to a standard one - // in order to get access to the contain array. We will use the permissions to decide which - // action to fall back to - if(!\in_array($requestAction, [ - 'index', 'view', 'delete', 'add', 'edit' - ])) { - $permissionsArray = $this->getController()->RegistryAuth->calculatePermissionsForView($requestAction); - $id = $this->getController()->getRequest()->getParam('pass')[0] ?? null; - if (isset($id)) { - $mappedRequestAction = ( isset($permissionsArray['edit']) && $permissionsArray['edit'] ) ? 'edit' : 'view'; - } else { - $mappedRequestAction = 'index'; + // Fully-qualify with plugin if not already qualified + $linkModelFqn = StringUtilities::qualifyModelPath($linkModelName, $link->plugin ?? null); + + // Permissions for current page and linked entity + $pagePermissions = $controller->RegistryAuth->calculatePermissionsForView(id: $id); + $linkedPermissions = $controller->RegistryAuth->getTablePermissions( + table: $controller->fetchTable($linkModelFqn), + id: (int)$link->value + ); + + try { + // Map the current action to a canonical one for breadcrumbs/contains + $mappedAction = $this->mapActionForBreadcrumb( + requestAction: $request->getParam('action'), + currentId: $id, + pagePermissions: $pagePermissions, + peopleActionOverride: function () use ($controller, $link, $linkedPermissions, $linkModelFqn): string { + // For People, derive edit/view based on roles and granted permissions + $alias = \Cake\ORM\TableRegistry::getTableLocator()->get($linkModelFqn)->getAlias(); + if ($alias !== 'People') { + return ''; + } + + $roles = $controller->RegistryAuth->getApplicationUserRoles($link->co_id); + + $canEdit = ( + in_array('platformAdmin', $linkedPermissions['entity']['edit'] ?? [], true) && !empty($roles['platform']) + ) || ( + in_array('coAdmin', $linkedPermissions['entity']['edit'] ?? [], true) && !empty($roles['co']) + ); + + $canView = ( + in_array('platformAdmin', $linkedPermissions['entity']['view'] ?? [], true) && !empty($roles['platform']) + ) || ( + in_array('coAdmin', $linkedPermissions['entity']['view'] ?? [], true) && !empty($roles['co']) + ); + + return $canEdit ? 'edit' : ($canView ? 'view' : ''); } - } - $containsList = 'get' . ucfirst($mappedRequestAction) . 'Contains'; + ); - $linkTable = TableRegistry::getTableLocator()->get($modelPath); - $contain = method_exists($linkTable, $containsList) ? $linkTable->$containsList() : []; + $linkTable = \Cake\ORM\TableRegistry::getTableLocator()->get($linkModelFqn); + $contain = $this->resolveContainList($linkTable, $mappedAction); - // Use the table alias for query building (avoid plugin-qualified names in SQL) + // Normalize attr (people_id → id when the attr matches the model’s own foreign key) $modelAlias = $linkTable->getAlias(); + $foreignKey = StringUtilities::classNameToForeignKey($modelAlias); + $linkAttr = ($link->attr === $foreignKey) ? 'id' : $link->attr; - $modelNameForeignKey = StringUtilities::classNameToForeignKey($modelAlias); - $linkAttr = $link->attr == $modelNameForeignKey ? 'id' : $link->attr; - $linkObj = $linkTable + // Fetch the linked entity; if not found, handle gracefully + $linkedEntity = $linkTable ->find() ->where(["$modelAlias.$linkAttr" => $link->value]) ->contain($contain) ->firstOrFail(); - if($index) { - // We need to determine the primary link of the parent, which might or might - // not be co_id - - if(method_exists($linkTable, "findPrimaryLink")) { - // If findPrimaryLink doesn't exist, we're probably working with CosTable - - $parentLink = $linkTable->findPrimaryLink($linkObj->id); - - $this->injectParents[ $modelPath . $parentLink->value] = [ - 'target' => [ - 'plugin' => $parentLink->plugin ?? null, - 'controller' => $modelsName, - 'action' => 'index', - '?' => [ - $parentLink->attr => $parentLink->value - ] - ], - 'label' => StringUtilities::localizeController( - controllerName: $modelsName, - pluginName: $link->plugin ?? null, - plural: true - ) - ]; - } + // Optional parent index breadcrumb + if ($index && method_exists($linkTable, 'findPrimaryLink')) { + $parentLink = $linkTable->findPrimaryLink($linkedEntity->id); + + // https://comanage-ioi-dev.workbench.incommon.org/registry-pe/authenticators?co_id=2 + $this->injectParents[strtolower($linkModelFqn) . ':index'] = [ + 'target' => [ + 'plugin' => $parentLink->plugin ?? null, + 'controller' => $linkModelFqn, + 'action' => 'index', + '?' => [ + $parentLink->attr => $parentLink->value + ] + ], + 'label' => StringUtilities::localizeController( + controllerName: $linkModelFqn, + pluginName: $link->plugin ?? null, + plural: true + ) + ]; } - // Find the allowed action - $breadcrumbAction = method_exists($linkObj, 'isReadOnly') ? - ($linkObj->isReadOnly() ? 'view' : 'edit') : - $mappedRequestAction; - - // We specifically need to check for the add action - if($mappedRequestAction == 'add' || $mappedRequestAction == 'delete') { - $breadcrumbAction = $mappedRequestAction; - } + // Determine target action for entity link + $breadcrumbAction = $this->determineEntityAction($linkedEntity, $mappedAction); + // Build a human-friendly label + [$title] = StringUtilities::entityAndActionToTitle( + entity: $linkedEntity, + modelPath: $linkModelFqn, + action: $breadcrumbAction, + domain: StringUtilities::pluginToTextDomain($link->plugin ?? null) + ); - // The action in the following injectParents dictates the action here - [$title,,] = StringUtilities::entityAndActionToTitle($linkObj, $modelPath, $breadcrumbAction); + $title = StringUtilities::stripActionPrefix($title); - $this->injectParents[ $linkTable->getTable() . $linkObj->id ] = [ + // Inject the entity breadcrumb (unique per table:id) + $this->injectParents[$this->composeEntityKey($linkTable->getTable(), (int)$linkedEntity->id)] = [ 'target' => [ - 'plugin' => $link->plugin ?? null, - 'controller' => $modelsName, - 'action' => $breadcrumbAction, - $linkObj->id + 'plugin' => $link->plugin ?? null, + 'controller' => $linkModelFqn, + 'action' => $breadcrumbAction, + (int)$linkedEntity->id ], - 'label' => $linkLabel ?? $title + 'label' => $linkLabel ?? $title, ]; } - catch(\Exception $e) { - // If anything goes wrong we don't want to crash the entire page - $this->llog('error', "Breadcrumbs failed: " . $e->getMessage()); - $this->llog( - 'error', - "Breadcrumbs failed: " . json_encode($e->getTrace(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + catch (\Cake\Datasource\Exception\RecordNotFoundException $e) { + $this->llog('error', "Breadcrumbs: linked entity not found for $linkModelFqn {$link->attr}={$link->value}"); + } + catch (\Throwable $e) { + // Never block rendering due to breadcrumbs + $this->llog('error', 'Breadcrumbs failed: ' . $e->getMessage()); + $this->llog('error', json_encode($e->getTrace(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); } } @@ -354,4 +369,85 @@ public function skipParents(array $skipPaths): void { $this->skipParentPaths = $skipPaths; } -} \ No newline at end of file + + /** + * Resolves the contain list for a given table and mapped action + * + * @param \Cake\ORM\Table $table Table instance + * @param string $mappedAction Mapped action name + * @return array List of associations to contain + * @since COmanage Registry v5.2.0 + */ + private function resolveContainList($table, string $mappedAction): array + { + $method = 'get' . ucfirst($mappedAction) . 'Contains'; + return method_exists($table, $method) ? $table->$method() : []; + } + + /** + * Maps the request action to a canonical action for breadcrumb purposes + * + * @param string $requestAction Current request action + * @param int|null $currentId Current entity ID + * @param array $pagePermissions Permissions for the current page + * @param callable $peopleActionOverride Override callback for People actions + * @return string Mapped action name + * @since COmanage Registry v5.2.0 + */ + private function mapActionForBreadcrumb( + string $requestAction, + ?int $currentId, + array $pagePermissions, + callable $peopleActionOverride + ): string { + // Custom override for People if provided + $override = $peopleActionOverride(); + if ($override !== '') { + return $override; + } + + if (in_array($requestAction, ['index', 'view', 'delete', 'add', 'edit'], true)) { + return $requestAction; + } + + if ($currentId !== null) { + return (!empty($pagePermissions['edit'])) ? 'edit' : 'view'; + } + + return 'index'; + } + + /** + * Determines the appropriate action for an entity in breadcrumbs + * + * @param \Cake\ORM\Entity $entity Entity instance + * @param string $mappedAction Mapped action name + * @return string Determined action name + * @since COmanage Registry v5.2.0 + */ + private function determineEntityAction($entity, string $mappedAction): string + { + if ($mappedAction === 'add' || $mappedAction === 'delete') { + return $mappedAction; + } + + if (method_exists($entity, 'isReadOnly')) { + return $entity->isReadOnly() ? 'view' : 'edit'; + } + + return $mappedAction; + } + + /** + * Composes a unique key for entity breadcrumb entries + * + * @param string $tableName Table name + * @param int $id Entity ID + * @return string Composed key + * @since COmanage Registry v5.2.0 + */ + private function composeEntityKey(string $tableName, int $id): string + { + return strtolower($tableName) . ':' . $id; + } +} diff --git a/app/src/Controller/Component/RegistryAuthComponent.php b/app/src/Controller/Component/RegistryAuthComponent.php index e4d114aad..aa39db4b9 100644 --- a/app/src/Controller/Component/RegistryAuthComponent.php +++ b/app/src/Controller/Component/RegistryAuthComponent.php @@ -49,20 +49,21 @@ namespace App\Controller\Component; +use App\Model\Entity; +use \App\Lib\Enum\AuthenticationEventEnum; +use \App\Lib\Enum\SuspendableStatusEnum; +use \App\Lib\Enum\TemplateableStatusEnum; +use \Cake\Chronos\Chronos; use \Cake\Controller\Component; use \Cake\Core\Configure; -use \Cake\Chronos\Chronos; use \Cake\Datasource\Exception\RecordNotFoundException; +use \Cake\Datasource\Paging\PaginatedResultSet; use \Cake\Event\EventInterface; use \Cake\Http\Exception\ForbiddenException; use \Cake\Http\Exception\UnauthorizedException; use \Cake\ORM\ResultSet; -use \Cake\Datasource\Paging\PaginatedResultSet; use \Cake\ORM\TableRegistry; use \Cake\Utility\Inflector; -use \App\Lib\Enum\AuthenticationEventEnum; -use \App\Lib\Enum\SuspendableStatusEnum; -use \App\Lib\Enum\TemplateableStatusEnum; class RegistryAuthComponent extends Component { @@ -594,13 +595,13 @@ public function calculatePermissionsForResultSet(ResultSet|PaginatedResultSet $r /** * Calculate permissions for use in a view. * - * @param string $action Action requested + * @param string|null $action Action requested * @param int|null $id Subject id, if applicable * @return array Array of permissions, suitable for the view * @since COmanage Registry v5.0.0 */ - public function calculatePermissionsForView(string $action, ?int $id=null): array { + public function calculatePermissionsForView(?string $action = null, ?int $id=null): array { return $this->calculatePermissions($id); } @@ -731,7 +732,7 @@ public function getPersonID(int $coId): ?int { * @return array Table permissions */ - protected function getTablePermissions($table, ?int $id): array { + public function getTablePermissions($table, ?int $id): array { $p = $table->getPermissions(); if(is_callable($p)) { diff --git a/app/src/Controller/DashboardsController.php b/app/src/Controller/DashboardsController.php index a17e138c5..2be4c2bfb 100644 --- a/app/src/Controller/DashboardsController.php +++ b/app/src/Controller/DashboardsController.php @@ -69,8 +69,8 @@ public function configuration() { $cur_co = $this->getCO(); [$title, , ] = StringUtilities::entityAndActionToTitle(null, - 'co.features', - 'all', + null, + 'co.features.all', 'menu'); $this->set('vv_title', $title); @@ -467,8 +467,8 @@ public function search() { // XXX The action is search and the result is not a modelPath. In this use the pattern is reversed. We should // probably reconsider the po naming for the result domain [$title, , ] = StringUtilities::entityAndActionToTitle(null, - 'search', - 'results', + null, + 'search.results', 'result'); $this->set('vv_title', $title); } diff --git a/app/src/Controller/MVEAController.php b/app/src/Controller/MVEAController.php index 6709e1ae8..1b3e0825e 100644 --- a/app/src/Controller/MVEAController.php +++ b/app/src/Controller/MVEAController.php @@ -36,6 +36,8 @@ use \App\Lib\Util\StringUtilities; class MVEAController extends StandardController { + use \App\Lib\Traits\BreadcrumbsTrait; + /** * Callback run prior to the request action. * @@ -58,16 +60,7 @@ public function beforeFilter(\Cake\Event\EventInterface $event) { // or external_identity_role_id) we need to look up the further links. $primaryLink = $this->getPrimaryLink(true); - if($primaryLink->attr == 'person_id' || $primaryLink->attr == 'group_id') { - $this->Breadcrumb->injectPrimaryLink($primaryLink); - } else { - $parentModel = StringUtilities::foreignKeyToClassName($primaryLink->attr); - - $parentPrimaryLink = $table->$parentModel->findPrimaryLink((int)$primaryLink->value); - - $this->Breadcrumb->injectPrimaryLink($parentPrimaryLink); - $this->Breadcrumb->injectPrimaryLink($primaryLink); - } + $this->Breadcrumb->injectPrimaryLink($primaryLink); // Set up the supertitle and links for subnavigation if(!empty($primaryLink->value)) { @@ -137,27 +130,72 @@ public function beforeFilter(\Cake\Event\EventInterface $event) { * @since COmanage Registry v5.0.0 * @param EventInterface $event Cake Event */ - + public function beforeRender(\Cake\Event\EventInterface $event) { /** var string $modelsName */ $modelsName = $this->getName(); $table = $this->getCurrentTable(); // field = model (or model_name) $fieldName = Inflector::underscore(Inflector::singularize($modelsName)); - - if(!$this->request->is('restful') && $this->request->getParam('action') != 'deleted') { - // If there is a default type setting for this model, pass it to the view - if($table->getSchema()->hasColumn('type_id')) { - $defaultTypeField = "default_" . $fieldName . "_type_id"; - - $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); - - $settings = $CoSettings->find()->where(['co_id' => $this->getCOID()])->firstOrFail(); - - $this->set('vv_default_type', $settings->$defaultTypeField); - } + + if($this->request->is('restful') || $this->request->getParam('action') === 'deleted') { + return parent::beforeRender($event); } - + + // If there is a default type setting for this model, pass it to the view + if($table->getSchema()->hasColumn('type_id')) { + $defaultTypeField = "default_" . $fieldName . "_type_id"; + + $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); + + $settings = $CoSettings->find()->where(['co_id' => $this->getCOID()])->firstOrFail(); + + $this->set('vv_default_type', $settings->$defaultTypeField); + } + + + // Person Breadcrumb link + // Get current breadcrumb parents + $vv_bc_parents = (array)$this->viewBuilder()->getVar('vv_bc_parents'); + + // Fetch the linked entity resolved by getPrimaryLink(true) + $plObj = $this->viewBuilder()->getVar('vv_primary_link_obj') ?? null; + if ($plObj === null) { + // No primary link object available; nothing to add + throw new \Exception('No primary link object available'); + } + + // Build additional parents for MVEA context + if ($plObj->person_id !== null) { + $mveaBreadcrumb = $this->buildMveaBreadcrumbs($plObj); + } + + if (!empty($mveaBreadcrumb)) { + $this->set('vv_bc_parents', [...$mveaBreadcrumb, ...$vv_bc_parents]); + } + return parent::beforeRender($event); } + + /** + * Build breadcrumb parents for MVEA pages based on the current primary link. + * + * Returns only the extra parents to prepend (eg: People index and the specific person), + * avoiding duplicates by checking existing vv_bc_parents. + * + * @since COmanage Registry v5.2.0 + * @return array + */ + protected function buildMveaBreadcrumbs($plObj): array + { + $table = $this->getCurrentTable(); + + // Resolve person_id via PrimaryLinkTrait helper on the table + $personId = (int)$table->lookupPersonId($plObj); + if (!$personId) { + return []; + } + + return $this->buildPersonBreadcrumbs($personId, true); + } } \ No newline at end of file diff --git a/app/src/Controller/MultipleAuthenticatorController.php b/app/src/Controller/MultipleAuthenticatorController.php index 93570cbb7..33fc82868 100644 --- a/app/src/Controller/MultipleAuthenticatorController.php +++ b/app/src/Controller/MultipleAuthenticatorController.php @@ -35,6 +35,8 @@ // This isn't "StandardMultipleAuthenticatorController" to avoid name length issues class MultipleAuthenticatorController extends StandardPluginController { + use \App\Lib\Traits\BreadcrumbsTrait; + // Cached info for redirect after delete private $redirectInfo = []; @@ -83,7 +85,22 @@ public function beforeFilter(\Cake\Event\EventInterface $event) { */ public function beforeRender(\Cake\Event\EventInterface $event) { - $this->set('vv_person_id', $this->requestParam('person_id')); + // Build and set breadcrumb parents via the new helper + $customParents = $this->buildAuthenticatorBreadcrumbs(); + + if (!empty($customParents)) { + // Get current breadcrumb parents + $vv_bc_parents = (array)$this->viewBuilder()->getVar('vv_bc_parents'); + $vv_bc_parents = [...$customParents, ...$vv_bc_parents]; + $this->set('vv_bc_parents', $vv_bc_parents); + } + + $personId = (int)($this->requestParam('person_id') ?? 0); + $authenticatorId = (int)($this->requestParam('authenticator_id') ?? 0); + $authenticatorStatId = (int)($this->requestParam('authenticator_status_id') ?? 0); + $this->set('vv_person_id', $personId); + $this->set('vv_authenticator_id', $authenticatorId); + $this->set('vv_authenticator_status_id', $authenticatorStatId); return parent::beforeRender($event); } @@ -164,7 +181,7 @@ public function index() { $this->populateAutoViewVars(); // Default index view title is model name - [$title, , ] = StringUtilities::entityAndActionToTitle($resultSet, $modelsName, 'index'); + $title = StringUtilities::localizeController($table->getAlias(), $this->plugin, true); $this->set('vv_title', $title); // Let the view render @@ -238,5 +255,66 @@ public function willHandleAuth(\Cake\Event\EventInterface $event): string { } return 'no'; - } + } + + /** + * Build breadcrumb parents for multiple-authenticator pages based on query params. + * + * Order (when all IDs are present): + * - People → edit person + * - Authenticators → AuthenticatorStatuses index filtered by person + * - Current authenticator (eg: SSH Key) → Authenticators/manage with full query + * + * Keys are unique and stable to prevent duplicates when merged by other code. + * + * @since COmanage Registry v5.2.0 + * @return array Breadcrumb parents + */ + protected function buildAuthenticatorBreadcrumbs(): array + { + $personId = (int)($this->requestParam('person_id') ?? 0); + $authenticatorId = (int)($this->requestParam('authenticator_id') ?? 0); + $authenticatorStatId = (int)($this->requestParam('authenticator_status_id') ?? 0); + + // Start with shared person-based crumbs + $parents = $this->buildPersonBreadcrumbs($personId, true); + + // 2) AuthenticatorStatuses index filtered by person + if ($personId > 0) { + $parents['authenticatorstatuses:' . $personId] = [ + 'label' => \App\Lib\Util\StringUtilities::localizeController('AuthenticatorStatuses', null, true), + 'target' => [ + 'plugin' => null, + 'controller' => 'AuthenticatorStatuses', + 'action' => 'index', + '?' => [ 'person_id' => $personId ], + ], + ]; + } + + // 3) The current authenticator (singular) → Authenticators/manage + if ($personId > 0 && $authenticatorId > 0 && $authenticatorStatId > 0) { + $label = \App\Lib\Util\StringUtilities::localizeController( + $this->getName(), // current controller (e.g. SshKeys) + $this->getPlugin() ?: null, + false // singular + ); + + $parents['authenticators:manage:' . $authenticatorStatId . ':' . $authenticatorId . ':' . $personId] = [ + 'label' => $label, + 'target' => [ + 'plugin' => null, + 'controller' => 'Authenticators', + 'action' => 'manage', + '?' => [ + 'authenticator_status_id' => $authenticatorStatId, + 'authenticator_id' => $authenticatorId, + 'person_id' => $personId, + ], + ], + ]; + } + + return $parents; + } } \ No newline at end of file diff --git a/app/src/Controller/StandardController.php b/app/src/Controller/StandardController.php index 79020e9b2..7c3fae976 100644 --- a/app/src/Controller/StandardController.php +++ b/app/src/Controller/StandardController.php @@ -152,7 +152,11 @@ public function beforeFilter(\Cake\Event\EventInterface $event) { $primaryLink = $this->getPrimaryLink(true); - if(!empty($primaryLink->attr) && $primaryLink->attr != 'co_id') { + if( + !is_subclass_of($event->getSubject(), \App\Controller\StandardPluginController::class) + && !empty($primaryLink->attr) + && $primaryLink->attr != 'co_id' + ) { // eg: EnrollmentFlowSteps -> EnrollmentFlow, JobHistoryRecords -> Job, etc $this->Breadcrumb->injectPrimaryLink($primaryLink); } @@ -201,9 +205,9 @@ public function beforeRender(\Cake\Event\EventInterface $event) { $id = (int)$params[0]; } } - - $this->set('vv_permissions', $this->RegistryAuth->calculatePermissionsForView($this->request->getParam('action'), $id)); - + + $this->set('vv_permissions', $this->RegistryAuth->calculatePermissionsForView($this->request->getParam('action'), (int)$id)); + // The template path may vary if we're in a plugin context $vv_template_path = ROOT . DS . "templates" . DS . $modelsName; @@ -420,8 +424,8 @@ public function edit(string $id) { try{ // Attempt the update the record - $table->patchEntity($saveObj, $this->request->getData(), $opts); - + $table->patchEntity($saveObj, $this->request->getData(), $opts); + // This throws \Cake\ORM\Exception\RolledbackTransactionException if aborted // in afterSave if($table->save($saveObj)) { @@ -447,8 +451,8 @@ public function edit(string $id) { } if(!empty($errors)) { - $this->Flash->error(__d('error', 'fields', [ implode(',', - array_map(function($v) use ($errors) { + $this->Flash->error(__d('error', 'fields', [ implode(',', + array_map(function($v) use ($errors) { return __d('error', 'flash', [$v, implode(',', array_values($errors[$v]))]); }, array_keys($errors))) ])); @@ -464,7 +468,7 @@ public function edit(string $id) { } $this->set('vv_obj', $obj); - $this->set('vv_permission_view', $this->RegistryAuth->calculatePermissionsForView('edit', $obj->id)); + $this->set('vv_permission_view', $this->RegistryAuth->calculatePermissionsForView('edit', (int)$obj->id)); // XXX should we also set '$model'? cake seems to autopopulate edit fields just fine without it // note index() uses $tableName, not 'vv_objs' or event 'vv_table_name' @@ -475,7 +479,7 @@ public function edit(string $id) { $this->populateAutoViewVars($obj); // Calculate and set title, supertitle and subtitle - [$title, $supertitle, $subtitle] = StringUtilities::entityAndActionToTitle($obj, $modelsName, 'edit'); + [$title, $supertitle, $subtitle] = StringUtilities::entityAndActionToTitle($obj, $table->getRegistryAlias(), 'edit'); // We might have calculated the following values earlier. For example, MVEAController runs before the StandarController // and makes similar calculations. We will keep the ones calculated before we get here @@ -694,8 +698,8 @@ protected function populateAutoViewVars(object $obj=null) { } /** - * Handle a provisioning request for a Standard object. - * + * Handle a provisioning request for a Standard object. + * * @since COmanage Registry v5.0.0 * @param string $id Object ID */ @@ -703,8 +707,6 @@ protected function populateAutoViewVars(object $obj=null) { public function provision($id) { /** var Cake\ORM\Table $table */ $table = $this->getCurrentTable(); - // $tableName = models - $tableName = $table->getTable(); // Note that only Primary Models support provisioning, but those that // don't won't have permission to execute this function. @@ -738,7 +740,7 @@ public function provision($id) { /** * Unfreeze a frozen record. - * + * * @since COmanage Registry v5.0.0 * @param string $id Entity ID */ @@ -777,8 +779,6 @@ public function view($id = null) { $modelsName = $this->getName(); /** var Cake\ORM\Table $table */ $table = $this->getCurrentTable(); - // $tableName = models - $tableName = $table->getTable(); // We use findById() rather than get() so we can apply subsequent // query modifications via traits @@ -800,7 +800,7 @@ public function view($id = null) { } $this->set('vv_obj', $obj); - $this->set('vv_permission_view', $this->RegistryAuth->calculatePermissionsForView('view', $obj->id)); + $this->set('vv_permission_view', $this->RegistryAuth->calculatePermissionsForView('view', (int)$obj->id)); // PrimaryLinkTrait $this->getPrimaryLink(); diff --git a/app/src/Controller/StandardPluginController.php b/app/src/Controller/StandardPluginController.php index 8f29211f2..31c936b7f 100644 --- a/app/src/Controller/StandardPluginController.php +++ b/app/src/Controller/StandardPluginController.php @@ -67,7 +67,11 @@ public function beforeFilter(\Cake\Event\EventInterface $event) { $this->Breadcrumb->skipConfig(['/^\//']); } - $this->Breadcrumb->injectPrimaryLink($primaryLink); + // The authenticator routes have a unique patern. We will not inject the Breadcrumb here but + // we will construct it in the MultipleAuthtenticatorController. + if(!str_ends_with($primaryLink->attr, '_authenticator_id')) { + $this->Breadcrumb->injectPrimaryLink($primaryLink); + } } } diff --git a/app/src/Lib/Traits/BreadcrumbsTrait.php b/app/src/Lib/Traits/BreadcrumbsTrait.php new file mode 100644 index 000000000..f4bae6d6e --- /dev/null +++ b/app/src/Lib/Traits/BreadcrumbsTrait.php @@ -0,0 +1,84 @@ + + */ + protected function buildPersonBreadcrumbs(int $personId, bool $includePeopleIndex = true): array + { + if ($personId <= 0) { + return []; + } + + $People = $this->fetchTable('People'); + $vv_bc_parents = (array)$this->viewBuilder()->getVar('vv_bc_parents'); + $parents = []; + + // 1) People index + if ($includePeopleIndex && empty($vv_bc_parents['cos:' . $this->getCOID()])) { + $parents['cos:' . $this->getCOID()] = [ + 'label' => __d('controllers', 'People', [99]), + 'target' => [ + 'plugin' => null, + 'controller' => 'people', + 'action' => 'index', + '?' => [ 'co_id' => $this->getCOID() ], + ], + ]; + } + + // 2) Person crumb + $person = $People->get($personId, contain: ['PrimaryName']); + $personKey = strtolower($People->getAlias()) . ':' . $personId; // eg: people:123 + + if (empty($vv_bc_parents[$personKey])) { + $parents[$personKey] = [ + 'label' => $People->generateDisplayField($person), + 'target' => [ + 'plugin' => null, + 'controller' => 'people', + 'action' => 'edit', + $personId, + ], + ]; + } + + return $parents; + } +} \ No newline at end of file diff --git a/app/src/Lib/Util/StringUtilities.php b/app/src/Lib/Util/StringUtilities.php index 571e39b60..287b9256c 100644 --- a/app/src/Lib/Util/StringUtilities.php +++ b/app/src/Lib/Util/StringUtilities.php @@ -132,6 +132,21 @@ public static function columnKey( return ($cfield !== $c) ? $cfield : \Cake\Utility\Inflector::humanize($c); } + /** + * Determines the translation domain for a plugin + * + * @param string|null $plugin Plugin name + * @return string Translation domain + * @since COmanage Registry v5.2.0 + */ + public static function pluginToTextDomain(?string $plugin): string + { + if (empty($plugin)) { + return 'operation'; + } + return \Cake\Utility\Inflector::singularize(\Cake\Utility\Inflector::tableize($plugin)); + } + /** * Determine the class basename of a Cake Entity. * @@ -172,84 +187,137 @@ public static function entityToForeignKey($entity): string { * - in all other cases the message id is constructed by the displayField. Either it is defined or dynamically * constructed * - * @param Entity|null $entity Entity object - * @param string $modelPath The path of the Model, from core Models it is the Model Name. For plugins it is the Plugin.ModelName - * @param string|null $action Request Action - * @param string $domain The po file the message ID is located in + * @param Entity|null $entity Entity object + * @param string|null $modelPath The path of the Model, from core Models it is the Model Name. For plugins it is the Plugin.ModelName + * @param string|null $action Request Action + * @param string $domain The po file the message ID is located in * * @return array List of title, supertitle, subtitle */ public static function entityAndActionToTitle($entity, - string $modelPath, + ?string $modelPath, ?string $action, - string $domain='operation'): array { + string $domain = 'operation'): array { + + if($entity === null && $modelPath === null) { + return [__d($domain, "$action", [99]), '', '']; + } + + // Initialize return slots $supertitle = ''; $subtitle = ''; $title = ''; - if($entity === null) { - return [__d($domain, "{$modelPath}.{$action}"), '', '']; - } - + // Extract plugin and model names: "Plugin.Model" → ["Plugin", "Model"] $plugin = ''; $modelsName = $modelPath; if(str_contains($modelPath, '.')) { [$plugin, $modelsName] = explode('.', $modelPath, 2); } - $linkTable = TableRegistry::getTableLocator()->get($modelPath); - $msgId = "{$action}.a"; - $msgIdOverride = "{$action}.{$modelsName}.a"; + 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]), '', '']; + } + // Base table and default message IDs for translation + $linkTable = TableRegistry::getTableLocator()->get($modelPath); + $msgId = "{$action}.a"; // eg: "edit.a" + $msgIdOverride = "{$action}.{$modelsName}.a"; // eg: "edit.People.a" + // If the model is a configuration table and a plugin, we render Configure instead of Edit + if (method_exists($linkTable, 'isConfigurationTable') + && $linkTable->isConfigurationTable() + && str_contains($linkTable->getRegistryAlias(), '.') + ) { + $msgId = "configure.a"; // eg: "edit.a" + $msgIdOverride = "configure.{$modelsName}.a"; // eg: "edit.People.a" + } + // If the entity actually belongs to a different model than the provided $modelsName, + // switch to that table and adjust the default message id pattern accordingly. + // This is necessary for TAB oriented views if(Inflector::singularize(self::entityToClassName($entity)) !== Inflector::singularize($modelsName)) { $linkTable = TableRegistry::getTableLocator()->get(self::entityToClassName($entity)); - // if the modelPath and the action are equal then we skip the concatenation + // If modelPath and action are equal, don’t concatenate (preserve legacy behavior) $msgId = $modelPath === $action ? $modelPath : "{$modelPath}.{$action}"; } + // 2) No action → default to the controller label for the model (singular) if($action === null) { return [__d('controller', $modelsName), '', '']; } - // Index view + // 3) Index view → use the controller plural form (token 99 convention) if($action === 'index') { - // 99 is the default for plural + if(!empty($plugin)) { + return [__d($domain, "controller.$modelsName", [99]), '', '']; + } return [__d('controller', $modelsName, [99]), '', '']; } // Add/Edit/View - // The MVEA Models have a entityId. The one from the parent model. + // The MVEA Models have an entityId. The one from the parent model. // We need to have a condition for this and exclude it. - if($entity->id !== null - && $action !== 'add' - && $action !== 'delete' - && method_exists($linkTable, 'generateDisplayField')) { - // We don't use a trait for this since each table will implement different logic - - $title = __d($domain, $msgIdOverride, $linkTable->generateDisplayField($entity)); - if ($msgIdOverride === $title) { - $title = __d($domain, $msgId, $linkTable->generateDisplayField($entity)); - } - $supertitle = $linkTable->generateDisplayField($entity); - // Pass the display field also into subtitle for dealing with External IDs - $subtitle = $linkTable->generateDisplayField($entity); + $display = null; + if (method_exists($linkTable, 'generateDisplayField')) { + $display = $linkTable->generateDisplayField($entity); } else { - // Default view title is edit object display field $field = $linkTable->getDisplayField(); + $display = $entity->$field ?? null; + } - if(!empty($entity->$field)) { - $title = __d($domain, $msgIdOverride, $entity->$field); - if($msgIdOverride === $title) { - $title = __d($domain, $msgId, $entity->$field); - } - } else { - $title = __d($domain, $msgId, __d('controller', $modelsName, [1])); - } + // 6) Edit/View-like case for an existing entity with a usable display + // Title: translate with override key first; if not found, fall back to default key. + // Super/Sub titles: set to the display (needed for External IDs in UI). + if ( + $entity->id !== null && + $action !== 'add' && + $action !== 'delete' && + $display !== null + ) { + $title = self::translateWithOverride($domain, $msgIdOverride, $msgId, $display); + $supertitle = $display; + $subtitle = $display; + + return [$title, $supertitle, $subtitle]; } + // 7) Fallbacks: + // - New entities (no id), + // - Add/Delete actions, + // - Or we simply lack a display. + // Use the display if we have it; otherwise singular controller label for the model. + $displayOrDefault = $display ?? __d('controller', $modelsName, [1]); + $title = self::translateWithOverride($domain, $msgIdOverride, $msgId, $displayOrDefault); + return [$title, $supertitle, $subtitle]; } + + /** + * Attempts to translate a message using an override key first, falling back to a default key if not found. + * + * @param string $domain Translation domain to use + * @param string $overrideKey Primary translation key to try first + * @param string $fallbackKey Fallback translation key if override not found + * @param string $value Value to substitute in translation + * @return string Translated string using either override or fallback key + * @since COmanage Registry v5.2.0 + */ + private static function translateWithOverride( + string $domain, + string $overrideKey, + string $fallbackKey, + string|int $value + ): string { + $translated = __d($domain, $overrideKey, $value); + return ($translated === $overrideKey) + ? __d($domain, $fallbackKey, $value) + : $translated; + } + /** * Determine the class name from a foreign key (eg: report_id -> Reports). * @@ -299,6 +367,22 @@ public static function localizeController(string $controllerName, ?string $plugi } } + /** + * Qualifies a model path with its plugin name if not already qualified + * + * @param string $modelPath Model path to qualify + * @param string|null $plugin Plugin name + * @since COmanage Registry v5.2.0 + * @return string Fully qualified model path + */ + public static function qualifyModelPath(string $modelPath, ?string $plugin): string + { + if (empty($plugin) || str_starts_with($modelPath, $plugin . '.')) { + return $modelPath; + } + return $plugin . '.' . $modelPath; + } + /** * Determine the model component of a Plugin path. * @@ -340,6 +424,18 @@ public static function pluginToEntityField(string $plugin): string { return Inflector::singularize(Inflector::underscore(self::pluginModel($plugin))); } + /** + * Strips action prefix (Edit|Delete|View) from a title + * + * @param string $title Title to process + * @return string Title without action prefix + * @since COmanage Registry v5.2.0 + */ + public static function stripActionPrefix(string $title): string + { + return preg_replace('/^(Edit|Delete|View)\s+/u', '', $title) ?? $title; + } + /** * Determine the Entity name from a Table object. * diff --git a/app/src/Lib/Util/TableUtilities.php b/app/src/Lib/Util/TableUtilities.php index 599e8e274..5862871de 100644 --- a/app/src/Lib/Util/TableUtilities.php +++ b/app/src/Lib/Util/TableUtilities.php @@ -62,16 +62,17 @@ public static function getTableFromRegistry(string $alias, array $options): Tabl } /** - * We calculate the model name from the primary link, the primary link value is the id - * of the record. We use these to traverse backwards to all the records associations - * Then we return a list where the keys are the model names and the values are the ids + * Traverse backwards through model associations starting from a primary link. * - * @param string $primaryLinkKey - * @param int $primaryLinkValue - * @param array $results + * Calculates model name from primary link and traverses backwards through all record + * associations. Returns list where keys are model names and values are record IDs. * - * @return void - * @since COmanage Registry v5.0.0 + * @param string $primaryLinkKey Primary link key name + * @param int $primaryLinkValue ID value of the primary link record + * @param array $results Reference to array to store results + * @param string|null $primaryLinkClassName Optional override for model class name + * @return void Results stored in $results parameter + * @since COmanage Registry v5.0.0 */ public static function treeTraversalFromPrimaryLink( string $primaryLinkKey, @@ -139,15 +140,16 @@ public static function treeTraversalFromPrimaryLink( } /** - * With a model name and the id know we return a list where the - * keys are the model names and the values are the ids + * Traverse backwards through model associations starting from model name and ID. * - * @param string $modelName - * @param int $id - * @param array $results + * Returns list where keys are model names and values are record IDs by traversing + * through all associated records. * - * @return void - * @since COmanage Registry v5.0.0 + * @param string $modelName Name of the model to start from + * @param int $id ID of the record to start from + * @param array $results Reference to array to store results + * @return void Results stored in $results parameter + * @since COmanage Registry v5.0.0 */ public static function treeTraversalFromId(string $modelName, int $id, array &$results): void { diff --git a/app/templates/element/breadcrumbs.php b/app/templates/element/breadcrumbs.php index ee449b68d..270a6640d 100644 --- a/app/templates/element/breadcrumbs.php +++ b/app/templates/element/breadcrumbs.php @@ -81,8 +81,8 @@ if(!empty($vv_bc_title_links)) { foreach($vv_bc_title_links as $tbc) { $this->Breadcrumbs->add( - $tbc['label'], - $tbc['target'] + \App\Lib\Util\StringUtilities::stripActionPrefix($tbc['label']), + $tbc['target'], ); } } @@ -90,7 +90,7 @@ // Insert the page title if(!empty($vv_title)) { $this->Breadcrumbs->add( - $vv_title + \App\Lib\Util\StringUtilities::stripActionPrefix($vv_title), ); }