Advent Calendar – 2025 – From Grid to Detail: Understanding the User Experience in the Short-URL Manager
The current UI from the user’s point of view
On the first call, the user lands in the overview. The site is built on a Vaadin grid, whose header contains a search bar, paging controls, and a small settings button with a gear icon. The most essential flow begins with the table displaying immediately understandable columns: the shortcode as a clearly typographically separated monospace value with copy action, the original URL as a clickable link, a creation time in local format, and an expiration badge that visually communicates semantic states such as “Expired”, “Today” or “in n days” via theme colours. The whole thing is designed for quick viewing and efficient one-handed operation: a click on a data record opens the detailed dialogue if required; a right-click or the context menu offers direct quick actions; and the gear button can be used to show or hide visible columns live.
The source code for this version can be found on GitHub at https://github.com/svenruppert/url-shortener/tree/feature/advent-2025-day-05
Central to everyday life is the small “Settings” button on the right of the search bar. It opens the column dialogue and directly affects the grid. This gives the user a lightweight tool to customise the table to their needs without losing context. In the dialogue, the user sees a tidy list of checkboxes—each named after the column header or the internal key. Each click immediately shows or hides the corresponding column; the state is persisted, so that when you revisit it, the view appears as if you had left. The behaviour is deliberately kept minimalist: no “Apply” button, but immediate feedback, supplemented by an “Apply bulk” option for collective changes.

The table is optimised for common workflows. The shortcode is formatted as a monospace span, and a copy button is right next to it that copies the full short URL to the clipboard. This eliminates the need to highlight text; a quick handover in chat, issue tracker, or email is done with one click. The original URL opens in a new tab, allowing the user to review the landing page without losing track. The creation and expiration times are kept compact; the expiration badge changes colour depending on the remaining term, signalling urgency.
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");
});
...
return new HorizontalLayout(code, copy, open, details);
})
.setHeader("Shortcode")
.setKey("shortcode")
.setAutoWidth(true)
.setResizable(true)
.setFlexGrow(0);
grid.addColumn(m -> DATE_TIME_FMT.format(m.createdAt()))
.setHeader("Created")
.setKey("created")
.setAutoWidth(true)
.setResizable(true)
.setSortable(true)
.setFlexGrow(0);
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");
...
return pill;
})
.setHeader("Expires")
.setKey("expires")
.setAutoWidth(true);
In addition to the dialogue, there is a second, faster entry point via the context menu. Right-clicking on a line opens actions such as “Show details”, “Open URL”, “Copy shortcode” and “Delete…”. This is especially useful if the user already knows what they want to do with the current record without leaving the main view. The flow remains fluid because every action docks directly to the grid.

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())));
If the user wants to know more or make changes, the application opens a standalone detail dialogue for the selected entry. From an interaction perspective, this is where deeper information and operations converge. The appeal remains deliberately unspectacular and fast:

private void openDetailsDialog(ShortUrlMapping item) {
var dlg = new DetailsDialog(urlShortenerClient, 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.addSavedListener(_ -> refresh());
dlg.open();
}
As a result, the overview feels like a familiar workboard for the user, which remembers which columns really count, can be copied and opened quickly, and with context menus and a focused detail dialogue offers exactly the depth of interaction that is needed in everyday short URL life — no more, but also no less.
Detail dialogue on the data record
The detail dialogue is the central interaction element when users want to take a closer look at or edit a single entry in the system. It provides all relevant information about a shortened URL and enables basic operations, such as opening, copying, deleting, or saving the record. All features are designed to be available within the application without context switching.
The implementation of the dialogue in the project is in the DetailsDialog class. This class encompasses all the logic for displaying and manipulating a ShortUrlMapping object. The dialogue is built entirely with Vaadin components:
package com.svenruppert.urlshortener.ui.vaadin.views.overview;
//SNIPP
public class DetailsDialog extends Dialog implements HasLogger {
private final URLShortenerClient urlShortenerClient;
private final ShortUrlMapping mapping;
private final DateTimeFormatter DATE_TIME_FMT=
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(ZoneId.systemDefault());
public DetailsDialog(URLShortenerClient urlShortenerClient, ShortUrlMapping mapping) {
this.urlShortenerClient = urlShortenerClient;
this.mapping = mapping;
setHeaderTitle("Details for " + mapping.shortCode());
var form = new FormLayout();
var urlField = new TextField("Original URL");
urlField.setValue(Optional.ofNullable(mapping.originalUrl()).orElse(""));
urlField.setWidthFull();
urlField.setReadOnly(true);
var createdAt = new Span(DATE_TIME_FMT.format(mapping.createdAt()));
var expiresAt = mapping.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");
var expiresField = new Span(expiresAt);
form.addFormItem(urlField, "Original URL");
form.addFormItem(createdAt, "Created");
form.addFormItem(expiresField, "Expires");
var openBtn = new Button(new Icon(VaadinIcon.EXTERNAL_LINK));
openBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
openBtn.getElement().setProperty("title", "Open original URL");
openBtn.addClickListener(_ -> {
UI.getCurrent().getPage().open(mapping.originalUrl(), "_blank");
fireEvent(new OpenEvent(this, mapping.originalUrl()));
});
var copyShortBtn = new Button(new Icon(VaadinIcon.COPY));
copyShortBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
copyShortBtn.getElement().setProperty("title", "Copy Short URL");
copyShortBtn.addClickListener(_ -> {
UI.getCurrent().getPage()
.executeJs("navigator.clipboard.writeText($0)", SHORTCODE_BASE_URL + mapping.shortCode());
Notification.show("Short URL copied");
fireEvent(new CopyShortEvent(this, mapping.shortCode()));
});
var copyUrlBtn = new Button(new Icon(VaadinIcon.LINK));
copyUrlBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
copyUrlBtn.getElement().setProperty("title", "Copy Original URL");
copyUrlBtn.addClickListener(_ -> {
UI.getCurrent().getPage()
.executeJs("navigator.clipboard.writeText($0)", mapping.originalUrl());
Notification.show("Original URL copied");
fireEvent(new CopyUrlEvent(this, mapping.originalUrl()));
});
var deleteBtn = new Button(new Icon(VaadinIcon.TRASH));
deleteBtn.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY);
deleteBtn.getElement().setProperty("title", "Delete Short URL");
deleteBtn.addClickListener(_ -> {
fireEvent(new DeleteEvent(this, mapping.shortCode()));
});
var saveBtn = new Button("Save", _ -> {
urlShortenerClient.update(mapping);
fireEvent(new SavedEvent(this));
close();
});
var buttons = new HorizontalLayout(openBtn, copyShortBtn, copyUrlBtn, deleteBtn, saveBtn);
add(form, buttons);
}
public Registration addOpenListener(ComponentEventListener<OpenEvent> listener) {
return addListener(OpenEvent.class, listener);
}
public Registration addCopyShortListener(ComponentEventListener<CopyShortEvent> listener) {
return addListener(CopyShortEvent.class, listener);
}
public Registration addCopyUrlListener(ComponentEventListener<CopyUrlEvent> listener) {
return addListener(CopyUrlEvent.class, listener);
}
public Registration addDeleteListener(ComponentEventListener<DeleteEvent> listener) {
return addListener(DeleteEvent.class, listener);
}
public Registration addSavedListener(ComponentEventListener<SavedEvent> listener) {
return addListener(SavedEvent.class, listener);
}
public static class OpenEvent extends ComponentEvent<DetailsDialog> {
private final String originalUrl;
public OpenEvent(DetailsDialog src, String originalUrl) {
super(src, false);
this.originalUrl = originalUrl;
}
public String originalUrl() { return originalUrl; }
}
public static class CopyShortEvent extends ComponentEvent<DetailsDialog> {
private final String shortCode;
public CopyShortEvent(DetailsDialog src, String shortCode) {
super(src, false);
this.shortCode = shortCode;
}
public String shortCode() { return shortCode; }
}
public static class CopyUrlEvent extends ComponentEvent<DetailsDialog> {
private final String url;
public CopyUrlEvent(DetailsDialog src, String url) {
super(src, false);
this.url = url;
}
public String url() { return url; }
}
public static class DeleteEvent extends ComponentEvent<DetailsDialog> {
private final String shortCode;
public DeleteEvent(DetailsDialog src, String shortCode) {
super(src, false);
this.shortCode = shortCode;
}
public String shortCode() { return shortCode; }
}
public static class SavedEvent extends ComponentEvent<DetailsDialog> {
public SavedEvent(DetailsDialog src) { super(src, false); }
}
}
The architecture follows a clear pattern: the dialogue displays data but does not trigger a direct UI update. Instead, it fires specific events (ComponentEvents) that are caught by the calling View (OverviewView). This means that the dialogue remains independent of its environment – a concept that can be implemented particularly elegantly in Vaadin.
The meaning of the individual buttons is immediately understandable: the open button opens the browser with the original URL, the two copy buttons copy the respective address to the clipboard, and the delete button sends a delete event that is further processed in the overview. It is particularly noteworthy that the save button internally calls urlShortenerClient.update(), thereby synchronising changes directly through the existing client-server infrastructure.
This makes the detail dialogue a self-contained UI element that encapsulates both presentation and interaction without dragging business logic into the interface. This pattern promotes reusability, testability, and a clear separation of responsibilities.
Context menu of grid rows
In the project, the context menu is anchored to the OverviewView and bound to the ShortUrlMapping objects‘ grid. The source code shows how this menu is structured and what actions are offered in it:
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())));
The logic is simple but effective: each menu item performs a clearly defined action. These actions draw directly on the existing mechanisms of the OverviewView – such as the openDetailsDialog() for detail views or confirmDelete() for removing a data record.
Each menu action uses the method e.getItem().ifPresent(...)to ensure that a context object (that is, a selected entry in the grid) exists. This pattern protects the application from null references and makes the menu robust against unforeseen UI states.
The integration into the OverviewView is straightforward and follows Vaadin’s component-oriented approach. By binding to the grid, the menu is automatically linked to the respective rows. Users can thus interact with the data in a context-sensitive manner – an ergonomic advantage over classic toolbar or button solutions.
The connection to the detail dialogue is seamless: If the user selects “Show details” in the context menu, the DetailsDialog for the corresponding ShortUrlMapping opens immediately. The interaction between the grid, context menu, and dialogue remains completely encapsulated within the view, keeping the code easy to maintain and understand.
Event integration between Overview and DetailDialog
The interaction between the OverviewView and the DetailsDialog is the functional heart of the editing workflow. It combines a tabular overview with a detailed view of individual datasets and ensures that changes in the dialogue are immediately reflected in the grid. The concept follows Vaadin’s component-oriented event mechanism, in which UI components can trigger their own events, which are processed by other elements – in this case, the OverviewView .
In the OverviewView, the dialogue opens when the user selects “Show details” from the context menu or double-clicks an entry. The openDetailsDialog() method takes over this task and, at the same time, binds all relevant listeners to the dialogue:
private void openDetailsDialog(ShortUrlMapping item) {
var dlg = new DetailsDialog(urlShortenerClient, 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.addSavedListener(_ -> refresh());
dlg.open();
}
Each listener processes a specific event triggered by the dialogue itself. The trick is that the DetailsDialog defines these events as their own, type-safe subclasses of ComponentEvent. This allows the OverviewView to respond to actions in a targeted manner without knowing the dialogue’s implementation details. Examples include DeleteEvent, CopyUrlEvent, CopyShortEvent, OpenEvent, and SavedEvent.
The crucial point is the refresh() at the end of the listener chain. This ensures that after a change to the dialogue, the grid data is reloaded. This keeps the display consistent and immediately reflects the current persistence state.
The dialogue itself triggers these events as soon as user actions occur. In the event of a deletion action, this is done via:
deleteBtn.addClickListener(_ -> {
fireEvent(new DeleteEvent(this, mapping.shortCode()));
});
Or when copying the short URL:
copyShortBtn.addClickListener(_ -> {
UI.getCurrent().getPage()
.executeJs("navigator.clipboard.writeText($0)", SHORTCODE_BASE_URL + mapping.shortCode());
Notification.show("Short URL copied");
fireEvent(new CopyShortEvent(this, mapping.shortCode()));
});
By triggering its own events, the dialogue can operate decoupled from its environment. It doesn’t know who calls it or what happens to the data afterwards – it just reports that an action occurred. This decoupling is an essential aspect of good UI architecture: it enables reuse and testable components without side effects.
The OverviewView then takes responsibility for updating the system in response to these events. Exquisite is the use of ComponentEventListener, which has type safety and clean demarcation of the event classes. Vaadin thus offers a clearly structured and idiomatic way to model complex UI flows.
Another example shows the connection between the deletion process and the server communication. When a DeleteEvent is fired, the OverviewView calls the internal method confirmDelete() to display a security prompt and, upon confirmation, delete the record server-side. Only then does the grid refresh again, so the removed entry disappears immediately.
This event-based integration ensures a consistent and reactive processing flow. Users experience changes in real time, without manual updates. For developers, the model offers a clear separation of responsibilities: the DetailsDialog encapsulates presentation and interaction, while OverviewView orchestrates and synchronises them. This creates a UI structure that can be flexibly expanded and easily supplemented with new actions.
Validation and error feedback in the dialogue
A central element of the edit dialogue is input validation. This is to ensure that only correct and complete data is transmitted to the server. The focus is on checking URLs, as they form the core of every ShortUrlMapping.
In the current project, validation is handled using the Vaadin Binder. The binder connects the UI fields to the properties of the underlying data model and provides built-in support for validations and error feedback.
A typical excerpt from the archive shows how this validation is implemented in practice:
binder.forField(urlField)
.asRequired("URL must not be empty")
.withValidator(url -> {
var validate = UrlValidator.validate(url);
return validate;
}, "Only HTTP(S) URLs allowed")
.bind(ShortUrlMapping::originalUrl, null);
Several steps are combined here:
- Required field check –
asRequired()ensures that the field is not left empty. If no input is made, Vaadin automatically displays an error message. - Content validation:
withValidator()is also used to validate the URL format. This uses theUrlValidator helper class.
The UrlValidator itself is a standalone utility class that syntactically checks whether a string is a valid HTTP or HTTPS URL. The implementation is:
package com.svenruppert.urlshortener.core.urlmapping;
import java.net.URI;
public final class UrlValidator {
private UrlValidator() {}
public static boolean validate(String url) {
if (url == null || url.isBlank()) {
return false;
}
try {
var uri = URI.create(url);
var scheme = uri.getScheme();
return scheme != null && (scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https"));
} catch (IllegalArgumentException e) {
return false;
}
}
}
This implementation is deliberately kept simple and uses only standard Java means. It verifies that the URL has a valid schema and starts with HTTP or HTTPS. This excludes all other protocols – an important aspect to avoid potential security risks (e.g. file:// or javascript://).
The result of this validation is immediately displayed in the UI. If the user enters an invalid URL, an error message will appear below the input field that comes from the message set withValidator() (“Only HTTP(S) URLs allowed”). The Vaadin binder automatically controls this visual feedback.
The interaction among Binder, TextField, and UrlValidator ensures consistent, user-friendly, and secure input validation. In addition, the system can be expanded if necessary, for example, to include additional checks (e.g. URL accessibility, blacklist filters or regex-based structure tests) without changing the existing architecture.
This validation ensures that the DetailsDialog is not just a display element, but also a reliable filter that catches erroneous input before it enters the persistence layer. This reduces error scenarios, increases data quality, and significantly improves the user experience.
UX fine-tuning and conclusion
The user experience (UX) of the interaction among the OverviewView, the DetailsDialog, and supporting UI components, such as the ColumnVisibilityDialog, is the result of numerous targeted design decisions. The goal was to create a reactive yet simple interface that avoids visual overload while still offering complete control over the data.
The application consistently follows Vaadin’s guiding principles for a component-based UI. All interactions – from column selection to detail view – are encapsulated in clearly defined classes. The user benefits from an intuitive interaction logic: every action is available where it makes semantic sense, and every change is immediately visible.
An example of this UX approach is direct feedback on user actions. When a URL is copied or opened, a notification appears immediately:
Notification.show("Shortcode copied");
This micro-feedback improves the perception of the system response and confirms to the user that their action was successful. The same principle applies to several components, for example, when copying the original URL or saving it in the DetailsDialog.
Another part of the fine-tuning is the deliberate use of Vaadin theme variants to differentiate controls visually. For example, the delete button in the detail dialogue clearly signals its critical function through the combination of LUMO_ERROR and LUMO_TERTIARY:
deleteBtn.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY);
This colouring adheres to the established Lumo design system and ensures that dangerous actions are immediately recognisable, without requiring additional explanatory text.
The modalization of the detail dialogue also contributes to user guidance:
setModal(true);
setDraggable(true);
setResizable(true);
This keeps the user’s focus clearly on the task at hand, while dragging and resizing offer flexibility. Especially in data-rich grids, this allows the dialogue to adapt to the situation without leaving the application.
Another UX aspect is maintaining the work context. After each action – such as saving or deleting – the grid is reloaded by the refresh() method without losing filter or paging information. This was deliberately kept this way in the implementation:
var req = new UrlMappingListRequest(searchField.getValue(), pagination.getCurrentPage(), pagination.getPageSize());
var mappings = urlShortenerClient.list(req);
grid.setItems(mappings);
This means the user remains in the same position within the data view and maintains their orientation and workflow rhythm.
The OverviewView’s structure demonstrates how a consistent user experience can be achieved through small, targeted design decisions. Instead of complex UI frameworks, the application uses Vaadin’s native component toolkit and combines it with a clear event architecture and lightweight HTTP communication.
Result
The implementation of the editing workflow via the DetailsDialog shows how a modern, reactive application flow can be created with pure Vaadin Flow and Core Java– without external frameworks, reflection, or client-side JavaScript logic. The key lies in clean component decoupling, precise event control, and immediate user feedback.
The user receives a UI that adapts to their workflows: fast, comprehensible and error-resistant. At the same time, developers can create a maintainable architecture with clear responsibilities: the view orchestrates, the dialogue interacts, and the client synchronises. This interplay of simplicity and clarity is the actual UX fine-tuning of this implementation – and at the same time, the basis for the further expansion stages of the project.
Cheers Sven
Discover more from Sven Ruppert
Subscribe to get the latest posts sent to your email.