Plugin (Helper) erstellen - minova-afis/aero.minova.rcp GitHub Wiki

Erstellen von neuen Plugins/Helpern

Hier wird das Erstellen von neuen Plugins für Masken der WFC Anwendung dokumentiert. Alle Funktionen, die nicht zu der Core-Anwendung gehören, werden in Plugins ausgelagert. Mit der Version 12 wird in Masken kein JavaScript mehr ausgewertet. Die entsprechenden Funktionen müssen ebenfalls in einen Helper ausgelagert werden.

Prinzipiell wird für jede Maske ein eigenes Repository und Plugin erstellt.

Grob müssen folgende Schritte befolgt werden:

Diese Schritte sind im Folgenden genauer beschrieben.

Anlegen des Projekts

1. client Ordner anlegen und füllen

In dem Repository für die Erweiterung muss es einen Ordner client geben. In diesem werden die Resourcen für die WFC-Anwendung abgelegt. Neben den eigentlichen Plugins müssen noch folgende Dateien erstellt werden:

  • .mvn/extensions.xml

  • minova.target

  • pom.xml

Die .mvn/extensions.xml Dateil sollte folgenden Inhalt haben:

<extensions>
  <extension>
    <groupId>org.eclipse.tycho.extras</groupId>
    <artifactId>tycho-pomless</artifactId>
    <version>2.4.0</version>
  </extension>
</extensions>

Damit braucht nicht jeder Unterordner eine eigene pom.xml um mit Maven gebaut werden zu können.

minova.target ist die Targetplatform für das Plugin. Sie kann aus einem bestehenden Projekt kopiert werden, es sind KEINE Anpassungen nötig. Die Target-Platform des Templates findet sich hier.

Auch die pom kann kopiert werden, es sind allerdings Anpassungen nötig. Komplette Datei für Workingtime hier.

Angepasst werden müssen:

  • artifactId

  • version

  • modules

  • connection und developerConnection

2. Plugin Erstellen

In dem client-Ordner des Repositories wird ein neues Plug-In Project erstellt.

newPluginProject

Erste Seite des Dialogs:

  • Name eingetragen, das Namesschema ist aero.minova.<Projektname>.helper.

  • Location anpassen, es wird der eben erstellte ../client/aero.minova.<Projektname>.helper Ordner ausgewählt

pluginProjectDialog1

Zweite Seite des Dialogs:

  • ID eintragen

  • Version (entsprechend der Version des app-Teils wenn vorhanden)

  • Name ausfüllen/anpassen

  • Java 17 auswählen

pluginProjectDialog2

Das Manifest des Plugins sollte dann etwa so aussehen. Die Checkbox "Activate this plug-in when one of its classes is loaded" wird gesetzt. Dies ist nötig, damit der Helper zur Laufzeit geladen werden kann.

pluginManifest

3. Package erstellen

Zuletzt wird ein neues Package erstellt. Dieses hat ebenfalls Namesschema aero.minova.<Projektname>.helper. In diesem wird die Helper-Klasse angelegt.

newPackage

Importieren eines Helpers

Ist der Helper schon implementiert und soll nur in Eclipse importiert werden sind diese Schritte zu befolgen. Vorraussetzung: Das Projekt wurde von git geclont.

  • Rechtsklick auf Package Explorer in Eclipse → Import.. → Projects from Git → Existing local repository → Entsprechendes Repository auswählen (muss evtl. erst über Add.. hinzugefügt werden)

  • Import using the New Project wizard auswählen, in dem Baum zu dem Helper navigieren

importWithWizard
  • Plug-in Project wählen

  • Projektnamen eintragen, ../client/aero.minova.<Projektname>.helper Ordner als Location wählen.

importNewProject
  • Seite 2 des Dialogs bei Default belassen, diese wird eh gleich verworfen (nächster Schritt)

  • MANIFEST.MF Datei mit der HEAD Revision ersetzten (also dem, was schon im git liegt)

Der Helper ist nun als Plugin-Projekt im Eclipse importiert und kann weiterentwickelt werden.

Erstellen der Klasse

Damit eine Helperklasse geladen werden kann, muss dies in der zugehörigen Maske vermerkt sein, entweder in der Form am Anfang oder in einem Grid. Eine Maske kann auch mehrere Helper haben, z.B. einen vom Anfang der Form, einen pro Grid und einen pro Option Page.

<!-- Vermerk in form-Tag, am Anfang der Maske -->
<form icon="JobDefinition" title="@tJobDefinition.Administration"
  helper-class="aero.minova.jobdefinition.helper.JobDefinitionHelper"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:noNamespaceSchemaLocation="../../../../../../ch.minova.install/src/ch/minova/install/xsd/XMLForm.xsd">
  ....
</form>

<!-- Vermerk in einem grid-Tag -->
<grid  id="GraduationStep" delete-requires-all-params="true" procedure-suffix="GraduationStep" helper-class="aero.minova.graduation.helper.GraduationStepHelper">
	....
</grid>

Die Helperklassen müssen die Klasse IHelper implementieren. Dafür muss das Package aero.minova.rcp.model zu den benötigten Plug-Ins hinzugefügt werden.

package aero.minova.jobdefinition.helper;

import aero.minova.rcp.model.form.MDetail;
import aero.minova.rcp.model.helper.ActionCode;
import aero.minova.rcp.model.helper.IHelper;

@Component
public class JobDefinitionHelper implements IHelper {

	@Override
	public void setControls(MDetail mDetail) {
		// TODO Auto-generated method stub
	}

	@Override
	public void handleDetailAction(ActionCode code) {
		// TODO Auto-generated method stub
	}
}

Methoden

In der setControls Methode wird das MDetail übergeben, dass unter anderem alle Felder und Grids enthält. Dieses sollte als lokale Variable gespeichert werden.

Die handleDetailAction Methode wird aufgerufen, wenn von dem/der Nutzer:in eine Aktion ausgefürt wird. Diese sind im nächsten Abschnitt erklärt.

Laden zur Laufzeit

Außerdem wird an die Klassen @Component geschrieben. Dies wird benötigt, damit der Helper zur Laufzeit geladen werden kann. Sobald mit diesem @Component gespeichert wird, sollte automatisch ein Ordner OSGI-INF erstellt werden, der eine XML Datei enthält. Sollte dies nicht geschehen, kann es über die Einstellungen aktiviert werden:

