diff --git a/composer.json b/composer.json index 5f3c7b5..aa0c3ad 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "doctrine/doctrine-bundle": "^3.3", "doctrine/doctrine-migrations-bundle": "^4.0", "doctrine/orm": "^3.7", + "knplabs/knp-paginator-bundle": "^6.10", "league/oauth2-client": "^2.9", "phpdocumentor/reflection-docblock": "^6.0", "phpstan/phpdoc-parser": "^2.3", diff --git a/composer.lock b/composer.lock index 88928bd..24eca64 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8f1bfc30d968cee3e1cb4b846d7b1e73", + "content-hash": "5651480dee3b63391faefc9646fe4a95", "packages": [ { "name": "composer/semver", @@ -1600,6 +1600,172 @@ ], "time": "2026-08-24T09:13:11+00:00" }, + { + "name": "knplabs/knp-components", + "version": "v5.3.0", + "source": { + "type": "git", + "url": "https://github.com/KnpLabs/knp-components.git", + "reference": "1c69651056fea716f5f6c60e11c59cabf6c1bf54" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/KnpLabs/knp-components/zipball/1c69651056fea716f5f6c60e11c59cabf6c1bf54", + "reference": "1c69651056fea716f5f6c60e11c59cabf6c1bf54", + "shasum": "" + }, + "require": { + "php": "^8.2", + "symfony/event-dispatcher-contracts": "^3.0" + }, + "conflict": { + "doctrine/dbal": "<4.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0", + "doctrine/mongodb-odm": "^2.5.5", + "doctrine/orm": "^3.0", + "doctrine/phpcr-odm": "^2.0", + "ext-pdo_sqlite": "*", + "jackalope/jackalope-doctrine-dbal": "^2.0", + "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2", + "propel/propel1": "^1.7", + "ruflin/elastica": "^7.0", + "solarium/solarium": "^6.0", + "symfony/http-foundation": "^6.4.4 || ^7.4", + "symfony/http-kernel": "^6.4.4 || ^7.4", + "symfony/property-access": "^6.4.4 || ^7.4", + "symfony/var-exporter": "^6.4.4 || ^7.4" + }, + "suggest": { + "doctrine/common": "to allow usage pagination with Doctrine ArrayCollection", + "doctrine/mongodb-odm": "to allow usage pagination with Doctrine ODM MongoDB", + "doctrine/orm": "to allow usage pagination with Doctrine ORM", + "doctrine/phpcr-odm": "to allow usage pagination with Doctrine ODM PHPCR", + "propel/propel1": "to allow usage pagination with Propel ORM", + "ruflin/elastica": "to allow usage pagination with ElasticSearch Client", + "solarium/solarium": "to allow usage pagination with Solarium Client", + "symfony/http-foundation": "to retrieve arguments from Request", + "symfony/property-access": "to allow sorting arrays" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Knp\\Component\\": "src/Knp/Component" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KnpLabs Team", + "homepage": "https://knplabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://github.com/KnpLabs/knp-components/contributors" + } + ], + "description": "Knplabs component library", + "homepage": "https://github.com/KnpLabs/knp-components", + "keywords": [ + "components", + "knp", + "knplabs", + "pager", + "paginator" + ], + "support": { + "issues": "https://github.com/KnpLabs/knp-components/issues", + "source": "https://github.com/KnpLabs/knp-components/tree/v5.3.0" + }, + "time": "2026-08-07T07:05:10+00:00" + }, + { + "name": "knplabs/knp-paginator-bundle", + "version": "v6.11.0", + "source": { + "type": "git", + "url": "https://github.com/KnpLabs/KnpPaginatorBundle.git", + "reference": "a91a854720df2dbfd04cbf8c44c3dad6ce22fdca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/KnpLabs/KnpPaginatorBundle/zipball/a91a854720df2dbfd04cbf8c44c3dad6ce22fdca", + "reference": "a91a854720df2dbfd04cbf8c44c3dad6ce22fdca", + "shasum": "" + }, + "require": { + "knplabs/knp-components": "^5.0", + "php": "^8.2", + "symfony/config": "^6.4 || ^7.4 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.4 || ^8.0", + "symfony/event-dispatcher": "^6.4 || ^7.4 || ^8.0", + "symfony/http-foundation": "^6.4 || ^7.4 || ^8.0", + "symfony/http-kernel": "^6.4 || ^7.4 || ^8.0", + "symfony/routing": "^6.4 || ^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.2", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2", + "symfony/templating": "^6.4 || ^7.4 || ^8.0", + "symfony/translation": "^6.4 || ^7.4 || ^8.0", + "twig/twig": "^3.0" + }, + "suggest": { + "symfony/translation": "To use the templates", + "twig/twig": "To use the templates" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "Knp\\Bundle\\PaginatorBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KnpLabs Team", + "homepage": "https://knplabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://github.com/KnpLabs/KnpPaginatorBundle/contributors" + } + ], + "description": "Paginator bundle for Symfony to automate pagination and simplify sorting and other features", + "homepage": "https://github.com/KnpLabs/KnpPaginatorBundle", + "keywords": [ + "bundle", + "knp", + "knplabs", + "pager", + "pagination", + "paginator", + "symfony" + ], + "support": { + "issues": "https://github.com/KnpLabs/KnpPaginatorBundle/issues", + "source": "https://github.com/KnpLabs/KnpPaginatorBundle/tree/v6.11.0" + }, + "time": "2026-09-11T08:30:00+00:00" + }, { "name": "league/oauth2-client", "version": "2.9.0", diff --git a/config/bundles.php b/config/bundles.php index 0dcf330..8f204de 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -13,4 +13,5 @@ return [ Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], Symfony\UX\Icons\UXIconsBundle::class => ['all' => true], + Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true], ]; diff --git a/config/packages/knp_paginator.yaml b/config/packages/knp_paginator.yaml new file mode 100644 index 0000000..93a2fc6 --- /dev/null +++ b/config/packages/knp_paginator.yaml @@ -0,0 +1,14 @@ +# Pagination for the admin listings. The parameter names are the bundle defaults; they are spelled +# out anyway because the query string is a URL contract -- a filtered, sorted, paged admin view is +# meant to be linkable, and the template overrides below build their links from these names. +knp_paginator: + default_options: + page_name: page + sort_field_name: sort + sort_direction_name: direction + distinct: true + + # Tailwind markup matching the rest of the app, adopted from the sibling project. + template: + pagination: '_partials/_paginator_sliding.html.twig' + sortable: '_partials/_paginator_sortable_link.html.twig' diff --git a/config/reference.php b/config/reference.php index 5f78136..4605e65 100644 --- a/config/reference.php +++ b/config/reference.php @@ -1490,6 +1490,29 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * }, * ignore_not_found?: bool|Param, // Ignore error when an icon is not found. Set to 'true' to fail silently. // Default: false * } + * @psalm-type KnpPaginatorConfig = array{ + * default_options?: array{ + * sort_field_name?: scalar|Param|null, // Default: "sort" + * sort_direction_name?: scalar|Param|null, // Default: "direction" + * filter_field_name?: scalar|Param|null, // Default: "filterField" + * filter_value_name?: scalar|Param|null, // Default: "filterValue" + * page_name?: scalar|Param|null, // Default: "page" + * distinct?: bool|Param, // Default: true + * wrap_queries?: bool|Param, // Default: false + * page_out_of_range?: scalar|Param|null, // Default: "ignore" + * default_limit?: scalar|Param|null, // Default: 10 + * }, + * template?: array{ + * pagination?: scalar|Param|null, // Default: "@KnpPaginator/Pagination/sliding.html.twig" + * rel_links?: scalar|Param|null, // Default: "@KnpPaginator/Pagination/rel_links.html.twig" + * filtration?: scalar|Param|null, // Default: "@KnpPaginator/Pagination/filtration.html.twig" + * sortable?: scalar|Param|null, // Default: "@KnpPaginator/Pagination/sortable_link.html.twig" + * }, + * page_range?: scalar|Param|null, // Default: 5 + * page_limit?: scalar|Param|null, // Default: null + * convert_exception?: bool|Param, // Default: false + * remove_first_page_param?: bool|Param, // Default: false + * } * @psalm-type ConfigType = array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -1503,6 +1526,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * security?: SecurityConfig, * monolog?: MonologConfig, * ux_icons?: UxIconsConfig, + * knp_paginator?: KnpPaginatorConfig, * "when@dev"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -1519,6 +1543,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * monolog?: MonologConfig, * maker?: MakerConfig, * ux_icons?: UxIconsConfig, + * knp_paginator?: KnpPaginatorConfig, * }, * "when@prod"?: array{ * imports?: ImportsConfig, @@ -1533,6 +1558,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * security?: SecurityConfig, * monolog?: MonologConfig, * ux_icons?: UxIconsConfig, + * knp_paginator?: KnpPaginatorConfig, * }, * "when@test"?: array{ * imports?: ImportsConfig, @@ -1548,6 +1574,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * security?: SecurityConfig, * monolog?: MonologConfig, * ux_icons?: UxIconsConfig, + * knp_paginator?: KnpPaginatorConfig, * }, * ...handleRequest($request); + // No defaultSortFieldName: one field cannot express the date/start/resource ordering the + // repository already sorts by, and naming one here would replace all three. Unsorted + // requests keep that ordering; a clicked column header takes precedence over it. + $pagination = $this->paginator->paginate( + $this->bookings->createFilteredQueryBuilder($filter), + $request->query->getInt('page', 1), + self::PER_PAGE, + ); + return $this->render('admin/bookings.html.twig', [ 'form' => $form, 'filter' => $filter, - 'bookings' => $this->bookings->findForAdmin($filter), + 'pagination' => $pagination, 'series' => $this->series->findActive($filter->fromDate(), $filter->user, $filter->type), ]); } diff --git a/src/Form/Admin/BookingFilterType.php b/src/Form/Admin/BookingFilterType.php index a291ff5..ac7b653 100644 --- a/src/Form/Admin/BookingFilterType.php +++ b/src/Form/Admin/BookingFilterType.php @@ -61,6 +61,10 @@ final class BookingFilterType extends AbstractType 'data_class' => BookingFilter::class, 'method' => 'GET', 'csrf_protection' => false, + // The paginator writes page/sort/direction into the same query string this form reads + // back. Without this the form counts them as extra fields, turns invalid, and the + // listing answers 422 to every sorted request. + 'allow_extra_fields' => true, ]); } diff --git a/src/Repository/BookingRepository.php b/src/Repository/BookingRepository.php index 5286183..78b4f08 100644 --- a/src/Repository/BookingRepository.php +++ b/src/Repository/BookingRepository.php @@ -216,9 +216,11 @@ class BookingRepository extends ServiceEntityRepository * and eager-selects the holder as well, because the admin list names a different person on * every row and would otherwise fire a query per booking. * - * @return list + * Unexecuted, because the paginator has to add the LIMIT and the ORDER BY of a clicked column + * header itself. Only to-one associations are fetch-joined, so a LIMIT over this query cannot + * truncate a half-hydrated row the way a joined collection would. */ - public function findForAdmin(BookingFilter $filter): array + public function createFilteredQueryBuilder(BookingFilter $filter): QueryBuilder { $qb = $this->createQueryBuilder('b') ->addSelect('u', 'r', 'g', 's') @@ -241,7 +243,7 @@ class BookingRepository extends ServiceEntityRepository $qb->andWhere('r.type = :type')->setParameter('type', $filter->type); } - return $qb->getQuery()->getResult(); + return $qb; } /** diff --git a/symfony.lock b/symfony.lock index 8727581..8dc7a4c 100644 --- a/symfony.lock +++ b/symfony.lock @@ -35,6 +35,9 @@ "migrations/.gitignore" ] }, + "knplabs/knp-paginator-bundle": { + "version": "v6.11.0" + }, "phpunit/phpunit": { "version": "13.3", "recipe": { diff --git a/tailwind/_components.css b/tailwind/_components.css index 568b588..0d1a5f1 100644 --- a/tailwind/_components.css +++ b/tailwind/_components.css @@ -2,4 +2,5 @@ @import "components/button.css"; @import "components/forms.css"; @import "components/badge.css"; +@import "components/datatable.css"; @import "components/toast.css"; diff --git a/tailwind/components/button.css b/tailwind/components/button.css index 10fba5d..4f0e39b 100644 --- a/tailwind/components/button.css +++ b/tailwind/components/button.css @@ -6,7 +6,10 @@ } .button--small { - @apply px-2 py-1 md:px-4 text-xs sm:text-sm; + /* The fixed height of .button is what made these look full-size; a small button sizes to its + own padding instead, so it fits inside a table row and next to a form field. */ + @apply h-auto md:h-auto leading-none md:leading-none; + @apply px-2 py-1.5 md:px-3 md:py-2 text-xs sm:text-sm normal-case; } .button--primary { diff --git a/tailwind/components/datatable.css b/tailwind/components/datatable.css new file mode 100644 index 0000000..d09726e --- /dev/null +++ b/tailwind/components/datatable.css @@ -0,0 +1,31 @@ +.data-table-wrapper { + @apply overflow-x-auto lg:overflow-x-visible mb-8; +} + +.data-table-wrapper__inner { + @apply align-middle inline-block min-w-full overflow-hidden lg:overflow-visible; +} + +.data-table { + @apply min-w-full; +} + +.data-table tbody { + @apply bg-white; +} + +.data-table tr { + @apply border border-gray-300; +} + +.data-table th { + @apply px-4 py-2 bg-gray-200 text-left leading-4 font-bold text-primary align-top; +} + +.data-table td { + @apply px-4 py-2 leading-5 align-top; +} + +.data-table tbody tr { + @apply hover:bg-gray-100; +} diff --git a/templates/_partials/_paginator_sliding.html.twig b/templates/_partials/_paginator_sliding.html.twig new file mode 100644 index 0000000..c6504aa --- /dev/null +++ b/templates/_partials/_paginator_sliding.html.twig @@ -0,0 +1,68 @@ +{# + The Knp paginator's sliding window, styled like the sibling project's admin tables: the bar + butts against the bottom border of the table it belongs to, hence border-t-0. + + Every control is a plain link -- the house rule hands navigation to the browser, so paging is a + normal request and the page ends up in the URL, next to the filter and sort parameters that + query|merge carries along. +#} +{% if pageCount > 0 %} +
+
+
+ Einträge {{ firstItemNumber }} + bis {{ lastItemNumber }} + von {{ totalCount }} +
+
+ +
+
+
+{% endif %} diff --git a/templates/_partials/_paginator_sortable_link.html.twig b/templates/_partials/_paginator_sortable_link.html.twig new file mode 100644 index 0000000..a349dbf --- /dev/null +++ b/templates/_partials/_paginator_sortable_link.html.twig @@ -0,0 +1,15 @@ + + {{ title|raw }} + {%- if sorted -%} + {%- if direction == 'desc' -%} + + + + {%- else -%} + + + + {%- endif -%} + {%- endif -%} + + diff --git a/templates/admin/bookings.html.twig b/templates/admin/bookings.html.twig index ae94e8a..d5f3469 100644 --- a/templates/admin/bookings.html.twig +++ b/templates/admin/bookings.html.twig @@ -6,6 +6,9 @@ destructive actions open the shared confirmation modal via htmx and then navigate back here, which is what ?return=admin says; without it a confirmed release swaps board fragments this page does not have. + + The appointment table is paginated; the series table is not, because a company has a handful of + standing bookings and cutting them across pages would only hide them. #} {% extends 'layout.html.twig' %} @@ -25,80 +28,109 @@ {{ form_row(form.user) }}
- Zurücksetzen - + Zurücksetzen +
{{ form_end(form) }} {% if series is not empty %} -

