Making-of: Der Web Report Designer in der Entwicklung Teil III

Im letzten Teil unserer Blogpost-Serie gehen wir näher auf die Herausforderungen in der Entwicklung des Web Report Designers ein – vor allem im Hinblick auf Back-End-Anfragen, Hotkeys und dem Formel-Editor.

Aktuelle Infos und alle Möglichkeiten zu Web & Cloud Reporting mit List & Label.

Notebook mit Web Report Designer Ansicht

Back-End-Anfragen/Abfragen

Beim Thema Back-End-Anfragen wollten wir möglichst flexibel bleiben und diese bei Bedarf schnell austauschen können. Deshalb wurde entschieden, dass der UI-Code und die eigentliche Kommunikation mit dem Back-End strikt getrennt werden müssen. Hierbei haben wir auf das NPM-Package axios gesetzt, welches schnell und einfach einen ‚Promise based HTTP(s) client‘ ermöglicht. Zwei Punkte haben uns bei dem Package überzeugt: das einfache Bearbeiten und die einfache Verwendung.

import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
 
const options: AxiosRequestConfig = getMergedConfig(sessionConfiguration, config);
return axios.post(subUrl, data, options);

Folgendes Konzept haben wir uns überlegt:

Konzept UI Dispatch Service Callback

UI: Für jede Anfrage die vom Frontend an das Back-End geht, wird ein Dispatch erstellt. Damit soll wiederkehrender Code verhindert werden. 

Der UI Code ruft hierbei die Dispatches auf und prüft anschließend das Ergebnis:

...
//create new variable
const response = await dispatch(addNewVariableToServer());
if (addNewVariableToServer.fulfilled.match(response)) {
    newUserVariable = response.payload;
}
...

Dispatch: Die Dispatches übernehmen die Kommunikation zwischen UI, Store und den eigentlichen Back-End-Axios Abfragen. Hier werden auch etwaige Fehlermeldungen bzw. fehlgeschlagene Abfragen behandelt. Nachfolgend exemplarisch der Dispatch um eine neue Summenvariable hinzuzufügen:

export const addNewVariableToServer = createAsyncThunk<
    // Return type of the payload creator
    SumVariable,
    // arguments to the payload creator
    void,
    // Types for ThunkAPI
    ThunkAPITypes
>('/addNewVariableToServer', async (_parameter, thunkApi) => {
    const dispatch = thunkApi.dispatch;
    const response = await dispatch(addDefaultSumVariable_Service());
    if (addDefaultSumVariable_Service.fulfilled.match(response)) {
        dispatch(resetSumVariablesFetchError());
        return response.payload;
    }
    else {
        dispatch(setSumVariablesFetchError(EnumSumVariablesErrorType.OnAddNewVariable));
        return thunkApi.rejectWithValue({ errorMessage: response.payload?.errorMessage ?? '' });
    }
});

In Zeile 10 des oben beschriebenen Codeblocks wird der Back-End-Aufruf gestartet und ein Promise zurückgegeben.
Durch Axios haben wir in Zeile 11 die Möglichkeit, auf das Ergebnis dieses Aufrufs zu warten. Anschließend wird mit der Methode „fulfilled“ geprüft, ob ein gültiger Statuscode, der keinen Fehler darstellt, als Ergebnis zurückkommt.

Sollte der Statuscode gültig sein, wird in Zeile 13 ff. der Fehlerspeicher zurückgesetzt und das eigentliche Ergebnis zurückgegeben. In diesem Fall ist das eine neue Summenvariable. 

Sollte die Back-End-Abfrage ungültig sein, so wird in Zeile 16 ff. unter anderem der Fehlerspeicher gesetzt und bei Bedarf die Fehlermeldung des Back-Ends zurückgegeben.

Service: Die Services sind nur für die Kommunikation mit dem Back-End zuständig, um bei Bedarf ungültige oder fehlgeschlagene Abfragen zurückzugeben. Anbei der Code für den Back-End-Aufruf, um eine neue Summenvariable hinzuzufügen:

export const addDefaultSumVariable_Service = createAsyncThunk<
    // Return type of the payload creator
    SumVariable,
    // arguments to the payload creator
    void,
    // Types for ThunkAPI
    ThunkAPITypes
>('/api/addDefaultSumVariable_Service', async (_parameter, thunkApi) => {
    await thunkApi.dispatch(incrementRequestCounter());
    const sessionConfiguration: SessionConfigurations = thunkApi.getState().config.SessionConfiguration;
 
    try {
        const response = await getRequest('AddDefaultSumVariable'
            , sessionConfiguration);
        await thunkApi.dispatch(decrementRequestCounter());
        if (response.status !== 200) {
            // Return the known error for future handling
            return thunkApi.rejectWithValue((await response.data) as ResponseError);
        }
        return response.data as SumVariable;
    } catch (error: unknown) {
        await thunkApi.dispatch(decrementRequestCounter());
        thunkApi.dispatch(handleFailedRequest());
        return thunkApi.rejectWithValue(generateRequestError(error));
    }
});