pluginAnnotations

Das Manifest sollte automatisch um einen Service-Component-Eintrag erweitert worden sein (zu finden in der Sourcecode-Darstellung, MANIFEST.MF). Der Ordner OSGI-INF sollte jetzt noch zu dem Build hinzugefügt werden (Manifest → Tab "Build").

Programmieren des neuen Helpers

Damit der neue Helper entwickelt werden kann, ohne dass dieser erst gebaut und auf dem CAS ausgeliefert werden muss, sind folgende Schritte nötig:

  • In der Debug Configuration → Tracing → aero.minova.rcp.dataservice → debug/uselocalhelper aktivieren

  • Das neue Plugin in die Debug-Configuration von Eclipse aufnehmen, damit die lokale Version genutzt wird (kleiner Pfeil neben Debug-Icon → Plug-ins → entsprechendes Plug-in auswählen)

Umsetzen von Funktionalitäten

Alle Helper liegen im Kontext, es ist also möglich Injection zu nutzen.

Über das MDetail kann auf die Felder und Grids zugegriffen werden. Dies funktioniert jeweils über den Feldnamen (Achtung bei OPs, die Felder heißen <opTitel>.<Feldname>):

MField startDate = mDetail.getField("StartDate");

Reagieren auf Detail-Aktionen

Beim Betätigen einer Aktion im Detail wird automatisch die Methode handleDetailAction aufgerufen. Mögliche Aktionen sind:

  • BEFORE-/ AFTERDEL: Vor/Nach dem Löschen eines Eintrages

  • BEFORE-/ AFTERNEW: Vor/Nach Erstellen eines neuen Eintrags, also leeren der Felder. Da hier keine Prozedur aufgerufen wird folgen die Events kurz nacheinander

  • BEFORE-/ AFTERSAVE: Vor/Nach dem Speichern/Updaten eines Eintrages

  • BEFORE-/ AFTERREVERT: Vor/Nach dem Zurücksetzten des Eintrages. Auch hier wird keine Prozedur aufgerufen

  • BEFORE-/ AFTERREAD: Vor/Nach dem Lesen eines Datensatzes, Achtung: AFTERREAD wird verschickt, sobald die HAUPT-Maske komplett geladen ist. Option Pages und Grids sind eventuell noch nicht geladen

ACHTUNG bei den Aktionen VOR Löschen/Speichern/Lesen/…​, die Aufrufe sind asynchron, es wird also nicht auf eine Antwort des Helpers gewartet bevor die eigentlichen Prozeduren ausgeführt werden.

Wenn für die Aktion eine Prozedur aufgerufen wird erfolgt das AFTER-Event nur nach erfolgreichem Durchführen dieser.

Logging

Für das Logging wird die Klasse org.eclipse.core.runtime.ILog verwendet. Der Logger wird über die org.eclipse.core.runtime.Platform erreicht:

ILog logger = Platform.getLog(this.getClass());

logger.info("Logging Info");
logger.error("Logging Error");

Belegen von Werten in Feldern

Einzelne Felder können aus dem MDetail geholt und über die setValue Methode mit Werten belegt werden. Dabei ist darauf zu achten, dass als Wert ein Value-Objekt zu verwenden ist, und der Datentyp zu dem Feld passen muss. Das ist besonders wichtig für Lookup-Values. Als Nutzer wird false angegeben.

// Feld "startDate" mit aktuellem Datum belegen
MField startDate = mDetail.getField("StartDate");
startDate.setValue(new Value(DateUtil.getDate("0")), false);

// Versuchen, das Feld employee mit dem Lookup-Value für "janiak" vorzubelegen
MLookupField employee = (MLookupField) mDetail.getField("EmployeeKey");
LookupValueAccessor va = (LookupValueAccessor) employee.getValueAccessor();
CompletableFuture<List<LookupValue>> valueFromAsync = va.getValueFromAsync(null, "janiak");
valueFromAsync.thenAccept(l -> Display.getDefault().asyncExec(() -> {
  if (!l.isEmpty()) {
    LookupValue employeeValue = l.get(0);
    employee.setValue(employeeValue, false);
  }
}));

Dirty-Flag

Damit das Dirty-Flag richtig funktioniert, müssen die vorbelegten Werte ans WFCDetailCASRequestsUtil geliefert werden. Das WFCDetailCASRequestsUtil kann injected werden, allerdings ist die Annotation @Optional zusätzlich nötig. Die Methode heißt setValueAsCleanForDirtyFlag und benötigt den neuen Value, den Namen des Feldes (OHNE den Prefix bei OptionPage-Feldern) und den Namen der OptionPage (oder null, wenn das Feld in der Hauptmaske liegt).

@Inject
@Optional
WFCDetailCASRequestsUtil casUtil;

public void preallocateValues() {
  employee.setValue(employeeValue, false);
  casUtil.setValueAsCleanForDirtyFlag(employeeValue, employee.getName(), null);

  bookingDate.setValue(new Value(DateUtil.getDate("0")), false);
  casUtil.setValueAsCleanForDirtyFlag(bookingDate.getValue(), bookingDate.getName(), null);
}

Belegen von Werten in Grids

Einzelne Werte in Grids können direkt in die zugrundeliegende Tabelle gesetzt werden, das Grid wird automatisch aktualisiert. Die Klasse aero.minova.rcp.model.Table enthält einige Methoden die das Setzen von Werten weiter vereinfachen, etwa kann statt dem Spaltenindex auch der Spaltenname angegeben werden.

//setValue(int columnIndex, int rowIndex, Value newValue)
mDetail.getGrid("InvoicePosition").setValue(4, 5, new Value("neuer Wert"))

Um ganze Zeilen hinzuzufügen wird eine Tabelle verwendet. Die Spaltennamen in der übergebenen Tabelle müssen mit denen des Grids übereinstimmen, da diese genutzt werden um die Werte an die richtige Stelle zu schreiben. Die Reihenfolge der Spalten muss also NICHT übereinstimmen und es muss auch nicht die gleiche Anzahl sein. Wenn eine Spalte des Grids in der übergebenen Tabelle nicht gefunden wurde bleibt die entsprechende Zelle im Grid leer.

