gstreamer0.10-ffmpeg
gstreamer0.10-plugins-good
packages.
Our Content Browser is sort of working. We can see our one location... we just don't have any results yet. That's because, for whatever location is selected, the Content Browser calls getSubItems()
. Our job here is to return the results. In this case, all of our recipes. If we had multiple locations, like recipes divided into categories, we could use the $location
variable to return the subset. But we'll query and return all recipes.
To do that, go to the top of the class and create a constructor with private RecipeRepository $recipeRepository
:
... lines 1 - 4 | |
use App\Repository\RecipeRepository; | |
... lines 6 - 11 | |
class RecipeBrowserBackend implements BackendInterface | |
{ | |
public function __construct(private RecipeRepository $recipeRepository) | |
{ | |
} | |
... lines 17 - 70 | |
} |
Then, down here in getSubItems()
, say $recipes = $this->recipeRepository
and use that same method from earlier: ->createQueryBuilderOrderedByNewest()
. Below add ->setFirstResult($offset)
... and ->setMaxResults($limit)
. The Content Browser comes with pagination built-in. It passes us the offset and limit for whatever page the user is on, we plug it into the query, and everyone is happy. Finish with getQuery()
and getResult()
:
... lines 1 - 11 | |
class RecipeBrowserBackend implements BackendInterface | |
{ | |
... lines 14 - 46 | |
public function getSubItems(LocationInterface $location, int $offset = 0, int $limit = 25): iterable | |
{ | |
$recipes = $this->recipeRepository | |
->createQueryBuilderOrderedByNewest() | |
->setFirstResult($offset) | |
->setMaxResults($limit) | |
->getQuery() | |
->getResult(); | |
} | |
... lines 56 - 70 | |
} |
Notice that getSubItems()
returns an iterable
... actually it's supposed to be an iterable of something called an ItemInterface
. So we can't just return these Recipe
objects.
Instead, in src/ContentBrowser/
, create another class called, how about RecipeBrowserItem
. Make this implement ItemInterface
- the one from Netgen\ContentBrowser
- then generate the four methods it needs:
... lines 1 - 2 | |
namespace App\ContentBrowser; | |
use Netgen\ContentBrowser\Item\ItemInterface; | |
class RecipeBrowserItem implements ItemInterface | |
{ | |
public function getValue() | |
{ | |
// TODO: Implement getValue() method. | |
} | |
public function getName(): string | |
{ | |
// TODO: Implement getName() method. | |
} | |
public function isVisible(): bool | |
{ | |
// TODO: Implement isVisible() method. | |
} | |
public function isSelectable(): bool | |
{ | |
// TODO: Implement isSelectable() method. | |
} | |
} |
This class will be a tiny wrapper around a Recipe
object. Watch: add a __construct()
method with private Recipe $recipe
:
... lines 1 - 4 | |
use App\Entity\Recipe; | |
... lines 6 - 7 | |
class RecipeBrowserItem implements ItemInterface | |
{ | |
public function __construct(private Recipe $recipe) | |
{ | |
} | |
... lines 13 - 32 | |
} |
Now, for getValue()
, this should return the "identifier", so return $this->recipe->getId()
. For getName()
, we just need something visual we can show, like $this->recipe->getName()
. And for isVisible()
, return true
. That's useful if a Recipe
could be published or unpublished. We have a similar situation with isSelectable()
:
... lines 1 - 7 | |
class RecipeBrowserItem implements ItemInterface | |
{ | |
... lines 10 - 13 | |
public function getValue() | |
{ | |
return $this->recipe->getId(); | |
} | |
public function getName(): string | |
{ | |
return $this->recipe->getName(); | |
} | |
public function isVisible(): bool | |
{ | |
return true; | |
} | |
public function isSelectable(): bool | |
{ | |
return true; | |
} | |
} |
If you had a set of rules where you wanted to show certain recipes but make them not selectable, you could return false
here.
And... we're done! That was easy!
Back over in our backend class, we need to turn these Recipe
objects into RecipeBrowserItem
objects. We can do that with array_map()
. I'll use the fancy fn()
syntax again, which will receive a Recipe $recipe
argument, followed by => new RecipeBrowserItem($recipe)
. For the second arg, pass $recipes
:
... lines 1 - 12 | |
class RecipeBrowserBackend implements BackendInterface | |
{ | |
... lines 15 - 47 | |
public function getSubItems(LocationInterface $location, int $offset = 0, int $limit = 25): iterable | |
{ | |
... lines 50 - 55 | |
return array_map(fn(Recipe $recipe) => new RecipeBrowserItem($recipe), $recipes); | |
} | |
... lines 59 - 73 | |
} |
This is a fancy way of saying:
Loop over all the recipes in the system, create a new
RecipeBrowserItem
for each one, and return that new array of items.
All right, let's see what this looks like! Refresh the layout, click on the Grid, go back to "Add items" and... got it! We see ten items!
But we should have multiple pages. Ah, that's because we're still returning 0
from getSubItemsCount()
. Let's fix that. Steal the query from above... paste, return this, remove setFirstResult()
and setMaxResults()
, add ->select('COUNT(recipe.id)')
, and then call getSingleScalarResult()
at the bottom:
... lines 1 - 12 | |
class RecipeBrowserBackend implements BackendInterface | |
{ | |
... lines 15 - 59 | |
public function getSubItemsCount(LocationInterface $location): int | |
{ | |
return $this->recipeRepository | |
->createQueryBuilderOrderedByNewest() | |
->select('COUNT(recipe.id)') | |
->getQuery() | |
->getSingleScalarResult(); | |
} | |
... lines 68 - 77 | |
} |
And just like that, when we refresh... and open the Content Browser... we have pages!
Tip
Though this solution works fine, search()
and searchCount()
are deprecated
in favor of searchItems()
and searchItemsCount()
. To use the new methods,
keep the old methods (because they're still part of the interface) and use the
following for the new methods:
class RecipeBrowserBackend implements BackendInterface
{
// ...
public function search(string $searchText, int $offset = 0, int $limit = 25): iterable
{
// deprecated
return [];
}
public function searchCount(string $searchText): int
{
// deprecated
return 0;
}
public function searchItems(SearchQuery $searchQuery)
{
$recipes = $this->recipeRepository
->createQueryBuilderOrderedByNewest($searchQuery->getSearchText())
->setFirstResult($searchQuery->getOffset())
->setMaxResults($searchQuery->getLimit())
->getQuery()
->getResult();
return new RecipeBrowserSearchResults($recipes);
}
public function searchItemsCount(SearchQuery $searchQuery)
{
return $this->recipeRepository
->createQueryBuilderOrderedByNewest($searchQuery->getSearchText())
->select('COUNT(recipe.id)')
->getQuery()
->getSingleScalarResult();
}
}
You'll also need a new RecipeBrowserSearchResults
class:
// src/ContentBrowser/RecipeBrowserSearchResults.php
namespace App\ContentBrowser;
use Netgen\ContentBrowser\Backend\SearchResultInterface;
use App\Entity\Recipe;
class RecipeBrowserSearchResults implements SearchResultInterface
{
public function __construct(private array $results)
{
}
public function getResults(): iterable
{
return array_map(fn (Recipe $recipe) => new RecipeBrowserItem($recipe), $this->results);
}
}
Thanks to Joris in the comments for noticing this!
Ok, but could we search for recipes? Absolutely. We can leverage search()
and searchCount()
. This is simple. Steal all of the logic from getSubItems()
, paste into search()
and pass $searchText
to the QueryBuilder method, which already allows this argument:
... lines 1 - 12 | |
class RecipeBrowserBackend implements BackendInterface | |
{ | |
... lines 15 - 68 | |
public function search(string $searchText, int $offset = 0, int $limit = 25): iterable | |
{ | |
$recipes = $this->recipeRepository | |
->createQueryBuilderOrderedByNewest($searchText) | |
->setFirstResult($offset) | |
->setMaxResults($limit) | |
->getQuery() | |
->getResult(); | |
return array_map(fn(Recipe $recipe) => new RecipeBrowserItem($recipe), $recipes); | |
} | |
... lines 80 - 88 | |
} |
If you want to have a bit less code duplication, you could isolate this into a private
method at the bottom.
Also copy the logic from the other count method... paste that into searchCount()
, and pass it $searchText
as well:
... lines 1 - 12 | |
class RecipeBrowserBackend implements BackendInterface | |
{ | |
... lines 15 - 80 | |
public function searchCount(string $searchText): int | |
{ | |
return $this->recipeRepository | |
->createQueryBuilderOrderedByNewest($searchText) | |
->select('COUNT(recipe.id)') | |
->getQuery() | |
->getSingleScalarResult(); | |
} | |
} |
And just like that, if we move over here and try to search... it works. That's awesome!
Alright - select a few items, hit "Confirm" and... oh no! It breaks! It still says "Loading". If you look down on the web debug toolbar, we have a 400 error. Dang. When we open that up, we see:
Value loader for
doctrine_recipe
value type does not exist.
There's just one final piece we need: A very simple class called the "value loader". That's next.
Hey Joris-Mak!
I see on my setup, 'searchItems' and 'searchItemsCount' are called. Then there is an error because they don't return anything, so it returns null, which isn't an int, and something blows up :wink:.
We do fill in these methods later. But, even without using the search functionality, when the methods are blank, you get an error? When does the error happen - right when the content browser loads?
I also see search and searchCount are marked deprecated?
Ah, darn! I had missed that!
I had to move the code to searchItems / searchItemsCount. The parameter you receive there is a SearchQuery object that contains basically the same as before (searchText, offset, limit).
I'll have to do something similar and add a note for it. If you have the code handy, I'd love it if you shared ;). But no big deal either way. Thanks for pointing this out.
Cheers!
We do fill in these methods later. But, even without using the search functionality, when the methods are blank, you get an error? When does the error happen - right when the content browser loads?
Oh, maybe I have to be more clear: The error is about searching. So I get an ajax-call error when trying to search in the content-browser.
And again, the error seems to be my editor already adding searchItems
and searchItemsCount
as well as search
and searchCount
, when I ask it to 'implement all abstract methods'.
So I get the new methods, and the old methods. Then the new ones are called, and I didn't fill those in because I was following your tutorial to the letter :P.
public function searchItems(SearchQuery $searchQuery)
{
$recipes = $this->recipeRepository
->createQueryBuilderOrderedByNewest($searchQuery->getSearchText())
->setFirstResult($searchQuery->getOffset())
->setMaxResults($searchQuery->getLimit())
->getQuery()
->getResult();
return new RecipeBrowserSearchResults($recipes);
}
public function searchItemsCount(SearchQuery $searchQuery)
{
return $this->recipeRepository
->createQueryBuilderOrderedByNewest($searchQuery->getSearchText())
->select('COUNT(recipe.id)')
->getQuery()
->getSingleScalarResult();
}
With my search-result-class (in RecipeBrowserSearchResults.php):
<?php
declare(strict_types=1);
namespace App\ContentBrowser;
use Netgen\ContentBrowser\Backend\SearchResultInterface;
use App\Entity\Recipe;
class RecipeBrowserSearchResults implements SearchResultInterface
{
/**
* @param Recipe[]
*/
public function __construct(private array $results)
{
}
public function getResults(): iterable
{
return array_map(fn (Recipe $recipe) => new RecipeBrowserItem($recipe), $this->results);
}
}
The error I'm getting if I don't do it this way:
Netgen\ContentBrowser\Pager\ItemSearchAdapter::getNbResults(): Return value must be of type int, null returned
But a little lightbulb goes on in my head:
Actually, not implementing the searchItems() and searchItemsCount() functions fixes this error as well!
If I dive into the BackendInterface that we're implementing, I'm seeing searchItems and searchItemsCount commented out in the interface, with a note that they will be added in 2.0.
So... I guess this is my editor making a mistake, and seeing searchItems() and searchItemsCount() as already part of the interface. Removing them even marks an error that they need to be implemented, but I think VSCode-Intelephense is misreading the commented out line in the interface.
They do work (magic methods I'm guessing). and search
and searchCount
are marked as deprecated and to-be-removed from 2.0. But I haven't read their docs yet - just this tutorial.
So I guess this is no issue yet, and I was a little bit quick on the draw. But you at least have a heads up that this change is incoming :) (or new code can already be made more future-proof by using the new methods).
Hey Joris-Mak!
Ah, this is great! I will try it out, then get a note added. Because, yes, while what I have works, we'd like to have you code the most future-proof / new version.
So... I guess this is my editor making a mistake, and seeing searchItems() and searchItemsCount() as already part of the interface. Removing them even marks an error that they need to be implemented, but I think VSCode-Intelephense is misreading the commented out line in the interface.
This might actually be VSCode being really smart! The methods weren't actually added to the interface right now because that would break backwards compatibility. The commented-out versions are a way to hint to users & editors that there is a new way of doing things. Usually, this means that you:
A) Should implement these new, commented-out methods fully.
B) Should add the old methods (because you have to... as they are still part of the interface) but leave them blank.
If the system is built correctly, what they usually do is look to see if the new methods exist on the class. If they do, they call those (and ignore the old methods). If they do not, it would trigger a deprecation then call the old methods. But I'll play with this code to see if this is how it behaves or not :).
Thanks again!
// composer.json
{
"require": {
"php": ">=8.1.0",
"ext-ctype": "*",
"ext-iconv": "*",
"babdev/pagerfanta-bundle": "^3.7", // v3.7.0
"doctrine/doctrine-bundle": "^2.7", // 2.7.0
"doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
"doctrine/orm": "^2.13", // 2.13.3
"easycorp/easyadmin-bundle": "^4.4", // v4.4.1
"netgen/layouts-contentful": "^1.3", // 1.3.2
"netgen/layouts-standard": "^1.3", // 1.3.1
"pagerfanta/doctrine-orm-adapter": "^3.6",
"sensio/framework-extra-bundle": "^6.2", // v6.2.8
"stof/doctrine-extensions-bundle": "^1.7", // v1.7.0
"symfony/console": "5.4.*", // v5.4.14
"symfony/dotenv": "5.4.*", // v5.4.5
"symfony/flex": "^1.17|^2", // v2.2.3
"symfony/framework-bundle": "5.4.*", // v5.4.14
"symfony/monolog-bundle": "^3.0", // v3.8.0
"symfony/proxy-manager-bridge": "5.4.*", // v5.4.6
"symfony/runtime": "5.4.*", // v5.4.11
"symfony/security-bundle": "5.4.*", // v5.4.11
"symfony/twig-bundle": "5.4.*", // v5.4.8
"symfony/ux-live-component": "^2.x-dev", // 2.x-dev
"symfony/ux-twig-component": "^2.x-dev", // 2.x-dev
"symfony/validator": "5.4.*", // v5.4.14
"symfony/webpack-encore-bundle": "^1.15", // v1.16.0
"symfony/yaml": "5.4.*", // v5.4.14
"twig/extra-bundle": "^2.12|^3.0", // v3.4.0
"twig/twig": "^2.12|^3.0" // v3.4.3
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
"symfony/debug-bundle": "5.4.*", // v5.4.11
"symfony/maker-bundle": "^1.47", // v1.47.0
"symfony/stopwatch": "5.4.*", // v5.4.13
"symfony/web-profiler-bundle": "5.4.*", // v5.4.14
"zenstruck/foundry": "^1.22" // v1.22.1
}
}
I see on my setup, 'searchItems' and 'searchItemsCount' are called. Then there is an error because they don't return anything, so it returns null, which isn't an int, and something blows up :wink:.
I also see search and searchCount are marked deprecated?
I had to move the code to searchItems / searchItemsCount. The parameter you receive there is a SearchQuery object that contains basically the same as before (searchText, offset, limit).
But to return results, you have to return an object, that will return the results...
So instead of doing the array_map, you have to create a little wrapper-object that implements SearchResultInterface, that contains a single method that returns the items.
I just made an object that receives the $recipes array in the constructor, and in the getResults() function does the array_map() call from your tutorial. Just another piece of boiletplate, but I guess it's needed for more advanced stuff or they wouldn't be doing it.