feat: list bookings inside the floor plan shapes
This commit is contained in:
1 parent
78839838f0
commit
a17c61400a
4 files changed
+246
-41
No files matched your search
@@ -3,25 +3,29 @@
|
||||
database row to a shape here, so a resource whose id is missing from the map is simply not
|
||||
drawn (and vice versa) -- app:seed and this map 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.
|
||||
|
||||
Found by convention from ResourceType::DESK->value, see board/_floorplan.html.twig.
|
||||
#}
|
||||
{% import 'board/plan/_spot.html.twig' as plan %}
|
||||
|
||||
{% set geometry = {
|
||||
'desk-01': { x: 40, y: 60 }, 'desk-02': { x: 200, y: 60 }, 'desk-03': { x: 360, y: 60 },
|
||||
'desk-04': { x: 40, y: 160 }, 'desk-05': { x: 200, y: 160 }, 'desk-06': { x: 360, y: 160 },
|
||||
'desk-01': { x: 40, y: 50 }, 'desk-02': { x: 200, y: 50 }, 'desk-03': { x: 360, y: 50 },
|
||||
'desk-04': { x: 40, y: 155 }, 'desk-05': { x: 200, y: 155 }, 'desk-06': { x: 360, y: 155 },
|
||||
'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: 365 }, 'desk-11': { x: 200, y: 365 }, 'desk-12': { x: 360, y: 365 }
|
||||
} %}
|
||||
{% 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">
|
||||
<rect x="10" y="20" width="500" height="420" rx="8" class="fill-white stroke-gray-300" stroke-width="2"></rect>
|
||||
<text x="20" y="14" class="fill-gray-400 text-xs font-semibold">Büro</text>
|
||||
<svg viewBox="0 0 520 486" class="w-full h-auto" role="group" aria-label="Raumplan Büro">
|
||||
<rect x="10" y="18" width="500" height="448" 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">Büro</text>
|
||||
|
||||
{% for resource in resources %}
|
||||
{% if resource.svgElementId and geometry[resource.svgElementId] is defined %}
|
||||
{{ plan.spot(spots[resource.id], geometry[resource.svgElementId], 140, 70,
|
||||
{{ plan.spot(spots[resource.id], geometry[resource.svgElementId], 140, 86,
|
||||
selected_date, is_past, time_params) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
{#
|
||||
The car park. See board/plan/_desk.html.twig for how geometry and Resource.svgElementId
|
||||
relate; found by convention from ResourceType::PARKING->value.
|
||||
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: 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-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 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>
|
||||
<text x="20" y="14" class="fill-gray-400 text-xs font-semibold">Parkplatz</text>
|
||||
<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="40" x2="265" y2="260" class="stroke-gray-200" stroke-width="2" stroke-dasharray="6 6"></line>
|
||||
<line x1="265" y1="36" x2="265" y2="310" class="stroke-gray-200" stroke-width="2" stroke-dasharray="6 6"></line>
|
||||
|
||||
{% for resource in resources %}
|
||||
{% if resource.svgElementId and geometry[resource.svgElementId] is defined %}
|
||||
{{ plan.spot(spots[resource.id], geometry[resource.svgElementId], 200, 55,
|
||||
{{ plan.spot(spots[resource.id], geometry[resource.svgElementId], 200, 78,
|
||||
selected_date, is_past, time_params) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
@@ -7,11 +7,18 @@
|
||||
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.
|
||||
#}
|
||||
|
||||
{% macro spot(spot, geometry, width, height, selected_date, is_past, time_params) %}
|
||||
{% set MAX_LINES = 3 %}
|
||||
{% set state = spot.state.name %}
|
||||
|
||||
{% if state == 'MINE' %}
|
||||
@@ -26,21 +33,32 @@
|
||||
|
||||
{% set interactive = not is_past and spot.actionable %}
|
||||
|
||||
{# The second line: what you would be told at a glance. For a partly-free spot that is the
|
||||
window still available, because that is the reason to click it. #}
|
||||
{% if state == 'MINE' %}
|
||||
{% set caption = spot.own.timeRange.allDay ? 'du' : 'du ' ~ spot.own.timeRange %}
|
||||
{% elseif state == 'PARTLY' %}
|
||||
{% set caption = 'frei ' ~ spot.suggested %}
|
||||
{% elseif spot.bookings|length > 1 %}
|
||||
{% set caption = spot.bookings|length ~ ' Buchungen' %}
|
||||
{% elseif spot.bookings is not empty %}
|
||||
{% set caption = spot.bookings|first.user.name %}
|
||||
{% else %}
|
||||
{% set caption = null %}
|
||||
{# 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 ? 'cursor-pointer' : 'cursor-default' }}"
|
||||
{%- if interactive %} 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"
|
||||
@@ -52,21 +70,16 @@
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
{%- endif %}>
|
||||
<title>
|
||||
{{- spot.resource.label }}
|
||||
{%- if spot.bookings is empty %} — frei
|
||||
{%- else %}
|
||||
{%- for booking in spot.bookings %} — {{ booking.user.id == app.user.id ? 'du' : booking.user.name }} {{ booking.timeRange }}{% endfor %}
|
||||
{%- for window in spot.free %} — frei {{ window }}{% endfor %}
|
||||
{%- endif -%}
|
||||
</title>
|
||||
<rect x="{{ geometry.x }}" y="{{ geometry.y }}" width="{{ width }}" height="{{ height }}" rx="6"
|
||||
class="{{ fill }} {{ stroke }}" stroke-width="2"></rect>
|
||||
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + (caption ? height / 2 - 4 : height / 2 + 4) }}"
|
||||
|
||||
{# 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>
|
||||
{% if caption %}
|
||||
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + height / 2 + 12 }}"
|
||||
text-anchor="middle" class="{{ text }} text-[10px]">{{ caption }}</text>
|
||||
{% endif %}
|
||||
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Booking\TimeRange;
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\ResourceType;
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
/**
|
||||
* What a shape 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.
|
||||
*/
|
||||
class FloorPlanTest extends WebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
private EntityManagerInterface $em;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = self::createClient();
|
||||
$this->client->disableReboot();
|
||||
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
$this->em->getConnection()->beginTransaction();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->em->getConnection()->rollBack();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testNoShapeCarriesATooltip(): void
|
||||
{
|
||||
$desk = $this->desk('desk-01');
|
||||
$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'));
|
||||
}
|
||||
|
||||
public function testABookingIsListedInsideTheShape(): void
|
||||
{
|
||||
$desk = $this->desk('desk-01');
|
||||
$this->book($desk, $this->user('[email protected]', 'Bea Kranz'), 540, 780);
|
||||
|
||||
self::assertSame(
|
||||
['Schreibtisch 01', 'Bea K. 09:00–13:00', 'frei 13:00–24:00'],
|
||||
$this->linesOf($this->board()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full names do not fit a 140px box at this size, so they are shortened.
|
||||
*/
|
||||
public function testALongNameIsShortenedToAFirstNameAndInitial(): void
|
||||
{
|
||||
$desk = $this->desk('desk-01');
|
||||
$this->book($desk, $this->user('[email protected]', 'Maximilian Wolkenstein'), 0, 1440);
|
||||
|
||||
self::assertContains('Maximilian W.', $this->linesOf($this->board()));
|
||||
}
|
||||
|
||||
public function testASingleWordNameIsLeftAlone(): void
|
||||
{
|
||||
$desk = $this->desk('desk-01');
|
||||
$this->book($desk, $this->user('[email protected]', 'Cher'), 0, 1440);
|
||||
|
||||
self::assertContains('Cher', $this->linesOf($this->board()));
|
||||
}
|
||||
|
||||
public function testYourOwnBookingReadsAsDu(): void
|
||||
{
|
||||
$desk = $this->desk('desk-01');
|
||||
$me = $this->user('[email protected]', 'Anna Berger');
|
||||
$this->book($desk, $me, 540, 780);
|
||||
|
||||
self::assertContains('du 09:00–13:00', $this->linesOf($this->board($me)));
|
||||
}
|
||||
|
||||
public function testAnAllDayBookingOmitsTheTimes(): void
|
||||
{
|
||||
$desk = $this->desk('desk-01');
|
||||
$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 --
|
||||
* and the counter has to account for the line the counter itself occupies.
|
||||
*/
|
||||
public function testTooManyBookingsAreSummarised(): void
|
||||
{
|
||||
$desk = $this->desk('desk-01');
|
||||
$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);
|
||||
$this->book($desk, $this->user('[email protected]', 'Dia Delta'), 1080, 1440);
|
||||
|
||||
$lines = $this->linesOf($this->board());
|
||||
|
||||
self::assertSame(
|
||||
['Schreibtisch 01', 'Ann A. 00:00–06:00', 'Bea B. 06:00–12:00', '+2 weitere'],
|
||||
$lines,
|
||||
);
|
||||
// Label plus MAX_LINES, never more.
|
||||
self::assertLessThanOrEqual(4, \count($lines));
|
||||
}
|
||||
|
||||
public function testAFreeShapeShowsOnlyItsLabel(): void
|
||||
{
|
||||
$this->desk('desk-01');
|
||||
|
||||
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.
|
||||
*/
|
||||
public function testTheShapeCarriesAnAccessibleLabel(): void
|
||||
{
|
||||
$desk = $this->desk('desk-01');
|
||||
$this->book($desk, $this->user('[email protected]', 'Bea Kranz'), 540, 780);
|
||||
|
||||
$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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function linesOf(\Symfony\Component\DomCrawler\Crawler $crawler): array
|
||||
{
|
||||
return $crawler->filter('svg g text')->each(
|
||||
static fn (\Symfony\Component\DomCrawler\Crawler $node): string => trim($node->text()),
|
||||
);
|
||||
}
|
||||
|
||||
private function board(?User $as = null): \Symfony\Component\DomCrawler\Crawler
|
||||
{
|
||||
$this->client->loginUser($as ?? $this->user('[email protected]', 'Anna Berger'));
|
||||
|
||||
return $this->client->request('GET', '/book/desk?date=2026-10-01');
|
||||
}
|
||||
|
||||
private function desk(string $svgElementId): Resource
|
||||
{
|
||||
$resource = new Resource(ResourceType::DESK, 'Schreibtisch 01', $svgElementId);
|
||||
$this->em->persist($resource);
|
||||
$this->em->flush();
|
||||
|
||||
return $resource;
|
||||
}
|
||||
|
||||
private function book(Resource $resource, User $user, int $start, int $end): void
|
||||
{
|
||||
$this->em->persist(new Booking($user, $resource, new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes($start, $end)));
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
private function user(string $email, string $name): User
|
||||
{
|
||||
$existing = $this->em->getRepository(User::class)->findOneBy(['email' => $email]);
|
||||
if (null !== $existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$user = new User($email, $name);
|
||||
$user->setRoles(['ROLE_EMPLOYEE']);
|
||||
$this->em->persist($user);
|
||||
$this->em->flush();
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user