mDetail.getGrid("InvoicePosition").addRows(tableWithNewRows);

Wenn die Daten eines Grids komplett ersetzt werden sollen und auch das Dirtyflag nicht anspringen soll muss das Setzen über die Klasse WFCDetailCASRequestsUtil geschehen. Wie für die Zeilen müssen die Spaltennamen übereinstimmen, Reihenfolge ist aber egal.

@Inject
@Optional
WFCDetailCASRequestsUtil casUtil;

public void setContent() {
   casUtil.setGridContent(mDetail.getGrid("InvoicePosition"), newDataTable);
}

Grids Validieren

Es ist möglich, Eingaben in Grids direkt zu validieren. Dafür muss eine Klasse das Interface IGridValidator implementieren. Über die Methode MGrid#addValidation(IGridValidator validator, List<Integer> columnsToValidate) wird die Validierung hinzugefügt.

Die Methode checkValid() wird genutzt, um ungültige Zellen rot zu färben und das Speichern zu verhindern.

Mit validateThrowingException() wird die Eingabe überprüft, bevor sie ins Grid eingetragen wird. Bei dem Aufruf steht der neue Wert also noch nicht in der Tabelle! Wenn der neue Wert nicht gültig ist, muss eine ValidationFailedException geworfen werden. Deren Text wird übersetzt und als Notification-Popup unten links angezeigt.

Die Spalten- und Zeilenindices entsprechen den Positionen in der Tabelle, die dem Grid zugrunde liegt. Auf die Werte kann also wie gewohnt über das MGrid zugegriffen werden.

Die beiden Methoden werden nur für Werte aufgerufen, die in einer Spalte stehen deren Index in den columnsToValidatesteht. Hier sollte darauf geachtet werden, dass die meisten Grids unsichtbare Spalten haben, in denen die Keys stehen, die nicht validiert werden sollten.

In dem folgenden Beispiel werden die Werte der Spalte 2 (eine Integer Spalte) darauf überprüft, ob sie kleiner als 10 sind:

@Component
public class GraduationStepHelper implements IHelper, IGridValidator {

	private MGrid graduationSteps;

	@Override
	public void setControls(MDetail mDetail) {
		graduationSteps = mDetail.getGrid("GraduationStep");
		List<Integer> columnsToValidate = List.of(2); // Es soll nur Spalte 2 überprüft werden
		graduationSteps.addValidation(this, columnsToValidate);
	}

	@Override
	public void handleDetailAction(ActionCode code) {
		// Es werden nur Einträge ins Grid überprüft, nicht die Aktionen
	}

	@Override
	public boolean checkValid(int columnIndex, int rowIndex) {
		return graduationSteps.getDataTable().getValue(columnIndex, rowIndex).getIntegerValue() <= 10;
	}

	@Override
	public void validateThrowingException(int columnIndex, int rowIndex, Object newValue) throws ValidationFailedException {
		if ((Integer) newValue > 10) {
			throw new ValidationFailedException("Wert darf nicht größer als 10 sein!");
		}
	}
}

Reagieren auf Wert-Änderungen

Um auf Wertänderungen reagieren zu können, muss eine Klasse den ValueChangeListener bzw. den GridChangeListener implementieren. Diese Klasse kann dann als Listener zu einem Feld oder Grid hinzugefügt werden um auf Wertänderungen zu reagieren.

TicketHelper ticketHelper = new TicketHelper(this);
mDetail.getField("OrderReceiverKey").addValueChangeListener(ticketHelper);

public class TicketHelper implements ValueChangeListener {
	@Override
	public void valueChange(ValueChangeEvent evt) {
		MLookupField lookupField = (MLookupField) evt.getField();
		String writtenText = lookupField.getWrittenText();
		if (writtenText != null && writtenText.startsWith("#")) {
				System.out.println("Eingegbenes Ticket: " + writtenText);
		}
	}
}

Mit der Methode ValueChangeEvent#isUser kann herausgefunden werden, ob die Änderung durch die Nutzer:in oder das System selbst ausgelöst wurde. Außerdem sind der alte sowie der neue Wert im Event gespeichert.

GridChangeEvent

Es gibt vier verschiedene Typen von GridChangeEvents. Jedes GridChangeEvent hat einen GridChangeType und entsprechend sind die Attribute (nicht) gesetzt.

  • RESET: Die Tabelle wurde komplett geändert (z.B. nach dem Laden eines neuen Datensatzes)

  • INSERT: Eine neue Zeile wurde ins Grid eingefügt. Der Index der neuen Zeile sowie die Zeile selbst wird mitgegeben

  • DELETE: Eine Zeile des Grids wurde gelöscht. Der Index, den die Zeile hatte, sowie die Zeile selbst wird mitgegeben

  • UPDATE: Ein einzelner Wert im Grid wurde geändert. Zeilen- und Spaltenindex sowie der alte und neue Wert und die geänderte Zeile werden mitgegeben

Auslesen des Dirty-Flags

Um herauszufinden, ob das Detail "Dirty" ist (also es Änderungen seit dem letzten Speichern/Lesen/Leeren des Details gab) wird das DirtyFlag ausgelesen. Dazu wird die DirtyFlagUtil injected (die @Optional Annotation muss hinzugefügt werden, um eine Exception beim Starten zu vermeiden).

@Inject
@Optional
DirtyFlagUtil dirtyFlagUtil;

public boolean checkDirty() {
  return dirtyFlagUtil.checkDirty(); // True wenn es eine Änderung gab, false ansonsten
}

Buttons

Alle Knöpfe funktionieren aus Sicht der Helper gleich, egal ob sie in der Maske in der Kopf-Section, einer nicht-Kopf-Section oder in Grids definiert wurden (Einziger kleiner Unterschied bei gruppierten Knöpfen, siehe weiter unten).

Buttons de-/aktivieren

In Version 11 konnten Buttons/Knöpfe über die Maske de-/aktiviert werden, je nachdem ob ein gewisses Feld einen Wert hat:

<!-- Alte Maske -->
<button icon="Print.Command" text="@Action.PrintInvoice" id="PrintInvoice">
       <dynamic property="enabled">KeyLong != null</dynamic>
</button>

