Einordnung und Zielsetzung aus UI-Sicht
Mit dem heutigen Tag des Adventskalenders wird der Fokus gezielt auf die Interaktionsebene gelegt, die in den vorangegangenen Teilen vorbereitet wurde. Während zu Beginn die grundlegende Struktur der Benutzeroberfläche und das Layout definiert wurden und nachfolgenddie interaktive Tabellenansicht mit Sortierung, Filterung und dynamischen Aktionen etabliert wurde, geht es nun darum, den Übergang von der Übersicht zur Detailbetrachtung konsequent zu gestalten. Der Benutzer soll nicht mehr nur eine tabellarische Sammlung von Datenpunkten sehen, sondern eine auf das jeweilige Objekt zugeschnittene Ansicht erhalten, die kontextbezogene Aktionen ermöglicht.
Der Quelltext für diese Version befindet sich auf GitHub unter https://github.com/svenruppert/url-shortener/tree/feature/advent-2025-day-03
Hier ist der Screenshot der Version, die wir nun implementieren.



Das zentrale Ziel dieses Kapitels besteht darin, die Benutzererfahrung zu verbessern, ohne dabei die Einfachheit der bisherigen Umsetzung aufzugeben. Die Oberfläche wird um eine Detailansicht erweitert, die als eigenständiger Dialog implementiert ist und damit bewusst einen klaren Rahmen zwischen der Gesamtliste und dem Einzelobjekt schafft. Diese Entscheidung folgt dem Prinzip der kognitiven Trennung von Informationsräumen: Eine Übersicht dient der Orientierung und dem Auffinden relevanter Einträge, während die Detailansicht eine isolierte, fokussierte Arbeitsumgebung schafft.
Aus Sicht der Architektur ist diese Erweiterung ein wichtiger Schritt hin zu einer komponentenorientierten UI, in der jede Funktionseinheit – etwa die Erstellung, das Anzeigen oder das Löschen von Kurzlinks – durch eigene, klar definierte Komponenten abgebildet wird. Der neue Dialog umfasst alle notwendigen UI-Elemente, Validierungen und Eventflüsse für die Anzeige und Interaktion mit einem einzelnen ShortUrlMapping-Objekt. Diese Entkopplung ermöglicht nicht nur eine bessere Testbarkeit und Wiederverwendbarkeit, sondern reduziert auch den kognitiven Overhead im Haupt-Grid, das bislang alle Interaktionen direkt aufnehmen musste.
Der Dialog dient als interaktive Schnittstelle zwischen der visuellen Darstellung und dem darunterliegenden Datenmodell. Er reflektiert das aktuelle Objekt, bietet Kopier- und Navigationsaktionen an und liefert visuelles Feedback zum Zustand des Eintrags – etwa über ein farbcodiertes Ablaufdatum. Durch diesen modularen Aufbau kann der Benutzer Details prüfen, Aktionen durchführen oder fehlerhafte Einträge entfernen, ohne dabei den Kontext der Anwendung zu verlieren.
Übersichtsliste (OverviewView): Interaktive Verbesserungen
Mit der dritten Ausbaustufe der Benutzeroberfläche wird die bisherige Listenansicht nicht nur erweitert, sondern auch funktional aufgewertet. Die OverviewView bildet das Fundament der täglichen Arbeit mit den erstellten Kurzlinks und wird in diesem Schritt um mehrere Mechanismen ergänzt, die das Verhalten der Anwendung natürlicher und effizienter gestalten.
Ein zentraler Aspekt betrifft die Reaktivität der Suchfelder. In den vorherigen Versionen wurden Suchanfragen sofort nach jeder Eingabe ausgelöst, was zwar technisch korrekt, in der Praxis jedoch ineffizient war. Durch den Einsatz des ValueChangeMode.LAZY in Kombination mit einer kurzen Verzögerung von 400 Millisekunden führt dazu, dass das System erst reagiert, wenn der Benutzer seine Eingabe abgeschlossen hat. Diese Verzögerung dient als natürliche „Denkpause“ und verhindert überflüssige Aktualisierungen des Grids. Ergänzend wurde der Enter-Key-Flow aktiviert, sodass eine gezielte Suchbestätigung per Tastatur erfolgen kann – ein typisches Nutzungsmuster im professionellen Umfeld.
codePart.setValueChangeMode(ValueChangeMode.LAZY);
codePart.setValueChangeTimeout(400);
codePart.addValueChangeListener(e -> refresh());
urlPart.setValueChangeMode(ValueChangeMode.LAZY);
urlPart.setValueChangeTimeout(400);
urlPart.addValueChangeListener(e -> refresh());
Eine zweite Verbesserung betrifft die Interaktionsvielfalt im Grid. Statt ausschließlich über Buttons zu agieren, kann der Benutzer nun per Doppelklick oder per Enter-Taste einen Datensatz öffnen. Diese Form der Interaktion ist nicht nur schneller, sondern entspricht auch den Erwartungen, die man von Desktop-Anwendungen gewohnt ist. Um die Bedienung weiter zu vereinfachen, wurde ein Kontextmenü ergänzt, das bei einem Rechtsklick automatisch die passenden Aktionen anbietet: Details anzeigen, Ziel-URL öffnen, Shortcode kopieren oder den Eintrag löschen.
grid.addItemDoubleClickListener(ev -> openDetailsDialog(ev.getItem()));
grid.addItemClickListener(ev -> {
if (ev.getClickCount() == 2) openDetailsDialog(ev.getItem());
});
GridContextMenu<ShortUrlMapping> menu = new GridContextMenu<>(grid);
menu.addItem("Show details", e -> e.getItem().ifPresent(this::openDetailsDialog));
menu.addItem("Open URL", e -> e.getItem().ifPresent(m -> UI.getCurrent().getPage().open(m.originalUrl(), "_blank")));
menu.addItem("Copy shortcode", e -> e.getItem().ifPresent(m -> UI.getCurrent().getPage().executeJs("navigator.clipboard.writeText($0)", m.shortCode())));
menu.addItem("Delete…", e -> e.getItem().ifPresent(m -> confirmDelete(m.shortCode())));
Neben der Funktionalität wurde auch die visuelle Struktur der Grid-Spalten überarbeitet. Der Shortcode wird nun in einer monospace-Schriftart dargestellt, was das schnelle visuelle Erfassen und Vergleichen erleichtert. Zusätzlich bietet ein kleines Copy-Symbol die Möglichkeit, den vollständigen Kurzlink per Mausklick in die Zwischenablage zu übernehmen. Hierbei wird über JavaScript der systemeigene Clipboard-Service des Browsers aufgerufen.
grid.addComponentColumn(m -> {
var code = new Span(m.shortCode());
code.getStyle().set("font-family", "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace");
var copy = new Button(new Icon(VaadinIcon.COPY));
copy.addThemeVariants(ButtonVariant.LUMO_TERTIARY_INLINE);
copy.getElement().setProperty("title", "Copy ShortUrl");
copy.addClickListener(_ -> {
UI.getCurrent().getPage().executeJs("navigator.clipboard.writeText($0)", SHORTCODE_BASE_URL + m.shortCode());
Notification.show("Shortcode copied");
});
var wrap = new HorizontalLayout(code, copy);
wrap.setSpacing(true);
wrap.setPadding(false);
return wrap;
}).setHeader("Shortcode").setAutoWidth(true).setFrozen(true).setResizable(true).setFlexGrow(0);
In der Spalte mit der Original-URL wurde das Layout auf eine ellipsenförmige Darstellung umgestellt: Lange URLs werden abgeschnitten, bleiben jedoch über den Tooltip vollständig einsehbar. Dieses Detail verbessert die Lesbarkeit, ohne dabei Informationen zu verlieren.
grid.addComponentColumn(m -> {
var a = new Anchor(m.originalUrl(), m.originalUrl());
a.setTarget("_blank");
a.getStyle()
.set("white-space", "nowrap")
.set("overflow", "hidden")
.set("text-overflow", "ellipsis")
.set("display", "inline-block")
.set("max-width", "100%");
a.getElement().setProperty("title", m.originalUrl());
return a;
}).setHeader("URL").setFlexGrow(1).setResizable(true);
Besonders hervorzuheben ist die neue Ablaufspalte (Expires), die durch farbcodierte Statusindikatoren visuell ergänzt wurde. Diese basieren auf Lumo-Badges und zeigen den verbleibenden Zeitraum bis zum Ablauf eines Links an: Grün für aktive, Gelb für bald ablaufende und Rot für bereits abgelaufene Einträge.
grid.addComponentColumn(m -> {
var pill = new Span(m.expiresAt()
.map(ts -> {
var days = Duration.between(Instant.now(), ts).toDays();
if (days < 0) return "Expired";
if (days == 0) return "Today";
return "in " + days + " days";
}).orElse("No expiry"));
pill.getElement().getThemeList().add("badge pill small");
m.expiresAt().ifPresent(ts -> {
long d = Duration.between(Instant.now(), ts).toDays();
if (d < 0) pill.getElement().getThemeList().add("error");
else if (d <= 3) pill.getElement().getThemeList().add("warning");
else pill.getElement().getThemeList().add("success");
});
return pill;
}).setHeader("Expires").setAutoWidth(true).setResizable(true).setFlexGrow(0);
Abschließend wurde die Ergonomie der Darstellung verbessert. Das Grid arbeitet nun mit reduziertem vertikalem Abstand (Compact Mode), behält jedoch dank alternierender Zeilenfarben seine Lesbarkeit. Die Zeilenhöhe wurde so angepasst, dass auch auf kleineren Monitoren möglichst viele Einträge sichtbar bleiben, ohne die UI überladen wirken zu lassen.
Mit diesen Änderungen wandelt sich die OverviewView von einer reinen Verwaltungsansicht zu einem zentralen Kontrollinstrument, das sowohl eine schnelle Übersicht als auch eine tiefe Interaktion ermöglicht. Die Grundlage für die Integration des Detaildialogs ist damit gelegt – er ergänzt diese Ansicht um die objektbezogene Perspektive, während die OverviewView weiterhin als Startpunkt der Navigation dient.
Detailansicht als eigenständige UI-Komponente (DetailsDialog)
Nachdem die Übersichtsliste um interaktive Funktionen erweitert wurde, folgt nun der nächste logische Schritt: die Einführung einer eigenständigen UI-Komponente, die sich auf die Darstellung und Verwaltung einzelner Datensätze konzentriert. Ziel ist es, eine modulare und wiederverwendbare Ansicht zu schaffen, die von der Hauptansicht entkoppelt agiert, aber über klar definierte Ereignisse mit ihr kommuniziert.
Der Dialog basiert auf der Klasse Dialog von Vaadin und wird für jedes ausgewählte ShortUrlMapping-Objekt geöffnet. Dabei liest er alle relevanten Eigenschaften des übergebenen Objekts aus – Shortcode, Ziel-URL, Erstellungszeitpunkt und optional das Ablaufdatum. Diese Werte werden in einem klar strukturierten Format dargestellt, ergänzt durch Aktionsschaltflächen zum Öffnen, Kopieren und Löschen des Eintrags.
Der folgende Ausschnitt zeigt den grundlegenden Aufbau des Dialogs:
public class DetailsDialog extends Dialog implements HasLogger {
public static final ZoneId ZONE = ZoneId.systemDefault();
private static final DateTimeFormatter DATE_TIME_FMT =
DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm").withZone(ZONE);
private final String shortCode;
private final String originalUrl;
private final Instant createdAt;
private final Optional<Instant> expiresAt;
private final TextField tfShort = new TextField("Shortcode");
private final TextField tfUrl = new TextField("Original URL");
private final TextField tfCreated = new TextField("Created on");
private final TextField tfExpires = new TextField("Expires");
private final Span statusPill = new Span();
private final Button openBtn = new Button("Open", new Icon(VaadinIcon.EXTERNAL_LINK));
private final Button copyShortBtn = new Button("Copy ShortURL", new Icon(VaadinIcon.COPY));
private final Button copyUrlBtn = new Button("Copy URL", new Icon(VaadinIcon.COPY));
private final Button deleteBtn = new Button("Delete…", new Icon(VaadinIcon.TRASH));
private final Button closeBtn = new Button("Close");
public DetailsDialog(ShortUrlMapping mapping) {
Objects.requireNonNull(mapping, "mapping");
this.shortCode = mapping.shortCode();
this.originalUrl = mapping.originalUrl();
this.createdAt = mapping.createdAt();
this.expiresAt = mapping.expiresAt();
setHeaderTitle("Details: " + shortCode);
setModal(true);
setDraggable(true);
setResizable(true);
setWidth("720px");
openBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
deleteBtn.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY);
var headerActions = new HorizontalLayout(openBtn, copyShortBtn, copyUrlBtn, deleteBtn);
getHeader().add(headerActions);
configureFields();
var form = new FormLayout();
form.add(tfShort, tfUrl, tfCreated, tfExpires, statusPill);
form.setColspan(tfUrl, 2);
add(form);
closeBtn.addClickListener(e -> close());
getFooter().add(closeBtn);
wireActions();
}
Bereits hier wird deutlich, dass der Dialog ein hohes Maß an Eigenständigkeit aufweist. Er initialisiert seine Inhalte direkt aus dem übergebenen Datenobjekt und verwendet ein FormLayout zur strukturierten Darstellung der Felder. Die Eingabefelder sind standardmäßig readonly, da der Dialog primär der Anzeige dient.
Die visuelle Rückmeldung zum Ablaufstatus erfolgt über eine sogenannte Status-Pill, die farblich und textlich anzeigt, ob ein Kurzlink noch aktiv, bald ablaufend oder bereits abgelaufen ist. Dies geschieht über eine kleine Hilfsmethode, die eine Statusbeschreibung samt Farbschema liefert:
private Status computeStatusText() {
return expiresAt.map(ts -> {
long d = Duration.between(Instant.now(), ts).toDays();
if (d < 0) return new Status("Expired", "error");
if (d == 0) return new Status("Expires today", "warning");
if (d <= 3) return new Status("Expires in " + d + " days", "warning");
return new Status("Valid (" + d + " days left)", "success");
}).orElse(new Status("No expiry", "contrast"));
}
Diese Statusberechnung ergänzt das visuelle Feedback aus der Übersichtstabelle und liefert eine konsistente Darstellung über die gesamte Anwendung hinweg.
Der eigentliche Mehrwert des DetailsDialog liegt in seiner Ereignisorientierung. Statt dass die aufrufende Ansicht (OverviewView) selbst alle Aktionen steuert, werden diese als Vaadin-Events definiert. So kann der Dialog Signale wie OpenEvent, CopyShortcodeEvent, CopyUrlEvent oder DeleteEvent an seine Umgebung senden, ohne selbst über deren Bedeutung Bescheid zu wissen:
public static class DeleteEvent extends ComponentEvent<DetailsDialog> {
public final String shortCode;
public DeleteEvent(DetailsDialog src, String sc) {
super(src);
this.shortCode = sc;
}
}
In der OverviewView werden diese Ereignisse empfangen und weiterverarbeitet:
var dlg = new DetailsDialog(item);
dlg.addDeleteListener(ev -> confirmDelete(ev.shortCode));
dlg.addOpenListener(ev -> logger().info("Open URL {}", ev.originalUrl));
dlg.addCopyShortListener(ev -> logger().info("Copied shortcode {}", ev.shortCode));
dlg.addCopyUrlListener(ev -> logger().info("Copied URL {}", ev.url));
dlg.open();
Damit entsteht eine klare Entkopplung zwischen der Präsentations- und der Anwendungslogik. Der Dialog kümmert sich um Darstellung und Interaktion; die umgebende Ansicht entscheidet, wie auf die Ereignisse reagiert werden soll.
Zusammenfassend lässt sich sagen: Der DetailsDialog etabliert ein Architekturprinzip, das über den konkreten Anwendungsfall hinausgeht. Durch die Kombination aus modularer UI-Komponente, deklarativen Ereignissen und klar definiertem Datenmodell wird die Anwendung nicht nur flexibler, sondern auch langfristig wartbarer. Der Benutzer profitiert dabei von einer kohärenten, klar strukturierten Detailansicht, die das Arbeiten mit einzelnen Einträgen wesentlich komfortabler und transparenter macht.
Erzeugungsdialog (CreateView): Ablaufdatum in der UI
Mit diesem Schritt erhält die Erzeugung neuer Kurzlinks eine wichtige semantische Ergänzung: das optionale Ablaufdatum. Ziel ist es, die beabsichtigte Lebensdauer bereits beim Anlegen präzise festzulegen und diese Information Ende-zu-Ende – von der UI über den Client bis zum Server und in die Persistenz – zu transportieren.

