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..54a9b860e 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.
*
@@ -137,27 +139,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..349b5d62b 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,134 @@ 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 (method_exists($linkTable, 'isConfigurationTable')
+ && $linkTable->isConfigurationTable()) {
+ $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 +364,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 +421,18 @@ public static function pluginToEntityField(string $plugin): string {
return Inflector::singularize(Inflector::underscore(self::pluginModel($plugin)));
}
+ /**
+ * Strips action prefix (Add/Edit/Delete) 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('/^(Add|Edit|Delete)\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
{