Adventskalender 2025 – Komponenten extrahieren – Teil 2

Sven Ruppert

Was bisher geschah

Im ersten Teil lag der Fokus bewusst auf der strukturellen Neuausrichtung der Benutzeroberfläche. Die zuvor gewachsene, zunehmend monolithische OverviewView wurde analysiert und gezielt entlastet, indem zentrale Funktionsbereiche in eigenständige UI-Komponenten ausgelagert wurden. Mit der Einführung der BulkActionsBar und der SearchBar entstanden klar abgegrenzte Bausteine, die jeweils eine definierte Verantwortung übernehmen und die View von operativer Detailarbeit befreien.

Dieses Refactoring war kein kosmetischer Schritt, sondern eine bewusste Investition in die langfristige Wartbarkeit der Anwendung. Durch die Trennung von Darstellung, Interaktion und Logik wurde eine modulare Basis geschaffen, die nicht nur besser testbar ist, sondern auch zukünftige Erweiterungen deutlich vereinfacht. Die OverviewView wandelte sich dabei von einem funktionsüberladenen Zentrum hin zu einer orchestrierenden Instanz, die Komponenten zusammenführt, statt deren interne Abläufe zu steuern.

Auf dieser Grundlage setzt nun der zweite Teil an. Während Teil 1 die Motivation, Ziele und ersten Extraktionen beleuchtet hat, richtet sich der Blick jetzt konsequent auf die neue Struktur der OverviewView selbst: Wie sich ihre Rolle verändert hat, wie Event-Flüsse vereinfacht wurden und wie das Zusammenspiel der extrahierten Komponenten zu einer klareren, stabileren Architektur führt.

Die Quelltexte zu diesem Artikel befinden sich auf GitHub unter: https://github.com/svenruppert/url-shortener/tree/feature/advent-2025-day-11

Hier ein Screenshot vom aktuellen Stand der Entwicklung aus Sicht des Benutzers.

Im Fokus steht die Aufteilung der zuvor monolithischen OverviewView in klar abgegrenzte, wiederverwendbare Bestandteile wie die BulkActionsBar und die SearchBar. Diese Trennung verbessert die Wartbarkeit, erhöht die Übersichtlichkeit und erleichtert das Testen einzelner Funktionsbereiche. Gleichzeitig wird damit der Weg geebnet, um weitere UI-Optimierungen und Interaktionsmuster in den kommenden Tagen schrittweise umzusetzen.

Neue Struktur der OverviewView

Nach dem Herauslösen der Subkomponenten präsentiert sich die neue OverviewView in deutlich gestraffter und klarerer Form. Während zuvor eine Vielzahl von Elementen, Event-Handlern und Logikfragmenten über die gesamte Klasse verteilt war, beschränkt sich die View nun auf wenige, klar umrissene Aufgaben: die Initialisierung der Kernkomponenten, das Aufsetzen des Grids sowie das Orchestrieren der Interaktionen zwischen SearchBar, BulkActionsBar und Backend.

Diese neue Struktur folgt einem einfachen, aber wirkungsvollen Prinzip: Die OverviewView konzentriert sich auf das Zusammenführen der Komponenten, nicht auf deren interne Funktionsweise. Sie definiert, welche Bausteine angezeigt werden, wie sie zusammenarbeiten und in welchem Kontext sie aktualisiert werden müssen. Der interne Aufbau der einzelnen Elemente – sei es das Handling von Filteränderungen, die Verwaltung von Bulk-Aktionen oder die technische Logik des DataProviders – liegt vollständig in den jeweiligen Komponenten.

Durch diesen klaren Zuschnitt wirkt die OverviewView deutlich aufgeräumter. Die zuvor üppigen Abschnitte mit eingestreuten Event-Listenern, komplexen UI-Layouts und manueller Fehlerbehandlung sind weitgehend verschwunden oder in ausgelagerte Komponenten übergegangen. An ihre Stelle treten kurze, gut verständliche Methoden, die den Ablauf der View steuern und die verschiedenen Bausteine miteinander verbinden.

Diese Reduktion macht die OverviewView nicht nur wartungsärmer, sondern auch leichter erweiterbar. Neue Funktionen lassen sich gezielt an den entsprechenden Stellen integrieren, ohne das Risiko, bestehende Logik zu beschädigen. Gleichzeitig entsteht eine gut testbare Struktur, da die Abhängigkeiten klar definiert und die Verantwortlichkeiten sauber getrennt sind.

