JSF / Primefaces ohne JavaScipt anpassen – columnToggler


Es gab folgende Fragestellung: können die Informationen der Primfaces Column Toggler (siehe: https://www.primefaces.org/showcase/ui/data/datatable/columnToggler.xhtml) Komponente in eine Jakarta EE Projekt in der Datenbank gespeichert und geladen werden? Die Komponente ist eine aus der reichhaltigen JSF Komponentenbibliothek PrimeFaces, die es dem Nutzer erlaubt Spalten einer Tabelle ein- und auszublenden. Ziel der Anforderung ist es, die Auswahl eines Nutzers in der Datenbank zu speichern und bei Aufruf der Seite wieder auszulesen, um die Anzeige vorzubefüllen.

Diese Aufgabe zeigt deutlich, das sehr gute Konzept von JSF als komponentenbasiertes UI-Framework. Die meisten Java Entwickler sind eher keine Full-Stack-Entwickler. Der UI Teil weicht mit Angular und React in der Handhabung zu stark von den Konzepten von JAVA ab. Auch ist der Sprachwechsel zu JavaScript /TypeScript und das abweichende Toolset (aus meiner Sicht häufig auch nicht so ausgereifte Set) ist problematisch.

In JSF benötigt der Entwickler im Allgemeine keine oder nur minimale JacaScript Kenntnisse, da die Komponenten den JavaScript Teil kapseln. Wie kann man aber ohne JavaScript die Interaktion zwischen Front- und Backend realisieren, wen die Grundfunktionalität in der Komponente nicht gegeben ist? Sehen wir uns an, um welche Komponente es sich handelt:

<p:dataTable id="products" var="product"
	value="#{dtBasicView.products}">
	<f:facet name="header">
		<div class="flex justify-content-between align-items-center">
			List of Products
			<div>
				<p:commandButton id="toggler" type="button" value="Columns" icon="pi pi-align-justify"/>
				<p:columnToggler datasource="products" trigger="toggler">
					<p:ajax event="toggle" listener="#{dtBasicView.onToggle}"/>
				</p:columnToggler>
			</div>
		</div>
	</f:facet>

Die Komponente wird im Header einer Tabelle eingebunden und kann wie alle Komponenten in JSF über ein einfaches Tag bei Aktionen über Ajax an das Backend angebunden werden (p:ajax). Mit einer guten IDE (ich verwende Eclipse) gibt Autovervollständigung im xhtml-Editor und die Möglichkeit direkt über die Namen der BackingBeans und Methoden in die jeweiligen Klassen zu springen.

Soviel zu Setup. Wie realisiere ich nun das Speichern und Laden der Informationen des Nutzers? Der Nutzer soll die Auswahl ja nicht bei jedem Seitenaufbau erneut eingeben müssen. Bei einem naiven Ansatz könnten in der onToggle Methode die Daten durch eine fixe BackingBean in der DB gespeichert werden. Beim erneuten Aufruf der Seite werden die Daten geeignet geladen und in der Komponente gesetzt. Das hätte zur Folge, dass jede Tabelle, die diese Logik verwendet, eine ggf. abgeleitet ManagedBean benötigt, die händisch angelegt werden muss. Bei mehreren Tabellen auf einer Seite müssten mehrere Beans angelegt oder die Logik mehrfach in einer Bean existieren.

Deutlich besser wäre es, wenn wir dies nur in der XHTML-Seite über Tags steuern könnten – wie es bei den meisten JSF Komponenten der Fall ist.

Mit Comopsite Componentes (CC) und dem richtigen Konzept ist dies möglich – wie fast alles in JSF sauber umsetzbar ist. Die angelegte Komponente soll nicht auf eine fixe Bean zu greifen, die übergeben wird, um die Aktionen im Backend auszuführen. Dann müssten wir ebenfalls im Vorfeld für jede Tabelle eine Bean anlegen. Die CC soll selbstständig die notwendige Bean anlegen und an diese die relevanten Informationen übergeben. Diese Bean muss allerdings im Toggler im Ajax-Tag über ihren Namen angesprochen werden können.

Eine Implementierung des tableColToggleController könnte wie folgt aussehen


<ui:composition xmlns="http://www.w3.org/1999/xhtml"
      xmlns:cc="http://xmlns.jcp.org/jsf/composite"
      xmlns:ui="http://xmlns.jcp.org/jsf/facelets"
      xmlns:h="http://xmlns.jcp.org/jsf/html"
      xmlns:f="http://xmlns.jcp.org/jsf/core"
      xmlns:p="http://primefaces.org/ui"
      >
    <cc:interface>
        <cc:attribute name="toggleControllerName" required="true" />
        <cc:attribute name="tableId" required="true" />
    </cc:interface>

    <cc:implementation>
        <f:event type="preRenderView" listener="#{primeTableColToggleControllerFactoryAB.
                   createOrFindToggleControllerBean(cc.attrs.toggleControllerName, cc.attrs.tableId).onPreRenderView}" />
    </cc:implementation>
