The Importance of UI in Import Processes

Why an import needs a UI at all

Import functions are often treated as purely technical details in applications. Data is read in, processed and then made available – ideally without further interaction. In practice, however, an import is rarely an invisible process. It marks a transition between existing system states, between old and new data, between trust and control. This is exactly where the need for a user interface arises.

In the URL shortener, the import is not designed to run in the background; it is an explicit, visible process. The UI does not take on the role of a technical decision-maker, but that of a transparent mediator. It creates a clearly demarcated space in which an import can occur without immediately modifying existing data. This decoupling alone justifies its own import interface.

The current state of the source code is available on

GitHub: https://github.com/svenruppert/url-shortener or https://3g3.eu/url

Overview of a URL shortener tool displaying a table with shortcodes, URLs, creation dates, active status, expiration, and action options.

Importing is always associated with uncertainty. Even if the data format is known, it remains unclear how incoming datasets interact with existing datasets. Are there any overlaps? Are conflicts arising? Are individual entries incomplete or invalid? These questions cannot be answered by a technical process alone, but require visibility. The UI makes this intermediate state tangible without prematurely evaluating it.

Instead of treating the import as an atomic step, it is understood as a state in the URL shortener. The user interface displays this state, records it, and prevents it from being overlooked and inadvertently entered into a production database. This makes the import process controlled, even if the UI itself does not control the content.

The clear distribution of roles is important here. The user interface does not interpret data, validate content, or make decisions about its technical correctness. Their task is exclusively to make the current technical status of the import visible. This deliberate restraint is crucial, as it prevents UI and import logic from being conflated.

So the import doesn’t need a UI because it’s complex, but because it’s a transition. The surface acts as a boundary between file and system, between potential changes and existing persistence. It gives the import a place, a time and a clear context – and that’s exactly where its raison d’être lies.

The entry point in the application

The import does not start automatically in the URL shortener; it only starts via a deliberately designed entry point in the application. It is where users manage existing short URLs and view the current database. This clearly positions the import as an exceptional act rather than part of the everyday processing flow.

Screenshot of a URL shortener interface showing the import section, including options to upload a ZIP file, preview of URLs, and displayed conflicts.
User interface for importing a ZIP file with a preview section showing a staging ID, number of new items, conflicts, and invalid entries. The interface includes a table for displaying short codes, URLs, and activation statuses.

Entry is done via an explicit action that opens a modal dialogue. This action only creates a new instance of the import dialogue and gives it the necessary dependencies. Further logic is deliberately not provided at this point. By opening the dialogue, the user leaves the application’s normal working context and enters a clearly demarcated area where the import is fully encapsulated.

The modal character of the dialogue fulfils an important function here. It indicates that the import is not a background process running in parallel, but a coherent process with its own state. As long as the dialogue is open, the rest of the UI state remains unchanged. There is no implicit update and no automatic data transfer.

From the user guidance perspective, this creates a clear-cut. The import is not understood as an extension of the table view, but as a temporary workspace. This separation prevents import actions from being mixed with regular editing steps. At the same time, it creates a mental orientation: everything that happens within this dialogue belongs to the import, and nothing beyond it.

From a technical standpoint, the UI assumes no further responsibility at this point. The entry point does not initiate validation or server communication; instead, it delegates the entire import process to the dialogue itself. Only when the user consciously uploads a file does the import process begin.

Button importButton = new Button("Import");
importButton.addClickListener(event -> {
ImportDialog dialog =
new ImportDialog(urlShortenerClient, () -> {
refreshGrid();
});
dialog.open();
});

An excerpt from the corresponding view illustrates this principle. The button opens the import dialogue and optionally passes a callback, allowing a return to the normal UI context after a successful import.

The code makes it clear that the entry point merely instantiates and opens the dialogue. There is no pre-check or interaction with the server or import APIs. The callback is used only for downstream view updates and is not part of the import process itself.

Thus, the entry point defines not only where the import starts, but also how it is perceived. It is a clear turning point in the context of use and forms the basis for all subsequent steps in the import dialogue.

The import dialog as a closed workspace

When the import dialogue opens, the application deliberately switches to a well-defined working mode. The dialogue is not intended as a mere overlay, but as a standalone UI space with its own logic, state, and clearly defined boundaries. Everything that happens within this dialogue belongs exclusively to the import and has no effect on the existing database until the process is explicitly completed.

This partitioning is a central design element. While the dialogue is open, the rest of the application remains frozen in its previous state. Tables, filters or active selections are neither updated nor influenced. This creates a clear separation between ongoing work and the import process, providing both technical and mental orientation.