Ein zentrales Element der neuen Struktur ist die Initialisierung der View, die nun klar strukturiert und weitgehend frei von eingebetteter Logik ist. Bereits im Konstruktor lässt sich diese neue Rollenverteilung deutlich erkennen:

public OverviewView() {
  setSizeFull();
  setPadding(true);
  setSpacing(true);
  add(new H2("URL Shortener – Overview"));
  initDataProvider();

  var pagingBar = new HorizontalLayout(prevBtn, nextBtn, pageInfo, btnSettings);
  pagingBar.setDefaultVerticalComponentAlignment(CENTER);
  HorizontalLayout bottomBar = new HorizontalLayout(new Span(), pagingBar);
  bottomBar.setWidthFull();
  bottomBar.expand(bottomBar.getComponentAt(0));
  bottomBar.setAlignItems(CENTER);
  VerticalLayout container = new VerticalLayout(searchBar, bottomBar);
  container.setPadding(false);
  container.setSpacing(true);
  container.setWidthFull();
  add(container);

  add(bulkBar);
  add(grid);
  configureGrid();
  addListeners();
  addShortCuts();

  try (var _ = withRefreshGuard(false)) {
    searchBar.setPageSize(25);
    searchBar.setSortBy("createdAt");
    searchBar.setDirValue("desc");
  } catch (Exception e) {
    throw new RuntimeException(e);
  }
}

Hier wird deutlich: Die OverviewView instanziiert nur noch ihre Komponenten, fügt sie in ein Layout ein und delegiert alle Details an spezialisierte Klassen. Weder Filterlogik noch Bulk-Operationen sind hier enthalten – die View orchestriert lediglich.

Ein weiteres Beispiel für die gestraffte Struktur ist die Behandlung der Selektion im Grid. Früher befand sich hier umfangreiche Logik zu Buttons, Zuständen und Aktionen; heute steuert die View nur noch Sichtbarkeit und Status der BulkActionsBar:

grid.addSelectionListener(event -> {
  var all = event.getAllSelectedItems();
  boolean hasSelection = !all.isEmpty();

  bulkBar.setVisible(hasSelection);

  if (hasSelection) {
    int count = all.size();
    String label = count == 1 ? "link selected" : "links selected";
    bulkBar.selectionInfoText(count + " " + label + " on page " + currentPage);
  } else {
    bulkBar.selectionInfoText("");
  }

  bulkBar.setButtonsEnabled(hasSelection);
});

Die View übernimmt also nur noch das Anzeigen oder Verbergen der BulkActionsBar. Die eigentliche Logik für die Ausführung der Aktionen liegt vollständig in der Komponente selbst.

Auch die Refresh-Mechanik ist nun klar abgegrenzt. Statt an vielen Stellen im Code Refresh-Aufrufe zu wiederholen, wurde die Methode safeRefresh() geschaffen, die zentral definiert, wie Aktualisierungen durchgeführt werden:

public void safeRefresh() {
  logger().info("safeRefresh");
  if (!suppressRefresh) {
    logger().info("refresh");
    dataProvider.refreshAll();
  }
}

Dieser Aufbau macht das Aktualisieren nicht nur sauberer, sondern verhindert auch doppelte Refreshes und unerwünschte Endlosschleifen.

Die neue Übersichtlichkeit zeigt sich ebenfalls in der Konfiguration des Grids, die zwar inhaltlich komplex bleibt, aber klar abgegrenzt und in eigene Methoden ausgelagert ist:

private void configureGrid() {
  logger().info("configureGrid..");
  grid.addThemeVariants(GridVariant.LUMO_ROW_STRIPES, GridVariant.LUMO_COMPACT);
  grid.setHeight("70vh");
  grid.setSelectionMode(Grid.SelectionMode.MULTI);

  configureColumShortCode();
  configureColumUrl();
  configureColumCreated();
  configureColumActive();
  configureColumExpires();
  configureColumActions();

  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())));
}

Durch diese Struktur wird klar erkennbar, welche Bereiche der View welche Aufgaben haben. Die mehrfach ausgelagerte Logik sorgt dafür, dass jede Methode eine klar definierte Funktion erfüllt.

Vereinfachte Event-Pipeline nach dem Refactoring

Durch das Refactoring wurde diese Komplexität deutlich reduziert. Die Event-Pipeline folgt nun einem linearen, vorhersehbaren Ablauf: Benutzer interagieren mit den UI-Komponenten; diese lösen klar definierte Aktionen aus; die OverviewView reagiert darauf mit überschaubaren Steuersignalen und aktualisiert schließlich das Grid über einen einheitlichen Refresh-Mechanismus. Besonders wichtig ist dabei die Einführung von safeRefresh() und withRefreshGuard(), die verhindern, dass unnötige oder rekursive Aktualisierungen ausgelöst werden.