Da wir kein JavaScript mehr in der Maske wollen, kann der entsprechende Code etwa wie folgt aussehen. Damit der Button immer aktuell bleibt, sollte das De-/Aktivieren mit einem ValueChangeListener auf das entsprechende Feld verbunden werden.

MField keyLong = mDetail.getField("KeyLong");
MButton printInvoice = mDetail.getButton("PrintInvoice");
printInvoice.setEnabled(keyLong.getValue() != null); // Button initial richtig de/aktivieren
keyLong.addValueChangeListener(evt -> { // Auf Änderung des KeyLongs reagieren
  printInvoice.setEnabled(keyLong.getValue() != null);
});

Manchmal ist es nötig, dass ein Button nur aktiv ist, wenn mindestens eine Zeile in einem Grid ausgewählt ist. Das lässt sich wie folgt umsetzten:

MButton button = mDetail.getButton("ButtonName");
MGrid grid = mDetail.getGrid("GridName");
grid.addSelectionListener(event -> {
	if (event instanceof ISelectionEvent) {
		button.setEnabled(!grid.getSelectedRows().getRows().isEmpty());
	}
});

Reagieren auf Button-Klick

Außerdem muss ein Helper auf das Drücken eines Buttons reagieren können. Dafür kann ein SimpleSelectionAdapter auf den Button registriert werden:

MButton printInvoice = mDetail.getButton("PrintInvoice");
printInvoice.addSelectionListener(new SimpleSelectionAdapter() {
	@Override
	public void handle(SelectionEvent e) {
		// Entsprechende Methode(n) ausführen
	}
});

ACHTUNG bei Buttons mit Dropdown in Abschnitten

In Version 12 können mehrere Knöpfe zu einer Gruppe zusammengefasst werden (group-Tag in der xml). Diese werden dann als Dropdown dargestellt. Befinden sich diese Knöpfe NICHT in den Kopfdaten muss im Helper eine if-Bedingung eingefügt werden, um zu verhindern, dass die Methoden ausgeführt werden, wenn auf den Pfeil geklickt wird oder der Button disabled ist. Sind die Knöpfe in den Kopfdaten muss nichts weiter beachtet werden.

<!-- Beispiel für gruppierte Knöpfe -->
<page icon="Calendar.ico" id="Administration" text="@tJournaltemplate.Group.Administration">
	<button id="insertTemplates" group="insert" icon="CompleteRecords.Command" text="@tJournaltemplate.action.insertTemplates"/>
	<button id="insertSingleTemplate" group="insert" icon="CompleteRecord.Command" text="@tJournaltemplate.action.insertSingleTemplate"/>
	...
</page>
MButton insertTemplates = mDetail.getButton("insertTemplates");
insertTemplates.addSelectionListener(new SimpleSelectionAdapter() {
	@Override
	public void handle(SelectionEvent e) {
		// Die if-Bedingung verhindert, dass die Methoden ausgeführt werden, wenn auf den Pfeil geklickt wird oder der Button disabled ist
		if (e.detail != SWT.ARROW && insertTemplates.isEnabled()) {
			// Entsprechende Methode(n) ausführen
		}
	}
});

Einstellungen der Anwendung auslesen

Es gibt zwei Möglichkeiten, die Einstellungen der Anwendung auszulesen. Mit Möglichkeit eins wird der Wert einer bestimmten Einstellung direkt injected. Dieser Wert wird auch automatisch aktualisiert, wenn sich die Einstellung ändert.

@Inject
@Preference(nodePath = ApplicationPreferences.PREFERENCES_NODE, value = ApplicationPreferences.AUTO_RELOAD_INDEX)
boolean autoReloadIndex;

Mit Möglichkeit zwei werden die gesamten Einstellungen ausgelesen. Das bietet die Möglichkeit, bei Änderungen einer Einstellung direkt zu reagieren.

// Preferences laden (Kann auch am Anfang der Klasse geschehen)
IEclipsePreferences preferences = InstanceScope.INSTANCE.getNode(ApplicationPreferences.PREFERENCES_NODE);

// Wert auslesen
boolean autoReloadIndex = preferences.getBoolean(ApplicationPreferences.AUTO_RELOAD_INDEX, false);

// Bei einer Änderung der Einstellung reagieren
preferences.addPreferenceChangeListener(event -> {
	if (event.getKey().equals(ApplicationPreferences.AUTO_RELOAD_INDEX)) {
		// TODO
	}
});

Ein-/Ausblenden von Sections

In Version 11 konnten einzelne Sections über die Maske ein- oder ausgeblendet werden.

<!-- Alte Maske -->
 <page id="Debug" text="@Administration" icon="Administration" visible="false">
    <dynamic property="visible">app.isSUMode()</dynamic>
    ...
</page>

Auch dies wird in Version 12 über einen Helper umgesetzt. Vorerst gibt es keinen Super User Modus mehr, stattdessen können versteckte Abschnitte über die Einstellungen eingeblendet werden ("Darstellung" Tab, die Einstellung hat ID ApplicationPreferences.SHOW_HIDDEN_SECTIONS). Die Umsetzung im Helper kann dann wie folgt aussehen:

//Preferences laden
IEclipsePreferences preferences = InstanceScope.INSTANCE.getNode(ApplicationPreferences.PREFERENCES_NODE);

//MSection über ihre ID aus dem MDetail holen
MSection debugSection = mDetail.getPage("Debug");

// Initial Sichtbarkeit entsprechend der Einstellung setzten
debugSection.setVisible(preferences.getBoolean(ApplicationPreferences.SHOW_HIDDEN_SECTIONS, false));

// Bei einer Änderung der Einstellung Sichtbarkeit anpassen
preferences.addPreferenceChangeListener(event -> {
	if (event.getKey().equals(ApplicationPreferences.SHOW_HIDDEN_SECTIONS)) {
		debugSection.setVisible(event.getNewValue().equals("true"));
	}
});

Felder und Grids auf read-only oder required

Felder und Grids können über die Helper auf read-only oder required gesetzt werden. Das Verhalten (kein Speichern ohne Value bei required und kein Bearbeiten von read-only Feldern) sowie die Darstellung wird automatisch angepasst. In Grids können aktuell nur ganze Spalten geändert werden. Für Grids gibt es die Möglichkeit, das ganze Grid auf einmal zu ändern. Zudem bietet das MDetail Methoden, mit dem alle Felder oder alle Felder und Grids auf einmal angepasst werden können. Auch das Zurücksetzen auf den Originalzustand (wie in der Maske definiert) ist möglich.