The import dialogue is implemented as a standalone Vaadin component. It is fully initialised when opened and has no implicit return channel to the calling view. This decoupling allows the dialog to manage its internal state independently. Neither when opened nor during the interaction is it assumed that import data already exists. The initial state is always neutral and empty.

Structurally, the dialogue is divided into distinct sections, each representing a specific phase of import. However, these areas are not implemented as step-by-step wizards; instead, they coexist within a common framework. This keeps the dialogue fully visible at all times and avoids implicit transitions that could obscure the actual state.

It is particularly important that the dialogue does not make a technical interpretation of the import data. It simply provides UI surfaces where results are displayed as soon as the server delivers them. Whether these spaces remain empty or are filled depends solely on the current import state, not on assumptions or expectations of the UI.

The control elements of the dialogue also follow this principle. Actions such as validation or application of the import are not permanently available, but are tied to the internal state of the dialogue. As long as there is no corresponding import data, the dialogue remains inactive. The UI does not enforce an order or progression, but only reacts to clearly defined state changes.

The import dialogue thus acts as a controlled container for a potentially critical operation. It fully encapsulates the import, makes its current status visible, and prevents incomplete or unchecked data from taking effect unnoticed. This clear boundary underpins all subsequent steps in the import process and explains why the dialogue was deliberately designed as a closed workspace.

Implementation of the ImportDialog in Vaadin

The import dialogue in the URL shortener clearly shows how little “framework magic” it takes to build a complete, state-driven UI with Vaadin Flow. The entire functionality is built from a few easy-to-understand building blocks: a dialogue container, standard components such as upload, grid, tabs, and button, and clear state variables that carry the dialogue throughout the import process. The result is an interface that treats the import as a separate workspace while keeping the source code manageable.

