refactor: use english for routes, parameters and internal messages

This commit is contained in:
fromme committed 2026-09-13 18:58:24 +02:00
1 parent bce8f58be3
commit a88ff7ff24
22 files changed
+106 -135

No files matched your search

+3 -3
View File
@@ -98,11 +98,11 @@ final readonly class BoardPresenter
*/ */
public static function urlParameters(ResourceType $type, \DateTimeImmutable $date, TimeRange $time): array public static function urlParameters(ResourceType $type, \DateTimeImmutable $date, TimeRange $time): array
{ {
$parameters = ['type' => $type->slug(), 'tag' => $date->format('Y-m-d')]; $parameters = ['type' => $type->value, 'date' => $date->format('Y-m-d')];
if (!$time->isAllDay()) { if (!$time->isAllDay()) {
$parameters['von'] = $time->start(); $parameters['from'] = $time->start();
$parameters['bis'] = $time->end(); $parameters['to'] = $time->end();
} }
return $parameters; return $parameters;
+2 -2
View File
@@ -21,11 +21,11 @@ final readonly class TimeRange implements \Stringable
public int $endMinute, public int $endMinute,
) { ) {
if ($startMinute < self::DAY_START || $endMinute > self::DAY_END) { if ($startMinute < self::DAY_START || $endMinute > self::DAY_END) {
throw new \InvalidArgumentException('Eine Buchung muss innerhalb eines Tages liegen.'); throw new \InvalidArgumentException('A time range must stay within a single day.');
} }
if ($startMinute >= $endMinute) { if ($startMinute >= $endMinute) {
throw new \InvalidArgumentException('Das Ende muss nach dem Beginn liegen.'); throw new \InvalidArgumentException('A time range must end after it starts.');
} }
} }
+1 -1
View File
@@ -66,7 +66,7 @@ final class SeedCommand extends Command
$this->em->flush(); $this->em->flush();
$io->success(\sprintf('%d neue Datensätze angelegt.', $created)); $io->success(\sprintf('%d new records created.', $created));
return Command::SUCCESS; return Command::SUCCESS;
} }
+9 -11
View File
@@ -29,11 +29,11 @@ final class BoardController extends AbstractController
{ {
} }
#[Route('/buchen/{type}', name: 'app_board', requirements: ['type' => '[a-z]+'], methods: ['GET'])] #[Route('/book/{type}', name: 'app_board', requirements: ['type' => '[a-z]+'], methods: ['GET'])]
public function index(Request $request, string $type): Response public function index(Request $request, string $type): Response
{ {
$resourceType = $this->resourceType($type); $resourceType = $this->resourceType($type);
$date = DateParam::parseOrToday($request->query->getString('tag')); $date = DateParam::parseOrToday($request->query->getString('date'));
return $this->render('board/index.html.twig', $this->board->context( return $this->render('board/index.html.twig', $this->board->context(
$resourceType, $resourceType,
@@ -51,7 +51,7 @@ final class BoardController extends AbstractController
* client-side event choreography. Requests without the htmx header get a plain redirect * client-side event choreography. Requests without the htmx header get a plain redirect
* rather than a bare pair of fragments, so the URL stays shareable. * rather than a bare pair of fragments, so the URL stays shareable.
*/ */
#[Route('/buchen/{type}/tag/{date}', name: 'app_board_day', requirements: ['type' => '[a-z]+', 'date' => DateParam::PATTERN], methods: ['GET'])] #[Route('/book/{type}/day/{date}', name: 'app_board_day', requirements: ['type' => '[a-z]+', 'date' => DateParam::PATTERN], methods: ['GET'])]
public function day(Request $request, string $type, string $date): Response public function day(Request $request, string $type, string $date): Response
{ {
$resourceType = $this->resourceType($type); $resourceType = $this->resourceType($type);
@@ -76,19 +76,17 @@ final class BoardController extends AbstractController
*/ */
public static function timeOf(Request $request): TimeRange public static function timeOf(Request $request): TimeRange
{ {
return TimeRange::parse($request->query->getString('von'), $request->query->getString('bis')) return TimeRange::parse($request->query->getString('from'), $request->query->getString('to'))
?? TimeRange::wholeDay(); ?? TimeRange::wholeDay();
} }
/** /**
* Route requirements cannot call a method, so the slug is matched loosely and resolved here. * Route requirements cannot enumerate the enum, so {type} is matched loosely and resolved
* here -- an unknown kind is a 404 rather than a 500.
*/ */
private function resourceType(string $slug): ResourceType private function resourceType(string $value): ResourceType
{ {
try { return ResourceType::tryFrom($value)
return ResourceType::fromSlug($slug); ?? throw $this->createNotFoundException(\sprintf('Unknown bookable kind "%s".', $value));
} catch (\ValueError $e) {
throw $this->createNotFoundException($e->getMessage(), $e);
}
} }
} }
+10 -10
View File
@@ -40,7 +40,7 @@ final class BookingController extends AbstractController
) { ) {
} }
#[Route('/buchungen', name: 'app_booking', methods: ['GET'])] #[Route('/bookings', name: 'app_booking', methods: ['GET'])]
public function index(): Response public function index(): Response
{ {
$today = new \DateTimeImmutable('today'); $today = new \DateTimeImmutable('today');
@@ -60,11 +60,11 @@ final class BookingController extends AbstractController
* out-of-band board blocks, so the modal's outerHTML target is swapped with an empty * out-of-band board blocks, so the modal's outerHTML target is swapped with an empty
* fragment and the modal simply disappears. * fragment and the modal simply disappears.
*/ */
#[Route('/buchungen/neu/{id}/{date}', name: 'app_booking_new', requirements: ['date' => DateParam::PATTERN], methods: ['GET', 'POST'])] #[Route('/bookings/new/{id}/{date}', name: 'app_booking_new', requirements: ['date' => DateParam::PATTERN], methods: ['GET', 'POST'])]
public function create(Request $request, Resource $resource, string $date): Response public function create(Request $request, Resource $resource, string $date): Response
{ {
if (!$resource->isActive()) { if (!$resource->isActive()) {
throw $this->createNotFoundException('Diese Ressource wird nicht mehr angeboten.'); throw $this->createNotFoundException('This resource has been retired.');
} }
$selected = DateParam::parseOrToday($date); $selected = DateParam::parseOrToday($date);
@@ -110,7 +110,7 @@ final class BookingController extends AbstractController
* Moves one occurrence, which is what an "override" of a series is: the other occurrences * Moves one occurrence, which is what an "override" of a series is: the other occurrences
* are untouched and this one is flagged as differing from its series. * are untouched and this one is flagged as differing from its series.
*/ */
#[Route('/buchungen/{id}/bearbeiten', name: 'app_booking_edit', methods: ['GET', 'POST'])] #[Route('/bookings/{id}/edit', name: 'app_booking_edit', methods: ['GET', 'POST'])]
public function edit(Request $request, Booking $booking): Response public function edit(Request $request, Booking $booking): Response
{ {
$this->denyUnlessOwn($booking); $this->denyUnlessOwn($booking);
@@ -145,7 +145,7 @@ final class BookingController extends AbstractController
]); ]);
} }
#[Route('/buchungen/{id}/freigeben', name: 'app_booking_release', methods: ['POST'])] #[Route('/bookings/{id}/release', name: 'app_booking_release', methods: ['POST'])]
public function release(Request $request, Booking $booking): Response public function release(Request $request, Booking $booking): Response
{ {
$this->denyUnlessOwn($booking); $this->denyUnlessOwn($booking);
@@ -169,11 +169,11 @@ final class BookingController extends AbstractController
/** /**
* Ends a series from today onwards. Past occurrences are history and stay put. * Ends a series from today onwards. Past occurrences are history and stay put.
*/ */
#[Route('/serien/{id}/beenden', name: 'app_series_end', methods: ['POST'])] #[Route('/series/{id}/end', name: 'app_series_end', methods: ['POST'])]
public function endSeries(Request $request, BookingSeries $series): Response public function endSeries(Request $request, BookingSeries $series): Response
{ {
if ($series->getUser()->getId() !== $this->currentUser()->getId()) { if ($series->getUser()->getId() !== $this->currentUser()->getId()) {
throw $this->createAccessDeniedException('Fremde Serien können nicht beendet werden.'); throw $this->createAccessDeniedException('A series may only be ended by the person who created it.');
} }
$this->denyUnlessCsrfValid($request, 'end-series-'.$series->getId()); $this->denyUnlessCsrfValid($request, 'end-series-'.$series->getId());
@@ -252,20 +252,20 @@ final class BookingController extends AbstractController
*/ */
private function timeParameters(TimeRange $time): array private function timeParameters(TimeRange $time): array
{ {
return $time->isAllDay() ? [] : ['von' => $time->start(), 'bis' => $time->end()]; return $time->isAllDay() ? [] : ['from' => $time->start(), 'to' => $time->end()];
} }
private function denyUnlessOwn(Booking $booking): void private function denyUnlessOwn(Booking $booking): void
{ {
if (!$booking->belongsTo($this->currentUser())) { if (!$booking->belongsTo($this->currentUser())) {
throw $this->createAccessDeniedException('Fremde Buchungen können nicht geändert werden.'); throw $this->createAccessDeniedException('A booking may only be changed by the person holding it.');
} }
} }
private function denyUnlessCsrfValid(Request $request, string $tokenId): void private function denyUnlessCsrfValid(Request $request, string $tokenId): void
{ {
if (!$this->isCsrfTokenValid($tokenId, $request->request->getString('_token'))) { if (!$this->isCsrfTokenValid($tokenId, $request->request->getString('_token'))) {
throw $this->createAccessDeniedException('Ungültiges CSRF-Token.'); throw $this->createAccessDeniedException('Invalid CSRF token.');
} }
} }
} }
+5 -24
View File
@@ -8,13 +8,14 @@ namespace App\Entity;
* The kinds of thing that can be booked. * The kinds of thing that can be booked.
* *
* **This enum is the extension point.** The dashboard builds one tile per case, the board route * **This enum is the extension point.** The dashboard builds one tile per case, the board route
* accepts one slug per case, and the floor plan is looked up by convention from the case's * accepts one backing value per case, and the floor plan is looked up by convention from that
* value. Adding a third bookable kind is therefore: a case here, a * same value. Adding a third bookable kind is therefore: a case here, a
* `templates/board/plan/_<value>.html.twig` with its geometry, and rows in `app:seed`. Nothing * `templates/board/plan/_<value>.html.twig` with its geometry, and rows in `app:seed`. Nothing
* else needs editing — in particular no template lists the kinds by hand. * else needs editing — in particular no template lists the kinds by hand.
* *
* The backing values are persisted in `resource.type`, which is why they stay English while the * The backing value is both what is persisted in `resource.type` and what appears in a URL, so
* URLs do not: `slug()` is what appears in a path, so the routes read German like the rest. * there is no slug mapping to keep in sync. The label, plural and description are the only
* things here anyone sees, which is why they are the only German ones.
*/ */
enum ResourceType: string enum ResourceType: string
{ {
@@ -52,24 +53,4 @@ enum ResourceType: string
self::PARKING => 'lucide:car', self::PARKING => 'lucide:car',
}; };
} }
/** What appears in a URL. */
public function slug(): string
{
return match ($this) {
self::DESK => 'schreibtische',
self::PARKING => 'parkplaetze',
};
}
public static function fromSlug(string $slug): self
{
foreach (self::cases() as $case) {
if ($case->slug() === $slug) {
return $case;
}
}
throw new \ValueError(\sprintf('"%s" ist kein bekannter Buchungsbereich.', $slug));
}
} }
+4 -4
View File
@@ -74,11 +74,11 @@ class EpClient
$code = $request->query->get('code'); $code = $request->query->get('code');
if (null === $code || '' === $code) { if (null === $code || '' === $code) {
throw new AuthorizationRequestException('Die Antwort von myep enthielt keinen Code.'); throw new AuthorizationRequestException('The callback from myep carried no authorization code.');
} }
if (null === $expectedState || $request->query->get('state') !== $expectedState) { if (null === $expectedState || $request->query->get('state') !== $expectedState) {
throw new AuthorizationRequestException('Der state-Parameter passt nicht zur Sitzung.'); throw new AuthorizationRequestException('The state parameter does not match the one this session started with.');
} }
return $this->getProvider()->getAccessToken('authorization_code', ['code' => $code]); return $this->getProvider()->getAccessToken('authorization_code', ['code' => $code]);
@@ -107,13 +107,13 @@ class EpClient
['headers' => ['Accept' => 'application/json']], ['headers' => ['Accept' => 'application/json']],
)); ));
} catch (ClientExceptionInterface $e) { } catch (ClientExceptionInterface $e) {
throw new UserinfoUnavailableException('userinfo konnte nicht abgerufen werden.', previous: $e); throw new UserinfoUnavailableException('The userinfo endpoint could not be reached.', previous: $e);
} }
$userinfo = json_decode((string) $response->getBody(), true); $userinfo = json_decode((string) $response->getBody(), true);
if (!\is_array($userinfo)) { if (!\is_array($userinfo)) {
throw new UserinfoUnavailableException('userinfo lieferte keine lesbare Antwort.'); throw new UserinfoUnavailableException('The userinfo endpoint returned an unreadable payload.');
} }
return $userinfo; return $userinfo;
+4 -4
View File
@@ -2,14 +2,14 @@
requests whose response carries nothing but out-of-band blocks, hence hx-swap="none". #} requests whose response carries nothing but out-of-band blocks, hence hx-swap="none". #}
{% set weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] %} {% set weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] %}
{# Every link keeps the board's span, so changing the day never silently widens it back to all-day. #} {# Every link keeps the board's span, so changing the day never silently widens it back to all-day. #}
{% set time_params = selected_time.allDay ? {} : { von: selected_time.start, bis: selected_time.end } %} {% set time_params = selected_time.allDay ? {} : { from: selected_time.start, to: selected_time.end } %}
<div class="rounded-md border border-gray-200 p-4"> <div class="rounded-md border border-gray-200 p-4">
<div class="flex items-center justify-between pb-4"> <div class="flex items-center justify-between pb-4">
<button type="button" <button type="button"
class="p-1 rounded-md hover:bg-gray-100" class="p-1 rounded-md hover:bg-gray-100"
aria-label="Voriger Monat" aria-label="Voriger Monat"
hx-get="{{ path('app_board_day', { type: type.slug, date: month_start|date_modify('-1 month')|date('Y-m-d') }|merge(time_params)) }}" hx-get="{{ path('app_board_day', { type: type.value, date: month_start|date_modify('-1 month')|date('Y-m-d') }|merge(time_params)) }}"
hx-swap="none"> hx-swap="none">
{{ ux_icon('lucide:chevron-left', { class: 'w-5 h-5' }) }} {{ ux_icon('lucide:chevron-left', { class: 'w-5 h-5' }) }}
</button> </button>
@@ -19,7 +19,7 @@
<button type="button" <button type="button"
class="p-1 rounded-md hover:bg-gray-100" class="p-1 rounded-md hover:bg-gray-100"
aria-label="Nächster Monat" aria-label="Nächster Monat"
hx-get="{{ path('app_board_day', { type: type.slug, date: month_start|date_modify('+1 month')|date('Y-m-d') }|merge(time_params)) }}" hx-get="{{ path('app_board_day', { type: type.value, date: month_start|date_modify('+1 month')|date('Y-m-d') }|merge(time_params)) }}"
hx-swap="none"> hx-swap="none">
{{ ux_icon('lucide:chevron-right', { class: 'w-5 h-5' }) }} {{ ux_icon('lucide:chevron-right', { class: 'w-5 h-5' }) }}
</button> </button>
@@ -49,7 +49,7 @@
<button type="button" <button type="button"
class="{{ classes }}" class="{{ classes }}"
aria-pressed="{{ day.is_selected ? 'true' : 'false' }}" aria-pressed="{{ day.is_selected ? 'true' : 'false' }}"
hx-get="{{ path('app_board_day', { type: type.slug, date: day.date|date('Y-m-d') }|merge(time_params)) }}" hx-get="{{ path('app_board_day', { type: type.value, date: day.date|date('Y-m-d') }|merge(time_params)) }}"
hx-swap="none"> hx-swap="none">
<span>{{ day.date|date('j') }}</span> <span>{{ day.date|date('j') }}</span>
{# Team view: the occupancy count is the whole point of showing the month. #} {# Team view: the occupancy count is the whole point of showing the month. #}
+2 -2
View File
@@ -4,8 +4,8 @@
partial there and nothing else. partial there and nothing else.
Colouring needs no JavaScript at all: the plan is re-rendered server side for the selected day Colouring needs no JavaScript at all: the plan is re-rendered server side for the selected day
and span, so free/belegt/deine is decided in Twig. Note "free" is always relative to the span and span, so free/taken/yours is decided in Twig. Note "free" is always relative to the span
in the header -- a desk booked 09:00-13:00 is free for an afternoon board and belegt for an in the header -- a desk booked 09:00-13:00 is free for an afternoon board and taken for an
all-day one. all-day one.
#} #}
<div class="rounded-md border border-gray-200 p-4"> <div class="rounded-md border border-gray-200 p-4">
+10 -10
View File
@@ -1,5 +1,5 @@
{# {#
The span the whole board is relative to. Free/belegt has no answer without it once bookings The span the whole board is relative to. Free/taken has no answer without it once bookings
can cover part of a day, so this drives the plan colours AND the calendar occupancy counts. can cover part of a day, so this drives the plan colours AND the calendar occupancy counts.
Both fields hit the same fragment endpoint as the calendar does, so narrowing the span is the Both fields hit the same fragment endpoint as the calendar does, so narrowing the span is the
@@ -12,27 +12,27 @@
<div class="flex flex-wrap items-end gap-3 pb-4" id="board-timerange"> <div class="flex flex-wrap items-end gap-3 pb-4" id="board-timerange">
<div> <div>
<label class="block text-xs text-gray-500 pb-1" for="board-von">Von</label> <label class="block text-xs text-gray-500 pb-1" for="board-from">Von</label>
<input type="time" id="board-von" name="von" value="{{ selected_time.allDay ? '' : selected_time.start }}" step="900" <input type="time" id="board-from" name="from" value="{{ selected_time.allDay ? '' : selected_time.start }}" step="900"
class="rounded-md border-0 py-1.5 text-sm text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-primary" class="rounded-md border-0 py-1.5 text-sm text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-primary"
hx-get="{{ path('app_board_day', { type: type.slug, date: day }) }}" hx-get="{{ path('app_board_day', { type: type.value, date: day }) }}"
hx-include="#board-bis" hx-include="#board-to"
hx-trigger="change" hx-trigger="change"
hx-swap="none"> hx-swap="none">
</div> </div>
<div> <div>
<label class="block text-xs text-gray-500 pb-1" for="board-bis">Bis</label> <label class="block text-xs text-gray-500 pb-1" for="board-to">Bis</label>
<input type="time" id="board-bis" name="bis" value="{{ selected_time.allDay ? '' : selected_time.end }}" step="900" <input type="time" id="board-to" name="to" value="{{ selected_time.allDay ? '' : selected_time.end }}" step="900"
class="rounded-md border-0 py-1.5 text-sm text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-primary" class="rounded-md border-0 py-1.5 text-sm text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-primary"
hx-get="{{ path('app_board_day', { type: type.slug, date: day }) }}" hx-get="{{ path('app_board_day', { type: type.value, date: day }) }}"
hx-include="#board-von" hx-include="#board-from"
hx-trigger="change" hx-trigger="change"
hx-swap="none"> hx-swap="none">
</div> </div>
{% if not selected_time.allDay %} {% if not selected_time.allDay %}
<button type="button" class="button button--small button--neutral" <button type="button" class="button button--small button--neutral"
hx-get="{{ path('app_board_day', { type: type.slug, date: day }) }}" hx-get="{{ path('app_board_day', { type: type.value, date: day }) }}"
hx-swap="none"> hx-swap="none">
Ganzer Tag Ganzer Tag
</button> </button>
+1 -1
View File
@@ -13,7 +13,7 @@
'desk-07': { x: 40, y: 260 }, 'desk-08': { x: 200, y: 260 }, 'desk-09': { x: 360, y: 260 }, 'desk-07': { x: 40, y: 260 }, 'desk-08': { x: 200, y: 260 }, 'desk-09': { x: 360, y: 260 },
'desk-10': { x: 40, y: 360 }, 'desk-11': { x: 200, y: 360 }, 'desk-12': { x: 360, y: 360 } 'desk-10': { x: 40, y: 360 }, 'desk-11': { x: 200, y: 360 }, 'desk-12': { x: 360, y: 360 }
} %} } %}
{% set time_params = selected_time.allDay ? {} : { von: selected_time.start, bis: selected_time.end } %} {% set time_params = selected_time.allDay ? {} : { from: selected_time.start, to: selected_time.end } %}
<svg viewBox="0 0 520 460" class="w-full h-auto" role="img" aria-label="Raumplan Büro"> <svg viewBox="0 0 520 460" class="w-full h-auto" role="img" aria-label="Raumplan Büro">
<rect x="10" y="20" width="500" height="420" rx="8" class="fill-white stroke-gray-300" stroke-width="2"></rect> <rect x="10" y="20" width="500" height="420" rx="8" class="fill-white stroke-gray-300" stroke-width="2"></rect>
+1 -1
View File
@@ -8,7 +8,7 @@
'parking-01': { x: 40, y: 60 }, 'parking-02': { x: 40, y: 130 }, 'parking-03': { x: 40, y: 200 }, 'parking-01': { x: 40, y: 60 }, 'parking-02': { x: 40, y: 130 }, 'parking-03': { x: 40, y: 200 },
'parking-04': { x: 280, y: 60 }, 'parking-05': { x: 280, y: 130 }, 'parking-06': { x: 280, y: 200 } 'parking-04': { x: 280, y: 60 }, 'parking-05': { x: 280, y: 130 }, 'parking-06': { x: 280, y: 200 }
} %} } %}
{% set time_params = selected_time.allDay ? {} : { von: selected_time.start, bis: selected_time.end } %} {% set time_params = selected_time.allDay ? {} : { from: selected_time.start, to: selected_time.end } %}
<svg viewBox="0 0 520 300" class="w-full h-auto" role="img" aria-label="Parkplatz"> <svg viewBox="0 0 520 300" class="w-full h-auto" role="img" aria-label="Parkplatz">
<rect x="10" y="20" width="500" height="260" rx="8" class="fill-white stroke-gray-300" stroke-width="2"></rect> <rect x="10" y="20" width="500" height="260" rx="8" class="fill-white stroke-gray-300" stroke-width="2"></rect>
+1 -1
View File
@@ -1,6 +1,6 @@
{# {#
The shared shape a plan is drawn from. Imported by each board/plan/_<type>.html.twig, so the The shared shape a plan is drawn from. Imported by each board/plan/_<type>.html.twig, so the
per-type partials carry geometry only and free/belegt/deine looks identical everywhere. per-type partials carry geometry only and free/taken/yours looks identical everywhere.
Clicking always opens a modal: the create modal when free, the edit modal when it is yours Clicking always opens a modal: the create modal when free, the edit modal when it is yours
(which is where changing the times and releasing live). Nothing else is clickable, so the SVG (which is where changing the times and releasing live). Nothing else is clickable, so the SVG
+1 -1
View File
@@ -57,7 +57,7 @@
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<a class="text-sm underline" <a class="text-sm underline"
href="{{ path('app_board', { type: booking.resource.type.slug, tag: booking.date|date('Y-m-d') }) }}">Im Plan</a> href="{{ path('app_board', { type: booking.resource.type.value, date: booking.date|date('Y-m-d') }) }}">Im Plan</a>
{# Ordinary form post: this page has no board fragments to swap, so it {# Ordinary form post: this page has no board fragments to swap, so it
reloads afterwards rather than pretending to be htmx. #} reloads afterwards rather than pretending to be htmx. #}
<form method="post" action="{{ path('app_booking_release', { id: booking.id }) }}"> <form method="post" action="{{ path('app_booking_release', { id: booking.id }) }}">
+2 -2
View File
@@ -12,7 +12,7 @@
#} #}
<div class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{% for tile in tiles %} {% for tile in tiles %}
<a href="{{ path('app_board', { type: tile.type.slug }) }}" <a href="{{ path('app_board', { type: tile.type.value }) }}"
class="group flex flex-col rounded-md border border-gray-200 p-6 hover:border-primary hover:bg-gray-50"> class="group flex flex-col rounded-md border border-gray-200 p-6 hover:border-primary hover:bg-gray-50">
<div class="flex items-center gap-3 pb-2"> <div class="flex items-center gap-3 pb-2">
{{ ux_icon(tile.type.icon, { class: 'h-6 w-6 shrink-0 text-primary' }) }} {{ ux_icon(tile.type.icon, { class: 'h-6 w-6 shrink-0 text-primary' }) }}
@@ -70,7 +70,7 @@
</div> </div>
</div> </div>
<a class="text-sm underline shrink-0" <a class="text-sm underline shrink-0"
href="{{ path('app_board', { type: booking.resource.type.slug, tag: booking.date|date('Y-m-d') }) }}"> href="{{ path('app_board', { type: booking.resource.type.value, date: booking.date|date('Y-m-d') }) }}">
Im Plan Im Plan
</a> </a>
</li> </li>
+16 -16
View File
@@ -44,11 +44,11 @@ class BookingControllerTest extends WebTestCase
{ {
$this->client->loginUser($this->user('[email protected]')); $this->client->loginUser($this->user('[email protected]'));
$this->client->request('GET', '/buchen/schreibtische/tag/2026-10-01', server: ['HTTP_HX-Request' => 'true']); $this->client->request('GET', '/book/desk/day/2026-10-01', server: ['HTTP_HX-Request' => 'true']);
$response = $this->client->getResponse(); $response = $this->client->getResponse();
self::assertResponseIsSuccessful(); self::assertResponseIsSuccessful();
self::assertSame('/buchen/schreibtische?tag=2026-10-01', $response->headers->get('HX-Push-Url')); self::assertSame('/book/desk?date=2026-10-01', $response->headers->get('HX-Push-Url'));
self::assertSame(2, substr_count((string) $response->getContent(), 'hx-swap-oob="true"')); self::assertSame(2, substr_count((string) $response->getContent(), 'hx-swap-oob="true"'));
self::assertStringContainsString('id="board-calendar"', (string) $response->getContent()); self::assertStringContainsString('id="board-calendar"', (string) $response->getContent());
self::assertStringContainsString('id="board-floorplan"', (string) $response->getContent()); self::assertStringContainsString('id="board-floorplan"', (string) $response->getContent());
@@ -58,9 +58,9 @@ class BookingControllerTest extends WebTestCase
{ {
$this->client->loginUser($this->user('[email protected]')); $this->client->loginUser($this->user('[email protected]'));
$this->client->request('GET', '/buchen/schreibtische/tag/2026-10-01'); $this->client->request('GET', '/book/desk/day/2026-10-01');
self::assertResponseRedirects('/buchen/schreibtische?tag=2026-10-01'); self::assertResponseRedirects('/book/desk?date=2026-10-01');
} }
/** /**
@@ -146,7 +146,7 @@ class BookingControllerTest extends WebTestCase
$this->client->loginUser($this->user('[email protected]')); $this->client->loginUser($this->user('[email protected]'));
$desk = $this->resource('D1'); $desk = $this->resource('D1');
$this->client->request('POST', \sprintf('/buchungen/neu/%d/2026-10-01', $desk->getId()), [ $this->client->request('POST', \sprintf('/bookings/new/%d/2026-10-01', $desk->getId()), [
'booking' => ['date' => '2026-10-01', '_token' => 'nope'], 'booking' => ['date' => '2026-10-01', '_token' => 'nope'],
], server: ['HTTP_HX-Request' => 'true']); ], server: ['HTTP_HX-Request' => 'true']);
@@ -167,7 +167,7 @@ class BookingControllerTest extends WebTestCase
$this->em->flush(); $this->em->flush();
$this->client->loginUser($this->user('[email protected]')); $this->client->loginUser($this->user('[email protected]'));
$this->client->request('POST', \sprintf('/buchungen/%d/freigeben', $booking->getId()), server: ['HTTP_HX-Request' => 'true']); $this->client->request('POST', \sprintf('/bookings/%d/release', $booking->getId()), server: ['HTTP_HX-Request' => 'true']);
$response = $this->client->getResponse(); $response = $this->client->getResponse();
self::assertSame('body', $response->headers->get('HX-Retarget')); self::assertSame('body', $response->headers->get('HX-Retarget'));
@@ -186,11 +186,11 @@ class BookingControllerTest extends WebTestCase
// Releasing lives in the edit modal now, not on the floor plan -- clicking your own desk // Releasing lives in the edit modal now, not on the floor plan -- clicking your own desk
// opens that modal, and the token is rendered there. // opens that modal, and the token is rendered there.
$crawler = $this->client->request('GET', \sprintf('/buchungen/%d/bearbeiten', $id), server: ['HTTP_HX-Request' => 'true']); $crawler = $this->client->request('GET', \sprintf('/bookings/%d/edit', $id), server: ['HTTP_HX-Request' => 'true']);
$vals = $crawler->filter(\sprintf('[hx-post="/buchungen/%d/freigeben"]', $id))->attr('hx-vals'); $vals = $crawler->filter(\sprintf('[hx-post="/bookings/%d/release"]', $id))->attr('hx-vals');
$token = json_decode((string) $vals, true, flags: \JSON_THROW_ON_ERROR)['_token']; $token = json_decode((string) $vals, true, flags: \JSON_THROW_ON_ERROR)['_token'];
$this->client->request('POST', \sprintf('/buchungen/%d/freigeben', $id), ['_token' => $token], server: ['HTTP_HX-Request' => 'true']); $this->client->request('POST', \sprintf('/bookings/%d/release', $id), ['_token' => $token], server: ['HTTP_HX-Request' => 'true']);
self::assertResponseIsSuccessful(); self::assertResponseIsSuccessful();
self::assertSame(3, substr_count((string) $this->client->getResponse()->getContent(), 'hx-swap-oob="true"')); self::assertSame(3, substr_count((string) $this->client->getResponse()->getContent(), 'hx-swap-oob="true"'));
@@ -205,9 +205,9 @@ class BookingControllerTest extends WebTestCase
$this->em->flush(); $this->em->flush();
$this->client->loginUser($anna); $this->client->loginUser($anna);
$crawler = $this->client->request('GET', '/buchen/schreibtische?tag=2026-10-01'); $crawler = $this->client->request('GET', '/book/desk?date=2026-10-01');
self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/buchungen/%d/bearbeiten"]', $booking->getId()))); self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/bookings/%d/edit"]', $booking->getId())));
} }
public function testBookingPartOfADay(): void public function testBookingPartOfADay(): void
@@ -338,7 +338,7 @@ class BookingControllerTest extends WebTestCase
$crawler = $this->client->request( $crawler = $this->client->request(
'GET', 'GET',
\sprintf('/buchungen/neu/%d/2026-10-01?von=09:00&bis=13:00', $desk->getId()), \sprintf('/bookings/new/%d/2026-10-01?from=09:00&to=13:00', $desk->getId()),
server: ['HTTP_HX-Request' => 'true'], server: ['HTTP_HX-Request' => 'true'],
); );
@@ -351,10 +351,10 @@ class BookingControllerTest extends WebTestCase
{ {
$this->client->loginUser($this->user('[email protected]')); $this->client->loginUser($this->user('[email protected]'));
$this->client->request('GET', '/buchen/schreibtische/tag/2026-10-01?von=09:00&bis=13:00', server: ['HTTP_HX-Request' => 'true']); $this->client->request('GET', '/book/desk/day/2026-10-01?from=09:00&to=13:00', server: ['HTTP_HX-Request' => 'true']);
self::assertResponseIsSuccessful(); self::assertResponseIsSuccessful();
self::assertSame('/buchen/schreibtische?tag=2026-10-01&von=09:00&bis=13:00', $this->client->getResponse()->headers->get('HX-Push-Url')); self::assertSame('/book/desk?date=2026-10-01&from=09:00&to=13:00', $this->client->getResponse()->headers->get('HX-Push-Url'));
} }
public function testOverridingOneOccurrenceLeavesTheSeriesAlone(): void public function testOverridingOneOccurrenceLeavesTheSeriesAlone(): void
@@ -375,7 +375,7 @@ class BookingControllerTest extends WebTestCase
self::assertCount(4, $occurrences); self::assertCount(4, $occurrences);
$first = $occurrences[0]; $first = $occurrences[0];
$crawler = $this->client->request('GET', \sprintf('/buchungen/%d/bearbeiten', $first->getId()), server: ['HTTP_HX-Request' => 'true']); $crawler = $this->client->request('GET', \sprintf('/bookings/%d/edit', $first->getId()), server: ['HTTP_HX-Request' => 'true']);
// Only the span is editable here: the day and the recurrence belong to the series. // Only the span is editable here: the day and the recurrence belong to the series.
self::assertCount(0, $crawler->filter('#booking_date')); self::assertCount(0, $crawler->filter('#booking_date'));
self::assertCount(0, $crawler->filter('#booking_repeat')); self::assertCount(0, $crawler->filter('#booking_repeat'));
@@ -410,7 +410,7 @@ class BookingControllerTest extends WebTestCase
*/ */
private function submitBookingModal(Resource $resource, string $routeDate, array $fields = ['allDay' => '1']): Response private function submitBookingModal(Resource $resource, string $routeDate, array $fields = ['allDay' => '1']): Response
{ {
$url = \sprintf('/buchungen/neu/%d/%s', $resource->getId(), $routeDate); $url = \sprintf('/bookings/new/%d/%s', $resource->getId(), $routeDate);
$crawler = $this->client->request('GET', $url, server: ['HTTP_HX-Request' => 'true']); $crawler = $this->client->request('GET', $url, server: ['HTTP_HX-Request' => 'true']);
self::assertResponseIsSuccessful(); self::assertResponseIsSuccessful();
+10 -10
View File
@@ -46,8 +46,8 @@ class DashboardControllerTest extends WebTestCase
foreach (ResourceType::cases() as $type) { foreach (ResourceType::cases() as $type) {
self::assertCount( self::assertCount(
1, 1,
$crawler->filter(\sprintf('a[href="/buchen/%s"]', $type->slug())), $crawler->filter(\sprintf('a[href="/book/%s"]', $type->value)),
\sprintf('no dashboard tile links to the %s board', $type->slug()), \sprintf('no dashboard tile links to the %s board', $type->value),
); );
self::assertStringContainsString($type->plural(), $crawler->html()); self::assertStringContainsString($type->plural(), $crawler->html());
} }
@@ -61,7 +61,7 @@ class DashboardControllerTest extends WebTestCase
$this->client->loginUser($this->user()); $this->client->loginUser($this->user());
$crawler = $this->client->request('GET', '/'); $crawler = $this->client->request('GET', '/');
self::assertCount(1, $crawler->filter('a[href="/buchungen"]')); self::assertCount(1, $crawler->filter('a[href="/bookings"]'));
} }
public function testTheNavigationIsGone(): void public function testTheNavigationIsGone(): void
@@ -86,7 +86,7 @@ class DashboardControllerTest extends WebTestCase
$this->client->loginUser($user); $this->client->loginUser($user);
$crawler = $this->client->request('GET', '/'); $crawler = $this->client->request('GET', '/');
$tile = $crawler->filter('a[href="/buchen/schreibtische"]')->text(); $tile = $crawler->filter('a[href="/book/desk"]')->text();
self::assertStringContainsString('von 2 heute frei', $tile); self::assertStringContainsString('von 2 heute frei', $tile);
self::assertStringContainsString('1', $tile); self::assertStringContainsString('1', $tile);
} }
@@ -104,7 +104,7 @@ class DashboardControllerTest extends WebTestCase
$this->client->loginUser($this->user()); $this->client->loginUser($this->user());
$crawler = $this->client->request('GET', '/'); $crawler = $this->client->request('GET', '/');
self::assertStringContainsString('heute alles belegt', $crawler->filter('a[href="/buchen/schreibtische"]')->text()); self::assertStringContainsString('heute alles belegt', $crawler->filter('a[href="/book/desk"]')->text());
} }
public function testYourOwnBookingIsCalledOutOnTheTile(): void public function testYourOwnBookingIsCalledOutOnTheTile(): void
@@ -116,7 +116,7 @@ class DashboardControllerTest extends WebTestCase
$this->client->loginUser($user); $this->client->loginUser($user);
$crawler = $this->client->request('GET', '/'); $crawler = $this->client->request('GET', '/');
self::assertStringContainsString('D1', $crawler->filter('a[href="/buchen/schreibtische"]')->text()); self::assertStringContainsString('D1', $crawler->filter('a[href="/book/desk"]')->text());
} }
public function testABoardOnlyShowsItsOwnKindOfResource(): void public function testABoardOnlyShowsItsOwnKindOfResource(): void
@@ -126,11 +126,11 @@ class DashboardControllerTest extends WebTestCase
$this->client->loginUser($this->user()); $this->client->loginUser($this->user());
$desks = $this->client->request('GET', '/buchen/schreibtische')->html(); $desks = $this->client->request('GET', '/book/desk')->html();
self::assertStringContainsString('D1', $desks); self::assertStringContainsString('D1', $desks);
self::assertStringNotContainsString('P1', $desks); self::assertStringNotContainsString('P1', $desks);
$parking = $this->client->request('GET', '/buchen/parkplaetze')->html(); $parking = $this->client->request('GET', '/book/parking')->html();
self::assertStringContainsString('P1', $parking); self::assertStringContainsString('P1', $parking);
self::assertStringNotContainsString('D1', $parking); self::assertStringNotContainsString('D1', $parking);
} }
@@ -138,7 +138,7 @@ class DashboardControllerTest extends WebTestCase
public function testAnUnknownBookingAreaIsNotFound(): void public function testAnUnknownBookingAreaIsNotFound(): void
{ {
$this->client->loginUser($this->user()); $this->client->loginUser($this->user());
$this->client->request('GET', '/buchen/fahrraeder'); $this->client->request('GET', '/book/bikes');
self::assertResponseStatusCodeSame(404); self::assertResponseStatusCodeSame(404);
} }
@@ -149,7 +149,7 @@ class DashboardControllerTest extends WebTestCase
self::assertStringContainsString( self::assertStringContainsString(
'noch nichts eingerichtet', 'noch nichts eingerichtet',
$this->client->request('GET', '/buchen/parkplaetze')->html(), $this->client->request('GET', '/book/parking')->html(),
); );
} }
+12 -20
View File
@@ -9,35 +9,27 @@ use PHPUnit\Framework\TestCase;
class ResourceTypeTest extends TestCase class ResourceTypeTest extends TestCase
{ {
public function testEverySlugResolvesBackToItsCase(): void /**
* The backing value is what appears in a URL, so it has to survive the round trip -- there
* is deliberately no separate slug mapping to keep in sync.
*/
public function testEveryValueResolvesBackToItsCase(): void
{ {
foreach (ResourceType::cases() as $type) { foreach (ResourceType::cases() as $type) {
self::assertSame($type, ResourceType::fromSlug($type->slug())); self::assertSame($type, ResourceType::tryFrom($type->value));
} }
} }
public function testSlugsAreUnique(): void public function testAnUnknownValueResolvesToNothing(): void
{ {
$slugs = array_map(static fn (ResourceType $type): string => $type->slug(), ResourceType::cases()); self::assertNull(ResourceType::tryFrom('bikes'));
self::assertSame($slugs, array_unique($slugs));
} }
public function testAnUnknownSlugIsRejected(): void public function testValuesAreUrlSafe(): void
{ {
$this->expectException(\ValueError::class); foreach (ResourceType::cases() as $type) {
self::assertMatchesRegularExpression('/^[a-z]+$/', $type->value);
ResourceType::fromSlug('fahrraeder'); }
}
/**
* The URL slugs are German while the backing values stay English, because the values are
* persisted in resource.type and renaming them would need a migration.
*/
public function testTheSlugIsNotTheStoredValue(): void
{
self::assertSame('desk', ResourceType::DESK->value);
self::assertSame('schreibtische', ResourceType::DESK->slug());
} }
/** /**
@@ -20,7 +20,7 @@ class HtmxExceptionListenerFunctionalTest extends WebTestCase
{ {
$client = self::createClient(); $client = self::createClient();
$client->catchExceptions(true); $client->catchExceptions(true);
$client->request('GET', '/gibt-es-nicht', server: ['HTTP_HX-Request' => 'true']); $client->request('GET', '/does-not-exist', server: ['HTTP_HX-Request' => 'true']);
$response = $client->getResponse(); $response = $client->getResponse();
self::assertSame(Response::HTTP_OK, $response->getStatusCode()); self::assertSame(Response::HTTP_OK, $response->getStatusCode());
@@ -33,7 +33,7 @@ class HtmxExceptionListenerFunctionalTest extends WebTestCase
{ {
$client = self::createClient(); $client = self::createClient();
$client->catchExceptions(true); $client->catchExceptions(true);
$client->request('GET', '/gibt-es-nicht'); $client->request('GET', '/does-not-exist');
// Asserting on headers rather than body: in debug mode Symfony's exception page renders // Asserting on headers rather than body: in debug mode Symfony's exception page renders
// the source of every stack frame, this file included, so any marker string searched for // the source of every stack frame, this file included, so any marker string searched for
@@ -81,7 +81,7 @@ class HtmxExceptionListenerTest extends KernelTestCase
{ {
return new ExceptionEvent( return new ExceptionEvent(
self::bootKernel(), self::bootKernel(),
Request::create('/buchungen', server: $server), Request::create('/bookings', server: $server),
HttpKernelInterface::MAIN_REQUEST, HttpKernelInterface::MAIN_REQUEST,
new \RuntimeException('boom'), new \RuntimeException('boom'),
); );
+7 -7
View File
@@ -16,20 +16,20 @@ class HxTraitTest extends TestCase
{ {
public function testHtmxRequestsGetAnHxRedirectInsteadOfA302(): void public function testHtmxRequestsGetAnHxRedirectInsteadOfA302(): void
{ {
$response = $this->controller()->redirectTo($this->request(['HTTP_HX-Request' => 'true']), '/buchungen'); $response = $this->controller()->redirectTo($this->request(['HTTP_HX-Request' => 'true']), '/bookings');
self::assertInstanceOf(HxRedirectResponse::class, $response); self::assertInstanceOf(HxRedirectResponse::class, $response);
self::assertSame('/buchungen', $response->headers->get('HX-Redirect')); self::assertSame('/bookings', $response->headers->get('HX-Redirect'));
// A 3xx would be followed by the browser and swapped as a whole page into the fragment. // A 3xx would be followed by the browser and swapped as a whole page into the fragment.
self::assertSame(Response::HTTP_OK, $response->getStatusCode()); self::assertSame(Response::HTTP_OK, $response->getStatusCode());
} }
public function testOrdinaryRequestsGetANormalRedirect(): void public function testOrdinaryRequestsGetANormalRedirect(): void
{ {
$response = $this->controller()->redirectTo($this->request(), '/buchungen'); $response = $this->controller()->redirectTo($this->request(), '/bookings');
self::assertInstanceOf(RedirectResponse::class, $response); self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/buchungen', $response->getTargetUrl()); self::assertSame('/bookings', $response->getTargetUrl());
} }
public function testOobResponseConcatenatesEveryRequestedBlock(): void public function testOobResponseConcatenatesEveryRequestedBlock(): void
@@ -69,8 +69,8 @@ class HxTraitTest extends TestCase
self::assertFalse($controller->oob('t.html.twig', ['b'])->headers->has('HX-Push-Url')); self::assertFalse($controller->oob('t.html.twig', ['b'])->headers->has('HX-Push-Url'));
self::assertSame( self::assertSame(
'/buchungen?datum=2026-09-13', '/bookings?date=2026-09-13',
$controller->oob('t.html.twig', ['b'], [], '/buchungen?datum=2026-09-13')->headers->get('HX-Push-Url'), $controller->oob('t.html.twig', ['b'], [], '/bookings?date=2026-09-13')->headers->get('HX-Push-Url'),
); );
} }
@@ -79,7 +79,7 @@ class HxTraitTest extends TestCase
*/ */
private function request(array $server = []): Request private function request(array $server = []): Request
{ {
return Request::create('/buchungen', server: $server); return Request::create('/bookings', server: $server);
} }
private function controller(): object private function controller(): object
+2 -2
View File
@@ -56,7 +56,7 @@ class EpClientTest extends KernelTestCase
$request->getSession()->set('oauth2state', 'abc'); $request->getSession()->set('oauth2state', 'abc');
$this->expectException(AuthorizationRequestException::class); $this->expectException(AuthorizationRequestException::class);
$this->expectExceptionMessage('keinen Code'); $this->expectExceptionMessage('no authorization code');
$this->client()->fetchAccessToken($request); $this->client()->fetchAccessToken($request);
} }
@@ -71,7 +71,7 @@ class EpClientTest extends KernelTestCase
$request->getSession()->set('oauth2state', 'expected'); $request->getSession()->set('oauth2state', 'expected');
$this->expectException(AuthorizationRequestException::class); $this->expectException(AuthorizationRequestException::class);
$this->expectExceptionMessage('state-Parameter'); $this->expectExceptionMessage('state parameter does not match');
$this->client()->fetchAccessToken($request); $this->client()->fetchAccessToken($request);
} }