// Felder
mDetail.getField("Description").setReadOnly(true);
mDetail.getField("Description").setRequired(true);
mDetail.getField("Description").resetReadOnlyAndRequired();

// Grids, äquivalent für required möglich
mDetail.getGrid("InvoicePosition").setColumnReadOnly(4, true);
mDetail.getGrid("InvoicePosition").setGridReadOnly(true);
mDetail.getGrid("InvoicePosition").resetReadOnlyAndRequiredColumns();

// Über mDetail alle Felder / Grids ändern
mDetail.setAllFieldsReadOnly(true);
mDetail.setAllGridsAndFieldsReadOnly(true);
mDetail.resetAllGridsAndFieldsReadOnlyAndRequired();

Sichtbarkeit von Feldern

Die Sichtbarkeit von Feldern kann über einen Helper geändert werden. Achtung beim unsichtbar-setzen von required Feldern! Das Feld muss trotzdem gefüllt sein, damit gespeichert werden kann, über die UI ist das aber nicht mehr möglich. Es gibt auch die Möglichkeit, die Sichtbarkeit auf den Originalzustand (wie in der Maske definiert) zurückzusetzen. Da bei jeder Änderung der Sichtbarkeit eines Feldes der gesamte Abschnitt neu gezeichnet werden muss, sollten diese Methoden sparsam eingesetzt werden.

mDetail.getField("Description").setVisible(false); // Feld unsichtbar setzten
mDetail.getField("Description").resetVisibility(); // Sichtbarkeit zurücksetzten
mDetail.resetAllFieldsVisibility(); // Sichtbarkeit aller Felder zurücksetzten

Param-String Felder

In der alten Version wurden die Felder, die in Param-String Feldern dargestellt werden über JavaScript in der Maske gesetzt:

<!-- Alte Maske -->
<jscript>
	tools.setProp('ExecutionParameter', 'xml-file', 'SQL2FileParameter.op.xml');
</jscript>

In Version 12 muss dies über einen Helper geschehen, indem die entsprechende Methode der Klasse WFCDetailCASRequestsUtil aufgerufen wird. Wenn noch nicht geschehen, wird die Maske automatisch runtergeladen und ausgelesen, die entsprechenden Felder werden im Detail erstellt. Der äquivalente Code kann so aussehen:

@Inject
@Optional // Ohne @Optional tritt ein Fehler auf
WFCDetailCASRequestsUtil casUtil;

public void updateParamString() {
  casUtil.updateParamStringField((MParamStringField) mDetail.getField("ExecutionParameter"), "SQL2FileParameter.op.xml");
}

tools.getSQLValue() Methode

In der alten Version wurden über JavaScript Werte aus der Datenbank geholt:

<!-- Alte Maske -->
<jscript>
	tools.getSQLValue('vJobDefinitionParameter', 'JobExecutorKey', JobExecutorKey, 'ClassName');
</jscript>

Dies kann in der neuen Version wie folgt abgebildet werden (Der IDataService kann injected werden):

Value jobExecuterKey = mDetail.getField("JobExecutorKey").getValue();
dataService.getSQLValue("vJobDefinitionParameter", "JobExecutorKey", jobExecuterKey, "ClassName", mDetail.getField("ClassName").getDataType());

Die Methode getSQLValue() nimmt folgende Parameter:

  • String tablename: der Name der angefragten Tabelle/ View

  • String requestColumn: der Name der Spalte für die der Wert gegeben ist

  • Value requestValue: der Wert nach dem gesucht werden soll (in der gegebenen Spalte)

  • String resultColumn: der Name der Spalte, für die der Wert zurückgegeben werden soll

  • DataType resultType: der Typ des angefragten Werts

Sortieren von Optionen für Lookup Felder

Manchmal kann es gewünscht sein, dass die Lookup-Werte nicht alphabetisch sortiert werden. Dafür kann Lookup Feldern ein Comparator<LookupValue> gegeben werden. In dem Beispiel werden die Optionen numerisch aufsteigend sortiert (es wird davon ausgegangen, dass alle KeyText zahlenwerte sind).

MLookupField issue = (MLookupField) mDetail.getField(WORKINGTIME_GITHUB_ISSUE);
issue.setComparatorForLookupContentProvider(new SortByNumber());

private class SortByNumber implements Comparator<LookupValue> {
	@Override
	public int compare(LookupValue o1, LookupValue o2) {
		return Integer.parseInt(o1.keyText) - Integer.parseInt(o2.keyText);
	}
}

Filtern von Optionen für Lookup Felder

Manchmal kann es gewünscht sein, dass für Lookup-Felder nicht alle Optionen, die von der Datenbank geliefert werden, auch angezeigt werden. Entsprechend kann Lookup Feldern ein Filter gegeben werden. Dieser muss von die Klasse Predicate<LookupValue> haben. In dem Beispiel wird das LookupValue mit KeyText "AUTOCREDIT" aus den angezeigten Optionen entfernt.

MLookupField invoiceTypeKey = (MLookupField) mDetail.getField("InvoiceTypeKey");
invoiceTypeKey.setFilterForContentProvider(value -> !value.getKeyText().equals("AUTOCREDIT"));

SiteParameter anfragen

Auch in Version 12 gibt es eine Tabelle tSiteParameter. Deren Werte können über den IDataService (der injected werden kann) angefragt werden. Als Parameter wird der Key sowie der Defaultwert (falls der Key nicht existiert) benötigt. Es wird immer ein String zurückgegeben. Siehe auch Trac Doku für alle möglichen SiteParameter und ihre Defaults.

String defaultCurrency = dataService.getSiteParameter("InvoiceDefaultCurrency", "2");

Ausführen von Handlern (z.B. zum Speichern)

Alle Handler können auch aus dem Code aufgerufen werden. Dafür müssen der ECommandService und der EHandlerService injected werden. Wenn die canExecute() Methode des jeweiligen Handlers false zurückgibt wir der Handler auch nicht ausgeführt.

