feat: render the floor plan in html instead of svg
This commit is contained in:
1 parent
5a01054b0e
commit
ad5d5acb00
20 files changed
+256
-356
No files matched your search
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260917120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Drops the SVG element ids now that the floor plan is plain HTML';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE resource DROP svg_element_id');
|
||||
$this->addSql('ALTER TABLE resource_group DROP svg_element_id');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE resource ADD svg_element_id VARCHAR(50) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE resource_group ADD svg_element_id VARCHAR(50) DEFAULT NULL');
|
||||
}
|
||||
}
|
||||
+12
-20
@@ -24,12 +24,10 @@ use Symfony\Component\DependencyInjection\Attribute\When;
|
||||
* Seeds the office rooms, the desks, the parking spots and a handful of employees.
|
||||
*
|
||||
* Dev only, and idempotent: it matches on the natural key (group and resource label, user email)
|
||||
* so it can be re-run after a migration without duplicating anything. The svgElementId values --
|
||||
* on both resources and groups -- are the contract with
|
||||
* templates/board/plan/_<type>.html.twig: change one and the other stops drawing it.
|
||||
* so it can be re-run after a migration without duplicating anything.
|
||||
*
|
||||
* The room names and their desk membership are placeholders standing in for the real office
|
||||
* layout, and they only make sense together with the geometry in _desk.html.twig.
|
||||
* layout.
|
||||
*/
|
||||
#[When('dev')]
|
||||
#[AsCommand(name: 'app:seed', description: 'Seeds desks, parking spots and demo employees (dev only)')]
|
||||
@@ -50,15 +48,10 @@ final class SeedCommand extends Command
|
||||
|
||||
$created = 0;
|
||||
|
||||
// Four rooms of three desks, in the order they are drawn down the plan.
|
||||
// Four rooms of three desks, in the order they are listed on the plan.
|
||||
$rooms = [];
|
||||
foreach ([
|
||||
['Raum Nord', 'room-01'],
|
||||
['Raum Ost', 'room-02'],
|
||||
['Raum Süd', 'room-03'],
|
||||
['Besprechungsraum', 'room-04'],
|
||||
] as $position => [$label, $svgElementId]) {
|
||||
[$room, $isNew] = $this->ensureGroup(ResourceType::DESK, $label, $position, $svgElementId);
|
||||
foreach (['Raum Nord', 'Raum Ost', 'Raum Süd', 'Besprechungsraum'] as $position => $label) {
|
||||
[$room, $isNew] = $this->ensureGroup(ResourceType::DESK, $label, $position);
|
||||
$rooms[] = $room;
|
||||
$created += $isNew;
|
||||
}
|
||||
@@ -67,15 +60,14 @@ final class SeedCommand extends Command
|
||||
$created += $this->ensureResource(
|
||||
ResourceType::DESK,
|
||||
\sprintf('Schreibtisch %02d', $i),
|
||||
\sprintf('desk-%02d', $i),
|
||||
$rooms[intdiv($i - 1, 3)],
|
||||
);
|
||||
}
|
||||
|
||||
// Parking is deliberately left ungrouped: the car park is one area, and it is what keeps
|
||||
// the "a plan without groups draws its own frame" path exercised by real data.
|
||||
// the "a plan without rooms" path exercised by real data.
|
||||
for ($i = 1; $i <= 6; ++$i) {
|
||||
$created += $this->ensureResource(ResourceType::PARKING, \sprintf('Parkplatz %02d', $i), \sprintf('parking-%02d', $i));
|
||||
$created += $this->ensureResource(ResourceType::PARKING, \sprintf('Parkplatz %02d', $i));
|
||||
}
|
||||
|
||||
// One administrator on purpose, so the dev environment exercises both roles that open
|
||||
@@ -97,13 +89,13 @@ final class SeedCommand extends Command
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function ensureResource(ResourceType $type, string $label, string $svgElementId, ?ResourceGroup $group = null): int
|
||||
private function ensureResource(ResourceType $type, string $label, ?ResourceGroup $group = null): int
|
||||
{
|
||||
$resource = $this->resources->findOneBy(['type' => $type, 'label' => $label]);
|
||||
|
||||
if (null !== $resource) {
|
||||
// Backfill only. A resource seeded before groups existed has no room and would never
|
||||
// be drawn inside one, but a group somebody moved it into by hand is left alone.
|
||||
// be listed under one, but a group somebody moved it into by hand is left alone.
|
||||
if (null !== $group && null === $resource->getGroup()) {
|
||||
$resource->setGroup($group);
|
||||
}
|
||||
@@ -111,7 +103,7 @@ final class SeedCommand extends Command
|
||||
return 0;
|
||||
}
|
||||
|
||||
$resource = new Resource($type, $label, $svgElementId);
|
||||
$resource = new Resource($type, $label);
|
||||
$resource->setGroup($group);
|
||||
$this->em->persist($resource);
|
||||
|
||||
@@ -121,7 +113,7 @@ final class SeedCommand extends Command
|
||||
/**
|
||||
* @return array{ResourceGroup, int} the group, and 1 if it was created
|
||||
*/
|
||||
private function ensureGroup(ResourceType $type, string $label, int $position, string $svgElementId): array
|
||||
private function ensureGroup(ResourceType $type, string $label, int $position): array
|
||||
{
|
||||
$group = $this->groups->findOneBy(['type' => $type, 'label' => $label]);
|
||||
|
||||
@@ -129,7 +121,7 @@ final class SeedCommand extends Command
|
||||
return [$group, 0];
|
||||
}
|
||||
|
||||
$group = new ResourceGroup($type, $label, $position, $svgElementId);
|
||||
$group = new ResourceGroup($type, $label, $position);
|
||||
$this->em->persist($group);
|
||||
|
||||
return [$group, 1];
|
||||
|
||||
+2
-20
@@ -28,16 +28,9 @@ class Resource
|
||||
#[Assert\NotBlank]
|
||||
private string $label;
|
||||
|
||||
/**
|
||||
* Id of the matching element in the hand-authored floor plan SVG. Null for anything that is
|
||||
* bookable but not drawn on the plan.
|
||||
*/
|
||||
#[ORM\Column(length: 50, nullable: true)]
|
||||
private ?string $svgElementId = null;
|
||||
|
||||
/**
|
||||
* Where it physically is. Nullable because a group is optional: a kind of resource that is
|
||||
* not split across rooms simply has none, and its plan draws its own single frame.
|
||||
* not split across rooms simply has none, and the plan lists it without a room heading.
|
||||
*/
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
@@ -50,11 +43,10 @@ class Resource
|
||||
#[ORM\Column]
|
||||
private bool $active = true;
|
||||
|
||||
public function __construct(ResourceType $type, string $label, ?string $svgElementId = null)
|
||||
public function __construct(ResourceType $type, string $label)
|
||||
{
|
||||
$this->type = $type;
|
||||
$this->label = $label;
|
||||
$this->svgElementId = $svgElementId;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
@@ -77,16 +69,6 @@ class Resource
|
||||
$this->label = $label;
|
||||
}
|
||||
|
||||
public function getSvgElementId(): ?string
|
||||
{
|
||||
return $this->svgElementId;
|
||||
}
|
||||
|
||||
public function setSvgElementId(?string $svgElementId): void
|
||||
{
|
||||
$this->svgElementId = $svgElementId;
|
||||
}
|
||||
|
||||
public function getGroup(): ?ResourceGroup
|
||||
{
|
||||
return $this->group;
|
||||
|
||||
@@ -40,19 +40,11 @@ class ResourceGroup
|
||||
#[ORM\Column]
|
||||
private int $position = 0;
|
||||
|
||||
/**
|
||||
* Id of the matching frame in the hand-authored floor plan SVG -- the same role svgElementId
|
||||
* plays on Resource. Null for a group that is not drawn.
|
||||
*/
|
||||
#[ORM\Column(length: 50, nullable: true)]
|
||||
private ?string $svgElementId = null;
|
||||
|
||||
public function __construct(ResourceType $type, string $label, int $position = 0, ?string $svgElementId = null)
|
||||
public function __construct(ResourceType $type, string $label, int $position = 0)
|
||||
{
|
||||
$this->type = $type;
|
||||
$this->label = $label;
|
||||
$this->position = $position;
|
||||
$this->svgElementId = $svgElementId;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
@@ -84,14 +76,4 @@ class ResourceGroup
|
||||
{
|
||||
$this->position = $position;
|
||||
}
|
||||
|
||||
public function getSvgElementId(): ?string
|
||||
{
|
||||
return $this->svgElementId;
|
||||
}
|
||||
|
||||
public function setSvgElementId(?string $svgElementId): void
|
||||
{
|
||||
$this->svgElementId = $svgElementId;
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,9 @@ namespace App\Enum;
|
||||
* The kinds of thing that can be booked.
|
||||
*
|
||||
* **This enum is the extension point.** The dashboard builds one tile per case, the board route
|
||||
* accepts one backing value per case, and the floor plan is looked up by convention from that
|
||||
* 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
|
||||
* else needs editing — in particular no template lists the kinds by hand.
|
||||
* accepts one backing value per case, and the floor plan renders every kind the same way.
|
||||
* Adding a third bookable kind is therefore: a case here and rows in `app:seed`.
|
||||
* Nothing else needs editing — in particular no template lists the kinds by hand.
|
||||
*
|
||||
* The backing value is both what is persisted in `resource.type` and what appears in a URL, so
|
||||
* there is no slug mapping to keep in sync. The label, plural and description are the only
|
||||
|
||||
@@ -8,10 +8,10 @@ use App\Entity\ResourceGroup;
|
||||
use App\Enum\SpotState;
|
||||
|
||||
/**
|
||||
* The spots of one room, in draw order.
|
||||
* The spots of one room, in display order.
|
||||
*
|
||||
* The floor plan needs the resources of a room together so it can frame them, and it needs the
|
||||
* room's own label and geometry key alongside them -- which is all this is. The grouping is done
|
||||
* The floor plan needs the resources of a room together so it can list them under a heading, and
|
||||
* it needs the room's own label alongside them -- which is all this is. The grouping is done
|
||||
* once in the presenter rather than in Twig, because deciding what goes where is not a
|
||||
* presentation concern and a template cannot express it without a nested loop over every spot
|
||||
* per room.
|
||||
@@ -32,7 +32,7 @@ final readonly class SpotGroup
|
||||
*
|
||||
* Ungrouped spots collect into a single trailing entry whose group is null. That entry is
|
||||
* not an error case: a kind of resource that is not split across rooms has no groups at all,
|
||||
* and its plan draws its own frame instead.
|
||||
* and the plan lists its spots without a heading instead.
|
||||
*
|
||||
* @param list<Spot> $spots
|
||||
*
|
||||
@@ -56,7 +56,7 @@ final readonly class SpotGroup
|
||||
// instance per row, so this groups exactly as an id would -- and it also works for a
|
||||
// group that has not been persisted yet, where every id is still null and every
|
||||
// group would otherwise collapse into one. The repository orders by group, so
|
||||
// insertion order is already draw order.
|
||||
// insertion order is already display order.
|
||||
$grouped[spl_object_id($group)][] = $spot;
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ final readonly class BoardContextFactory
|
||||
'is_past' => $selected < $today,
|
||||
'resources' => $resources,
|
||||
'spots' => $spots,
|
||||
// The same spots again, partitioned into the rooms the plan frames them with.
|
||||
// The same spots again, partitioned into the rooms the plan lists them under.
|
||||
'spot_groups' => SpotGroup::over(array_values($spots)),
|
||||
'my_bookings_today' => $mine[$selected->format('Y-m-d')] ?? [],
|
||||
];
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{#
|
||||
The frame around a plan: heading, legend, span picker. The drawing itself lives in
|
||||
board/plan/_<type>.html.twig and is included by convention, so a new ResourceType needs a new
|
||||
partial there and nothing else.
|
||||
The plan: heading, legend, span picker, then one section per room with a card per spot.
|
||||
Every ResourceType renders the same way, so a new one needs no template of its own.
|
||||
|
||||
Colouring needs no JavaScript at all: the plan is re-rendered server side for the selected day
|
||||
and span, so free/taken/yours is decided in Twig. Note "free" is always relative to the span
|
||||
@@ -28,6 +27,33 @@
|
||||
{% if resources is empty %}
|
||||
<p class="py-8 text-center text-sm text-gray-500">Für diesen Bereich ist noch nichts eingerichtet.</p>
|
||||
{% else %}
|
||||
{% include 'board/plan/_' ~ type.value ~ '.html.twig' %}
|
||||
{% import 'board/_spot.html.twig' as plan %}
|
||||
{% set time_params = selected_time.allDay ? {} : { from: selected_time.start, to: selected_time.end } %}
|
||||
|
||||
<div class="space-y-6">
|
||||
{#
|
||||
Rooms in their configured order, then anything without one. Ungrouped spots get no
|
||||
heading: a kind of resource that is not split across rooms (the car park) has
|
||||
nothing to say there that the page heading has not already said.
|
||||
#}
|
||||
{% for spot_group in spot_groups %}
|
||||
<section>
|
||||
{% if spot_group.group %}
|
||||
{% set free = spot_group.freeCount %}
|
||||
<h3 class="!mb-0 pb-2 text-sm font-semibold text-gray-500 md:text-sm">
|
||||
{{- spot_group.group.label -}}
|
||||
{%- if free > 0 %} <span class="text-emerald-600">· {{ free }} frei</span>
|
||||
{%- else %} <span>· belegt</span>{% endif -%}
|
||||
</h3>
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{% for spot in spot_group.spots %}
|
||||
{{ plan.spot(spot, selected_date, is_past, time_params) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,99 @@
|
||||
{#
|
||||
One bookable thing on the plan: a card coloured by its state, with who holds it listed inside.
|
||||
|
||||
The state comes from App\Model\Spot, not from counting bookings here. That distinction is
|
||||
the fix for a real bug: a desk booked 09:00-13:00 used to render exactly like one booked all
|
||||
day on an all-day board, and carried no hx-get either -- so a second person could not book the
|
||||
afternoon at all unless they happened to narrow the span by hand first.
|
||||
|
||||
Who holds what is listed INSIDE the card rather than in a title tooltip: a tooltip only
|
||||
appears on hover, so it is useless on touch and invisible when scanning the plan, which is the
|
||||
one thing a floor plan is for.
|
||||
|
||||
Clicking always opens a modal: the create modal when anything is still free, the edit modal
|
||||
when it is yours (which is where changing the times and releasing live). Nothing else is
|
||||
clickable, so a card carries no confirm dialogs or inline CSRF tokens.
|
||||
#}
|
||||
{% macro spot(spot, selected_date, is_past, time_params) %}
|
||||
{% set MAX_LINES = 3 %}
|
||||
{% set state = spot.state.name %}
|
||||
|
||||
{% if state == 'MINE' %}
|
||||
{% set colours = 'bg-primary border-primary-dark text-white' %}
|
||||
{% elseif state == 'TAKEN' %}
|
||||
{% set colours = 'bg-gray-200 border-gray-400 text-gray-600' %}
|
||||
{% elseif state == 'PARTLY' %}
|
||||
{% set colours = 'bg-amber-50 border-amber-500 text-amber-900' %}
|
||||
{% else %}
|
||||
{% set colours = 'bg-emerald-50 border-emerald-500 text-emerald-900' %}
|
||||
{% endif %}
|
||||
|
||||
{% set interactive = not is_past and spot.actionable %}
|
||||
{# An administrator may free somebody else's desk -- a colleague calls in sick and the spot
|
||||
should go back into the pool. Colours are untouched: MINE still means mine, and only
|
||||
clickability widens. Past days stay inert for admins too; there is no past-write path.
|
||||
|
||||
Only a card that is not already actionable: a partly booked one still opens the create
|
||||
modal, because taking the free window is the more useful act there and an admin can reach
|
||||
that booking from /admin/bookings anyway. #}
|
||||
{% set manageable = not is_past and not interactive and is_granted('ROLE_ADMIN') and spot.bookings is not empty %}
|
||||
|
||||
{# One line per booking, then the window still free if there is one -- that last line is the
|
||||
reason a partly booked card is worth clicking, so it earns its place. #}
|
||||
{% set lines = [] %}
|
||||
{% for booking in spot.bookings %}
|
||||
{% if booking.user.id == app.user.id %}
|
||||
{% set who = 'du' %}
|
||||
{% else %}
|
||||
{# "Anna Berger" -> "Anna B." : keeps a card on one line per booking at phone width. #}
|
||||
{% set parts = booking.user.name|split(' ')|filter(part => part is not empty) %}
|
||||
{% set who = parts|length > 1 ? parts|first ~ ' ' ~ parts|last|slice(0, 1) ~ '.' : booking.user.name %}
|
||||
{% endif %}
|
||||
{% set lines = lines|merge([who ~ (booking.timeRange.allDay ? '' : ' ' ~ booking.timeRange)]) %}
|
||||
{% endfor %}
|
||||
{% if state == 'PARTLY' %}
|
||||
{% set lines = lines|merge(['frei ' ~ spot.suggested]) %}
|
||||
{% endif %}
|
||||
|
||||
{# Summarise rather than let one busy desk stretch its whole row. Two lines plus a counter
|
||||
beats three lines and a silent omission. #}
|
||||
{% if lines|length > MAX_LINES %}
|
||||
{% set lines = lines|slice(0, MAX_LINES - 1)|merge(['+' ~ (lines|length - MAX_LINES + 1) ~ ' weitere']) %}
|
||||
{% endif %}
|
||||
|
||||
{% set tag = interactive or manageable ? 'button' : 'div' %}
|
||||
<{{ tag }} data-spot
|
||||
{%- if tag == 'button' %} type="button"{% endif %}
|
||||
class="flex min-h-[5.5rem] w-full flex-col items-center rounded-md border-2 px-2 py-2 text-center {{ colours }}
|
||||
{{- tag == 'button' ? ' cursor-pointer hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2' : ' cursor-default' }}"
|
||||
{%- if interactive and state == 'MINE' %}
|
||||
hx-get="{{ path('app_booking_edit', { id: spot.own.id }|merge(time_params)) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
{%- elseif interactive %}
|
||||
{# The modal is appended to the end of <body>; closing it is Stimulus, not htmx.
|
||||
It prefills the free window itself -- see Booking\CreateController. #}
|
||||
hx-get="{{ path('app_booking_new', { id: spot.resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
{%- elseif manageable %}
|
||||
{# Not app_booking_edit: a card can hold several bookings on one day, and an admin
|
||||
releases rather than reschedules. The modal lists them and frees one. #}
|
||||
hx-get="{{ path('app_admin_spot', { id: spot.resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
{%- endif %}>
|
||||
<span class="text-xs font-semibold{{ lines is empty ? ' my-auto' : '' }}">{{ spot.resource.label }}</span>
|
||||
|
||||
{% if lines is empty %}
|
||||
{# The colour already says "free" to anyone who can see it. #}
|
||||
<span class="sr-only">frei</span>
|
||||
{% else %}
|
||||
<ul class="pt-1 text-[11px] leading-4">
|
||||
{% for line in lines %}
|
||||
<li>{{ line }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</{{ tag }}>
|
||||
{% endmacro %}
|
||||
@@ -1,47 +0,0 @@
|
||||
{#
|
||||
The office. Hand-authored geometry in two maps: `rooms` is keyed by ResourceGroup.svgElementId
|
||||
and `geometry` by Resource.svgElementId, so a row whose id is missing from its map is simply
|
||||
not drawn (and vice versa) -- app:seed and these maps have to agree.
|
||||
|
||||
Box height is set by what goes inside it, not by taste: _spot.html.twig puts the label at
|
||||
+18 and up to three lines at +34, +47 and +60, so 86 leaves a comfortable margin below the
|
||||
last one. Changing MAX_LINES there means changing the height here.
|
||||
|
||||
A room band is 118 high: 16 of margin, an 86-high desk, 16 again. The 30 between two bands is
|
||||
what the next room's caption sits in, since room() draws it above the rect. Moving a desk
|
||||
between rooms therefore means moving its y here as well as its group in the database -- the
|
||||
drawing is artwork, and no amount of indirection changes that.
|
||||
|
||||
Found by convention from ResourceType::DESK->value, see board/_floorplan.html.twig.
|
||||
#}
|
||||
{% import 'board/plan/_spot.html.twig' as plan %}
|
||||
|
||||
{% set rooms = {
|
||||
'room-01': { x: 10, y: 22, width: 500, height: 118 },
|
||||
'room-02': { x: 10, y: 170, width: 500, height: 118 },
|
||||
'room-03': { x: 10, y: 318, width: 500, height: 118 },
|
||||
'room-04': { x: 10, y: 466, width: 500, height: 118 }
|
||||
} %}
|
||||
|
||||
{% set geometry = {
|
||||
'desk-01': { x: 40, y: 38 }, 'desk-02': { x: 200, y: 38 }, 'desk-03': { x: 360, y: 38 },
|
||||
'desk-04': { x: 40, y: 186 }, 'desk-05': { x: 200, y: 186 }, 'desk-06': { x: 360, y: 186 },
|
||||
'desk-07': { x: 40, y: 334 }, 'desk-08': { x: 200, y: 334 }, 'desk-09': { x: 360, y: 334 },
|
||||
'desk-10': { x: 40, y: 482 }, 'desk-11': { x: 200, y: 482 }, 'desk-12': { x: 360, y: 482 }
|
||||
} %}
|
||||
{% set time_params = selected_time.allDay ? {} : { from: selected_time.start, to: selected_time.end } %}
|
||||
|
||||
<svg viewBox="0 0 520 594" class="w-full h-auto" role="group" aria-label="Raumplan Büro">
|
||||
{% for spot_group in spot_groups %}
|
||||
{% if spot_group.group and rooms[spot_group.group.svgElementId] is defined %}
|
||||
{{ plan.room(spot_group.group.label, rooms[spot_group.group.svgElementId], spot_group.freeCount) }}
|
||||
{% endif %}
|
||||
|
||||
{% for spot in spot_group.spots %}
|
||||
{% if spot.resource.svgElementId and geometry[spot.resource.svgElementId] is defined %}
|
||||
{{ plan.spot(spot, geometry[spot.resource.svgElementId], 140, 86,
|
||||
selected_date, is_past, time_params) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</svg>
|
||||
@@ -1,32 +0,0 @@
|
||||
{#
|
||||
The car park. See board/plan/_desk.html.twig for how geometry, Resource.svgElementId and the
|
||||
box height relate; found by convention from ResourceType::PARKING->value.
|
||||
#}
|
||||
{% import 'board/plan/_spot.html.twig' as plan %}
|
||||
|
||||
{% set geometry = {
|
||||
'parking-01': { x: 40, y: 50 }, 'parking-02': { x: 40, y: 140 }, 'parking-03': { x: 40, y: 230 },
|
||||
'parking-04': { x: 280, y: 50 }, 'parking-05': { x: 280, y: 140 }, 'parking-06': { x: 280, y: 230 }
|
||||
} %}
|
||||
{% set time_params = selected_time.allDay ? {} : { from: selected_time.start, to: selected_time.end } %}
|
||||
|
||||
<svg viewBox="0 0 520 346" class="w-full h-auto" role="group" aria-label="Parkplatz">
|
||||
<rect x="10" y="18" width="500" height="308" rx="8" class="fill-white stroke-gray-300" stroke-width="2"></rect>
|
||||
<text x="20" y="12" class="fill-gray-400 text-xs font-semibold">Parkplatz</text>
|
||||
{# The lane between the two rows. #}
|
||||
<line x1="265" y1="36" x2="265" y2="310" class="stroke-gray-200" stroke-width="2" stroke-dasharray="6 6"></line>
|
||||
|
||||
{#
|
||||
The car park is one area, so nothing here is grouped and every spot arrives in the single
|
||||
null-group entry -- which is why this plan keeps drawing its own frame above instead of
|
||||
letting room() do it. See board/plan/_desk.html.twig for the grouped case.
|
||||
#}
|
||||
{% for spot_group in spot_groups %}
|
||||
{% for spot in spot_group.spots %}
|
||||
{% if spot.resource.svgElementId and geometry[spot.resource.svgElementId] is defined %}
|
||||
{{ plan.spot(spot, geometry[spot.resource.svgElementId], 200, 78,
|
||||
selected_date, is_past, time_params) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</svg>
|
||||
@@ -1,120 +0,0 @@
|
||||
{#
|
||||
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 the states look identical everywhere.
|
||||
|
||||
The state comes from App\Model\Spot, not from counting bookings here. That distinction is
|
||||
the fix for a real bug: a desk booked 09:00-13:00 used to render exactly like one booked all
|
||||
day on an all-day board, and carried no hx-get either -- so a second person could not book the
|
||||
afternoon at all unless they happened to narrow the span by hand first.
|
||||
|
||||
Who holds what is listed INSIDE the shape rather than in an SVG <title> flyout: a tooltip only
|
||||
appears on hover, so it is useless on touch and invisible when scanning the plan, which is the
|
||||
one thing a floor plan is for. The box therefore has a fixed layout -- label on top, one line
|
||||
per booking below -- and the per-type partials size their boxes to fit MAX_LINES of them.
|
||||
|
||||
Clicking always opens a modal: the create modal when anything is still free, the edit modal
|
||||
when it is yours (which is where changing the times and releasing live). Nothing else is
|
||||
clickable, so the SVG carries no confirm dialogs or inline CSRF tokens.
|
||||
|
||||
room() lives here for the same reason spot() does: a plan that frames its shapes into rooms
|
||||
and one that does not must otherwise draw their borders in two places and drift apart.
|
||||
#}
|
||||
|
||||
{#
|
||||
The frame around one room, captioned with its name and how many of its spots are still free
|
||||
for the whole of the board's span.
|
||||
|
||||
The caption sits above the rect rather than inside it, so it never collides with a spot box,
|
||||
and the geometry only has to say where the room is.
|
||||
#}
|
||||
{% macro room(label, geometry, free) %}
|
||||
<rect x="{{ geometry.x }}" y="{{ geometry.y }}"
|
||||
width="{{ geometry.width }}" height="{{ geometry.height }}" rx="8"
|
||||
class="fill-white stroke-gray-300" stroke-width="2"></rect>
|
||||
<text x="{{ geometry.x + 10 }}" y="{{ geometry.y - 6 }}" class="fill-gray-400 text-xs font-semibold">
|
||||
{{- label -}}
|
||||
{%- if free > 0 %} <tspan class="fill-emerald-600">· {{ free }} frei</tspan>
|
||||
{%- else %} <tspan class="fill-gray-400">· belegt</tspan>{% endif -%}
|
||||
</text>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro spot(spot, geometry, width, height, selected_date, is_past, time_params) %}
|
||||
{% set MAX_LINES = 3 %}
|
||||
{% set state = spot.state.name %}
|
||||
|
||||
{% if state == 'MINE' %}
|
||||
{% set fill = 'fill-primary' %}{% set stroke = 'stroke-primary-dark' %}{% set text = 'fill-white' %}
|
||||
{% elseif state == 'TAKEN' %}
|
||||
{% set fill = 'fill-gray-200' %}{% set stroke = 'stroke-gray-400' %}{% set text = 'fill-gray-600' %}
|
||||
{% elseif state == 'PARTLY' %}
|
||||
{% set fill = 'fill-amber-50' %}{% set stroke = 'stroke-amber-500' %}{% set text = 'fill-amber-900' %}
|
||||
{% else %}
|
||||
{% set fill = 'fill-emerald-50' %}{% set stroke = 'stroke-emerald-500' %}{% set text = 'fill-emerald-900' %}
|
||||
{% endif %}
|
||||
|
||||
{% set interactive = not is_past and spot.actionable %}
|
||||
{# An administrator may free somebody else's desk -- a colleague calls in sick and the spot
|
||||
should go back into the pool. Colours are untouched: MINE still means mine, and only
|
||||
clickability widens. Past days stay inert for admins too; there is no past-write path.
|
||||
|
||||
Only a shape that is not already actionable: a partly booked one still opens the create
|
||||
modal, because taking the free window is the more useful act there and an admin can reach
|
||||
that booking from /admin/bookings anyway. #}
|
||||
{% set manageable = not is_past and not interactive and is_granted('ROLE_ADMIN') and spot.bookings is not empty %}
|
||||
|
||||
{# One line per booking, then the window still free if there is one -- that last line is the
|
||||
reason a partly booked shape is worth clicking, so it earns its place. #}
|
||||
{% set lines = [] %}
|
||||
{% for booking in spot.bookings %}
|
||||
{% if booking.user.id == app.user.id %}
|
||||
{% set who = 'du' %}
|
||||
{% else %}
|
||||
{# "Anna Berger" -> "Anna B." : full names do not fit a 140px box at this size. #}
|
||||
{% set parts = booking.user.name|split(' ')|filter(part => part is not empty) %}
|
||||
{% set who = parts|length > 1 ? parts|first ~ ' ' ~ parts|last|slice(0, 1) ~ '.' : booking.user.name %}
|
||||
{% endif %}
|
||||
{% set lines = lines|merge([who ~ (booking.timeRange.allDay ? '' : ' ' ~ booking.timeRange)]) %}
|
||||
{% endfor %}
|
||||
{% if state == 'PARTLY' %}
|
||||
{% set lines = lines|merge(['frei ' ~ spot.suggested]) %}
|
||||
{% endif %}
|
||||
|
||||
{# Overflow rather than spill out of the box. Two lines plus a counter beats three lines and
|
||||
a silent omission. #}
|
||||
{% if lines|length > MAX_LINES %}
|
||||
{% set lines = lines|slice(0, MAX_LINES - 1)|merge(['+' ~ (lines|length - MAX_LINES + 1) ~ ' weitere']) %}
|
||||
{% endif %}
|
||||
|
||||
<g class="{{ interactive or manageable ? 'cursor-pointer' : 'cursor-default' }}"
|
||||
{%- if interactive or manageable %} role="button"{% endif %}
|
||||
aria-label="{{ spot.resource.label }}{% if lines is empty %} — frei{% else %} — {{ lines|join(', ') }}{% endif %}"
|
||||
{%- if interactive and state == 'MINE' %}
|
||||
hx-get="{{ path('app_booking_edit', { id: spot.own.id }|merge(time_params)) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
{%- elseif interactive %}
|
||||
{# The modal is appended to the end of <body>; closing it is Stimulus, not htmx.
|
||||
It prefills the free window itself -- see Booking\CreateController. #}
|
||||
hx-get="{{ path('app_booking_new', { id: spot.resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
{%- elseif manageable %}
|
||||
{# Not app_booking_edit: a shape can hold several bookings on one day, and an admin
|
||||
releases rather than reschedules. The modal lists them and frees one. #}
|
||||
hx-get="{{ path('app_admin_spot', { id: spot.resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
{%- endif %}>
|
||||
<rect x="{{ geometry.x }}" y="{{ geometry.y }}" width="{{ width }}" height="{{ height }}" rx="6"
|
||||
class="{{ fill }} {{ stroke }}" stroke-width="2"></rect>
|
||||
|
||||
{# Nothing to list: the colour already says "free", so the label sits centred as before. #}
|
||||
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + (lines is empty ? height / 2 + 4 : 18) }}"
|
||||
text-anchor="middle" class="{{ text }} text-xs font-semibold">{{ spot.resource.label }}</text>
|
||||
|
||||
{% for line in lines %}
|
||||
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + 34 + loop.index0 * 13 }}"
|
||||
text-anchor="middle" class="{{ text }} text-[10px]">{{ line }}</text>
|
||||
{% endfor %}
|
||||
</g>
|
||||
{% endmacro %}
|
||||
@@ -3,7 +3,7 @@
|
||||
this day moves, the rest of the series is untouched and the occurrence is flagged as
|
||||
differing from it.
|
||||
|
||||
Releasing lives here too rather than on the floor plan, so the SVG needs no confirm dialogs
|
||||
Releasing lives here too rather than on the floor plan, so a card needs no confirm dialogs
|
||||
or inline CSRF tokens -- clicking your own desk always just opens this. The release button
|
||||
opens the shared confirmation modal over this one.
|
||||
#}
|
||||
|
||||
@@ -150,22 +150,22 @@ class BookingAdminControllerTest extends WebTestCase
|
||||
*/
|
||||
public function testAnOccupiedDeskIsClickableForAnAdminOnly(): void
|
||||
{
|
||||
$desk = $this->resource('D1', ResourceType::DESK, 'desk-01');
|
||||
$desk = $this->resource('D1');
|
||||
$this->booking($this->user('[email protected]', 'Bea Berger'), $desk);
|
||||
$date = (new \DateTimeImmutable('today'))->format('Y-m-d');
|
||||
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$crawler = $this->client->request('GET', '/book/desk?date='.$date);
|
||||
self::assertCount(0, $crawler->filter('g[hx-get*="/admin/spot/"]'));
|
||||
self::assertCount(0, $crawler->filter('[data-spot][hx-get*="/admin/spot/"]'));
|
||||
|
||||
$this->client->loginUser($this->admin());
|
||||
$crawler = $this->client->request('GET', '/book/desk?date='.$date);
|
||||
self::assertCount(1, $crawler->filter(\sprintf('g[hx-get="/admin/spot/%d/%s"]', $desk->getId(), $date)));
|
||||
self::assertCount(1, $crawler->filter(\sprintf('[data-spot][hx-get="/admin/spot/%d/%s"]', $desk->getId(), $date)));
|
||||
}
|
||||
|
||||
public function testTheSpotModalListsTheHolderAndReleasesTheBooking(): void
|
||||
{
|
||||
$desk = $this->resource('D1', ResourceType::DESK, 'desk-01');
|
||||
$desk = $this->resource('D1');
|
||||
$booking = $this->booking($this->user('[email protected]', 'Bea Berger'), $desk);
|
||||
$this->client->loginUser($this->admin());
|
||||
|
||||
@@ -185,9 +185,9 @@ class BookingAdminControllerTest extends WebTestCase
|
||||
*/
|
||||
public function testTheSpotModalShowsOnlyThatResourcesBookings(): void
|
||||
{
|
||||
$desk = $this->resource('D1', ResourceType::DESK, 'desk-01');
|
||||
$desk = $this->resource('D1');
|
||||
$this->booking($this->user('[email protected]', 'Bea Berger'), $desk);
|
||||
$this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('D2', ResourceType::DESK, 'desk-02'));
|
||||
$this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('D2'));
|
||||
$this->client->loginUser($this->admin());
|
||||
|
||||
$crawler = $this->client->request(
|
||||
@@ -273,12 +273,9 @@ class BookingAdminControllerTest extends WebTestCase
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* The floor plan only draws resources whose svgElementId appears in the geometry map.
|
||||
*/
|
||||
private function resource(string $label, ResourceType $type = ResourceType::DESK, ?string $svgElementId = null): Resource
|
||||
private function resource(string $label, ResourceType $type = ResourceType::DESK): Resource
|
||||
{
|
||||
$resource = new Resource($type, $label, $svgElementId);
|
||||
$resource = new Resource($type, $label);
|
||||
$this->em->persist($resource);
|
||||
$this->em->flush();
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ class BookingControllerTest extends WebTestCase
|
||||
public function testReleasingOwnBookingSwapsTheBoard(): void
|
||||
{
|
||||
$anna = $this->user('[email protected]');
|
||||
$booking = new Booking($anna, $this->resource('D1', svgElementId: 'desk-01'), new \DateTimeImmutable('2026-10-01'));
|
||||
$booking = new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01'));
|
||||
$this->em->persist($booking);
|
||||
$this->em->flush();
|
||||
$id = $booking->getId();
|
||||
@@ -205,7 +205,7 @@ class BookingControllerTest extends WebTestCase
|
||||
public function testTheFloorPlanOpensTheEditModalForYourOwnBooking(): void
|
||||
{
|
||||
$anna = $this->user('[email protected]');
|
||||
$booking = new Booking($anna, $this->resource('D1', svgElementId: 'desk-01'), new \DateTimeImmutable('2026-10-01'));
|
||||
$booking = new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01'));
|
||||
$this->em->persist($booking);
|
||||
$this->em->flush();
|
||||
|
||||
@@ -225,7 +225,7 @@ class BookingControllerTest extends WebTestCase
|
||||
*/
|
||||
public function testASecondPersonCanBookTheRestOfADayFromTheDefaultBoard(): void
|
||||
{
|
||||
$desk = $this->resource('D1', svgElementId: 'desk-01');
|
||||
$desk = $this->resource('D1');
|
||||
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(540, 780)));
|
||||
$this->em->flush();
|
||||
|
||||
@@ -233,7 +233,7 @@ class BookingControllerTest extends WebTestCase
|
||||
|
||||
// 1. The shape is clickable on the all-day board.
|
||||
$crawler = $this->client->request('GET', '/book/desk?date=2026-10-01');
|
||||
$opener = $crawler->filter(\sprintf('g[hx-get*="/bookings/new/%d/"]', $desk->getId()));
|
||||
$opener = $crawler->filter(\sprintf('[data-spot][hx-get*="/bookings/new/%d/"]', $desk->getId()));
|
||||
self::assertCount(1, $opener, 'a partly booked desk must still open the booking modal');
|
||||
|
||||
// 2. The modal it opens is prefilled with a window that is actually free.
|
||||
@@ -255,14 +255,14 @@ class BookingControllerTest extends WebTestCase
|
||||
|
||||
public function testAFullyBookedDeskIsNotClickable(): void
|
||||
{
|
||||
$desk = $this->resource('D1', svgElementId: 'desk-01');
|
||||
$desk = $this->resource('D1');
|
||||
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01')));
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$crawler = $this->client->request('GET', '/book/desk?date=2026-10-01');
|
||||
|
||||
self::assertCount(0, $crawler->filter(\sprintf('g[hx-get*="/bookings/new/%d/"]', $desk->getId())));
|
||||
self::assertCount(0, $crawler->filter(\sprintf('[data-spot][hx-get*="/bookings/new/%d/"]', $desk->getId())));
|
||||
}
|
||||
|
||||
public function testBookingPartOfADay(): void
|
||||
@@ -606,13 +606,9 @@ class BookingControllerTest extends WebTestCase
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Note $svgElementId: the floor plan only draws resources whose id appears in the geometry
|
||||
* map in _floorplan.html.twig, so a test that needs to click a shape must use a real one.
|
||||
*/
|
||||
private function resource(string $label, ResourceType $type = ResourceType::DESK, ?string $svgElementId = null): Resource
|
||||
private function resource(string $label, ResourceType $type = ResourceType::DESK): Resource
|
||||
{
|
||||
$resource = new Resource($type, $label, $svgElementId);
|
||||
$resource = new Resource($type, $label);
|
||||
$this->em->persist($resource);
|
||||
$this->em->flush();
|
||||
|
||||
|
||||
@@ -121,8 +121,8 @@ class DashboardControllerTest extends WebTestCase
|
||||
|
||||
public function testABoardOnlyShowsItsOwnKindOfResource(): void
|
||||
{
|
||||
$this->resource('D1', svgElementId: 'desk-01');
|
||||
$this->resource('P1', ResourceType::PARKING, 'parking-01');
|
||||
$this->resource('D1');
|
||||
$this->resource('P1', ResourceType::PARKING);
|
||||
|
||||
$this->client->loginUser($this->user());
|
||||
|
||||
@@ -163,9 +163,9 @@ class DashboardControllerTest extends WebTestCase
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function resource(string $label, ResourceType $type = ResourceType::DESK, ?string $svgElementId = null): Resource
|
||||
private function resource(string $label, ResourceType $type = ResourceType::DESK): Resource
|
||||
{
|
||||
$resource = new Resource($type, $label, $svgElementId);
|
||||
$resource = new Resource($type, $label);
|
||||
$this->em->persist($resource);
|
||||
$this->em->flush();
|
||||
|
||||
|
||||
@@ -15,10 +15,10 @@ use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
/**
|
||||
* What a shape on the floor plan says.
|
||||
* What a card on the floor plan says.
|
||||
*
|
||||
* Who holds what is listed inside the box rather than in an SVG <title>: a tooltip only shows on
|
||||
* hover, which is useless on touch and invisible when scanning the plan.
|
||||
* Who holds what is listed inside the card rather than in a title tooltip: a tooltip only shows
|
||||
* on hover, which is useless on touch and invisible when scanning the plan.
|
||||
*/
|
||||
class FloorPlanTest extends WebTestCase
|
||||
{
|
||||
@@ -39,20 +39,20 @@ class FloorPlanTest extends WebTestCase
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testNoShapeCarriesATooltip(): void
|
||||
public function testNoCardCarriesATooltip(): void
|
||||
{
|
||||
$desk = $this->desk('desk-01');
|
||||
$desk = $this->desk();
|
||||
$this->book($desk, $this->user('[email protected]', 'Bea Kranz'), 540, 780);
|
||||
|
||||
$crawler = $this->board();
|
||||
|
||||
self::assertCount(0, $crawler->filter('svg title'));
|
||||
self::assertCount(0, $crawler->filter('svg g title'));
|
||||
self::assertCount(1, $crawler->filter('[data-spot]'));
|
||||
self::assertCount(0, $crawler->filter('[data-spot][title], [data-spot] [title]'));
|
||||
}
|
||||
|
||||
public function testABookingIsListedInsideTheShape(): void
|
||||
{
|
||||
$desk = $this->desk('desk-01');
|
||||
$desk = $this->desk();
|
||||
$this->book($desk, $this->user('[email protected]', 'Bea Kranz'), 540, 780);
|
||||
|
||||
self::assertSame(
|
||||
@@ -62,11 +62,11 @@ class FloorPlanTest extends WebTestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* Full names do not fit a 140px box at this size, so they are shortened.
|
||||
* Full names are shortened so a card stays one line per booking at phone width.
|
||||
*/
|
||||
public function testALongNameIsShortenedToAFirstNameAndInitial(): void
|
||||
{
|
||||
$desk = $this->desk('desk-01');
|
||||
$desk = $this->desk();
|
||||
$this->book($desk, $this->user('[email protected]', 'Maximilian Wolkenstein'), 0, 1440);
|
||||
|
||||
self::assertContains('Maximilian W.', $this->linesOf($this->board()));
|
||||
@@ -74,7 +74,7 @@ class FloorPlanTest extends WebTestCase
|
||||
|
||||
public function testASingleWordNameIsLeftAlone(): void
|
||||
{
|
||||
$desk = $this->desk('desk-01');
|
||||
$desk = $this->desk();
|
||||
$this->book($desk, $this->user('[email protected]', 'Cher'), 0, 1440);
|
||||
|
||||
self::assertContains('Cher', $this->linesOf($this->board()));
|
||||
@@ -82,7 +82,7 @@ class FloorPlanTest extends WebTestCase
|
||||
|
||||
public function testYourOwnBookingReadsAsDu(): void
|
||||
{
|
||||
$desk = $this->desk('desk-01');
|
||||
$desk = $this->desk();
|
||||
$me = $this->user('[email protected]', 'Anna Berger');
|
||||
$this->book($desk, $me, 540, 780);
|
||||
|
||||
@@ -91,19 +91,19 @@ class FloorPlanTest extends WebTestCase
|
||||
|
||||
public function testAnAllDayBookingOmitsTheTimes(): void
|
||||
{
|
||||
$desk = $this->desk('desk-01');
|
||||
$desk = $this->desk();
|
||||
$this->book($desk, $this->user('[email protected]', 'Bea Kranz'), 0, 1440);
|
||||
|
||||
self::assertSame(['Schreibtisch 01', 'Bea K.'], $this->linesOf($this->board()));
|
||||
}
|
||||
|
||||
/**
|
||||
* A box fits three lines. More than that is summarised rather than allowed to spill out --
|
||||
* A card lists three lines. More than that is summarised rather than allowed to spill out --
|
||||
* and the counter has to account for the line the counter itself occupies.
|
||||
*/
|
||||
public function testTooManyBookingsAreSummarised(): void
|
||||
{
|
||||
$desk = $this->desk('desk-01');
|
||||
$desk = $this->desk();
|
||||
$this->book($desk, $this->user('[email protected]', 'Ann Alpha'), 0, 360);
|
||||
$this->book($desk, $this->user('[email protected]', 'Bea Beta'), 360, 720);
|
||||
$this->book($desk, $this->user('[email protected]', 'Cem Gamma'), 720, 1080);
|
||||
@@ -119,33 +119,29 @@ class FloorPlanTest extends WebTestCase
|
||||
self::assertLessThanOrEqual(4, \count($lines));
|
||||
}
|
||||
|
||||
public function testAFreeShapeShowsOnlyItsLabel(): void
|
||||
public function testAFreeCardShowsOnlyItsLabel(): void
|
||||
{
|
||||
$this->desk('desk-01');
|
||||
$this->desk();
|
||||
|
||||
self::assertSame(['Schreibtisch 01'], $this->linesOf($this->board()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Screen readers get the same information the box shows, because role="img" on the <svg>
|
||||
* would otherwise hide every one of those <text> elements.
|
||||
* Free is otherwise only a colour, which a screen reader cannot see.
|
||||
*/
|
||||
public function testTheShapeCarriesAnAccessibleLabel(): void
|
||||
public function testAFreeCardSaysSoToScreenReaders(): void
|
||||
{
|
||||
$desk = $this->desk('desk-01');
|
||||
$this->book($desk, $this->user('[email protected]', 'Bea Kranz'), 540, 780);
|
||||
$this->desk();
|
||||
|
||||
$label = $this->board()->filter('svg g[aria-label]')->attr('aria-label');
|
||||
|
||||
self::assertSame('Schreibtisch 01 — Bea K. 09:00–13:00, frei 13:00–24:00', $label);
|
||||
self::assertSame('frei', $this->board()->filter('[data-spot] .sr-only')->text());
|
||||
}
|
||||
|
||||
public function testARoomIsDrawnWithItsNameAndHowManyAreFree(): void
|
||||
{
|
||||
$room = $this->room('Raum Nord', 'room-01');
|
||||
$this->deskIn($room, 'desk-01', 'Schreibtisch 01');
|
||||
$this->deskIn($room, 'desk-02', 'Schreibtisch 02');
|
||||
$this->deskIn($room, 'desk-03', 'Schreibtisch 03');
|
||||
$room = $this->room('Raum Nord');
|
||||
$this->deskIn($room, 'Schreibtisch 01');
|
||||
$this->deskIn($room, 'Schreibtisch 02');
|
||||
$this->deskIn($room, 'Schreibtisch 03');
|
||||
|
||||
self::assertSame(['Raum Nord · 3 frei'], $this->roomsOf($this->board()));
|
||||
}
|
||||
@@ -157,9 +153,9 @@ class FloorPlanTest extends WebTestCase
|
||||
*/
|
||||
public function testAPartlyBookedDeskIsNotCountedAsFree(): void
|
||||
{
|
||||
$room = $this->room('Raum Nord', 'room-01');
|
||||
$partly = $this->deskIn($room, 'desk-01', 'Schreibtisch 01');
|
||||
$this->deskIn($room, 'desk-02', 'Schreibtisch 02');
|
||||
$room = $this->room('Raum Nord');
|
||||
$partly = $this->deskIn($room, 'Schreibtisch 01');
|
||||
$this->deskIn($room, 'Schreibtisch 02');
|
||||
$this->book($partly, $this->user('[email protected]', 'Bea Kranz'), 540, 780);
|
||||
|
||||
self::assertSame(['Raum Nord · 1 frei'], $this->roomsOf($this->board()));
|
||||
@@ -171,8 +167,8 @@ class FloorPlanTest extends WebTestCase
|
||||
*/
|
||||
public function testTheFreeCountFollowsTheSelectedSpan(): void
|
||||
{
|
||||
$room = $this->room('Raum Nord', 'room-01');
|
||||
$desk = $this->deskIn($room, 'desk-01', 'Schreibtisch 01');
|
||||
$room = $this->room('Raum Nord');
|
||||
$desk = $this->deskIn($room, 'Schreibtisch 01');
|
||||
$this->book($desk, $this->user('[email protected]', 'Bea Kranz'), 540, 780);
|
||||
|
||||
self::assertSame(['Raum Nord · belegt'], $this->roomsOf($this->board()));
|
||||
@@ -181,28 +177,28 @@ class FloorPlanTest extends WebTestCase
|
||||
|
||||
public function testARoomWithNothingLeftSaysSoInsteadOfShowingAZero(): void
|
||||
{
|
||||
$room = $this->room('Raum Nord', 'room-01');
|
||||
$desk = $this->deskIn($room, 'desk-01', 'Schreibtisch 01');
|
||||
$room = $this->room('Raum Nord');
|
||||
$desk = $this->deskIn($room, 'Schreibtisch 01');
|
||||
$this->book($desk, $this->user('[email protected]', 'Bea Kranz'), 0, 1440);
|
||||
|
||||
self::assertSame(['Raum Nord · belegt'], $this->roomsOf($this->board()));
|
||||
}
|
||||
|
||||
public function testRoomsAreDrawnInTheirConfiguredOrder(): void
|
||||
public function testRoomsAreListedInTheirConfiguredOrder(): void
|
||||
{
|
||||
$this->deskIn($this->room('Zweiter Raum', 'room-02', 1), 'desk-04', 'Schreibtisch 04');
|
||||
$this->deskIn($this->room('Erster Raum', 'room-01', 0), 'desk-01', 'Schreibtisch 01');
|
||||
$this->deskIn($this->room('Zweiter Raum', 1), 'Schreibtisch 04');
|
||||
$this->deskIn($this->room('Erster Raum', 0), 'Schreibtisch 01');
|
||||
|
||||
self::assertSame(['Erster Raum · 1 frei', 'Zweiter Raum · 1 frei'], $this->roomsOf($this->board()));
|
||||
}
|
||||
|
||||
/**
|
||||
* A resource with no group is still bookable and still drawn -- the plan simply does not
|
||||
* frame it. That is the path the car park runs on.
|
||||
* A resource with no group is still bookable and still listed -- the plan simply gives it no
|
||||
* room heading. That is the path the car park runs on.
|
||||
*/
|
||||
public function testAnUngroupedDeskIsDrawnWithoutARoom(): void
|
||||
public function testAnUngroupedDeskIsListedWithoutARoom(): void
|
||||
{
|
||||
$this->desk('desk-01');
|
||||
$this->desk();
|
||||
|
||||
$crawler = $this->board();
|
||||
|
||||
@@ -211,29 +207,29 @@ class FloorPlanTest extends WebTestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* The room captions, which sit directly under the <svg> rather than inside a shape's <g>.
|
||||
* The room headings.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function roomsOf(\Symfony\Component\DomCrawler\Crawler $crawler): array
|
||||
{
|
||||
return $crawler->filter('svg > text')->each(
|
||||
return $crawler->filter('#board-floorplan section > h3')->each(
|
||||
static fn (\Symfony\Component\DomCrawler\Crawler $node): string => trim(preg_replace('/\s+/', ' ', $node->text()) ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
private function room(string $label, string $svgElementId, int $position = 0): ResourceGroup
|
||||
private function room(string $label, int $position = 0): ResourceGroup
|
||||
{
|
||||
$group = new ResourceGroup(ResourceType::DESK, $label, $position, $svgElementId);
|
||||
$group = new ResourceGroup(ResourceType::DESK, $label, $position);
|
||||
$this->em->persist($group);
|
||||
$this->em->flush();
|
||||
|
||||
return $group;
|
||||
}
|
||||
|
||||
private function deskIn(ResourceGroup $group, string $svgElementId, string $label): Resource
|
||||
private function deskIn(ResourceGroup $group, string $label): Resource
|
||||
{
|
||||
$resource = new Resource(ResourceType::DESK, $label, $svgElementId);
|
||||
$resource = new Resource(ResourceType::DESK, $label);
|
||||
$resource->setGroup($group);
|
||||
$this->em->persist($resource);
|
||||
$this->em->flush();
|
||||
@@ -242,11 +238,13 @@ class FloorPlanTest extends WebTestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* The label, then one entry per listed line.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function linesOf(\Symfony\Component\DomCrawler\Crawler $crawler): array
|
||||
{
|
||||
return $crawler->filter('svg g text')->each(
|
||||
return $crawler->filter('[data-spot] > span:not(.sr-only), [data-spot] li')->each(
|
||||
static fn (\Symfony\Component\DomCrawler\Crawler $node): string => trim($node->text()),
|
||||
);
|
||||
}
|
||||
@@ -258,9 +256,9 @@ class FloorPlanTest extends WebTestCase
|
||||
return $this->client->request('GET', '/book/desk?date=2026-10-01'.$query);
|
||||
}
|
||||
|
||||
private function desk(string $svgElementId): Resource
|
||||
private function desk(): Resource
|
||||
{
|
||||
$resource = new Resource(ResourceType::DESK, 'Schreibtisch 01', $svgElementId);
|
||||
$resource = new Resource(ResourceType::DESK, 'Schreibtisch 01');
|
||||
$this->em->persist($resource);
|
||||
$this->em->flush();
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ class SpotGroupTest extends TestCase
|
||||
*/
|
||||
private function spot(string $label, ?ResourceGroup $group, array $bookings = []): Spot
|
||||
{
|
||||
$resource = new Resource(ResourceType::DESK, $label, strtolower($label));
|
||||
$resource = new Resource(ResourceType::DESK, $label);
|
||||
$resource->setGroup($group);
|
||||
|
||||
return Spot::for($resource, $bookings, TimeRange::wholeDay(), $this->viewer());
|
||||
@@ -119,7 +119,7 @@ class SpotGroupTest extends TestCase
|
||||
{
|
||||
return new Booking(
|
||||
new UserWithId('[email protected]', 'Bea', 2),
|
||||
new Resource(ResourceType::DESK, 'X', 'x'),
|
||||
new Resource(ResourceType::DESK, 'X'),
|
||||
new \DateTimeImmutable('2026-10-01'),
|
||||
TimeRange::fromMinutes($start, $end),
|
||||
);
|
||||
|
||||
@@ -134,7 +134,7 @@ class SpotTest extends TestCase
|
||||
|
||||
private function resource(): Resource
|
||||
{
|
||||
return new Resource(ResourceType::DESK, 'D1', 'desk-01');
|
||||
return new Resource(ResourceType::DESK, 'D1');
|
||||
}
|
||||
|
||||
private function viewer(): User
|
||||
|
||||
@@ -316,7 +316,7 @@ class BookingManagerTest extends KernelTestCase
|
||||
|
||||
private function resource(string $label, ResourceType $type = ResourceType::DESK): Resource
|
||||
{
|
||||
$resource = new Resource($type, $label, strtolower($label));
|
||||
$resource = new Resource($type, $label);
|
||||
$this->em->persist($resource);
|
||||
$this->em->flush();
|
||||
|
||||
|
||||
Reference in new issue
Block a user