</ui:composition>

An die CC wird der Name der Bean (toggleControllerName) und die ID der Tabelle aus der XHMTL Seite übergeben. Eine Implementierung mit <ui:param> kann hier nicht verwendet werden, da bei Auswertung von <ui:param> die Parameter der CC noch nicht belegt sind.

Die Nutzung der CC erfolgt über das Tag tableColToggleController in der XHTML-Seite und den entsprechenden onToggle-Aufruf, der deb übergebenen Namen verwendet.

<schoeso:tableColToggleController toggleControllerName="addressToglleBean" tableId="tblAdressen" />
<p:dataTable id="tblAdressen" value="#{primeTableTogglerUiVB.addressDataDtoList}" var="item" paginator="false" emptyMessage="keine Daten vorhanden" lazy="false">
	<f:facet name="header">
		<div class="flex justify-content-between align-items-center">
			Spalten auswählen
			<div>
				<p:commandButton id="toggler" type="button" value="Columns" icon="pi pi-align-justify"/>
				<p:columnToggler datasource="tblAdressen" trigger="toggler">
					<p:ajax event="toggle" listener="#{addressToglleBean.onToggle}" update="@parent"/>
				</p:columnToggler>
			</div>
		</div>
	</f:facet>

Was macht die Magie im Hintergrund? Da es nach meinem Wissen aktuell nicht möglich ist, aus einer CC jeweils eine CDI-Bean der gleichen Klasse mit unterschiedlichen Namen zu erzeugen, verwenden wir eine Factory, die einmalig im Projekt existieren muss und gut in eine Basis-Bibliothek ausgelagert werden kann (primeTableColToggleControllerFactoryAB).

Die Kernlogik sieht wie folgt aus

public PrimeTableColToggleController createOrFindToggleControllerBean(String beanName, String tableId) {
	FacesContext facesContext = FacesContext.getCurrentInstance();
	Map<String, Object> viewMap = facesContext.getViewRoot().getViewMap();
	if (beanName != null && !viewMap.containsKey(beanName)) {
		String applicationName = "TestAppl"; 
		PrimeTableColToggleController controller = new PrimeTableColToggleController(this.userDtoVal, applicationName, tableId, this.tableColStatusService);
		viewMap.put(beanName, controller);
		return controller;
	} else {
		return (PrimeTableColToggleController) viewMap.get(beanName);
	}
}

Der entscheidende Punkt ist, dass die eigentlich von der UI angesprochene Bean vom Typ PrimeTableColToggleController hier bei Bedarf erzeugt wird und in die ViewMap gelegt wird. Die ViewMap wird von JSF verwendet, um über die Keys die Objekte in den XHTML-Seiten über die EL anzusprechen. Dies sind im Allgemeinen die CDI-Beans, die dort automatisch abgelegt werden. Wir erzeugen eine eigene Bean mit der notwendigen Logik und legen dies ebenfalls dort ab, damit wir sie über den Namen ansprechen können. Wichtig ist, dass es sich nicht um eine CDI-Bean handelt – sie hat also keinen CDI-Lifecycle.

Da die Bean aber in der Map liegt, wird sie mit dem View zerstört und kann über ihren Namen angesprochen werden.

