Skip to content

CFM-274_Breadcrumb_Component_fixes #378

Merged
merged 2 commits into from
Mar 30, 2026
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 94 additions & 31 deletions app/src/Controller/Component/BreadcrumbComponent.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,9 @@

namespace App\Controller\Component;

use \Cake\Controller\Component;
use \Cake\Event\EventInterface;
use \Cake\ORM\TableRegistry;
use \Cake\Utility\Inflector;
use \App\Lib\Util\StringUtilities;
use Cake\Controller\Component;
use Cake\Event\EventInterface;
use App\Lib\Util\StringUtilities;

class BreadcrumbComponent extends Component {
use \App\Lib\Traits\LabeledLogTrait;
Expand All @@ -52,28 +50,52 @@ class BreadcrumbComponent extends Component {

// Configuration provided by the controller
// Don't render any breadcrumbs
protected $skipAllPaths = [];
protected array $skipAllPaths = [];
// Don't render the configuration link
protected $skipConfigPaths = [];
protected array $skipConfigPaths = [];
// Don't render the parent links
protected $skipParentPaths = [];
protected array $skipParentPaths = [];

// Inject parent links (these render before the index link, if set)
// The parent links are constructed as part of the injectPrimaryLink function, as well as in the StandardController and
// in the StandardPluginController, MVEAController, ProvisioningHistoryRecordController, etc. These controllers are
// a descendant from the StandardController we will calculate the Parents twice. To avoid duplicates, the
// injectParents table has to be an associative array. The uniqueness of the key will preserve the uniqueness of
// the parent path while the order of firing will create the correct breadcrumb path order
protected $injectParents = [];
// Accumulator for parent breadcrumb links discovered during the request lifecycle.
// As the system resolves context (e.g. mapping a primary link to a Person or Group),
// it stores the generated URLs and labels here. This ensures all breadcrumbs are
// gathered, deduplicated by their unique entity keys, and ordered correctly before
// being pushed to the view for rendering.
protected array $injectParents = [];
// Inject title links (immediately before the title breadcrumb)
protected $injectTitleLinks = [];

protected array $injectTitleLinks = [];

/**
* Defines dynamic primary links derived from request query parameters.
*
* This allows a controller to declare that a specific action's breadcrumb
* hierarchy is dependent on the URL state (e.g., `?person_id=5`) rather than
* a strictly defined route parameter. The component will automatically intercept
* the configured action, read the query parameter, and feed it into the standard
* `injectPrimaryLink()` pipeline.
*
* Example:
* ```
* $this->Breadcrumb->configureQueryPrimaryLinks([
* 'status' => ['person_id', 'group_id']
* ]);
* ```
* If the current request is for the `status` action, the component will check the
* URL for `person_id`. If it finds it, it stops and generates breadcrumbs for that
* person. If it doesn't, it falls back to checking for `group_id`.
*
* @var array<string, array<int, string>>
*/
protected array $queryPrimaryLinkConfigs = [];

/**
* Callback run prior to rendering the view.
*
* @since COmanage Registry v5.0.0
* @param EventInterface $event Cake Event
*/

public function beforeRender(EventInterface $event) {
$controller = $event->getSubject();
$request = $controller->getRequest();
Expand All @@ -82,6 +104,28 @@ public function beforeRender(EventInterface $event) {
return;
}

$action = $request->getParam('action');

// Automatically inject primary links from query params if configured
if (!empty($this->queryPrimaryLinkConfigs[$action])) {
foreach ($this->queryPrimaryLinkConfigs[$action] as $queryParam) {
$val = $request->getQuery($queryParam);
if ($val) {
$link = (object)[
'attr' => $queryParam,
'value' => $val,
'co_id' => method_exists($controller, 'getCOID') ? $controller->getCOID() : null
];

// Force the component to use the model derived from the query param (e.g. People)
// instead of the global page model (e.g. Cos).
$modelName = StringUtilities::foreignKeyToClassName($queryParam);
$this->injectPrimaryLink($link, true, null, $modelName);
break; // Only inject the first matching parameter
}
}
}

// Determine the request target, but strip off query params
$requestTarget = $request->getRequestTarget(false);

Expand Down Expand Up @@ -168,15 +212,21 @@ public function beforeRender(EventInterface $event) {

/**
* Inject the primary link into the breadcrumb path.
*
*
* @param object $link Primary Link (as returned by getPrimaryLink())
* @param bool $index Include link to parent index
* @param string|null $linkLabel Override the constructed label
* @param string|null $linkModelNameOverride Override the inferred model name
*
*@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,
?string $linkModelNameOverride = null
): void
{
$controller = $this->getController();
$request = $controller->getRequest();
Expand All @@ -186,7 +236,8 @@ public function injectPrimaryLink(object $link, bool $index = true, string $link
$id = $idParam !== null ? (int)$idParam : null;

// Determine link model name, optionally overridden by the view
$linkModelName = $controller->viewBuilder()->getVar('vv_primary_link_model')
$linkModelName = $linkModelNameOverride
?? $controller->viewBuilder()->getVar('vv_primary_link_model')
?? StringUtilities::foreignKeyToClassName($link->attr);

// Fully-qualify with plugin if not already qualified
Expand Down Expand Up @@ -300,19 +351,31 @@ public function injectPrimaryLink(object $link, bool $index = true, string $link
}
}

/**
* Set configuration for deriving primary links from query parameters.
* Format: ['actionName' => ['query_param_1', 'query_param_2']]
*
* @since COmanage Registry v5.2.0
* @param array $config Array of action to query parameters mapping
*/
public function configureQueryPrimaryLinks(array $config): void
{
$this->queryPrimaryLinkConfigs = array_merge($this->queryPrimaryLinkConfigs, $config);
}

/**
* Inject a title link based on the display field of an entity into the breadcrumb set.
*
*
* @param Table $table Table for $entity
* @param Entity $entity Entity to generate title link for
* @param string $action Action to link to
* @param string|null $label If set, use this label instead of the entity's displayField
* @since COmanage Registry v5.0.0
* @param Table $table Table for $entity
* @param Entity $entity Entity to generate title link for
* @param string $action Action to link to
* @param string $label If set, use this label instead of the entity's displayField
*/

public function injectTitleLink(
$table,
$entity,
Table $table,
Entity $entity,
string $action='edit',
?string $label=null
): void {
Expand All @@ -332,7 +395,7 @@ public function injectTitleLink(
/**
* Set the set of paths that should be skipped when rendering breadcrumbs.
* Paths are specified as regular expressions, eg: '/^\/cos\/select/'
*
*
* @since COmanage Registry v5.0.0
* @param array $skipPaths Array of regular expressions describing paths to be skipped
*/
Expand All @@ -346,7 +409,7 @@ public function skipAll(array $skipPaths): void
* Set the set of paths which should not get a "configuration" breadcrumb even
* though they might otherwise ordinarily get one (by being configuration objects).
* Paths are specified as regular expressions, eg: '/^\/provisioning-targets\/status/'
*
*
* @since COmanage Registry v5.0.0
* @param array $skipPaths Array of regular expressions describing paths
*/
Expand All @@ -359,7 +422,7 @@ public function skipConfig(array $skipPaths): void
/**
* Set the set of paths that should not automatically get a link back to their parent.
* Paths are specified as regular expressions, eg: '/^\/co-settings\/edit/'
*
*
* @since COmanage Registry v5.0.0
* @param array $skipPaths Array of regular expressions describing paths
*/
Expand Down
4 changes: 4 additions & 0 deletions app/src/Controller/ProvisioningTargetsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ public function initialize(): void {
// Configure breadcrumb rendering
$this->Breadcrumb->skipConfig(['/^\/provisioning-targets\/status/']);
$this->Breadcrumb->skipParents(['/^\/provisioning-targets\/status/']);

$this->Breadcrumb->configureQueryPrimaryLinks([
'status' => ['person_id', 'group_id']
]);
}

/**
Expand Down
6 changes: 3 additions & 3 deletions app/src/Lib/Util/StringUtilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ public static function entityAndActionToTitle($entity,
// - 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]);
$displayOrDefault = empty($display) ? __d('controller', $modelsName, [1]) : $display;
$title = self::translateWithOverride($domain, $msgIdOverride, $msgId, $displayOrDefault);

return [$title, $supertitle, $subtitle];
Expand All @@ -390,9 +390,9 @@ private static function translateWithOverride(
string $fallbackKey,
string|int $value
): string {
$translated = __d($domain, $overrideKey, $value);
$translated = __d($domain, $overrideKey, [$value]);
return ($translated === $overrideKey)
? __d($domain, $fallbackKey, $value)
? __d($domain, $fallbackKey, [$value])
: $translated;
}

Expand Down