Aus UI-Sicht wird die bestehende CreateView um DatePicker und TimePicker erweitert, flankiert von einer Checkbox „No expiry“. Diese Kombination ermöglicht sowohl das explizite Festlegen eines Endzeitpunkts als auch die bewusste Entscheidung für eine unbegrenzte Gültigkeit. Eine kleine, aber entscheidende UX-Regel: Die Zeitangabe bleibt so lange deaktiviert, bis ein Datum gewählt ist, und beide Felder sind deaktiviert, wenn „No expiry“ aktiviert ist.
// Felder und Grundkonfiguration
private final TextField urlField = new TextField("Target URL");
private final TextField aliasField = new TextField("Alias (optional)");
private final Button shortenButton = new Button("Shorten");
private final DatePicker expiresDate = new DatePicker("Expires (date)");
private final TimePicker expiresTime = new TimePicker("Expires (time)");
private final Checkbox noExpiry = new Checkbox("No expiry");
private final FormLayout form = new FormLayout();
public CreateView() {
setSpacing(true);
setPadding(true);
urlField.setWidthFull();
aliasField.setWidth("300px");
shortenButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
form.add(urlField, aliasField);
configureExpiryFields();
form.setResponsiveSteps(
new FormLayout.ResponsiveStep("0", 1),
new FormLayout.ResponsiveStep("600px", 2)
);
form.setColspan(urlField, 2);
var actions = new HorizontalLayout(shortenButton);
actions.setAlignItems(Alignment.END);
// Binder für Validierungen
Binder<ShortenRequest> binder = new Binder<>(ShortenRequest.class);
ShortenRequest request = new ShortenRequest();
binder.forField(urlField)
.asRequired("URL must not be empty")
.withValidator(url -> url.startsWith("http://") || url.startsWith("https://"), "Only HTTP(S) URLs allowed")
.bind(ShortenRequest::getUrl, ShortenRequest::setUrl);
binder.forField(aliasField)
.withValidator(a -> a == null || a.isBlank() || a.length() <= AliasPolicy.MAX, "Alias is too long (max " + AliasPolicy.MAX + ")")
.withValidator(a -> a == null || a.isBlank() || a.matches(REGEX_ALLOWED), "Only [A-Za-z0-9_-] allowed")
.bind(ShortenRequest::getShortURL, ShortenRequest::setShortURL);
shortenButton.addClickListener(_ -> {
var validated = binder.validate();
if (validated.hasErrors()) return;
if (!validateExpiryInFuture()) return;
if (binder.writeBeanIfValid(request)) {
computeExpiresAt().ifPresent(request::setExpiresAt);
var code = createShortCode(request, computeExpiresAt());
code.ifPresentOrElse(c -> {
Notification.show("Short link created: " + c);
clearForm(binder);
},
() -> Notification.show("Alias already assigned or error saving", 3000, Notification.Position.MIDDLE));
}
});
add(new H2("Create new short link"), form, actions);
}
Die Kapselung der Ablauflogik in kleine Hilfsmethoden sorgt für Lesbarkeit und Testbarkeit. Die Berechnung nutzt die lokale Zeitzone und liefert ein Instant, das serverseitig unverändert verarbeitet werden kann.
private static final ZoneId ZONE = ZoneId.systemDefault();
private void configureExpiryFields() {
expiresDate.setClearButtonVisible(true);
expiresDate.setPlaceholder("dd.MM.yyyy");
expiresTime.setStep(Duration.ofMinutes(1));
expiresTime.setPlaceholder("HH:mm");
// Zeit erst aktivieren, wenn ein Datum gesetzt ist
expiresTime.setEnabled(false);
expiresDate.addValueChangeListener(ev -> {
boolean hasDate = ev.getValue() != null;
expiresTime.setEnabled(hasDate && !noExpiry.getValue());
});
noExpiry.addValueChangeListener(ev -> {
boolean disabled = ev.getValue();
expiresDate.setEnabled(!disabled);
expiresTime.setEnabled(!disabled && expiresDate.getValue() != null);
});
form.add(noExpiry, expiresDate, expiresTime);
}
private Optional<Instant> computeExpiresAt() {
if (Boolean.TRUE.equals(noExpiry.getValue())) return Optional.empty();
LocalDate d = expiresDate.getValue();
LocalTime t = expiresTime.getValue();
if (d == null || t == null) return Optional.empty();
return Optional.of(ZonedDateTime.of(d, t, ZONE).toInstant());
}
private boolean validateExpiryInFuture() {
var exp = computeExpiresAt();
if (exp.isPresent() && exp.get().isBefore(Instant.now())) {
Notification.show("Expiry must be in the future");
return false;
}
return true;
}
private void clearForm(Binder<ShortenRequest> binder) {
urlField.clear();
aliasField.clear();
noExpiry.clear();
expiresDate.clear();
expiresTime.clear();
binder.setBean(new ShortenRequest());
urlField.setInvalid(false);
aliasField.setInvalid(false);
}
Wichtig ist die transparente Weitergabe an den Client. Statt den Zeitpunkt im UI zu speichern, wird er in der Anfrage an den Server übermittelt. Die CreateView ruft hierfür den Client mit der erweiterten Signatur auf. Der Client validiert nur dann, wenn ein Alias gesetzt ist, und übergibt die Daten unverändert an den Server.
private Optional<String> createShortCode(ShortenRequest req, Optional<Instant> expiresAt) {
logger().info("createShortCode with ShortenRequest '{}'", req);
try {
var customMapping = urlShortenerClient.createCustomMapping(req.getShortURL(), req.getUrl(), expiresAt.orElse(null));
return Optional.ofNullable(customMapping.shortCode());
} catch (IllegalArgumentException | IOException e) {
logger().error("Error saving", e);
return Optional.empty();
}
}
Auf der Client-Seite wird die Payload als ShortenRequest serialisiert und an den Endpunkt /shorten gesendet. Die Antwort wird nicht nur auf den Shortcode reduziert, sondern auch als vollständiges ShortUrlMapping geparst. Dadurch kennt die UI sofort den serverseitig bestätigten Zustand – inklusive des expiresAt.
public ShortUrlMapping createCustomMapping(String alias, String url, Instant expiredAt) throws IOException {
logger().info("Create custom mapping alias='{}' url='{}' expiredAt='{}'", alias, url, expiredAt);
if (alias != null && !alias.isBlank()) {
var validate = AliasPolicy.validate(alias);
if (!validate.valid()) {
var reason = validate.reason();
throw new IllegalArgumentException(reason.defaultMessage);
}
}
URL shortenUrl = serverBaseAdmin.resolve(PATH_ADMIN_SHORTEN).toURL();
HttpURLConnection connection = (HttpURLConnection) shortenUrl.openConnection();
connection.setRequestMethod("POST");
connection.setDoOutput(true);
connection.setRequestProperty(CONTENT_TYPE, JSON_CONTENT_TYPE);
var shortenRequest = new ShortenRequest(url, alias, expiredAt);
String body = shortenRequest.toJson();
try (OutputStream os = connection.getOutputStream()) {
os.write(body.getBytes(UTF_8));
}
int status = connection.getResponseCode();
if (status == 200 || status == 201) {
try (InputStream is = connection.getInputStream()) {
String jsonResponse = new String(is.readAllBytes(), UTF_8);
ShortUrlMapping shortUrlMapping = fromJson(jsonResponse, ShortUrlMapping.class);
return shortUrlMapping;
}
}
if (status == 409) {
throw new IllegalArgumentException("Alias already in use");
}
throw new IOException("Unexpected status: " + status);
}
Auf Server-Seite nimmt der ShortenHandler die erweiterte Anfrage entgegen, validiert die erforderlichen Felder und beauftragt anschließend den Store mit der Anlage. Die Antwort enthält das vollständige Mapping-Objekt, das Client und UI unmittelbar weiterverwenden können.
final String body = readBody(ex.getRequestBody());
ShortenRequest req = fromJson(body, ShortenRequest.class);
if (isNullOrBlank(req.getUrl())) {
writeJson(ex, BAD_REQUEST, "Missing 'url'");
return;
}
final Result<ShortUrlMapping> urlMappingResult = store.createMapping(req.getShortURL(), req.getUrl(), req.getExpiresAt());
urlMappingResult
.ifPresentOrElse(success -> logger().info("mapping created success {}", success.toString()),
failed -> logger().info("mapping created failed - {}", failed));
urlMappingResult
.ifSuccess(mapping -> {
final Headers h = ex.getResponseHeaders();
h.add("Location", "/r/" + mapping.shortCode());
writeJson(ex, fromCode(201), toJson(mapping));
})
.ifFailure(errorJson -> {
try {
var parsed = JsonUtils.parseJson(errorJson);
var errorCode = Integer.parseInt(parsed.get("code"));
var message = parsed.get("message");
writeJson(ex, fromCode(errorCode), message);
} catch (Exception e) {
writeJson(ex, CONFLICT, errorJson);
}
});
Zusammengefasst entsteht ein konsistenter, durchgängiger Ablauf: Der Benutzer definiert beim Anlegen optional ein Ablaufdatum, die UI validiert grundlegende Regeln, der Client überträgt die Semantik verlustfrei und der Server persistiert sie verlässlich. Die OverviewView und der DetailsDialog können diese Information anschließend sofort anzeigen und interpretieren. Damit wird die Domäne um eine zentrale Eigenschaft ergänzt, ohne den bestehenden Bedienfluss zu verkomplizieren.
Navigation und Paketstruktur
Die bisherige Benutzeroberfläche wurde durch den Detaildialog und die erweiterten Formularfunktionen inhaltlich deutlich komplexer. Um diese Entwicklung auch strukturell sauber abzubilden, erfolgte eine klare Neuordnung der Paketstruktur im UI-Modul. Das Ziel war dabei nicht nur eine logische Gruppierung nach Verantwortlichkeiten, sondern auch eine langfristige Grundlage für erweiterte Navigationskonzepte sowie modulare Erweiterungen.
Zuvor befand sich die OverviewView noch im allgemeinen Paket com.svenruppert.urlshortener.ui.vaadin.views. Mit der Einführung des Detaildialogs und der wachsenden inhaltlichen Bedeutung dieses Bereichs wurde sie in ein eigenes Unterpaket „views.overview“ aufgenommen und dorthin verschoben. Diese Entscheidung schafft nicht nur Raum für zusätzliche Komponenten (z. B. Kontextmenüs, Hilfsdialoge oder Filter), sondern folgt auch dem Prinzip der funktionalen Kohärenz: Alle Klassen, die gemeinsam die Übersichtsfunktion bilden, sind nun zentral gebündelt.
Im Code spiegelt sich dieser Schritt in der Anpassung des Imports innerhalb des MainLayout wider:
// alt:
import com.svenruppert.urlshortener.ui.vaadin.views.OverviewView;
// neu:
import com.svenruppert.urlshortener.ui.vaadin.views.overview.OverviewView;
Dieser kleine, aber bedeutende Schritt markiert den Übergang von einer rein seitenbasierten Struktur hin zu einem komponentenorientierten Aufbau. Das MainLayout fungiert weiterhin als zentrales Navigationselement der Anwendung, die einzelnen Views sind nun stärker voneinander entkoppelt und können eigenständig weiterentwickelt werden. Damit entsteht eine klare Trennung zwischen Layout-Logik (zentrale Navigation, Menüs, visuelle Rahmenbedingungen) und Funktionslogik (Anzeige, Interaktion, Datenfluss).
Die Routenbeziehungen bleiben dabei bewusst einfach gehalten. Die OverviewView ist weiterhin unter dem Pfad /overview registriert und nutzt das MainLayout als übergeordnetes Layout-Element:
@PageTitle("Overview")
@Route(value = OverviewView.PATH, layout = MainLayout.class)
public class OverviewView extends VerticalLayout implements HasLogger {
public static final String PATH = "overview";
// ...
}
Durch diese Konfiguration bleibt die Navigation konsistent mit den anderen Bestandteilen der Anwendung wie CreateView, AboutView oder YoutubeView. Neue Views lassen sich einfach ergänzen, ohne den zentralen Navigationsmechanismus anpassen zu müssen. Das sorgt für Wartbarkeit und Skalierbarkeit – zwei zentrale Anforderungen für eine Anwendung, die schrittweise im Rahmen eines Adventskalenders wächst.
Das MainLayout selbst ist weitgehend unverändert, wurde jedoch im Zuge der Paketumstellung angepasst, um Imports und Menüeinträge zu aktualisieren. Besonders wichtig ist dabei die Referenzierung der OverviewView, da sie den Einstiegspunkt der Benutzerinteraktion darstellt:
SideNavItem overview = new SideNavItem("Overview", OverviewView.class, VaadinIcon.LIST.create());
SideNavItem create = new SideNavItem("Create", CreateView.class, VaadinIcon.PLUS.create());
SideNavItem about = new SideNavItem("About", AboutView.class, VaadinIcon.INFO_CIRCLE.create());
SideNavItem youtube = new SideNavItem("Youtube", YoutubeView.class, VaadinIcon.YOUTUBE.create());
SideNav nav = new SideNav(overview, create, about, youtube);
addToDrawer(nav);
Mit dieser sauberen Trennung zwischen Routing, Struktur und Darstellung wird der Grundstein für die weitere Entwicklung der Benutzeroberfläche gelegt. Zukünftige Features – etwa Detailfilter, Benutzerpräferenzen oder administrative Funktionen – können problemlos in eigenen Teilpaketen und Namespaces integriert werden, ohne die Kernnavigation zu beeinträchtigen. Damit wird der Schritt zu einer nachhaltigen UI-Architektur vollzogen, die sowohl wachsende Komplexität als auch Erweiterbarkeit gezielt unterstützt.
Interaktionsmuster und UX-Kohärenz
Mit der zunehmenden Funktionsdichte der Anwendung wächst auch die Bedeutung einer konsistenten Benutzererfahrung. Während die ersten Tage des Adventskalenders die technische Grundlage gelegt haben, stand bisher vor allem die Funktionalität im Vordergrund. In diesem Kapitel verschiebt sich der Schwerpunkt: Es geht um die Kohärenz der Interaktionsmuster, also darum, wie Benutzer mit der Anwendung in einem gleichmäßigen, vorhersehbaren Rhythmus interagieren.
Zentral ist dabei das Ziel, für wiederkehrende Aktionen ein einheitliches Verhalten zu etablieren. Ob in der Übersicht, im Detaildialog oder in Formularen – Kopieren, Öffnen oder Löschen sollen sich stets gleich anfühlen. Die Anwendung vermittelt dadurch Verlässlichkeit, was insbesondere für technische Benutzer bei webbasierten Werkzeugen entscheidend ist.
Einheitliches Kopierverhalten
Ein gutes Beispiel ist das Kopieren von URLs und Kurzlinks. Sowohl im Grid als auch im Detaildialog werden dieselben Mechanismen eingesetzt: ein Button mit dem VaadinIcon.COPY-Symbol: eine asynchrone Clipboard-Aktion über JavaScript und eine dezente Bestätigungsmeldung. Dadurch wird vermieden, dass Benutzer in verschiedenen Ansichten unterschiedliche Interaktionsformen erlernen müssen.
copy.addClickListener(_ -> {
UI.getCurrent().getPage().executeJs("navigator.clipboard.writeText($0)", SHORTCODE_BASE_URL + m.shortCode());
Notification.show("Shortcode copied");
});
Ein solches Detail wirkt unscheinbar, hat jedoch großen Einfluss auf die wahrgenommene Professionalität der Anwendung. Das Feedbacksystem über Notifications spielt hierbei eine zentrale Rolle: Der Benutzer erhält ein unmittelbares, unaufdringliches Signal über den Erfolg seiner Aktion – eine Art visuelle Quittung, die Vertrauen schafft.
Kontextmenüs und Mehrfachinteraktion
Ein weiteres Element der UX-Kohärenz ist das Kontextmenü in der Übersichtstabelle. Es ermöglicht den Zugriff auf dieselben Aktionen, die auch über Buttons oder Doppelklicks erreichbar sind. Diese redundante, aber bewusste Mehrfachinteraktion folgt dem Grundsatz der User Freedom: Benutzer dürfen wählen, ob sie über direkte Icons, die Tastatur oder das Kontextmenü agieren.
GridContextMenu<ShortUrlMapping> menu = new GridContextMenu<>(grid);
menu.addItem("Show details", e -> e.getItem().ifPresent(this::openDetailsDialog));
menu.addItem("Open URL", e -> e.getItem().ifPresent(m -> UI.getCurrent().getPage().open(m.originalUrl(), "_blank")));
menu.addItem("Copy shortcode", e -> e.getItem().ifPresent(m -> UI.getCurrent().getPage().executeJs("navigator.clipboard.writeText($0)", m.shortCode())));
menu.addItem("Delete…", e -> e.getItem().ifPresent(m -> confirmDelete(m.shortCode())));
Die Entscheidung, Kontextmenüs zu verwenden, ist nicht nur ästhetisch, sondern auch ergonomisch: Sie reduziert die optische Dichte des Grids, ohne Funktionalität einzubüßen. Aktionen erscheinen nur dann, wenn sie gebraucht werden – ein Ansatz, der in komplexen Verwaltungsoberflächen essenziell ist.
Konsistenz der Rückmeldungen
Auch bei Fehlermeldungen und Validierungen wird ein einheitliches Muster verfolgt. Das System bricht Benutzeraktionen nicht abrupt ab, sondern kommuniziert klar, warum eine Eingabe nicht akzeptiert wurde. Beispiel: Das Ablaufdatum darf nicht in der Vergangenheit liegen. Diese Regel wird sowohl visuell als auch per Nachricht vermittelt.
if (exp.isPresent() && exp.get().isBefore(Instant.now())) {
Notification.show("Expiry must be in the future");
return false;
}
Durch präzise Rückmeldungen wird verhindert, dass der Benutzer das System als unvorhersehbar empfindet. Jede Validierung, jeder Hinweis und jede Erfolgsmeldung folgen demselben kommunikativen Stil – kurz, eindeutig und höflich.
HTTPS-Voraussetzung für Clipboard
Ein technisches, aber wichtiges Detail betrifft die Verwendung der Clipboard-API: Sie funktioniert in modernen Browsern nur innerhalb sicherer Kontexte (HTTPS oder localhost). Daher ist das Kopier-Feature bewusst so gestaltet, dass es elegant fehlschlägt, wenn kein Zugriff auf die Zwischenablage besteht. Die Anwendung stürzt nicht ab, sondern reagiert still – ein Aspekt, der die Robustheit und Professionalität der Benutzererfahrung unterstreicht.
Die in diesem Kapitel beschriebenen Mechanismen – konsistentes Feedback, redundante Bedienoptionen und sichere Fallbacks – tragen gemeinsam zu einer kohärenten Benutzererfahrung bei. Sie bilden die Grundlage für Vertrauen und Vorhersehbarkeit, zwei Eigenschaften, die für Software mit wachsender Komplexität essenziell sind. Insgesamt entsteht eine Oberfläche, die sich intuitiv bedienen lässt, auch wenn ihre technische Tiefe im Hintergrund stetig zunimmt.
Cheers Sven