Das Ergebnis ist eine Architektur, in der Events nicht mehr quer durch die View propagiert werden, sondern strukturiert an einer Handvoll definierter Schnittstellen entlanglaufen. Gut sichtbar wird das bereits im zentralen Listener-Setup der OverviewView:

private void addListeners() {
  ComponentUtil.addListener(UI.getCurrent(),
                            MappingCreatedOrChanged.class,
                            _ -> {
                              logger().info("Received MappingCreatedOrChanged -> refreshing overview");
                              refreshPageInfo();
                              safeRefresh();
                            });

  grid.addSelectionListener(event -> {
    var all = event.getAllSelectedItems();
    boolean hasSelection = !all.isEmpty();

    bulkBar.setVisible(hasSelection);

    if (hasSelection) {
      int count = all.size();
      String label = count == 1 ? "link selected" : "links selected";
      bulkBar.selectionInfoText(count + " " + label + " on page " + currentPage);
    } else {
      bulkBar.selectionInfoText("");
    }

    bulkBar.setButtonsEnabled(hasSelection);
  });

  btnSettings.addClickListener(_ -> new ColumnVisibilityDialog<>(grid, columnVisibilityService).open());
  prevBtn.addClickListener(_ -> {
    if (currentPage > 1) {
      currentPage--;
      refreshPageInfo();
      safeRefresh();
    }
  });

  nextBtn.addClickListener(_ -> {
    int size = Optional.ofNullable(searchBar.getPageSize()).orElse(25);
    int maxPage = Math.max(1, (int) Math.ceil((double) totalCount / size));
    if (currentPage < maxPage) {
      currentPage++;
      refreshPageInfo();
      safeRefresh();
    }
  });
}

Hier zeigt sich die neue Klarheit: Externe Events wie MappingCreatedOrChanged führen zu einem gezielten Refresh; die Grid-Selektion beeinflusst ausschließlich die BulkActionsBar; die Paging-Buttons steuern nur noch Seite und Refresh. Die komplexe Logik aus früheren Tagen ist klar aufgeteilt.

Auch die Tastaturkürzel integrieren sich nahtlos in diese vereinfachte Pipeline und nutzen ebenfalls die gekapselte Bulk-Logik:

private void addShortCuts() {
  var current = UI.getCurrent();

  current.addShortcutListener(_ -> {
                                if (!grid.getSelectedItems().isEmpty()) {
                                  bulkBar.confirmBulkDeleteSelected();
                                }
                              },
                              Key.DELETE);
}

Auf der Seite der SearchBar wird die Event-Verarbeitung ebenfalls stark entlastet und folgt einem klaren Muster. Änderungen an Filtern oder Einstellungen führen stets über die OverviewView und ihren einheitlichen Refresh-Mechanismus:

activeState.addValueChangeListener(_ -> {
  holdingComponent.setCurrentPage(1);
  holdingComponent.safeRefresh();
});

pageSize.addValueChangeListener(e -> {
  holdingComponent.setCurrentPage(1);
  holdingComponent.setGridPageSize(e.getValue());
  holdingComponent.safeRefresh();
});

resetBtn.addClickListener(_ -> {
  try (var _ = withRefreshGuard(true)) {
    resetElements();
    holdingComponent.setCurrentPage(1);
  } catch (Exception e) {
    throw new RuntimeException(e);
  }
});

Gerade der Einsatz von withRefreshGuard(true) in Kombination mit safeRefresh() sorgt dafür, dass bestimmte interne Zustandsänderungen nicht sofort zu einem Kaskaden-Refresh führen, sondern kontrolliert und bewusst ausgelöst werden.

Verbesserungen im MultiAliasEditorStrict

Der MultiAliasEditorStrict ist ein zentrales UI-Element innerhalb des URL-Shorteners, da er die Bearbeitung und Verwaltung mehrerer Alias-Varianten eines Shortlinks ermöglicht. Im Zuge des Refactorings wurde auch dieser Editor überarbeitet, um ihn besser in die neue Komponentenarchitektur einzubetten und gleichzeitig die Benutzererfahrung zu verbessern. Viele der ursprünglichen Herausforderungen resultierten aus einer engen Verzahnung mit der OverviewView sowie aus einer wenig strukturierten internen Logik, die im Laufe der Entwicklung gewachsen war.