//Beispielhafes Ausführen des Handlers zum Speichern des Details
String commandID = Constants.AERO_MINOVA_RCP_RCP_COMMAND_SAVEDETAIL
ParameterizedCommand cmd = commandService.createCommand(commandID, null);
handlerService.executeHandler(cmd);

Benachrichtigungen an Nutzer:in

Es gibt prinzipiell zwei Möglichkeiten, Nachrichten anzeigen zu lassen. Zum einen kann ein kleines Popup erstellt werden, das auch ohne Interaktion wieder verschwindet. Die andere Möglichkeit ist als Fehlermeldung, die aktiv weggeklickt werden muss.

Beide Methoden werden über den IEventBroker aufgerufen. Der Nachrichtstring wird automatisch übersetzt, ob mit oder ohne "@" am Anfang.

Außerdem wird der übersetzte String formattiert, wobei die einzusetzenden Parameter mit % getrennt werden. Beispiel: Aus dem String @msg.TextTooLong %110>100 und der zugehörigen Übersetzung in den .messagesProperties: Maximale L\u00E4nge \u00FCberschritten ({0})! ergibt sich die Übersetzung Maximale Länge überschritten (110>100)!.

broker.send(Constants.BROKER_SHOWNOTIFICATION, "msg.notification"); // Popup
broker.send(Constants.BROKER_SHOWERRORMESSAGE, "msg.errormessage"); // Fehlermeldung
broker.send(Constants.BROKER_SHOWERRORMESSAGE, "msg.TextTooLong %110>100"); // Fehlermeldung mit Parametern

Öffnen von Dialogen

Es können Dialoge geöffnet werden, die Hinweise anzeigen oder Auswahlmöglichkeiten zwischen mehreren vorgefertigten Optionen geben.

MessageDialog dialog = new MessageDialog(
	Display.getDefault().getActiveShell(), // Aktive Shell auslesen
	translationService.translate("@msg.QuestionTitle", null), // Titel (übersetzt)
	null, // Titelbild (meistens leer)
	translationService.translate("@msg.QuestionMessage", null), // Nachricht (übersetzt)
	MessageDialog.QUESTION, // Art des Icons im Dialog, andere Möglichkeiten siehe MessageDialog
	new String[] {	translationService.translate("@Yes", null), // Array mit den Knöpfen, kann auch nur einer sein
		translationService.translate("@No", null) },
	0); // Index der vorausgewählten Option (wird bei Enter automatisch ausgewählt)

int result = dialog.open(); // Dialog öffnen und Ergebnis auslesen (Index der gewählten Option)

if (result == 0) { // Überprüfen, was geklickt wurde
	// TODO: Entsprechende "Yes" Methode ausführen
} else {
	// TODO: Entsprechende "No"/ Abbrechen Methode ausführen
}

XBS Auslesen

Um Werte aus der .xbs auszulesen bietet die Klasse XBSUtil einige Methoden an. Die Preferences (also die gesamte .xbs) sind über die MApplication (kann injected werden) erreichbar.

Preferences preferences = (Preferences) mApplication.getTransientData().get(Constants.XBS_FILE_NAME); // Gesamte .xbs
Node node = XBSUtil.getNodeWithName(preferences, "NodeName"); // ERSTE Node mit gegebenem Namen
Map<String, String> mapOfNode = XBSUtil.getMapOfNode(node); // Map der Node
Map<String, String> mapOfNode = XBSUtil.getMapOfNode(preferences, "NodeName"); // Äquivalent zu oberem Aufruf

Maskenspezifische Einstellungen

Es ist möglich, für eine Masken ein zusätzliches Tab in den Einstellungen hinzuzufügen (z.B. um den/die Mitarbeiter:in in der Stundenerfassung vorzubelegen). Dafür muss eine neue Klasse definiert werden, die die Klasse PreferenceTabDescriptorerweitert. Das verwendete Icon muss (wie die andern Bilder der Maske) im src/main/app/images/ Ordner abgelegt werden. Die Einstellungen werden dann wie auch die Haupteinstellungen aufgebaut, siehe auch Klasse PreferenceWindowModel. Auf die eingestellten Werte kann dann wie für alle anderen Einstellungen auch zugegriffen werden.

public class WorkingTimePreferencePage extends PreferenceTabDescriptor {

	public WorkingTimePreferencePage() {
		super("aero.minova.rcp.preferencewindow", "icons/SIS.png", "sisTab", getTranslationService().translate("@Preferences.WorkingTime", null), 0.6);

		TranslationService translationService = getTranslationService();

		PreferenceSectionDescriptor psd;
		psd = new PreferenceSectionDescriptor("user", translationService.translate("@Preferences.WorkingTime.UserPreselect", null), 0.1);
		this.add(psd);
		psd.add(new PreferenceDescriptor(Constants.USER_PRESELECT_DESCRIPTOR,
				translationService.translate("@Preferences.WorkingTime.UserPreselectDescription", null), 0.1, DisplayType.STRING,
				System.getProperty("user.name")));
	}

	/**
	 * Translation Service aus dem Kontext holen
	 */
	private static TranslationService getTranslationService() {
		IEclipseContext serviceContext = EclipseContextFactory.getServiceContext(FrameworkUtil.getBundle(WorkingTimePreferencePage.class).getBundleContext());
		return serviceContext.get(IWorkbench.class).getApplication().getContext().get(TranslationService.class);
	}
}

Wenn die Klasse erstellt ist muss sie noch in der Manifest.MF Datei eingetragen werden. Unter Extensions wird minova.preferencepage ausgewählt und die eben erstellte Klasse wird als page eingetragen. Atomatisch wird ein plugin.xml file erstellt, das folgenden Inhalt haben sollte (als Klasse natürlich die eben erstellte):

<plugin>
   <extension
         point="minova.preferencepage">
      <page
            class="aero.minova.workingtime.preferencepage.WorkingTimePreferencePage">
      </page>
   </extension>
</plugin>

Erstellen eines Wizards

Masken können um einen Knopf erweitert werden, der einen Wizard öffnet. Dafür muss prinzipiell kein Helper erstellt werden. Das Erstellen des Plugins funktioniert äquivalent.