Hierbei wird ein GET-Request (‚getRequest‘) zum Server aufgerufen und anschließend geprüft, ob es sich um ein gültiges Ergebnis handelt. Sollte es zu einem Fehler kommen, wird das Ergebnis rejected und der Fehler zurückgegeben.

Um die verschiedenen Aufrufe wie z.B. GET- oder POST-Requests auf dem Back-End zu realisieren, wurden einheitliche Methoden geschrieben:

export const getRequest = (subUrl: string,
    sessionConfiguration: SessionConfigurations,
    config?: AxiosRequestConfig): Promise<AxiosResponse> => {
 
    if (sessionConfiguration.InstanceID === '') {
        return new Promise<AxiosResponse>(() => {
            return {
                data: '',
                status: 403,
                statusText: 'No InstanceID',
                headers: {},
                config: {}
            };
        });
    }
 
    const options: AxiosRequestConfig = getMergedConfig(sessionConfiguration, config);
    return axios.get(subUrl, options);
};
export const postRequest = (subUrl: string,
    sessionConfiguration: SessionConfigurations,
    data?: unknown,
    config?: AxiosRequestConfig): Promise<AxiosResponse> => {
 
    if (sessionConfiguration.InstanceID === '') {
        return new Promise<AxiosResponse>(() => {
            return {
                data: '',
                status: 403,
                statusText: 'No InstanceID',
                headers: {},
                config: {}
            };
        });
    }
 
    const options: AxiosRequestConfig = getMergedConfig(sessionConfiguration, config);
    return axios.post(subUrl, data, options);
};

Die einheitlichen Methoden werden an allen Stellen im Source Code verwendet, um sie bei Bedarf schnell und einfach austauschen zu können. So lässt sich ein stundenlanges Refactoring vermeiden.

Hotkeys

Um ein schnelles und einfaches Bearbeiten der Listen zu ermöglichen, wollten wir Hotkeys wie z.B. STRG+C, STRG+V oder auch das Hinzufügen eines neuen Objekts ermöglichen. Hierzu haben wird das NPM-Package react-hotkeys-hook verwendet.

Das größte Problem dabei war die Unterstützung der verschiedenen Browser. Leider gibt es unter Chrome/Firefox/Edge keine einheitliche Verwendung von Shortcuts. Deshalb haben wir zuerst eine Liste mit Shortcuts angefertigt, die von den verschiedenen Browsern belegt sind. Anschließend wurde bestimmt, welche Shortcuts verwendet werden können.

Durch das NPM-Package kann man schnell und einfach diese Shortcuts hinzufügen. Als Beispiel hier die Shortcut-Definitionen, um ein neues Objekt hinzuzufügen:

// Collision with chrome shortcut
   // Ctrl + T - Create TextBox
   useHotkeys('t', () => {
       SetAllowDrawing(true);
       SetNewObjectType(EnumObjectType.Text);
   }, hotkeyProviderOptions, []);
 
   // Ctrl + L - Create ShapeLine
   useHotkeys('l', () => {
       SetAllowDrawing(true);
       SetNewObjectType(EnumObjectType.Line);
   }, hotkeyProviderOptions, []);
 
   // Collision with chrome shortcut
   // Ctrl + R - Create ShapeRectangle
   useHotkeys('r', () => {
       SetAllowDrawing(true);
       SetNewObjectType(EnumObjectType.Rectangle);
   }, hotkeyProviderOptions, []);

Über die Hook „useHotkeys“ kann ein neuer Shortcut angelegt werden. Beim ersten Parameter handelt es sich um den „Key“, der den Shortcut auslöst. Hier können auch die „Keys“ „STRG“, „ALT“ oder „SHIFT“ verwendet werden.

Sobald ein Shortcut ausgelöst wird, löst dies wiederum die Funktion aus, die als zweiter Parameter angegeben ist.

Beim dritten Parameter handelt es sich um Optionen, die pro Hook gesetzt werden können. Mit ihnen ist es möglich, bestimmte Events zu filtern. Dies wird benötigt, um z.B. in einem Input-Feld den Shortcut nicht auszulösen. Zudem kann mit den Optionen verhindert werden, dass vorbelegte Hotkeys entfernt werden.

Beim letzten Parameter handelt es sich um ein sogenanntes Dependency-Array. Wenn Ihre Callback-Aktionen z.B. von einem instabilen Wert abhängen oder dieser sich im Laufe der Zeit ändert, dann sollten Sie diesen Wert zu Ihrem deps-Array hinzufügen.

Weiterführende Informationen finden Sie in der Dokumentation des Packages.

Formel-Editor

Beim Design des Formeleditors haben wir uns an dem bereits existierenden Desktop-Formelassistenten orientiert. Um die Editorfunktionalität zu implementieren, haben wir uns für den populären Monaco-Editor entschieden, der unter anderem in Visual Studio Code verwendet wird. Dabei haben wir auf das NPM-Package @monaco-editor/react gesetzt.

