Building a Menu in Symfony With Events and Ordering
Menus are simple things, you have a root element, it has children, their children have children, and so on.
All nice, clean and simple and utterly dull.
Add Knp Menu and bundle to the composer file, run update, go through the docs and set it up, and be on your way.
But what happens if you need to construct a menu from different bundles and you have no idea how many items there are
and their order.
That is when things start to be just a bit tricky.
Lets assume there are three bundles, Alpha, Users and Scouts, Alpha bundle will be the one that generates the menu,
and that following is the menu tree we need to end up with
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
root
dashboard <- comes from the alpha bundle
pages
alpha pages <- comes from alpha bundle
users pages <- comes from the users bundle
users
admins
list <- comes from the users bundle
add new <- comes from the users bundle
users
list <- comes from the users bundle
add new <- comes from the users bundle
scout users <- comes from scout bundle
search scouts <- comes from scout bundle
scout jobs <- comes from scout bundle
logout
Yes, you could just go in and add all the values into your Builder file and be done with it
<?php
$menu = $factory->createItem('root');
// Pages
$pages = $factory->createItem('Pages', array());
$alphaPages = $factory->createItem('Alpha Pages', array('route'=>'list_alpha_pages'));
$pages->addChild($menu);
$usersPages = $factory->createItem('Users Pages', array('route'=>'list_users_pages'));
$pages->addChild($menu);
$menu->addChild($pages);
// Users
$usersRoot = $factory->createItem('Users', array());
$admins = $factory->createItem('Admins', array());
// ... add more children
$usersRoot->addChild($admins);
$users = $factory->createItem('Users', array());
// ... add more children
$usersRoot->addChild($users);
$menu->addChild($usersRoot);
// ... add more children
This particular approach would get out of hand pretty fast, if you have lots of menu items,
from different parts of the system, and especially if their appearance depends on other factors
(if the bundle is used at all for example). Soon you would be injecting all kinds of services and doing all
kinds of checks and end up with a Megamoth with Jenga issues. Yes you could split the code into smaller methods
and compose it all, but still it is bad.
Better thing do to is to extent the menu with events, the how to is explained pretty good in the docs,
but in my use case it had a shortcoming with ordering, to be perfectly clear, this is not a shortcoming of the
bundle/lib itself, execution of all the registered event listeners can be guaranteed, but NOT the order of their
execution.
My approach differs slightly from the one described in the docs. I opted to have a separate menu event type classes per
bundle, so they could be as small(or as big) as possible. The event object class on the other hand is same and is
defined once (in Alpha bundle) and used everywhere.
First Thing is to create a menu event types class for Alpha bundle, as mentioned previously the Alpha bundle will have
the menu builder, so it will need the CONFIGURE_ROOT event type defined in it, as well as CONFIGURE_PAGES event
type (it was developed first, and only pages were inside of it, things changed…)
The Scouts bundle does not need its menu event type class, because the menu items from it will not have any children
items. If that changes in future it would be easy to add the class and set things up.
Next up is the Event object class, aptly named ConfigureMenuEvent
With this you now have a menu builder emitting events that will need some listeners to pick up. As described in the
docs it would look something like this for the Alpha bundle
OK, so we got this far, events are being fired, listened to, and handled, this looks pretty much plain vanilla setup as
in the docs, so why all this scribbling the dribble then, you might ask, rightly so. Well, note the lines 28, 35 and 37.
While looking around for a possible solution for the ordering of elements, I came across useful functions like
moveToPosition($position), as useful as it is, I could not use it because I would have to
execute it only after all the elements of the menu were assembled. Digging around some more I got to the
reorderChildren($order) which would take the array of the item names and reorder the menu
item in relation to the order of the names in the array.
This was promising, but still the problem of passing around the order number remained.
I discovered you can pass extra info with each menu item in Knp Menu. That, there, and then was my A-HA moment.
I could use this extra info to pass around the order number I need for each menu item relative to its parent,
and when it is all said and done then call the reorderChildren($order) which would sort
things out properly.
The implementation is on lines 28 and 37. That extra info will be used later to sort the menu items.
Line 35 triggers an event that would collect all the child items for the pages menu item, you can see the setup of
those items for this bundle on line 49 in method onMenuConfigurePages.
The listener class for the Users bundle would look like this
On lines 34 and 44 events are triggered to collect the Admin and User sub menu items, and those are added in the
onMenuConfigureAdmins and onMenuConfigureUsers methods respectively.
On line 49 in method onMenuConfigurePages page menu item children are set.
And finally the listener for the Scouts bundle would look like this
In method onMenuConfigureUsers the children of the users menu item are added, and their order number is set.
You might have noted that I am using increments of 10 for the order number, reasoning is simple, if you need to add a
new item in between the existing ones you should have room to, with least surprises in ordering.
With all this in place your menu would be constructed, the items you wanted would be present but the ordering would be
questionable for now, because, as previously stated, the only thing you can be sure of is that the events will finish
before line 7 in the snippet below, but you still have no idea on their order of execution.
And with that we have reached the climax of this writeup, the reorderMenuItems function executed on line 7,
the code of the method would look like this
And with that we have reached the climax of this writeup, the reorderMenuItems function executed on line 7,
the code of the method would look like this
<?phppublicfunctionreorderMenuItems($menu)
{
$menuOrderArray =array();
$addLast =array();
$alreadyTaken =array();
foreach ($menu->getChildren() as $key => $menuItem) {
if ($menuItem->hasChildren()) {
$this->reorderMenuItems($menuItem);
}
$orderNumber = $menuItem->getExtra('orderNumber');
if ($orderNumber !=null) {
if (!isset($menuOrderArray[$orderNumber])) {
$menuOrderArray[$orderNumber] = $menuItem->getName();
} else {
$alreadyTaken[$orderNumber] = $menuItem->getName();
// $alreadyTaken[] = array('orderNumber' => $orderNumber, 'name' => $menuItem->getName());
}
} else {
$addLast[] = $menuItem->getName();
}
}
// sort them after first pass
ksort($menuOrderArray);
// handle position duplicates
if (count($alreadyTaken)) {
foreach ($alreadyTaken as $key => $value) {
// the ever shifting target
$keysArray =array_keys($menuOrderArray);
$position =array_search($key, $keysArray);
if ($position ===false) {
continue;
}
$menuOrderArray =array_merge(array_slice($menuOrderArray, 0, $position), array($value), array_slice($menuOrderArray, $position));
}
}
// sort them after second pass
ksort($menuOrderArray);
// add items without ordernumber to the end
if (count($addLast)) {
foreach ($addLast as $key => $value) {
$menuOrderArray[] = $value;
}
}
if (count($menuOrderArray)) {
$menu->reorderChildren($menuOrderArray);
}
}
Line 6 addLast array will hold all the menu items that have no order number, for my needs those would be added to the
end of the menu tree, as usual YMMV.
Line 8 alreadyTaken array will hold the items that need to be added, but their spot is already taken. The idea is that
in first pass all the menu items without position collision would be setup, and in second pass the position collisions
would be resolved.
Line 12 if the menu item has children do a recursion and sort out the child items first before proceeding with ordering.
Lines 18-27 either add to addLast array, alreadyTaken array or set the order position of the element.
Line 37 when the execution gets to this point, you should have the menu items with their order number setup,
and possibly have elements in the addLast and alreadyTaken arrays to be processed
Lines 34-47 process the already taken and insert them at their appropriate place in the array by slicing and merging.
Lines 53-57 will process the menu elements that have no order numbers and add them to the end of the tree.
And finally on line 60 the new order array is passed to the Knp Menu to reorder each the items.
This kind of events based menu will allow for greater flexibility as your application grows, and easier menu ordering
if/when needed.
For example if the Scout bundle got its own variant of pages, and they needed to appear under the pages menu item,
the changes in service.yml would be the following