Die Wizards sind dafür gedacht, die Möglichkeit für komplexe Eingaben zu bieten. Wenn nur eine Frage mit mehreren vorgefertigten Antwortmöglichkeiten (z.B. "Soll gespeichert werden? Ja/Nein") gestellt wird, sollte besser ein Dialog genutzt werden.

Eintrag in der Maske

Um den Wizard zu öffnen muss er in der Maske vermerkt sein. Zudem sit wichtig, dass ein Helper angegeben ist, damit das Plugin geladen wird.

<detail ...>
  <head>
    <!-------Button der den Wizard öffnen soll----->
    <button icon="AutoInvoice.Command" text="@AutoInvoiceWizard.Button" id="AutoInvoice"/>
    ....
  </head>
  ....
</detail>
<events>
  <onclick refid="AutoInvoice">
    <!-------Name des Wizards----->
    <wizard wizardname="aero.minova.invoice.wizard.AutoInvoiceWizard"/>
  </onclick>
</events>

Klassen des Wizards

Jeder Wizard besteht aus mindestens drei Klassen.

  1. Der Wizard selbst

  2. Mindestens einer Page

  3. Der FinishAction

Der Wizard

@Component
public class AutoInvoiceWizard extends MinovaWizard implements IMinovaWizard {

        private AutoInvoicePage1 page1;
	private AutoInvoicePage2 page2;

	public AutoInvoiceWizard() {
		super("@AutoInvoice.WizardName");
		this.setFinishAction(new AutoInvoiceFinishAction());
	}

	@Override
	public void addPages() {

		// Die Pages sollen nur hinzugefügt werden, wenn noch keine Pages erstellt wurden
		if (this.getPages().length != 0) {
			return;
		}

		//Titel übersetzen
		this.setWindowTitle(translationService.translate("@AutoInvoice.WizardName", null));

		// Pages erstellen
                page1 = new AutoInvoicePage1(translationService.translate("@AutoInvoice.Page1Name", null),
				translationService.translate("@AutoInvoice.Page1Description", null));
		addPage(page1);
		page2 = new AutoInvoicePage2(translationService.translate("@AutoInvoice.Page2Name", null),
				translationService.translate("@AutoInvoice.Page2Description", null));
		addPage(page2);

		super.addPages();
	}
}

Wie auch bei Helpern muss @Component an die Klasse geschrieben werden. Außerdem wird implements IMinovaWizard benötigt, damit der Wizard zur Laufzeit geladen werden kann. Wie bei dem Helper sollte automatisch im Ordner OSGI-INF eine neue .xml Datei für den Wizard angelegt worden sein.

Der MinovaWizard wird extended, um die Konfiguration zu erleichtern. Etwa wird die FinishAction automatisch aufgerufen und die Pages liegen im Kontext. Außerdem sind hier schon einige oft benutzte Klassen wie der translationService oder der dataService injected, auf diese kann direkt zugegriffen werden.

Im Konstruktor wird der Name gesetzt (dieser wird allerdings noch nicht übersetzt). Außerdem wird die passende FinishAction hinzugefügt.

addPages Methode:

  • Die if-Abfrage verhindert, dass die Pages mehrmals hinzugefügt werden

  • Der Titel wird übersetzt (inzwischen ist der TranslationService verfügbar)

  • Die Pages werden erstellt

  • super.addPages() ist wichtig, damit die Pages automatisch im Kontext liegen (und Injection verfügbar ist). Außerdem werden einige Listener für bessere Bedienbarkeit registriert

Die Pages

Jeder Wizard benötigt mindestens eine Page. Es gibt prinzipiell zwei verschiedene Arten von Pages, mit Feldern oder mit Tabellen. Beide Arten von Pages extenden die MinovaWizardPage! In dieser wird dafür gesorgt, dass die Knöpfe entsprechend de-/aktiviert werden, wenn alle Pflichtfelder gefüllt sind. Außerdem steuert sie das Enterverhalten und bietet Methoden für das Erstellen von Feldern.

Pages mit Feldern
public class AutoInvoicePage1 extends MinovaWizardPage {
	protected AutoInvoicePage1(String pageName, String pageDescription) {
		super(pageName, pageName, pageDescription);
	}

	@Override
	public void createControl(Composite parent) {

                // Composite mit Layout erstellen
		Composite composite = getComposite(parent);

                // "Normales" Feld
		MField bookingDate = createMField(DataType.INSTANT, DateTimeType.DATE, "@AutoInvoice.InvoiceDate", true);
		createUIField(bookingDate, DateTimeType.DATE, composite, 0, 0);

                // Feld mit weiteren Konfiguration
                MField  descriptionField = createMField(DataType.STRING, null, "@WorkingTime.Description", true);
		descriptionField.setNumberColumnsSpanned(4);
		descriptionField.setNumberRowsSpanned(3);
		descriptionField.setFillHorizontal(true);
		createUIField(descriptionField, null, composite, 4, 0);

                // Lookup Feld
                Lookup employeeLookup = new Lookup();
		employeeLookup.setTable("tEmployee");
		MField employeeField = createMLookupField(employeeLookup, "@WorkingTime.EmployeeText", true);
		createUILookupField(employeeField, composite, 0, 0);
		employeeField.setValue(originalMDetail.getField("EmployeeKey").getValue(), false);

                // Konfiguration abschließen
		TranslateUtil.translate(composite, translationService, locale);
		valueChange(null); // Update der Knöpfe
		init();
	}

	@Override
	public void valueChange(ValueChangeEvent evt) {
		// TODO Weitere Validierung der Felder

		super.valueChange(evt); // Update der Knöpfe!
	}
}

createControl() Methode:

  • Erstellen eines Composites. Hierfür wird die Methode aus der MinovaWizardPage verwendet, die sich um das Layout kümmert.

  • Erstellen der Felder (auf dem Composite). Dies geschieht in zwei Schritten, zuerst das MField, dann das UI. Damit kann etwa die Breite der Felder noch angepasst werden. Lookup-Felder benötigen zudem ein aero.minova.rcp.form.model.xsd.Lookup, in dem die gewünschte Tabelle/Prozedur angegeben ist

  • Übersetzen der Felder mit TranslateUtil.translate(composite, translationService, locale)

  • Aktualisieren der Knöpfe mit valueChange(null)

  • Zuletzt init() aufrufen, damit die onSelect() Methode zur verfügung steht