public final class ImportDialog
extends dialog
implements HasLogger {
private final URLShortenerClient client;
private final Upload upload = new Upload();
private byte[] zipBytes;
private final Button btnValidate = new Button("Validate");
private final Button btnApply = new Button("Apply Import");
private final Button btnClose = new Button("Close");
private final Div applyHint = new Div();
private string stagingId;

Getting started is already remarkably simple. Dialog is a normal Java class that extends Dialogue. This means the UI is not a declarative construct or a template; it can be fully traced in Java code. At the same time, the dialogue deliberately remains stateful: it holds both the uploaded ZIP bytes and the stagingId generated by server-side validation.

With this structure, it’s already clear how Vaadin Flow works here: Components are objects, and the dialogue has them like ordinary fields. The UI is thus automatically addressable without requiring “UI IDs” or bindings. At the same time, the import state is not kept externally; it belongs precisely to the dialogue in which it is made visible.

The constructor then shows the core of the Vaadin mechanics. With just a few lines, the dialogue is configured as a modal, resizable container. There is no separate configuration file and no DSL, but pure Java calls. This allows the import dialogue’s visual framework to be understood directly.

setHeaderTitle("Import");
setModal(true);
setResizable(true);
setDraggable(true);
setWidth("1100px");
setHeight("750px");

Immediately afterwards, it becomes apparent how Vaadin Flow typically models interactions. The buttons are initially deactivated and are unlocked only via events. This initialisation is a central part of the state model. The dialogue always starts neutrally and does not force progress unless there is a ZIP file.

btnValidate.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
btnValidate.setEnabled(false);
btnApply.addThemeVariants(ButtonVariant.LUMO_SUCCESS);
btnApply.setEnabled(false);
btnClose.addClickListener(_ -> close());
btnValidate.addClickListener(_ -> validate());
btnApply.addClickListener(_ -> applyImport());

The interaction of UI and file upload can also be read directly. The upload will be limited to ZIP files and will have a maximum size. The key point here is the in-memory handler: once the file is successfully uploaded, the bytes are placed in zipBytes and the next step is unlocked by enabling btnValidate. The transition from “upload available” to “validation possible” is thus a single, very understandable state change.

upload.setAcceptedFileTypes(".zip", APPLICATION_ZIP);
upload.setMaxFiles(1);
upload.setMaxFileSize(IMPORT_MAX_ZIP_BYTES);
UploadHandler inMemoryUploadHandler = UploadHandler
.inMemory((metadata, bytes) -> {
String fileName = metadata.fileName();
long contentLength = metadata.contentLength();
logger().info("uploaded file: fileName: {} , contentLength {}", fileName, contentLength);
zipBytes = bytes;
logger().info("setting zipBytes..");
btnValidate.setEnabled(true);
});
upload.setUploadHandler(inMemoryUploadHandler);

The actual UI structure is then created in buildContent(). Here, too, the strength of Vaadin Flow becomes apparent: Layouts are components, and the dialogue is simply built by assembling components. Headings, upload area, preview summary, tabs, and a central content container are merged into a single VerticalLayout. The code reads like a description of the interface.

var root = new VerticalLayout(
new H3("Upload ZIP"),
upload,
new H3("Preview"),
summary,
applyRow,
tabs,
tabContent
);
root.setSizeFull();
root.setPadding(false);
renderTab(tabContent);
return root;

For displaying results, the dialogue uses two grid instances and switches between them via tabs. The decisive factor is that the tab interaction does not trigger any backend logic; it only updates the visible projection. In Vaadin Flow, switching is a single listener that empties the container and uses the appropriate combination of paging bar and grid.

tabs.addSelectedChangeListener(e -> renderTab(tabContent));
private void renderTab(VerticalLayout container) {
container.removeAll();
container.setSizeFull();
if (tabs.getSelectedTab() == tabInvalid) {
container.add(pagingInvalid, gridInvalid);
container.expand(gridInvalid);
} else {
container.add(pagingConflicts, gridConflicts);
container.expand(gridConflicts);
}
}

At this point, it becomes clear why the implementation remains so compact despite several states. Vaadin Flow provides the UI building blocks, and the dialogue connects them via a few, clearly defined state variables and listeners. The actual import process remains server-side. The UI only needs to know when a state is reached and how to display it.

This makes the ImportDialog a good example of how Vaadin Flow reduces complexity: not by hiding logic, but by using a direct programming model in which UI components, events, and state converge into a single, easy-to-read Java codebase. Especially for a minimalist import function, a complete, robust interface can be created quickly without requiring additional framework layers.

File upload: Transport instead of interpretation

In the import dialogue, the actual import process does not begin with a technical decision, but with a purely technical step: uploading a ZIP file. This moment marks the transition from an empty, neutral dialogue state to a potentially processable import without already making any content evaluation.

The upload component is firmly anchored in the dialogue and visible from the beginning. It deliberately serves as the first point of interaction within the closed work area. However, their task is clearly limited. The upload is used exclusively to receive a file and make its content temporarily available in the UI context. Neither the structure nor the semantics of the data contained play a role at this point.

This attitude is directly reflected in the source code. The upload is configured to accept exactly one ZIP file, and the maximum file size must not be exceeded. These restrictions do not serve the purpose of technical validation; they serve only the technical validation of the UI process.

upload.setAcceptedFileTypes(".zip", APPLICATION_ZIP);
upload.setMaxFiles(1);
upload.setMaxFileSize(IMPORT_MAX_ZIP_BYTES);

Once a file is selected and uploaded, an in-memory upload handler processes it. The dialogue saves the complete contents of the ZIP file as a byte array in an internal field. At this point, there is no check to verify whether the file contains import data or is structured correctly. In the UI, the file is initially a binary block.

UploadHandler inMemoryUploadHandler = UploadHandler
.inMemory((metadata, bytes) -> {
zipBytes = bytes;
btnValidate.setEnabled(true);
});
upload.setUploadHandler(inMemoryUploadHandler);

These few lines mark a crucial UI state change. Upon successful upload completion, the validation button is enabled. That’s all that happens at this point. The upload alone does not trigger any server communication and does not create an import state. It merely signals that there is now enough information to trigger the next user step.

Errors during uploads are also dealt with exclusively from a technical perspective. If a file is rejected, for example, due to an incorrect data type or a file size that is too large, the UI responds with a corresponding notification. However, there is still no evaluation of the file’s content.

upload.addFileRejectedListener(event -> {
String errorMessage = event.getErrorMessage();
Notification notification = Notification.show(errorMessage, 5000,
Notification.Position.MIDDLE);
notification.addThemeVariants(NotificationVariant.LUMO_ERROR);
});

The upload section of the dialogue thus clarifies a central design principle: The user interface does not interpret imported data. It merely ensures that a file is received in a technically correct manner and transfers this state into a clearly recognisable next possibility for action.

Only by clicking the “Validate” button does the dialogue leave this purely technical state of preparation. The file upload thus serves as the necessary, but deliberately content-free, basis for all subsequent steps of the import process.

Validation as UI state change

By clicking on the “Validate” button, the import dialogue leaves its purely preparatory state for the first time. Up to this point, only technical requirements were created: a file was uploaded and stored in memory without its content being interpreted or further processed. The validate() method now marks the clear transition from a passive UI state to a state-changing step that makes the import visible for the first time.

From a user interface perspective, validation is not a technical review process, but a coordinated flow of multiple UI updates, all triggered by a single user action. The dialogue deliberately assumes no responsibility for the content. It asks the server to verify the uploaded ZIP content and processes only the technical response it receives.

The introduction to the method is correspondingly defensive. First, the dialogue state checks whether a ZIP file exists. If this is missing, the UI displays a brief notification and cancels the process. At this point, no exception is propagated, and no internal state is changed. The validation is thus clearly tied to a previous, explicit user action.

if (zipBytes == null || zipBytes.length == 0) {
Notification.show("No ZIP uploaded.", 2500, Notification.Position.TOP_CENTER);
return;
}

Only when this prerequisite is fulfilled is the actual validation step triggered. The dialogue passes the complete contents of the ZIP file to the client and calls the dedicated server endpoint for import preview. For the UI, this call is a black box. It does not know the internal validation rules or the criteria by which entries are classified as new, conflicting, or invalid.

String previewJson = client.importValidateRaw(zipBytes);

The server’s response is received in its entirety as a JSON string. Instead of converting this into a fixed data model, the UI extracts the individual values relevant to representing the import state. This includes the generated stagingId, as well as the counts of new, conflicting, and invalid entries. These values are immediately visible in the interface and constitute the first concrete import state displayed by the dialogue.

this.stagingId = extractJsonString(previewJson, "stagingId");
int newItems = extractJsonInt(previewJson, "newItems", 0);
int conflicts = extractJsonInt(previewJson, "conflicts", 0);
int invalid = extractJsonInt(previewJson, "invalid", 0);

With these values set, the character of the dialogue changes noticeably. The previously empty preview area is populated, and the import receives an identity for the first time: the stagingId. Nevertheless, the UI remains strictly descriptive. It does not evaluate whether the figures are plausible or in a certain relationship to each other. It only shows the current technical status.

At the same time, the dialogue reads the result lists directly from the server response. Iterators are used to read JSON arrays for conflicts and invalid entries, and to convert them into UI-internal row objects. These are then assigned to the corresponding grids. Here, too, there is no interpretation. If an array is empty or non-existent, the tables remain empty – a state that the UI treats as completely valid.

for (String obj : new ItemsArrayIterator(r, "conflictItems")) {
Map<String, String> m = JsonUtils.parseJson(obj);
conflictRows.add(ConflictRow.from(m));
}

After the grids are filled, paging information is updated, and the associated UI components are synchronised. This step is also performed exclusively based on the previously determined result lists, without any additional feedback to the server.

With the completion of the validate() method,  the import dialogue is in a new, stable state. The uploaded data is validated on the server side; the results are visible, and the dialogue can now decide which further actions to offer the user. Note that validation itself does not trigger an import. It only changes the UI state and creates transparency about what a subsequent import would do.

The validate() method is thus the central linchpin of the entire import dialogue. It combines the upload and presentation of results without making technical decisions itself. In this role, it becomes the core of the UI-controlled import process.

Presentation of results without interpretation

After validation, the import dialogue is in a state where concrete results are available for the first time. However, these results are not interpreted, weighted, or further processed; they are only presented. The last chapter describes exactly this moment: the transformation of raw information delivered on the server side into visible UI structures – without the user interface drawing its own conclusions.

Central to this chapter is the realisation that the dialogue lacks a technical view of conflicts or invalid entries. He knows neither their significance nor their effects. Instead, it performs a purely mechanical task: it takes lists of results and renders them into prepared UI components.

This projection is done via two separate tables, each associated with a specific result type. Conflicts and invalid entries are deliberately not presented together; they are kept in separate contexts. The dialogue offers two tabs for this purpose, which the user can switch between. However, this change only changes the view of the data, not its content or state.

The technical basis of this representation consists of two grid components, which are already fully configured when the dialogue is set up. Columns, widths, and display logic are fixed before it is even known whether data will ever be displayed. The grids thus exist independently of the existence of results and represent a stable projection surface.

gridConflicts.addColumn(ConflictRow::shortCode).setHeader("shortCode").setAutoWidth(true).setFlexGrow(0);
gridConflicts.addColumn(ConflictRow::d iff).setHeader("diff").setAutoWidth(true).setFlexGrow(0);
gridConflicts.addColumn(ConflictRow::existingUrl).setHeader("existingUrl").setAutoWidth(true);
gridConflicts.addColumn(ConflictRow::incomingUrl).setHeader("incomingUrl").setAutoWidth(true);

These grids are filled not through a traditional data model, but via direct iteration over JSON fragments in the server response. The UI reads each object from its corresponding array and converts it into a simple line representation. These row objects contain exactly the fields that should be displayed, no more and no less.

for (String obj : new ItemsArrayIterator(r, "conflictItems")) {
Map<String, String> m = JsonUtils.parseJson(obj);
conflictRows.add(ConflictRow.from(m));
}

It is noteworthy that the dialogue makes no assumptions about the number of expected entries or about which fields must be present. If an array is empty or cannot be read, the associated table remains empty. This state is not treated by the UI as an error; it is considered a valid result of validation.

The tab control also follows this principle of neutrality. The currently selected tab only determines which table is visible. When switching, no data is reloaded, and no states are changed. The UI only shows a different section of the same import state.

tabs.addSelectedChangeListener(e -> renderTab(tabContent));

The presentation of results is supplemented by a simple paging component that operates only on results already loaded. It provides a better overview of large datasets but is fully local and does not execute any additional server queries. Here, too, no content filter is applied; paging is purely representation-oriented.

The interplay of grids, tabs, and paging results in a deliberately restrained user interface. It shows what’s there and doesn’t show anything that hasn’t been clearly delivered. Neither is there an attempt to compensate for missing data, nor are implicit assumptions made about the “actual” state of the import.

This makes it clear that the result displayed in the import dialogue is not an analysis tool, but a mirror. It reflects the state delivered by the server exactly and defers any further evaluation to subsequent steps in the import process.

Action Control and Apply Logic

After uploading, validating, and displaying the results, one central question remains in the import dialogue: Can the import actually be used? The answer to this question is not implicit or automatic. Instead, it is completely controlled by the UI state.

The basis of this control is not an additional server call, but the already known import state. All information relevant to the decision is already available to the UI. The updateApplyState() method serves as a central hub that interprets the state and translates it into concrete UI activations or deactivations.

The starting point is a strictly defensive default state. Regardless of what happened before, the Apply button will be disabled first. The UI never assumes that an import is automatically applicable. Only when all conditions are explicitly fulfilled is this state lifted.

btnApply.setEnabled(false);
btnApply.setText("Apply Import");

The first hard test point is the presence of a stagingId. Without this identifier, there is no valid import context from a UI perspective. Even if the data is already displayed, the import remains inapplicable until a server-side confirmed staging state is reached. The UI does not treat this case as an error, but as an incomplete state.

Then, the two result dimensions that have already been made visible in the previous chapters are considered: invalid entries and conflicts. Invalid entries generally block the import. As soon as at least one invalid record exists, the Apply function remains deactivated, and the dialogue explicitly communicates this state via a hint text. The UI does not force a correction; it simply makes it clear that an import is not possible under these conditions.

Conflicts, on the other hand, are treated differently. From a UI perspective, they do not represent an absolute exclusion but rather a deliberate decision-making process. The dialogue includes a checkbox for this purpose, allowing the user to specify whether conflicting entries should be skipped during import. Only with this explicit consent is the import released despite existing conflicts.

This differentiation is directly evident in the interplay of the checkbox, information text and apply button. Activating or deactivating the checkbox immediately triggers a reassessment of the UI state without loading or recalculating any additional data. The UI responds only to the known state.

if (conflicts > 0 && !chkSkipConflicts.getValue()) {
applyHint.setText("Apply disabled: " + conflicts + " conflict(s). Tick "Skip conflicts on apply" to proceed.");
return;
}

When the import is finally approved, not only does the button’s activation change, but its label does as well. This allows the UI to clearly signal the conditions under which the import will be executed. This visual notice is part of the action management and serves to ensure transparency, not to enforce professional rules.

By clicking “Apply Import”, the dialogue leaves its purely display and decision modes. Only at this point is another server call triggered, which actually applies the previously validated import. Up to this point, the UI has only managed states, displayed them and demanded decisions.

The action control thus forms the deliberate conclusion of the import dialogue. It bundles all previously built-up information and converts it into an explicit user decision. It is precisely this restraint – not to apply anything automatically, to imply anything – that makes dialogue a controlled and comprehensible tool within the application.


Discover more from Sven Ruppert

Subscribe to get the latest posts sent to your email.

Leave a Reply