Durch das NPM-Package ist es sehr einfach, einen vollständigen „Formel-Editor“ zu implementieren. Um eine Anwendung mit der Basisfunktionalität auszustatten, reicht der folgende Code aus.

import React from "react";
import ReactDOM from "react-dom";
 
import Editor from "@monaco-editor/react";
 
function App() {
  return (
   <Editor
     height="90vh"
     defaultLanguage="javascript"
     defaultValue="// some comment"
   />
  );
}
 
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

Eigene Schlüsselwörter und Syntax-Konstrukte können natürlich ebenfalls hinzugefügt werden. Hierzu müssen Sie wie folgt vorgehen. Zuerst müssen Sie eine neue Sprache zum Monaco-Editor hinzufügen:

const handleLanguageRegistration = (monacoInstance: Monaco) => {
    // register language
    monacoInstance.languages.register({ id: 'MyLanguage' });
    // add functions
    registerSuggestionProvider(monacoInstance);
}

Anschließend können Sie eigene Funktionen hinzufügen:

const registerSuggestionProvider = (monacoInstance: Monaco) => {
        //add add True and False to "methods list"
        const tempFormulaMethodsList = [...FormulaMethodsList];
        //no need for FunctionInfo. it is only for recommendation and it is not in the Functionslist
        const boolTempMethod: FunctionInfo = { Groups: [], ParameterRestrictions: [0, 0], ReturnType: LlExprType.Bool, SyntaxHint: { $type: "", Item1: "", Item2: "", Item3: "", Item4: "" } };
        tempFormulaMethodsList.push({ FunctionName: "True", FunctionInfo: boolTempMethod, ParametersInfo: [] });
        tempFormulaMethodsList.push({ FunctionName: "False", FunctionInfo: boolTempMethod, ParametersInfo: [] });
        const functionsList = tempFormulaMethodsList.map(formula => {
            return formula.FunctionName;
        });
        if (SumVariablesList && UserVariablesList && VariablesFieldsList !== undefined) {
            const variablesList = extractVariablesFromStore(VariablesFieldsList, SumVariablesList, UserVariablesList, ReportParametersList, HideFields);
            // Register a tokens provider for the language
            monacoInstance.languages.setMonarchTokensProvider('MyLanguage', languageDef(functionsList, variablesList) as monacoEditor.languages.IMonarchLanguage);
            // Set the editing configuration for the language
            monacoInstance.languages.setLanguageConfiguration('MyLanguage', configuration as monacoEditor.languages.LanguageConfiguration);
            // //Register a completion item provider for the language
            const provider = registerCompletionitemProvider(monacoInstance, tempFormulaMethodsList, variablesList);
            completionItemProviderRef.current = provider;
        }
    };

Über die Funktion „setLanguageConfiguration“ können Sie die Sprachdefinition setzen. Das heißt, in dieser Funktion werden die Line- oder Blockkommentare, Klammern und „Auto-Closing“-Paare definiert. Sie können z. B. definieren, dass wenn „(“ (öffnende Klammer) getippt wird, automatisch eine „)“ (schließende Klammer) hinzugefügt wird.

Über die Funktion „setMonarchTokensProvider“ können Sie alle Funktionen und Schlüsselwörter über die Monarch-Implementation definieren. Das heißt, für die aktuelle Sprache wird die Syntaxdefinition mit aktuellen Variablen und Methoden bereitgestellt. In der Sprachdefinition sind alle Funktionen als „Functions“ (denen später in der „defineTheme“-Methode eine bestimmte Farbe gegeben werden kann) sowie alle Variablen als „Typewords“ gespeichert. Auch Leerzeichen, Begrenzungszeichen, Operatoren, Zahlen und Kommentare werden dort definiert.

Anschließend können Sie über die Funktion „registerCompletionitemProvider“ die Vorschlagsliste anlegen. Die Vorschlagsliste wird mit allen Variablen sowie Funktionen gefüllt (die Funktionen werden in Form eines Snippets ausgefüllt, bei dem eine Anzahl von Parametern angegeben wird und bei der Eingabe im Editor durch Drücken von „Tab“ zwischen den Parametern gewechselt werden kann).

Weiterführende Informationen über das Thema finden Sie hier.

Nun sind wir am Ende unsere Making-of-Serie über den Web Report Designer angelangt. Wir hoffen, wir konnten Ihnen mit unseren Einblicken und Erfahrungen bei Ihren eigenen oder zukünftigen Projekten helfen.  Falls Sie den neuen Web Report Designer selber mal ausprobieren wollen, dann gehen Sie einfach auf unsere Online Demo und überzeugen Sie sich selbst von unserem Ergebnis. Sie können auch jederzeit die kostenlose List & Label Trial herunterladen.

Empfohlene Artikel

Schreibe einen Kommentar