Weitere Validierungen können (wie bei Helpern) über die valueChange() Methode vorgenommen werden. Da schon die MinovaWizardPage den ValueChangeListener implementiert ist dies nicht nochmal nötig. Wichtig ist, dass am Ende der Methode super.valueChange(evt) aufgerufen wird, damit die Knöpfe richtig aktualisiert werden!

Pages mit Tabellen
public class AutoInvoicePage2 extends MinovaWizardPage {

	protected AutoInvoicePage2(String pageName, String pageDescription) {
		super(pageName, pageName, pageDescription);
	}

	@Override
	public void createControl(Composite parent) {
		Composite composite = getCompositeNattable(parent);

		MinovaWizardIndexUtil indexUtil = new MinovaWizardIndexUtil("AutoInvoiceContracts.xml");
		ContextInjectionFactory.inject(indexUtil, context);
		NatTable natTable = indexUtil.createNattable(composite);

		setPageComplete(false);
		init();
	}

	@Override
	protected void onSelect() {
		// TODO: Prozedur aufrufen um Tabelle zu füllen

                setPageComplete(true);
	}
}

createControl() Methode:

  • Erstellen eines Composites für die NatTable

  • Erstellen der NatTable über Klasse MinovaWizardIndexUtil. Diese lädt automatisch (wenn nötig) das angegebene Formular vom CAS und baut die NatTable auf. Dafür muss die Klasse im Kontext liegen. Die Tabelle wird automatisch übersetzt

  • setPageComplete(false) aufrufen, damit der Wizard nicht von vornherein abschließbar ist.

  • Zuletzt init() aufrufen, damit die onSelect() Methode zur verfügung steht

Die onSelect() Methode wird aufgerufen, sobald die Nutzer:in diese Page öffnet. Hier kann dann z.B. die Tabelle über eine Prozedur gefüllt werden. Entsprechend sollte auch setPageComplete(true) aufgerufen werden, damit der Wizard dann abgeschlossen werden kann.

Die FinishAction

public class AutoInvoiceFinishAction implements IMinovaWizardFinishAction {

	private AutoInvoiceWizard minovaWizard;

        @Override
	public void setWizard(MinovaWizard minovaWizard) {
		this.minovaWizard = (AutoInvoiceWizard) minovaWizard;
	}

	@Override
	public boolean execute() {
                //TODO: Wizard abschließen
		return true;
	}
}

Jede FinishAction implementiert die IMinovaFinishAction.

In der Methode setWizard() wird der Wizard übergeben.

Die Methode execute() wird aufgerufen, sobald die Nutzer:in auf "Abschließen" klickt. Hier sollte dann also entsprechend eine Prozedur aufgerufen oder der Wizard anderweitig abgeschlossen werden. Wichtig ist die Rückgabe.

  • Bei true wird der Wizard automatisch geschlossen

  • Bei false bleibt der Wizard geöffnet

Abhängigkeit auf Maven-Projekte

Hintergrund: In Helpern können erstmal nur Libraries genutzt werden, die auch in der WFC-Anwendung eingebunden sind. Für einige Funktionalitäten werden allerdings weitere Abhängigkeiten benötigt (z.B. für API-Anbindungen). Ist dies der Fall müssen diese Abhängigkeiten (als jars) mit dem Helper ausgeliefert werden, damit der Helper von der Anwendung genutzt werden kann. Da wir in der Anwendung und in den Helpern pomless arbeiten, reicht es leider nicht, nur eine Dependency hinzuzufügen.

pom Anpassen

Zuerst muss die pom (sollte im client-Ordner liegen) um die entsprechenden Abhängigkeiten erweitert werden. Ziel ist, dass die jars in einen Ordner kopiert werden, von dem aus sie im Helper genutzt werden können. Dafür ist folgendes Plugin nötig:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-dependency-plugin</artifactId>
    <version>2.10</version>
    <executions>
      <execution>
      <id>copy-libraries</id>
      <phase>validate</phase>
      <goals>
        <goal>copy</goal>
      </goals>
      <configuration>
        <artifactItems> <!-- Hier Abhängigkeiten eintragen (können auch mehrere items sein) -->
          <item> <!-- Beispiel -->
            <groupId>aero.minova</groupId>
            <artifactId>cas.openapi.api.client</artifactId>
            <version>1.0.4</version>
          </item>
        </artifactItems>
        <outputDirectory>lib</outputDirectory> <!-- Name des Output-Ordners -->
        <stripVersion>true</stripVersion>
        <overWriteReleases>true</overWriteReleases>
        <overWriteSnapshots>true</overWriteSnapshots>
      </configuration>
    </execution>
  </executions>
</plugin>

Wenn die pom angepasst ist muss der Helper einmal gebaut werden, damit der Ordner mit den jars erstellt wird.

Wird eine neue Version benötigt, kann diese einfach in der pom eingetragen werden, der Helper muss erneut gebaut werden.

MANIFEST anpassen

Damit die Abhängigkeiten im Code verwendet werden können muss die MANIFEST.MF Datei wie folgt angepasst werden:

Runtime → Classpath → Add…​ → Entsprechende jars auswählen

Außerdem müssen die jars mit dem Helper ausgeliefert werden:

Build → Entsprechende jars auswählen (es kann auch gleich der gesamte Ordner gewählt werden)

Nun kann ganz normal mit der Abhängigkeit gearbeitet werden, eventuell ist noch ein Eintrag in Dependencies → Required Plug-ins nötig.

Bauen (und Release) des Plugins

Das Plugin kann gebaut werden, indem der mvn clean verify Befehl in dem client Ordner des entsprechenden Repositories ausgeführt wird. Das gebaute .jar File liegt dann im target-Ordner des jeweiligen Plugins.

jarExample

Alternativ kann auch ein Github-Workflow angelegt werden, der zusätzlich zum App Teil auch den Helper baut. Beispiel für Workingtime hier.

Dieser kann über Actions → Release App and Helper genutzt werden. Der Helper wird dann mit gebaut und in den src/main/app/plugins Ordner abgelegt, von wo er automatisch vom CAS ausgeliefert werden kann (Wenn das Projekt als Abhängigkeit eingetragen ist).

⚠️ **GitHub.com Fallback** ⚠️