Durch Parameter oder Logik, kennt der PrimeTableColToggleController alle notwendigen Informationen für das Speichern der Nutzerauswahl

  • Applikation (übergeben)
  • User (übergeben)
  • Webseite (ermittelt)
  • ID der Tabelle (übergeben)
  • Spalte der Tabelle (ermittelt)

Hier die relevanten Teile des Controllers. Der Controller hält keine CDI Annotationen und kann auch Dependency Injection nicht verwenden.

public class PrimeTableColToggleController implements Serializable {
...
public void onPreRenderView(ComponentSystemEvent event) throws AbortProcessingException {
  if (!this.initialized) {
    FacesContext context = FacesContext.getCurrentInstance();
    UIComponent root = context.getViewRoot(); // Das ViewRoot ist der Startpunkt im Komponentenbaum
    this.setPageName(this.getCurrentXhtmlFileName());
    // Suche nach der DataTable anhand des letzten Teils ihrer ID - JSFUtils
    DataTable dataTable = (DataTable) this.findComponentByIdEndingWith(root, this.getTableId());
    LOG.info("Tabelle {}", dataTable);
    if (dataTable != null) {
	Map<String, Boolean> colVisibleStatusMap = this.tableColStatusService.getColVisibleStatusMap();
	LOG.info("Map {}", colVisibleStatusMap);
	for (UIComponent child : dataTable.getChildren()) {
		if (child instanceof Column column) {
			String colNameInt = this.getColumnInternalName(column);
			if (colVisibleStatusMap.containsKey(colNameInt)) {
				column.setVisible(colVisibleStatusMap.get(colNameInt));
				LOG.info(colNameInt + " gesetzt " + colVisibleStatusMap.get(colNameInt));
				colVisibleStatusMap.remove(colNameInt);
			} else {
				LOG.info(colNameInt + " nicht gefunden");
			}
		}
	}
	// nicht mehr existierende Spalten löschen
	for (String item : colVisibleStatusMap.keySet()) {
		this.tableColStatusService.removeColData(this.userDto, null, this.getPageName(), item);
	}
    }
    this.initialized = true;
  }
}

Die Methode, die bei jedem Aufbau der Seite aus dem erzeugten Controller aufgerufen wird hat einen Semaphor, um festzustellen, dass es der erste Aufbau ist. Ist dies der Fall, wird der Name der Seite bestimmt, die Tabelle gesucht und die entsprechenden Spalten auf nicht sichtbar gesetzt. Der Toggler befüllt sich dann aus diesen Informationen und wird damit in der UI korrekt angezeigt. Einige Hilfsmethoden habe ich hier nicht abgebildet. Bei Fragen schickt mir gerne eine E-Mail.

Die onToggle Methode muss dann nur noch die gespeicherten Daten aktualisieren

public void onToggle(ColumnToggleEvent event) {
	Integer index = (Integer) event.getData();
	UIColumn column = event.getColumn();
	Column column2 = (Column) column;
	Visibility visibility = event.getVisibility();
	this.tableColStatusService.saveColData(this.userDto, null, this.getPageName(), 
            this.getColumnInternalName(column2), Visibility.VISIBLE.equals(visibility));

	String text = "Column: " + index + " AriaHeader: " + column.getAriaHeaderText() + 
                      " HeaderText" + column.getHeaderText() + " toggled: " + visibility + " ";
	FacesMessage msg = new FacesMessage(FacesMessage.SEVERITY_INFO, text, null);
	FacesContext.getCurrentInstance().addMessage(null, msg);
}

Ergebnis

Durch ein einfaches Tag kann das gewünschte Verhalten vollständig gekapselt werden. In der Entwicklung muss nur noch das Tag schoeso:tableColToggleController verwendet und die Basis-Bibliothek mit der CC und der Implementierung eingebunden werden.

Mit JSF kann effizient auch für komplexe Anforderungen eine Lösung gefunden werden, bei der man sich in der täglichen Entwicklung auf die Fachlichkeit konzentrieren kann, ohne Boilerplate-Code zu erzeugen. Richtig angewendet schlägt JSF fast alle Lösungen im Zeitaufwand für die Lösung und im Komfort bei der Nutzung – wenn man sich im Jakarta EE Kontext bewegt!

Also: viel Spaß mit effizienten Lösungen und zufriedenen Kunden.