Wiederkehrende Buchungen

- +

Wiederkehrende Buchungen

+
+
+ + + + + + + + + + + + + {% for entry in series %} + + + + + + + + + {% endfor %} + +
Mitarbeiter:inRessourceGruppeWiederholungBis
{{ entry.user.name }}{{ entry.resource.label }}{{ entry.resource.group ? entry.resource.group.label : '–' }}{{ entry.describe }}{{ entry.endDate|format_datetime(pattern: 'd. MMMM y') }} + +
+
+
{% endif %}

Termine

-

+

{{ filter.fromDate|format_datetime(pattern: 'd. MMMM y') }} bis {{ filter.toDate|format_datetime(pattern: 'd. MMMM y') }}

- {% if bookings is empty %} -

Keine Buchungen in diesem Zeitraum.

- {% else %} - - {% endif %} +
+
+ + + + + + + + + + + + + + + {% for booking in pagination %} + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
{{ knp_pagination_sortable(pagination, 'Mitarbeiter:in', 'u.name') }}{{ knp_pagination_sortable(pagination, 'Datum', 'b.date') }}{{ knp_pagination_sortable(pagination, 'Zeit', 'b.startMinute') }}{{ knp_pagination_sortable(pagination, 'Typ', 'r.type') }}{{ knp_pagination_sortable(pagination, 'Ressource', 'r.label') }}GruppeStatus
{{ booking.user.name }}{{ booking.date|format_datetime(pattern: 'EEE, d. MMM y') }}{{ booking.timeRange }}{{ booking.resource.type.label }}{{ booking.resource.label }}{{ booking.resource.group ? booking.resource.group.label : '–' }} + {% if booking.partOfSeries %} + wiederkehrend + {% endif %} + {% if booking.overridden %} + abweichend + {% endif %} + +
+ Im Plan + +
+
Keine Buchungen in diesem Zeitraum.
+ {{ knp_pagination_render(pagination) }} +
+
{% endblock %} diff --git a/tests/Controller/Admin/BookingAdminControllerTest.php b/tests/Controller/Admin/BookingAdminControllerTest.php index cad82e4..120d77d 100644 --- a/tests/Controller/Admin/BookingAdminControllerTest.php +++ b/tests/Controller/Admin/BookingAdminControllerTest.php @@ -125,7 +125,7 @@ class BookingAdminControllerTest extends WebTestCase $crawler = $this->client->request('GET', '/admin/bookings?user='.$bea->getId()); self::assertResponseIsSuccessful(); - $text = $crawler->filter('ul')->text(); + $text = $crawler->filter('tbody')->text(); self::assertStringContainsString('Bea Berger', $text); self::assertStringNotContainsString('Carl Conrad', $text); } @@ -139,7 +139,7 @@ class BookingAdminControllerTest extends WebTestCase $crawler = $this->client->request('GET', '/admin/bookings?type=parking'); self::assertResponseIsSuccessful(); - $text = $crawler->filter('ul')->text(); + $text = $crawler->filter('tbody')->text(); self::assertStringContainsString('Carl Conrad', $text); self::assertStringNotContainsString('Bea Berger', $text); } @@ -236,6 +236,50 @@ class BookingAdminControllerTest extends WebTestCase ); } + /** + * The list is paginated, so the window no longer decides how much gets rendered. Page two is + * the part of the assertion that matters: the first page could be right by accident if the + * limit were applied without an offset. + */ + public function testTheListIsPaginated(): void + { + $user = $this->user('bea@ep-reisen.de', 'Bea Berger'); + $desk = $this->resource('D1'); + for ($day = 0; $day < 30; ++$day) { + $this->booking($user, $desk, new \DateTimeImmutable('today +'.$day.' days')); + } + $this->client->loginUser($this->admin()); + + $crawler = $this->client->request('GET', '/admin/bookings'); + + self::assertResponseIsSuccessful(); + self::assertCount(25, $crawler->filter('tbody tr')); + + $crawler = $this->client->request('GET', '/admin/bookings?page=2'); + + self::assertResponseIsSuccessful(); + self::assertCount(5, $crawler->filter('tbody tr')); + } + + /** + * Sorting has to survive next to the filter: both live in the query string, and a sort link + * that dropped the filter would silently widen what is being paged through. + */ + public function testAColumnHeaderSortsWithoutLosingTheFilter(): void + { + $bea = $this->user('bea@ep-reisen.de', 'Bea Berger'); + $this->booking($bea, $this->resource('D1')); + $this->booking($this->user('carl@ep-reisen.de', 'Carl Conrad'), $this->resource('D2')); + $this->client->loginUser($this->admin()); + + $crawler = $this->client->request('GET', '/admin/bookings?user='.$bea->getId().'&sort=u.name&direction=desc'); + + self::assertResponseIsSuccessful(); + $text = $crawler->filter('tbody')->text(); + self::assertStringContainsString('Bea Berger', $text); + self::assertStringNotContainsString('Carl Conrad', $text); + } + private function confirmationToken(string $uri): string { $crawler = $this->client->request('GET', $uri, server: ['HTTP_HX-Request' => 'true']); @@ -246,9 +290,9 @@ class BookingAdminControllerTest extends WebTestCase return json_decode((string) $vals, true, flags: \JSON_THROW_ON_ERROR)['_token']; } - private function booking(User $user, Resource $resource): Booking + private function booking(User $user, Resource $resource, ?\DateTimeImmutable $date = null): Booking { - $booking = new Booking($user, $resource, new \DateTimeImmutable('today')); + $booking = new Booking($user, $resource, $date ?? new \DateTimeImmutable('today')); $this->em->persist($booking); $this->em->flush();