Eine der wichtigsten Neuerungen betrifft die Konsistenz seines Verhaltens. Der MultiAliasEditorStrict ist als eigenständiges, klar fokussiertes UI-Element implementiert, das ausschließlich für das Erfassen und Validieren von Aliasen verantwortlich ist:

public class MultiAliasEditorStrict
    extends VerticalLayout {

  private static final String RX = "^[A-Za-z0-9_-]{3,64}$";
  private final Grid<Row> grid = new Grid<>(Row.class, false);
  private final TextArea bulk = new TextArea("Aliases (comma/space/newline)");
  private final Button insertBtn = new Button("Take over");
  private final Button validateBtn = new Button("Validate all");
  private final String baseUrl;
  private final Function<String, Boolean> isAliasFree;   // Server-Check (true = frei)

  public MultiAliasEditorStrict(String baseUrl,
                                Function<String, Boolean> isAliasFree) {
    this.baseUrl = baseUrl;
    this.isAliasFree = isAliasFree;
    build();
  }

Schon hier wird deutlich, dass der Editor eine klar umrissene Aufgabe hat: Er kennt das baseUrl-Präfix für die Vorschau, hält seine eigenen UI-Elemente und bietet eine isAliasFree-Funktion an, um serverseitig Alias-Konflikte zu prüfen.

Der Aufbau der Oberfläche ist vollständig in der build()-Methode enthalten. Dort werden Texteingabe, Toolbar und Grid zusammengesetzt:

private void build() {
  setPadding(false);
  setSpacing(true);

  bulk.setWidthFull();
  bulk.setMinHeight("120px");
  bulk.setValueChangeMode(ValueChangeMode.LAZY);
  bulk.setClearButtonVisible(true);
  bulk.setPlaceholder("z. B.\nnews-2025\npromo_x\nabc123");

  insertBtn.addClickListener(_ -> parseBulk());
  validateBtn.addClickListener(_ -> validateAll());

  var toolbar = new HorizontalLayout(insertBtn, validateBtn);
  toolbar.setSpacing(true);

  configureGrid();

  add(bulk, toolbar, grid);
}

Die Benutzerführung wird dadurch klar: Zuerst werden Aliase im Bulk-Feld eingetragen, dann per “Take over” ins Grid übernommen und schließlich über “Validate all” geprüft.

Auch in Bezug auf die Struktur und die interne Architektur wurde der MultiAliasEditorStrict verbessert. Anstatt Logikfragmente rund um Validierung, Synchronisation und UI-Aktualisierung unstrukturiert zu verteilen, wurden diese Bereiche klar voneinander getrennt und in dedizierte Methoden überführt. Das zeigt sich insbesondere im Grid-Aufbau und in der Validierungslogik.

Das Grid selbst stellt die Alias-Zeilen, eine Vorschau und den Status in kompakter Form dar:

private void configureGrid() {
  grid.addComponentColumn(row -> {
    var tf = new TextField();
    tf.setWidthFull();
    tf.setMaxLength(64);
    tf.setPattern(RX);
    tf.setValue(Objects.requireNonNullElse(row.getAlias(), ""));
    tf.setEnabled(row.getStatus() != Status.SAVED);
    tf.addValueChangeListener(ev -> {
      row.setAlias(ev.getValue());
      validateRow(row);
      grid.getDataProvider().refreshItem(row);
    });
    return tf;
  }).setHeader("Alias").setFlexGrow(1);

  grid.addColumn(r -> baseUrl + Objects.requireNonNullElse(r.getAlias(), ""))
      .setHeader("Preview").setAutoWidth(true);

  grid.addComponentColumn(row -> {
    var lbl = switch (row.getStatus()) {
      case NEW -> "New";
      case VALID -> "Valid";
      case INVALID_FORMAT -> "Format";
      case CONFLICT -> "Taken";
      case ERROR -> "Error";
      case SAVED -> "Saved";
    };
    var badge = new Span(lbl);
    var theme = switch (row.getStatus()) {
      case VALID, SAVED -> "badge success";
      case CONFLICT, INVALID_FORMAT, ERROR -> "badge error";
      default -> "badge";
    };
    badge.getElement().getThemeList().add(theme);
    if (row.getMsg() != null && !row.getMsg().isBlank()) badge.setTitle(row.getMsg());
    return badge;
  }).setHeader("Status").setAutoWidth(true);

  grid.addComponentColumn(row -> {
    var del = new Button("✕", e -> {
      var items = new ArrayList<>(grid.getListDataView().getItems().toList());
      items.remove(row);
      grid.setItems(items);
    });
    del.getElement().setProperty("title", "Remove");
    return del;
  }).setHeader("").setAutoWidth(true);

  grid.setItems(new ArrayList<>());
  grid.setAllRowsVisible(false);
  grid.setHeight("320px");
}

Hier wird gut sichtbar, wie alle relevanten Informationen – Alias, Vorschau-URL, Status und Löschaktion – in einer eigenen, in sich geschlossenen Tabelle zusammenlaufen. Die Statusanzeige übernimmt dabei eine zentrale Rolle, um dem Benutzer Rückmeldung zu Formatfehlern, Konflikten, erfolgreicher Validierung oder bereits gespeicherten Einträgen zu geben.

Die eigentliche Intelligenz des Editors steckt in der parseBulk()– und validateRow()-Logik. parseBulk() übernimmt die Aufgabe, die freie Texteingabe aufzubereiten, Duplikate zu erkennen und neue Zeilen im Grid anzulegen:

private void parseBulk() {
  var text = Objects.requireNonNullElse(bulk.getValue(), "");
  var tokens = Arrays.stream(text.split("[,;\\s]+"))
      .map(String::trim)
      .filter(s -> !s.isBlank())
      .distinct()
      .toList();

  if (tokens.isEmpty()) {
    Notification.show("No aliases to insert", 2000, Notification.Position.TOP_CENTER);
    return;
  }

  Set<String> existing = grid.getListDataView().getItems()
      .map(Row::getAlias)
      .filter(Objects::nonNull)
      .collect(Collectors.toCollection(() -> new TreeSet<>(String.CASE_INSENSITIVE_ORDER)));

  var view = grid.getListDataView();
  int added = 0;
  for (String tok : tokens) {
    if (existing.contains(tok)) continue;
    var r = new Row(tok);
    validateRow(r);
    view.addItem(r);
    existing.add(tok);
    added++;
  }
  grid.getDataProvider().refreshAll();
  bulk.clear();

  Notification.show("Inserted: " + added, 2000, Notification.Position.TOP_CENTER);
}

Die Validierung einzelner Zeilen ist in validateRow() gekapselt und folgt einem klaren Stufenmodell aus Formatprüfung, Duplikatcheck und optionaler Server-Abfrage:

private void validateRow(Row r) {
  var a = Objects.requireNonNullElse(r.getAlias(), "");
  if (!a.matches(RX)) {
    r.setStatus(Status.INVALID_FORMAT);
    r.setMsg("3–64: A–Z a–z 0–9 - _");
    return;
  }

  long same = grid.getListDataView().getItems()
      .filter(x -> x != r && Objects.equals(a, x.getAlias()))
      .count();
  if (same > 0) {
    r.setStatus(Status.CONFLICT);
    r.setMsg("Duplicate in list");
    return;
  }

  if (isAliasFree != null) {
    try {
      if (!isAliasFree.apply(a)) {
        r.setStatus(Status.CONFLICT);
        r.setMsg("Alias taken");
        return;
      }
    } catch (Exception ex) {
      r.setStatus(Status.ERROR);
      r.setMsg("Check failed");
      return;
    }
  }
  r.setStatus(Status.VALID);
  r.setMsg("");
}

Zusammen mit dem Status-Enum und der inneren Row-Klasse ergibt sich daraus ein in sich geschlossener, gut nachvollziehbarer Zustandsautomat für jeden Alias:

public enum Status { NEW, VALID, INVALID_FORMAT, CONFLICT, ERROR, SAVED }

public static final class Row {
  private String alias;
  private Status status = Status.NEW;
  private String msg = "";

  Row(String a) {
    this.alias = a;
  }

  public String getAlias() { return alias; }
  public void setAlias(String a) { this.alias = a; }

  public Status getStatus() { return status; }
  public void setStatus(Status s) { this.status = s; }

  public String getMsg() { return msg; }
  public void setMsg(String m) { this.msg = m; }
}

Insgesamt zeigt die Überarbeitung des MultiAliasEditorStrict, wie selbst kleinere UI-Komponenten erheblich von sauberer Struktur, klaren Verantwortlichkeiten und konsistentem Verhalten profitieren können. Die Komponente ist nun stabiler, nachvollziehbarer und besser integrierbar – ideale Voraussetzungen für zukünftige Erweiterungen oder Anpassungen.

Cheers Sven

Total
0
Shares
Previous Post

Adventskalender 2025 – Komponenten extrahieren – Teil 1

Next Post

Automatisierung der JVM Thread-Dump-Analyse mit KI: Praktische Observability für Java auf Amazon ECS und EKS

Related Posts