Content Browser
We can now embed lists, grids, or thumb galleries of recipes into any layout dynamically. That's super cool! And we could always create more query types to, for example, choose between the latest recipes or most popular recipes.
But what about being able to manually select recipes? Maybe we want to feature four specific recipes on the homepage. In the Layouts area, on the grid, if you change the "Collection type", we can switch to "Manual collection". But then... we can't actually select any items.
Enabling Manual Items in the Config
To allow items (in our case, recipes) to be selected manually, we first need to allow that in the config. Earlier, when we created the value_types
config, we set manual_items
to false
. Change that to true
:
netgen_layouts: | |
// ... lines 2 - 3 | |
value_types: | |
doctrine_recipe: | |
// ... line 6 | |
manual_items: true | |
// ... lines 8 - 36 |
And now, when we try the page, we're greeted with an error!
Netgen Content Browser backend for
doctrine_recipe
value type does not exist.
Yep! We need to implement a class that helps Layouts browse our recipes. That's called a "content browser".
Configuring the "item type" in NetgenContentBrowserBundle
Adding a content browser is actually done by a completely different bundle, which you can use outside of Netgen Layouts. It's handy if you need a nice interface for browsing and selecting items.
Since the content browser lives in a different bundle, it's not required, but I'm going to configure this with a new config file called netgen_content_browser.yaml
. Inside, set the root key to netgen_content_browser
to configure the "NetgenContentBrowserBundle":
netgen_content_browser: | |
// ... lines 2 - 8 |
Inside of this, we get to describe all of the different "manual things" that we want to be able to browse. To do that, add an item_types
key, and, for the first item, go grab the value type's internal name - doctrine_recipe
- so that these match, paste, then give this a name. How about... Recipes
with a cute strawberry icon:
netgen_content_browser: | |
item_types: | |
# must match "value_types" key in netgen_layouts config | |
doctrine_recipe: | |
name: 'Recipes 🍓' | |
// ... lines 6 - 8 |
The only other thing we need here is a preview
key with a template
sub-key, which I'll set to nglayouts/content_browser/recipe_preview.html.twig
:
netgen_content_browser: | |
item_types: | |
# must match "value_types" key in netgen_layouts config | |
doctrine_recipe: | |
name: 'Recipes 🍓' | |
preview: | |
template: 'nglayouts/content_browser/recipe_preview.html.twig' |
Oh! And make sure you spell "template" correctly. Whoops! Anyways, we're setting this preview.template
because the configuration requires us to... but we'll worry about creating that template later.
Creating the Backend Class
If we head over and refresh... we get the same error. That's because we need a backend class that will connect to this new item type. Creating a backend is a simple process, but it does require a few different classes.
In the src/
directory, let's create a new directory called ContentBrowser/
... and inside of that, a PHP class called RecipeBrowserBackend
. This needs to implement BackendInterface
: the one from Netgen\ContentBrowser\Backend
:
// ... lines 1 - 2 | |
namespace App\ContentBrowser; | |
use Netgen\ContentBrowser\Backend\BackendInterface; | |
// ... lines 6 - 8 | |
class RecipeBrowserBackend implements BackendInterface | |
{ | |
// ... lines 11 - 54 | |
} |
Then, go to "Code"->"Generate" (or Command
+N
on a Mac) to implement the nine methods this needs! Don't worry: it's not as bad as it looks:
// ... lines 1 - 2 | |
namespace App\ContentBrowser; | |
use Netgen\ContentBrowser\Backend\BackendInterface; | |
use Netgen\ContentBrowser\Item\ItemInterface; | |
use Netgen\ContentBrowser\Item\LocationInterface; | |
class RecipeBrowserBackend implements BackendInterface | |
{ | |
public function getSections(): iterable | |
{ | |
// TODO: Implement getSections() method. | |
} | |
public function loadLocation($id): LocationInterface | |
{ | |
// TODO: Implement loadLocation() method. | |
} | |
public function loadItem($value): ItemInterface | |
{ | |
// TODO: Implement loadItem() method. | |
} | |
public function getSubLocations(LocationInterface $location): iterable | |
{ | |
// TODO: Implement getSubLocations() method. | |
} | |
public function getSubLocationsCount(LocationInterface $location): int | |
{ | |
// TODO: Implement getSubLocationsCount() method. | |
} | |
public function getSubItems(LocationInterface $location, int $offset = 0, int $limit = 25): iterable | |
{ | |
// TODO: Implement getSubItems() method. | |
} | |
public function getSubItemsCount(LocationInterface $location): int | |
{ | |
// TODO: Implement getSubItemsCount() method. | |
} | |
public function search(string $searchText, int $offset = 0, int $limit = 25): iterable | |
{ | |
// TODO: Implement search() method. | |
} | |
public function searchCount(string $searchText): int | |
{ | |
// TODO: Implement searchCount() method. | |
} | |
} |
Finally, to link this backend class to the item type in our config, we need to give this service a tag. We'll do this the same way we did earlier for the query type: with AutoconfigureTag
. In fact, I'll steal this AutoconfigureTag
since I'm here... paste that... and add the use
statement for it. This time, the tag name is netgen_content_browser.backend
, and instead of type
, use item_type
. Set this to the key we have in the config: doctrine_recipe
. Paste and... cool!
// ... lines 1 - 7 | |
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; | |
'netgen_content_browser.backend', [ 'item_type' => 'doctrine_recipe' ]) | (|
class RecipeBrowserBackend implements BackendInterface | |
{ | |
// ... lines 13 - 56 | |
} |
This time when we refresh... the error is gone. Let's temporarily add a new Grid to the layout... and choose "Manual collection". Now... check it out! Because we have a backend, we see an "Add items" button! And when we click it... it fails. That shouldn't be too surprising... since our backend class is still completely empty. If you want to see the exact error, you could open up the AJAX call.
Creating the Location Class
The content browser system works like this: in these methods, we describe a "tree structure", kind of like a filesystem. "Locations" are like directories and "items" are like the "files", or, in our case, the individual recipes.
We're going to keep things really simple. Instead of having different "directories" or "categories" of recipes that you can browse, we're going to have a single directory - or "location" - that all recipes will live inside. You'll see what this looks like in the UI in a few minutes.
To get this working, inside src/ContentBrowser/
, we need to create a class that represents a location. I'll call it BrowserRootLocation
. This class... isn't super interesting: it's just some low-level plumbing that we must have. Make this implement LocationInterface
, and below, generate the three methods we need:
// ... lines 1 - 2 | |
namespace App\ContentBrowser; | |
use Netgen\ContentBrowser\Item\LocationInterface; | |
class BrowserRootLocation implements LocationInterface | |
{ | |
public function getLocationId() | |
{ | |
// TODO: Implement getLocationId() method. | |
} | |
public function getName(): string | |
{ | |
// TODO: Implement getName() method. | |
} | |
public function getParentId() | |
{ | |
// TODO: Implement getParentId() method. | |
} | |
} |
Again, this class will represent the one and only "location". So for getLocationId()
, we can return anything. I'm going to return 0
. You'll see how that's used in a second. For getName()
, this is what will be displayed in the admin section. I'll return 'All'
. And for getParentId()
, return null
:
// ... lines 1 - 6 | |
class BrowserRootLocation implements LocationInterface | |
{ | |
public function getLocationId() | |
{ | |
return 0; | |
} | |
public function getName(): string | |
{ | |
return 'All'; | |
} | |
public function getParentId() | |
{ | |
return null; | |
} | |
} |
If you have a more complex system with multiple sub-directories, you could create a hierarchy of locations.
All right, let's update our backend class to use this. Up here, getSections()
will be called as soon as the user opens up the content browser. Our job is to return all of the root "directories" - or "locations". We have just one: return [new BrowserRootLocation()]
:
// ... lines 1 - 10 | |
class RecipeBrowserBackend implements BackendInterface | |
{ | |
public function getSections(): iterable | |
{ | |
return [new BrowserRootLocation()]; | |
} | |
// ... lines 17 - 60 | |
} |
After this is called, the content browser will call getLocationId()
on each one and make an AJAX request to get more information about them. For us, this will happen just one time where the ID is 0
. It looks weird, but all we need to do is return that same location: if ($id === '0')
, then return new BrowserRootLocation()
:
// ... lines 1 - 10 | |
class RecipeBrowserBackend implements BackendInterface | |
{ | |
// ... lines 13 - 17 | |
public function loadLocation($id): LocationInterface | |
{ | |
if ($id === '0') { | |
return new BrowserRootLocation(); | |
} | |
// ... lines 23 - 24 | |
} | |
// ... lines 26 - 60 | |
} |
Notice I'm using '0'
as a string, but... in getLocationId()
we returned an integer:
// ... lines 1 - 6 | |
class BrowserRootLocation implements LocationInterface | |
{ | |
public function getLocationId() | |
{ | |
return 0; | |
} | |
// ... lines 13 - 22 | |
} |
That's because the id will be passed into JavaScript and used in an Ajax call. By the time it gets here, it'll be a string. A small detail to keep in mind.
At the end, just in case throw
a new \InvalidArgumentException()
and pass a message about an invalid location:
// ... lines 1 - 10 | |
class RecipeBrowserBackend implements BackendInterface | |
{ | |
// ... lines 13 - 17 | |
public function loadLocation($id): LocationInterface | |
{ | |
if ($id === '0') { | |
return new BrowserRootLocation(); | |
} | |
throw new \InvalidArgumentException(sprintf('Invalid location "%s"', $id)); | |
} | |
// ... lines 26 - 60 | |
} |
Ok! So our backend has one location. For the other methods, let's return the simplest thing possible. Leave loadItem()
empty for a moment, for getSubLocations()
, return []
, for getSubLocationsCount()
, return 0
, for getSubItems()
, return []
, for getSubItemsCount()
, return 0
, for search()
, return []
... and finally, for searchCount()
, return 0
:
// ... lines 1 - 10 | |
class RecipeBrowserBackend implements BackendInterface | |
{ | |
// ... lines 13 - 26 | |
public function loadItem($value): ItemInterface | |
{ | |
// TODO: Implement loadItem() method. | |
} | |
public function getSubLocations(LocationInterface $location): iterable | |
{ | |
return []; | |
} | |
public function getSubLocationsCount(LocationInterface $location): int | |
{ | |
return 0; | |
} | |
public function getSubItems(LocationInterface $location, int $offset = 0, int $limit = 25): iterable | |
{ | |
return []; | |
} | |
public function getSubItemsCount(LocationInterface $location): int | |
{ | |
return 0; | |
} | |
public function search(string $searchText, int $offset = 0, int $limit = 25): iterable | |
{ | |
return []; | |
} | |
public function searchCount(string $searchText): int | |
{ | |
return 0; | |
} | |
} |
Phew... We'll talk about each of those methods later. But our backend class is at least somewhat functional now.
If we refresh the admin area again... click on our grid, and go to "Add Items"... it loads! Say "hello" to the content browser! It's currently empty, but you can see the "All", which is from our one location. There are no items inside yet... because we need to return them from getSubItems()
. Let's do that next