Arc Rider Docs · Markdown

Developer Kit · Knowledge Base

Arc & Ninox Development Rules

Arc & Ninox Development Rules

Diese Datei dient als Wissensbasis für die Entwicklung von Arc Widgets in Ninox. Sie wird kontinuierlich erweitert.

1. Ninox Script Syntax (Strict)

Ninox verwendet eine eigene Skriptsprache, die JavaScript ähnelt, aber wichtige Unterschiede aufweist.

Kommentare & Block-Strings

* ❌ **VERBOTEN**: `//` oder `/* ... */` (Führt zu Syntaxfehlern in bestimmten Kontexten). * ✅ **ERLAUBT**: `--- Mein Kommentar ---;` (WICHTIG: Mit Semikolon `;` abschließen, wenn es als Statement steht). * ⚠️ **ACHTUNG**: Innerhalb von `--- ---` dürfen keine geschweiften Klammern `{ }` verwendet werden, außer zur Variablen-Interpolation. * Falsch: `--- User: { name: string } ---` (Ninox versucht `name: string` als Code auszuführen). * Richtig: `--- User: ( name: string ) ---` oder `--- User: [Name: String] ---`.

⚠️ KRITISCH: Keine Kommentare innerhalb von JSON-Objekten

Kommentare sind NUR als eigenständige Statements erlaubt, NIEMALS innerhalb von JSON-Objekt-Definitionen.

// ❌ FALSCH: Kommentar innerhalb von JSON-Objekt
blocks: [{
    --- Das ist mein Block ---;  // FEHLER!
    width: "100%",
    value: "..."
}]

// ❌ FALSCH: comment Property (keine gültige Ninox-Property) blocks: [{ comment: "Das ist mein Block", // FEHLER! width: "100%", value: "..." }]

// ✅ RICHTIG: Kommentar als eigenständiges Statement vor dem Objekt --- Das ist mein Block ---; let myBlock := { width: &quot;100%&quot;, value: &quot;...&quot; };</code></pre>

Faustregel: Kommentare mit --- ... ---; sind Statements und können nur dort erscheinen, wo Statements erlaubt sind - NICHT innerhalb von { } Objekt-Literalen.

Variablen & Deklaration

* ❌ **VERBOTEN**: `const`, `let`, `var` innerhalb von Widget-Konfigurationsobjekten. * ✅ **ERLAUBT**: `let myVar := ...` (nur im äußeren Ninox-Skript-Scope, nicht im JSON-Objekt). * **Zuweisung**: Ninox nutzt `:=` für Zuweisungen im Skript-Teil.

Variablen-Zuweisung für Widget-Snippets

Bei der Zuweisung komplexer Widget-Konfigurationen zu Variablen, KEINE do...end Blöcke verwenden. Stattdessen interne let Statements vor die Variablen-Zuweisung setzen.

// ❌ FALSCH: Variable mit do...end Block
let my_card := do
    let list := (select Items);
    arcCustomCard({
        uniqueId: "card-1",
        value: list.[{ ... }]
    })
end;

// ✅ RICHTIG: let Statements davor, Variable ohne do...end let list := (select Items); let my_card := arcCustomCard({ uniqueId: &quot;card-1&quot;, value: list.[{ ... }] });</code></pre>

Für wiederverwendbare Funktionen in Snippet-Dateien: <pre><code class="language-javascript">// Funktionen global definieren (auf Top-Level) function myHelper(data : any) do arcCustomLayout({ ... }) end;

// Dann in Variablen-Definitionen verwenden let list := (select Items); let my_card := arcCustomCard({ uniqueId: &quot;card-1&quot;, value: myHelper({ items: list }) });</code></pre>

Vorteile: - Saubere Code-Struktur - Funktionen sind global verfügbar - Variablen sind direkt nutzbar ohne extra Scoping

Strings & Interpolation

* **HTML**: HTML-Strings müssen in Triple-Dashes `---` eingeschlossen werden. * **Interpolation**: Variablen in HTML-Strings werden mit `{ variableName }` eingefügt. * Beispiel: `value: ---
Text
---`

Funktions-Scope

* ✅ **GLOBAL**: Funktionen sollten immer auf Top-Level definiert werden. * ❌ **LOKAL**: Verschachtelte Funktionen (Funktion innerhalb einer Funktion) vermeiden, da Ninox diese nicht im globalen Scope findet.

Funktions-Reihenfolge (Top-to-Bottom)

* **Funktionen müssen vor ihrer Verwendung definiert sein.** Ninox wertet das Script von oben nach unten aus. Ruft `navBar` `pillButton` auf, muss `pillButton` vor `navBar` stehen, sonst: "Die Funktion ist nicht definiert: pillButton(any)". * Neue Helper-Funktionen, die von bestehendem Code genutzt werden, immer über dem ersten Aufruf platzieren.

2. Widget Konfiguration

Grundstruktur

Die Konfiguration erfolgt immer als Funktionsaufruf, der ein JSON-Objekt zurückgibt.
arcCustomWidgetName({
    uniqueId: "unique-id-" + Nr,
    property: "value",
    blocks: [ ... ]
})

Best Practices

1. **uniqueId**: MUSS vorhanden und einzigartig sein. Oft kombiniert mit der Record-ID (`Nr`). * ⚠️ **WICHTIG**: Niemals Sonderzeichen, Kommas, Punkte oder Leerzeichen in IDs verwenden * Diese IDs landen in HTML-IDs (`id="..."`) und können das Layout zerstören * ❌ Falsch: `uniqueId: data.id + "-time-" + timeItem.label` (wenn label "0,5 Std." enthält) * ✅ Richtig: `uniqueId: data.id + "-time-" + index` oder sanitized key
    // ❌ Falsch: Label mit Sonderzeichen
    for timeItem in timeDetails do
        {
            value: arcCustomLayout({
                uniqueId: "detail-" + timeItem.label  // "0,5 Std." → HTML-Fehler!
            })
        }
    end
    
    // ✅ Richtig: Index oder sanitized key verwenden
    for timeItem in timeDetails do
        let idx := index(timeDetails, timeItem);
        {
            value: arcCustomLayout({
                uniqueId: "detail-" + idx  // Saubere ID
            })
        }
    end
    
2. **embedded**: Meistens `true`, wenn es Teil eines Layouts ist. 3. **Kein JSDoc**: Ninox-Editor unterstützt kein JSDoc. Dokumentation erfolgt separat oder via `--- Text ---`. 4. **Widget Values**: Werte immer direkt übergeben. KEIN `if/else` für leere Werte – Widgets halten intern immer empty/null. Besser: `value: selectedTab.content` statt `value: if x then x else "" end`.

⚠️ HOHE PRIORITÄT: Null- und Leer-Fallbacks – KRITISCH FÜR AI

Arc Widgets fangen null automatisch ab. Wenn eine Bedingung nichts zurückgibt (kein else-Zweig), erhält das Widget null und rendert korrekt. Daher:

  • NIEMALS else "" end für Widget-Werte – Leerstring bricht das Rendering.
  • NIEMALS else null end – redundant; den else-Zweig komplett weglassen.
  • IMMER if Bedingung then widget end – kein else nötig. Widgets fangen null ab.
// ❌ FALSCH: else "" end
leftSideContent: if hasData then arcCustomButton({...}) else "" end

// ❌ FALSCH: else null end (redundant) leftSideContent: if hasData then arcCustomButton({...}) else null end

// ✅ RICHTIG: else weglassen – Widget erhält null automatisch leftSideContent: if hasData then arcCustomButton({...}) end</code></pre>

In den meisten Fällen ist else "" end NICHT nötig – unsere Widgets fangen null automatisch ab, wenn es als Wert übergeben wird. Diese Regel ist wichtig, weil sie: - Den Code verschlankt - Typfehler mit any und gemischten Typen vermeidet (z.B. „dann/sonst liefern unterschiedliche Typen“) - Redundanz reduziert else [] end ist NIEMALS nötig – null übergeben oder den else-Zweig weglassen; Widgets handhaben leere Arrays.

// ❌ VERBOTEN: else "" end
leftSideContent: if hasData then arcCustomButton({...}) else "" end

// ❌ REDUNDANT: else null end (niemals nötig) leftSideContent: if hasData then arcCustomButton({...}) else null end

// ✅ RICHTIG: else weglassen – Widgets fangen null automatisch ab leftSideContent: if hasData then arcCustomButton({...}) end

// ❌ Unnötig: else &quot;&quot; end für einfache Werte value: if data.name then data.name else &quot;&quot; end

// ✅ Richtig: else weglassen – Widget erhält null, wenn Bedingung falsch value: if data.name then data.name end

// ❌ Unnötig: else [] end participants: if mitarbeiterData then [{ value: mitarbeiterData.name }] else [] end

// ✅ Richtig: else weglassen – Widget erhält null participants: if mitarbeiterData then [{ value: mitarbeiterData.name }] end</code></pre>

Ausnahme: else nur verwenden, wenn der Fallback semantische Bedeutung hat (z.B. ein Standard-Label, das von „leer“ abweicht).

3. Datenzugriff & Actions

Queries

Ninox-Abfragen werden direkt in die Widget-Struktur integriert, oft um Arrays für `blocks` zu generieren. * **Select**: `(select TableName)` * **Relation**: `record.RelationName` * **Mapping**: `(select Table).[{ value: ... }]`

⚠️ WICHTIG: Performance bei Filterung

Grundregel: Bei select-Queries immer where verwenden, nicht die Bracket-Syntax [...].

// ❌ Langsamer: Bracket-Syntax bei select
let list := (select Tickets)[number(Status) = 1];

// ✅ Schneller: where-Syntax bei select let list := (select Tickets where number(Status) = 1);

// ✅ Mit konditionaler Filterung let filterPrio := number(filter_prio); let list := (select Tickets where if filterPrio then number(Prio) = filterPrio else true end);</code></pre>

Ausnahme: Bei Untertabellen/Relations funktioniert nur die Bracket-Syntax: <pre><code class="language-javascript">// ✅ Richtig: Bracket-Syntax bei Relations (where nicht möglich) let items := record.Positionen[Aktiv = true];

// ❌ Falsch: where bei Relations (funktioniert nicht) let items := record.Positionen where Aktiv = true;</code></pre>

Zusammenfassung: - (select Table where ...) → performant bei Haupttabellen - relation[...] → nur bei Untertabellen/Relations (dort geht kein where)

⚠️ Filterwerte MÜSSEN außerhalb der Query definiert werden

Filterwerte für where-Klauseln oder [...]-Filter MÜSSEN als Variable VOR der Query definiert werden. Ninox kann Feldverweise aus anderen Kontexten (Dashboard, Parent-Record etc.) innerhalb von Query-Ausdrücken nicht zuverlässig auflösen.

--- ❌ FALSCH: Inline-Feldzugriff in der Query ---;
let list := (select Termine where Status = dashboard.filter_status);
let items := record.Positionen[Typ = parent.SelectedType];

--- ✅ RICHTIG: Filterwert vorher extrahieren ---; let filterStatus := dashboard.filter_status; let list := (select Termine where Status = filterStatus);

let selectedType := parent.SelectedType; let items := record.Positionen[Typ = selectedType];</code></pre>

Dies gilt für ALLE Queries – select ... where, Untertabellen [...] und any/all-Ausdrücke.

Mehrfachauswahl-Felder (Dynamic Choice Fields)

Ninox Mehrfachauswahl-Felder speichern intern eine kommaseparierte Liste von IDs. Um diese dynamisch zu verarbeiten:

1. IDs extrahieren mit numbers(): <pre><code class="language-javascript">// numbers() gibt ein Array der ausgewählten IDs zurück let selectedIds := numbers(Niederlassung.Typ); // z.B. [1, 3, 5]</code></pre>

2. Record anhand ID holen mit record(): <pre><code class="language-javascript">// record(Tabelle, ID) holt den vollständigen Datensatz let typRecord := record(Typ_Firma, number(typId));</code></pre>

3. Auf Felder des Records zugreifen: <pre><code class="language-javascript">typRecord.Titel // Text-Feld typRecord.'Farbe Hintergrund' // Feld mit Leerzeichen → in Hochkommas</code></pre>

Vollständiges Pattern für dynamische Badges: <pre><code class="language-javascript">badges: for typId in numbers(Niederlassung.Typ) do let typRecord := record(Typ_Firma, number(typId)); { width: &quot;auto&quot;, height: &quot;auto&quot;, value: arcCustomBadge({ uniqueId: Nr + &quot;-typ-badge-&quot; + typId, value: text(typRecord.Titel), backgroundColor: if typRecord.'Farbe Hintergrund' then text(typRecord.'Farbe Hintergrund') else &quot;#888888&quot; end }) } end</code></pre>

Wichtig: - numbers() funktioniert nur bei Mehrfachauswahl-Feldern - Bei Einfachauswahl: direkt number(Feld) verwenden - record() benötigt den Tabellennamen als erstes Argument (nicht die Tabellen-ID)

JSON-Lookup-Tabellen (for...do item end Pattern)

Um eine JSON-Liste filterbar zu machen, muss sie mit for item in [...] do item end gewrapped werden. Nur so kann man später mit first(list[condition]) auf die Elemente zugreifen.

// ❌ Falsch: Direktes Array ist nicht filterbar in Ninox
let labels := [{ diff: 0, label: "Heute" }, { diff: 1, label: "Morgen" }];
first(labels[diff = 0]);  // Funktioniert nicht!

// ✅ Richtig: Mit for...do item end aktivieren let labels := for item in [ { diff: 0, label: &quot;Heute&quot; }, { diff: 1, label: &quot;Morgen&quot; }, { diff: 2, label: &quot;Übermorgen&quot; }, { diff: -1, label: &quot;Gestern&quot; }, { diff: -2, label: &quot;Vorgestern&quot; } ] do item end;

// Jetzt filterbar! let matchedLabel := first(labels[number(myDiff) = number(item.diff)]); if matchedLabel then text(matchedLabel.label) else &quot;Fallback&quot; end</code></pre>

Pattern für relative Datums-Texte: <pre><code class="language-javascript">// Oben definieren let relativeDateLabels := for item in [ { diff: 0, label: &quot;Heute&quot; }, { diff: 1, label: &quot;Morgen&quot; }, { diff: 2, label: &quot;Übermorgen&quot; }, { diff: -1, label: &quot;Gestern&quot; }, { diff: -2, label: &quot;Vorgestern&quot; } ] do item end;

// Unten verwenden let actDate := date(myRecord.Datum); let diff := round((actDate - today()) / 86400000); // Millisekunden → Tage let matchedLabel := first(relativeDateLabels[number(diff) = number(item.diff)]); title: if matchedLabel then text(matchedLabel.label) else if diff &gt; 1 then &quot;In &quot; + diff + &quot; Tagen&quot; else if diff = 1 then &quot;In 1 Tag&quot; // Singular! else if diff = -1 then &quot;Vor 1 Tag&quot; // Singular! else &quot;Vor &quot; + abs(diff) + &quot; Tagen&quot; end end end end</code></pre>

Wichtig - Datums-Differenz in Ninox: - ❌ days() funktioniert nur mit der appointment-Funktion - ✅ round((datum1 - datum2) / 86400000) - rechnet Millisekunden in Tage um - 86400000 = 24h × 60min × 60s × 1000ms - round() rundet auf ganze Tage - ⚠️ Grammatik beachten: Singular bei 1 Tag ("In 1 Tag", nicht "In 1 Tagen")

Vorteile: - Zentrale Definition, leicht erweiterbar - Sauberer Code in der Anwendung - Beliebig viele Spezialfälle (Heute, Morgen, Übermorgen...) ohne verschachtelte if/else - Korrekte Singular/Plural-Grammatik

IMMER Anzeigenamen, NIEMALS interne IDs in NinoxScript

In NinoxScript-Code (Formeln, .arc-Dateien) IMMER den Anzeigenamen (Caption) der Felder verwenden, NIEMALS die interne _id.

Das YAML-Schema zeigt Felder so: <pre><code class="language-text">Aufgabe: ← ANZEIGENAME → im Code verwenden _id: A ← INTERNE ID → NIEMALS im NinoxScript</code></pre>

  • Aufgabe: { _id: A } → im Code Aufgabe verwenden, NICHT A
  • Fällig: { _id: B } → im Code 'Fällig' verwenden, NICHT B
  • Feldnamen mit Umlauten (ä, ö, ü, ß) oder Sonderzeichen MÜSSEN in einfache Anführungszeichen gesetzt werden: 'Fällig', 'Größe', 'Straße', 'E-Mail'
  • Feldnamen ohne Sonderzeichen brauchen keine Anführungszeichen: Aufgabe, Status, Datum
--- ❌ FALSCH: interne _id verwenden ---;
if A = "offen" then 10 else 0 end
let x := B + 5
(select Termine where C = "aktiv")

--- ✅ RICHTIG: Anzeigenamen verwenden ---; if Aufgabe = &quot;offen&quot; then 10 else 0 end let x := 'Fällig' + 5 (select Termine where Status = &quot;aktiv&quot;)</code></pre>

Ausnahme: Seed-Daten JSON (ninox:seed) verwenden _id als Feld-Keys, da die Ninox REST API diese erwartet.

fieldId immer verwenden

Bei allen Widget-Actions und Select-Fields muss fieldId() verwendet werden, nicht nur der Feldname als String. Die Nr ist entscheidend für die korrekte Feld-ID.

// ❌ Falsch: Nur Feldname als String
field: "Bearbeiter"

// ✅ Richtig: fieldId mit korrekter Record-Nr field: fieldId(Nr, &quot;Bearbeiter&quot;)

// ✅ Bei JSON-Konfigurationen: fieldId oben definieren wo Nr verfügbar ist selectField: fieldId(Nr, &quot;Bearbeiter&quot;) // Im JSON wo Ninox-Kontext verfügbar ist</code></pre>

Wichtig: In verschachtelten Strukturen oder any-Parametern hat Ninox keinen Zugriff mehr auf Nr. Daher fieldId immer dort aufrufen, wo der Record-Kontext noch vorhanden ist.

arcCustomSelect / Verknüpfungsfelder – value muss number(Nr) sein

Bei dynamischen Selects und Verknüpfungsfeldern muss der value in items number(Nr) sein, nicht rohes Nr. Ninox erwartet numerische Werte für Record-Referenzen.

// ❌ FALSCH: rohes Nr
items: (select Artikel).[{ title: text('Beschreibung Excel'), value: Nr }]

// ✅ RICHTIG: number(Nr) items: (select Artikel).[{ title: text('Beschreibung Excel'), value: number(Nr) }]</code></pre>

fieldId – Tabellenname erforderlich

fieldId() für tabellenübergreifende Referenzen (z.B. in changeFieldValues) muss den Anzeigenamen der Tabelle verwenden, nicht die interne Tabellen-ID. fieldId("XE", "FieldName") funktioniert nicht.

// ✅ RICHTIG: Tabellen-Anzeigename
fieldId: fieldId("Auftraege", "Bearbeiter")

// ❌ FALSCH: Tabellen-ID funktioniert nicht fieldId: fieldId(&quot;A&quot;, &quot;Bearbeiter&quot;)</code></pre>

if/else Typ-Konsistenz – KRITISCH

Ninox verlangt, dass beide Zweige then und else denselben Datentyp zurückgeben. Sonst: „Die Ausdrücke für 'dann' und 'sonst' liefern unterschiedliche Datentypen zurück“.

// ❌ FALSCH: string vs nid – unterschiedliche Typen
title: text(if 'Beschreibung Excel' then 'Beschreibung Excel' else Nr end)

// ✅ RICHTIG: text() in beiden Zweigen – gleicher Typ title: if 'Beschreibung Excel' then text('Beschreibung Excel') else text(Nr) end</code></pre>

Bei Mischung von Text und Widget (z.B. Tabellenspalten-Wert: Text ODER arcCustomSelect) müssen beide Zweige denselben Typ liefern. Für den Textfall arcCustomText verwenden, damit beide Widgets zurückgeben:

// ❌ FALSCH: html(text(...)) vs widget – weiterhin string vs nid
value: if Artikel then html(text(Artikel.'Beschreibung Excel')) else arcCustomSelect({...}) end

// ✅ RICHTIG: arcCustomText vs arcCustomSelect – beide Widgets value: if Artikel then arcCustomText({ value: text(...), fontSize: &quot;13px&quot; }) else arcCustomSelect({...}) end</code></pre>

Dashboard-Filterfelder – current. Prefix verwenden

Wenn Filterfelder (z.B. filter_status_quotes, filter_category) auf dem Dashboard-Record (dem Formular/Record, auf dem das Widget läuft) gespeichert sind, immer über current. in Queries und Datenlogik zugreifen. Der Dashboard-Record ist this / current; ohne Prefix kann Ninox das Feld im falschen Kontext auflösen (z.B. in einer Schleife über Tickets).

// ❌ Falsch: filter_status_quotes ohne current – falscher Kontext in Queries
let list := list_tickets[number(Status) = number(filter_status_quotes)];

// ✅ Richtig: current.filter_status_quotes – expliziter Dashboard-Record let list := list_tickets[number(Status) = 4 and if current.filter_status_quotes then number(Status) = number(current.filter_status_quotes) else true end];</code></pre>

Faustregel: Filterfelder auf dem Dashboard → in Queries und Filterlogik current.filter_feldname verwenden.

Dynamische IDs in Schleifen (Counter-Pattern)

Wenn du dynamische uniqueIds in Schleifen brauchst, muss die Counter-Variable einmal mit let definiert und dann ohne let überschrieben werden.

// ❌ Falsch: let in jeder Iteration → IDs bleiben gleich
for item in items do
    let idx := idx + 1;  // Falsch! Neue Variable in jeder Iteration
    { uniqueId: "item-" + idx }
end

// ✅ Richtig: let einmal vor der Schleife, dann ohne let überschreiben let idx := 0; for item in items do idx := idx + 1; // Richtig! Überschreibt die äußere Variable { uniqueId: &quot;item-&quot; + idx } end</code></pre>

Problem bei falschem Pattern: Alle Widgets bekommen dieselbe ID → Widgets werden nicht angezeigt (besonders bei Select/Input).

setItem – Mutiert Objekt In-Place setItem mutiert das übergebene Objekt in-place. Es gibt kein neues Objekt mit der Änderung zurück. Alles, was nach dem setItem-Aufruf kommt, sieht das mutierte Objekt. Wenn der modifizierte State an eine Action übergeben wird (z.B. type: "update" mit value), die gleiche Variable verwenden, die an setItem übergeben wurde – sie enthält nun die Änderung.

// ❌ Falsch: rowActionDataTeil enthält ggf. nicht die Änderung (Rückgabewert von setItem)
let rowActionDataTeil := setItem(parseJSON(...), "teiluntersuchung_recordId", text(Nr));
value: formatJSON(rowActionDataTeil)  // Hat ggf. nicht die Änderung

// ✅ Bevorzugt: dataBag patch dataBag: { base: helper_state, patch: { teiluntersuchung_recordId: text(Nr) } }

// ✅ Manuell: Geparstes Objekt in Variable, setItem mutiert es, diese Variable verwenden let parsedStateTeil := parseJSON(text(current.helper_state)); setItem(parsedStateTeil, &quot;teiluntersuchung_recordId&quot;, text(Nr)); value: formatJSON(parsedStateTeil) // parsedStateTeil hat die Änderung</code></pre>

Faustregel: In Variable parsen → setItem darauf aufrufen → diese Variable für formatJSON / value nutzen.

Flexible Block-Breiten statt fixer Werte

Statt feste Pixel-Breiten für Widgets zu verwenden, setze den Block auf width: "fraction" und das Widget auf width: "100%". So passt sich die Breite dynamisch an.

// ❌ Unnötig komplex: Feste Breiten und extra Konfiguration
{
    width: "auto",
    value: arcCustomSelect({
        width: "140px"  // Fix
    })
}

// ✅ Besser: Block fraction, Widget 100% { width: &quot;fraction&quot;, value: arcCustomSelect({ width: &quot;100%&quot; // Füllt Block aus }) }</code></pre>

Actions

⚠️ WICHTIG: Action-Typ Hierarchie

Grundregel: Bei Feld-Updates IMMER widget-vorgefertigte Actions verwenden, NIEMALS customJS für Standard-Operationen.

1. ERSTE WAHL: update Action - Für alle Feld-Updates verwenden - Ninox Trigger werden automatisch ausgelöst - Sauber, wartbar, performant 2. ZWEITE WAHL: Ninox Trigger + Helper-Feld - Für komplexe Operationen, die Ninox-Funktionen benötigen - Z.B. openURL() für Mobile-Kompatibilität 3. LETZTE WAHL: customJS - NUR für spezielle UI-Anforderungen - Niemals für Feld-Updates - Niemals für Standard-Operationen

// ❌ FALSCH: customJS für Feld-Updates
actions: [{
    type: "customJS",
    value: "ninoxUpdate(" + Nr + ", '" + fieldId(Nr, "Status") + "', 'Aktiv'); " +
           "ninoxUpdate(" + Nr + ", '" + fieldId(Nr, "Tab") + "', 'day');"
}]

// ✅ RICHTIG: Separate update Actions actions: [{ type: &quot;update&quot;, recordId: Nr, field: fieldId(Nr, &quot;Status&quot;), value: &quot;Aktiv&quot; }, { type: &quot;update&quot;, recordId: Nr, field: fieldId(Nr, &quot;Tab&quot;), value: &quot;day&quot; }]</code></pre>

Vorteile von update Actions: - ✅ Ninox Trigger werden automatisch ausgeführt - ✅ Validierungen greifen - ✅ Änderungen werden korrekt protokolliert - ✅ Bessere Performance - ✅ Wartbarer Code

Update Action Details

* ✅ **UPDATE**: Für Datenänderungen `type: "update"` verwenden (sauberer als customJS). * **recordId**: Innerhalb von Mappings/Schleifen (`select Table`) meist `Nr` (Referenz auf den aktuellen Datensatz). * **field/fieldId**: Parameter für das Feld. Achtung: Manche Widgets verlangen `field`, andere `fieldId`. * Wert ist oft die interne ID (z.B. `"A1"`) oder dynamisch ermittelt via `fieldId("TableName", "FieldName")` (besser, da robust gegen Umbenennung).
    actions: [{
        type: "update",
        recordId: Nr, // Aktueller Datensatz in der Schleife
        field: fieldId("MyTable", "Status"), // oder "A1"
        value: "newValue"
    }]
    

⚠️ KRITISCH: recordId bei Actions - NIEMALS number() verwenden

Bei type: "popup" oder type: "update" muss recordId immer die rohe Nr sein, NICHT number(Nr).

Grund: Ninox benötigt die versteckte nxid (Tabelle + Record-Nummer), die in Nr enthalten ist. number() entfernt diese Information!

// ❌ FALSCH: number() entfernt nxid → Record kann nicht geöffnet werden
actions: [{
    type: "popup",
    recordId: number(helper_nextOpenActivity.Nr)  // Fehler!
}]

// ✅ RICHTIG: Nr direkt verwenden actions: [{ type: &quot;popup&quot;, recordId: helper_nextOpenActivity.Nr // Behält nxid }]

// ✅ RICHTIG: Bei update auch Nr direkt actions: [{ type: &quot;update&quot;, recordId: Nr, // Nicht number(Nr) field: fieldId(Nr, &quot;Status&quot;), value: &quot;active&quot; }]</code></pre>

Faustregel: recordId in Actions = immer die rohe Nr ohne Konvertierung!

Multiple Actions Pattern

Widgets unterstützen Arrays von Actions, die sequenziell ausgeführt werden:
// Navigation + Datum setzen
actions: [{
    type: "update",
    recordId: Nr,
    field: fieldId(Nr, "Datum von"),
    value: today()
}, {
    type: "update",
    recordId: Nr,
    field: fieldId(Nr, "helper_mobileNav"),
    value: "day"
}]

Bestätigungsdialog (confirm)

Jede Action kann vor der Ausführung einen Bestätigungsdialog anzeigen, indem eine confirm-Property hinzugefügt wird. Der Dialog wird außerhalb des Ninox-Widget-DOM gerendert und überlebt daher Hintergrund-Neuladungen des Funktionsfelds (z.B. Datenaktualisierungen durch andere User).

// ✅ Destruktive Aktion mit Bestätigung
actions: [{
    type: "delete",
    recordId: Nr,
    confirm: {
        title: "Datensatz löschen",
        message: "Möchten Sie diesen Datensatz wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
        confirmLabel: "Löschen",
        cancelLabel: "Abbrechen",
        destructive: true
    }
}]

// ✅ Nicht-destruktive Aktion mit Bestätigung actions: [{ type: &quot;update&quot;, recordId: Nr, field: fieldId(Nr, &quot;Status&quot;), value: &quot;Archiviert&quot;, confirm: { title: &quot;Datensatz archivieren&quot;, message: &quot;Dieser Datensatz wird ins Archiv verschoben.&quot;, confirmLabel: &quot;Archivieren&quot;, destructive: false } }]</code></pre>

Properties:

PropertyTypeDefaultBeschreibung
titlestring—Dialog-Titel
messagestring—Beschreibungstext
confirmLabelstring"Bestätigen"Label des Bestätigen-Buttons (frei wählbar)
cancelLabelstring"Abbrechen"Label des Abbrechen-Buttons (frei wählbar)
destructivebooleanfalseRoter Confirm-Button + Warn-Icon; false = blaues Info-Icon

Verhalten: - Bei Bestätigung: Die Aktion wird normal ausgeführt - Bei Abbruch: Die Aktion wird übersprungen, keine Seiteneffekte - Funktioniert mit ALLEN Action-Typen (update, delete, popup, create, etc.)

Limitation: Bei actions-Arrays mit mehreren Einträgen confirm auf eine einzelne Action setzen (typischerweise die erste oder einzige destruktive Action). Jede Action im Array wird unabhängig bestätigt.

Verkettete Actions (then)

Actions können mit der then-Property Folge-Aktionen nach Abschluss ausführen. Nützlich, wenn z.B. nach einem Löschen der UI-State zurückgesetzt werden soll (z.B. Detail-Ansicht schließen).

// Datensatz löschen, dann Auswahl zurücksetzen
actions: [{
    type: "delete",
    recordId: selectedItem.Nr,
    confirm: {
        title: "Datensatz löschen",
        message: "Wirklich löschen?",
        confirmLabel: "Löschen",
        cancelLabel: "Abbrechen",
        destructive: true
    },
    then: [{
        type: "update",
        recordId: Nr,
        field: mainContentStateField,
        value: formatJSON({ selectedId: "" })
    }]
}]
  • then muss ein Array von Action-Objekten sein
  • Wird sequenziell nach Abschluss der Haupt-Aktion ausgeführt
  • Funktioniert mit confirm – then läuft nur nach Bestätigung und erfolgreicher Haupt-Aktion

URLs und Telefonnummern öffnen (Mobile App Kompatibilität)

⚠️ Problem: window.open() via customJS funktioniert im Web, aber nicht in Ninox Mobile Apps

✅ Lösung: Ninox Helper-Feld mit openUrl() Trigger verwenden

Pattern: 1. Erstelle ein Text-Feld (z.B. trigger_openUrl) mit folgendem Trigger: <pre><code class="language-ninox"> openURL(trigger_openUrl); trigger_openUrl := null </code></pre>

2. Nutze update Action statt customJS: <pre><code class="language-javascript"> // ❌ Falsch: Funktioniert nur im Web actions: [{ type: &quot;customJS&quot;, value: &quot;window.open('tel:+49123456789')&quot; }] // ✅ Richtig: Funktioniert Web + Mobile App actions: [{ type: &quot;update&quot;, recordId: current.Nr, field: fieldId(current.Nr, &quot;trigger_openUrl&quot;), // Aktuell: field, später: fieldId value: &quot;tel:+49123456789&quot; // oder &quot;https://...&quot; für Links }] </code></pre>

Beispiele: <pre><code class="language-javascript">// Telefonnummer actions: [{ type: &quot;update&quot;, recordId: current.Nr, field: fieldId(current.Nr, &quot;trigger_openUrl&quot;), value: &quot;tel:&quot; + phoneNumber }]

// Google Maps actions: [{ type: &quot;update&quot;, recordId: current.Nr, field: fieldId(current.Nr, &quot;trigger_openUrl&quot;), value: &quot;https://www.google.com/maps/search/?api=1&amp;query=&quot; + urlEncode(address) }]

// Externe Website actions: [{ type: &quot;update&quot;, recordId: current.Nr, field: fieldId(current.Nr, &quot;trigger_openUrl&quot;), value: &quot;https://example.com&quot; }]</code></pre>

> ⚠️ Hinweis: Aktuell muss field statt fieldId verwendet werden. Dies wird in zukünftigen Widget-Versionen vereinheitlicht.

Vorteile: - ✅ Funktioniert in Web und Mobile App - ✅ Zentralisierte URL-Logik (ein Trigger für alle URL-Typen) - ✅ Keine customJS Security-Einschränkungen

  • Generic Slots: Für flexible Komponenten actionsLeft / actionsRight statt nur actions verwenden, wenn das Layout es erfordert.
  • *Feature Request*: Vereinheitlichung von field und fieldId Parameter über alle Widgets hinweg.

Formatierung (format-Funktion)

Ninox bietet die format()-Funktion zum Formatieren von Zahlen, Datum und Zeit. Die Funktion verwendet spezielle Format-Token (ähnlich Moment.js).

Referenz: <a href="https://forum.ninox.com/t/p8yzv31/format">Ninox format() Dokumentation</a>

Datumsformatierung

// ❌ Falsch: Ungültige Format-Tokens
format(myDate, "EEEE, dd.MM.yyyy")  // Produziert "2222, Di.12.2025"

// ✅ Richtig: Korrekte Ninox Format-Tokens format(myDate, &quot;dddd, DD.MM.YYYY&quot;) // &quot;Dienstag, 01.12.2025&quot;

// ✅ Alternative: text() für Standard-Formatierung text(myDate) // Nutzt System-Locale

// ✅ Wochentag extrahieren weekdayName(weekday(myDate)) // &quot;Dienstag&quot; substr(weekdayName(weekday(myDate)), 0, 2) // &quot;Di&quot;</code></pre>

Format-Tokens für Datum: - dddd - Vollständiger Wochentag (Monday, Dienstag) - ddd - Abgekürzter Wochentag (Mon, Di) - DD - Tag mit führender Null (01-31) - MM - Monat mit führender Null (01-12) - YYYY - Jahr 4-stellig (2025) - Do - Tag mit Ordnungszahl (1st, 2nd, 3rd) - MMMM - Vollständiger Monatsname (January, Dezember)

Format-Tokens für Zeit: - HH - Stunden 24h-Format mit führender Null (00-23) - mm - Minuten mit führender Null (00-59) - ss - Sekunden mit führender Null (00-59)

Zahlenformatierung

// Format: format(number, "prefix#thousands.decimals suffix#thousands_sep#decimal_sep")
format(2385.97, "#,##0.00 €#,#0.0")  // "2.385,97 €" (DE)
format(2385.97, "$#,.#0,0")          // "$2,385.97" (US)
format(12345, "0000000000")          // "0000012345" (Leading zeros)

4. Design Patterns

Nested Layouts

Um komplexe Designs zu erreichen, werden `arcCustomLayout` Widgets tief verschachtelt. * Level 1: Container (Vertical) * Level 2: Row (Horizontal) * Level 3: Item (Content)

Generic Props

Wenn Funktionen erstellt werden, sollten die Parameter generisch benannt sein, um Wiederverwendbarkeit zu sichern.
  • Inhalt: Meistens value für den anzuzeigenden Text/Inhalt (z.B. bei arcCustomText, arcCustomBadge).
  • Benennung:
  • subtitle statt company
  • meta statt timestamp
  • action statt clickFunction

5. Layout & Dimensionen

Fullscreen-Konfiguration

Für Dashboard-Layouts, die ohne Ninox-UI-Elemente angezeigt werden sollen: * ✅ `embedded: false` (kein eingebettetes Widget) * ✅ `fullscreen: true` * ✅ `fullscreenMode: if isAdminMode() then "" else "full" end` (im Nicht-Admin-Modus wird die Ninox-UI ausgeblendet) * ✅ `height: "100%"` für den äußersten Container (nicht `auto`)

Width & Height nach Orientierung

Orientierung bestimmt, welche Dimension flexibel sein sollte: * **Vertical (`direction: "vertical"`)**: `width: "100%"` (Container nimmt volle Breite), `height: "auto"` (passt sich Inhalt an) oder `"100%"` für Fullscreen * **Horizontal (`direction: "horizontal"`)**: `height: "auto"` oder feste Höhe, `width` kann `"100%"`, `"auto"`, oder `"fraction"` sein

Scrollbare Content-Bereiche

Um Navigation/Header oben sichtbar zu halten, während nur der Content scrollt: * Erstelle einen Wrapper mit `height: "fraction"` (nimmt verbleibenden Platz) * Setze `scrollSettings: { scrollY: true, scrollX: false }` * Setze `alignY: "top"` für die Ausrichtung
// Navigation (height: auto) + Content Wrapper (height: fraction + scrollY: true)
blocks: [
    { /* Header/Nav */ height: "auto" },
    { /* Scrollable Content */ height: "fraction", value: arcCustomLayout({ scrollSettings: { scrollY: true } }) }
]

CSS Positioning & Layering

Position Sticky

Für Sticky-Header, die beim Scrollen oben kleben bleiben: * ✅ **Styles im Block**: `styles: "position: sticky; top: 0px; z-index: 10; background-color: #F9FAFE;"` * ✅ **Overflow anpassen**: Parent-Container darf NICHT `overflow: hidden` haben * Verwende `overflow-x: hidden` statt `overflow: hidden` wenn nötig * ✅ **Hintergrund setzen**: Ohne `background-color` scheint der Content durch * ✅ **Z-Index**: Für Layering über anderen Elementen (z.B. `z-index: 10`)
// ❌ Blockiert Sticky:
arcCustomLayout({
    styles: "overflow: hidden;",
    blocks: [{ styles: "position: sticky; top: 0px;" }]
})

// ✅ Funktioniert: arcCustomLayout({ styles: &quot;overflow-x: hidden;&quot;, // Nur horizontal blockieren blocks: [{ styles: &quot;position: sticky; top: 0px; z-index: 10; background-color: #F9FAFE;&quot; }] })</code></pre>

Wichtig: position: sticky funktioniert nur, wenn zwischen dem sticky Element und dem scrollbaren Container KEIN overflow: hidden ist.

Widget-spezifische Props

arcCustomSelect

* **recordId** & **field**: Pflichtfelder für Daten-Binding * **autoFocus deaktivieren**: Verhindert automatisches Tastatur-Öffnen (wichtig auf Tablets)
    focusAction: {
        autoFocus: false
    }
    

arcCustomBadge

* **Text-Farbe**: `fontColor:` (nicht `color:` oder `textColor:`) * **Border**: Badge hat per default einen sichtbaren Border. Für randlose Badges `borderColor` auf `transparent` oder gleiche Farbe wie `backgroundColor` setzen.
    arcCustomBadge({
        value: "Text",
        fontColor: "#FFFFFF",  // ✅ Korrekt
        backgroundColor: "#3388FF",
        borderColor: "#3388FF"  // ✅ Gleiche Farbe wie Background für unsichtbaren Border
    })
    

arcCustomButton

* **Icon-Only Buttons**: Für Buttons ohne Text nur mit Icon * Verwende `title: ""` (leerer String) für reine Icon-Buttons * Setze `icon` Parameter mit `arcCustomIcon` * Besser als verschachtelte Layouts, da hover-Actions und button states bereits integriert sind
    // ✅ Sauber: Button mit Icon, ohne Text
    arcCustomButton({
        uniqueId: "close-btn",
        title: "",  // Leer für Icon-Only
        width: "30px",
        height: "30px",
        backgroundColor: "#EBEDF5",
        borderRadius: "50%",
        icon: arcCustomIcon({
            name: "x",
            color: "#555555",
            size: "16px"
        }),
        actions: [{ type: "update", recordId: Nr, field: "...", value: null }]
    })
    
    // ❌ Unnötig komplex: Layout-Wrapper mit Icon
    arcCustomLayout({
        width: "30px",
        height: "30px",
        clickAction: { ... },
        blocks: [{
            value: arcCustomIcon({ ... })
        }]
    })
    
* **Actions im Widget**: Verwende `actions` Array im Button statt `clickAction` im Block-Wrapper * Nutzt Button-Features (hover, active, disabled states) * Ermöglicht zukünftige `hoverActions` Konfiguration
  • Badge-System: Buttons unterstützen Badges für Notifications/Counter

arcCustomIcon

* **Eingebautes Container-Handling**: Das Icon-Widget verwaltet seinen Container selbst * Nutze `containerSize` statt externe Layout-Wrapper für Icon-Größe * `size` Parameter steuert Icon-Größe innerhalb des Containers * Kein zusätzliches `arcCustomLayout` um Icons nötig * **Container-Styling**: Alle Container-Styles können direkt am Icon gesetzt werden * `backgroundColor`: Hintergrundfarbe des Icon-Containers * `borderRadius`: Abrundung des Containers (z.B. `"50%"` für Kreis) * `borderColor`: Rahmenfarbe des Containers * `borderSize`: Rahmenstärke des Containers
    // ✅ Icon mit vollständigem Container-Styling
    arcCustomIcon({
        name: "user",
        color: "#818599",              // Icon-Farbe
        size: "30px",                  // Icon-Größe
        containerSize: "52px",         // Container-Größe
        backgroundColor: "#EBEDF5",    // Container-Hintergrund
        borderRadius: "50%",           // Runder Container
        borderColor: "#DDD",           // Container-Rahmen
        borderSize: "1px"              // Rahmen-Stärke
    })
    
    // ❌ Unnötig: Zusätzliches Layout für Styling
    arcCustomLayout({
        width: "52px",
        height: "52px",
        backgroundColor: "#EBEDF5",
        styles: "border-radius: 50%; border: 1px solid #DDD;",
        blocks: [{
            value: arcCustomIcon({
                name: "user",
                color: "#818599",
                size: "30px"
            })
        }]
    })
    
* **Vorteil**: Ein Widget statt verschachtelter Layouts → weniger Code, bessere Performance

arcCustomTable – Leerzustand

* **Leerzustand via emptyTable**: Nutze `emptyTable` settings (title, value) statt die Tabelle mit `if hasItems then table else arcCustomLayout({ ... empty state ... }) end` zu umschließen. * Das Table-Widget zeigt leere Listen intern an; kein extra Layout für „Keine Daten“ nötig.

Ninox Zeitfelder – Anzeige

*** `text()` statt `format()` für Zeit**: Ninox kann falsche Zeiten (z.B. 00:00) anzeigen bei `format(timeField, "HH:mm")` oder Zeit-Arithmetik. Nutze `text(timeField)` für zuverlässige Anzeige. * **Felder**: `'Uhrzeit von'` und `'Uhrzeit bis'` für Zeitbereiche.

6. Code-Qualität & Best Practices

Leserliche Variablennamen

Verwende aussagekräftige Namen für Loop-Variablen, besonders bei verschachtelten Schleifen:
// ❌ Schlecht:
for t in types do
    for i in items do
        // Welches "i"? Welches "t"?
    end
end

// ✅ Gut: for typeItem in types do for iconItem in icons do // Klar und verständlich end end</code></pre>

Type Coercion bei any Parametern

⚠️ Problem: Wenn Werte als any in Funktionsparametern übergeben werden, verlieren sie ihre Typinformation in Ninox. Dies führt zu Fehlern bei Operationen oder Funktionsaufrufen, die spezifische Typen erwarten.

✅ Lösung: Explizite Typ-Konvertierung mit number(), date(), text() etc.

Zahlen-Operationen

// ❌ Falsch: Type-Loss bei any Parameter
function calculateNextDay(data : any) do
    data.currentDate + 1  // Fehler: "Ungültiger Operator: any - number"
end

// ✅ Richtig: Explizite date() Konvertierung function calculateNextDay(data : any) do date(data.currentDate) + 1 // Funktioniert end</code></pre>

Datum-Formatierung

// ❌ Falsch: format() erwartet Date-Typ
format(footerData.currentDate, "DD.MM.YYYY")  // Fehler bei any-Typ

// ✅ Richtig: date() Konvertierung vor format() format(date(footerData.currentDate), &quot;DD.MM.YYYY&quot;) // Funktioniert

// ✅ Auch für month(), year(), day() month(date(footerData.currentDate)) year(date(footerData.currentDate))</code></pre>

Nested Object Properties

// ❌ Falsch: Verschachtelte any-Properties ohne Konvertierung
let weekEnd := date(data.dataDates.firstWeekDay) + 6  // firstWeekDay verliert Typ

// ✅ Richtig: Beide Werte konvertieren let firstDay := date(data.dataDates.firstWeekDay); let weekEnd := date(firstDay) + 6; // Explizite Konvertierung</code></pre>

Häufige Konvertierungen: - number() - Für Zahlen und Berechnungen - date() - Für Datum-Operationen - text() - Für String-Operationen - time() - Für Zeit-Werte

Text-Konvertierung in Schleifen

Werte aus Ninox-Objekten sollten mit `text()` konvertiert werden, besonders in verschachtelten Strukturen:
// ✅ Konsistente Konvertierung:
for typeItem in cardData.types do
    {
        value: arcCustomBadge({
            value: text(typeItem.value),
            fontColor: if typeItem.color then text(typeItem.color) else "#A114A8" end,
            backgroundColor: if typeItem.backgroundColor then text(typeItem.backgroundColor) else "#FDDEFF" end
        })
    }
end

Grund: In tiefen Verschachtelungen können Ninox-Werte ihren Typ verlieren. text() stellt sicher, dass Strings korrekt übergeben werden.

⚠️ KRITISCH: Konsistente Datentypen in if/else Zweigen

Beide Zweige eines if/else müssen denselben Datentyp zurückgeben. Bei Vergleichen oder Kombinationen von JSON-Werten mit String-Literalen, die JSON-Werte mit text() wrappen.

// ❌ FALSCH: Vergleich von any-Typ mit String
backgroundColor: if emp.backgroundColor then emp.backgroundColor else "#888888" end
// Fehler: "any" Typ verglichen mit String-Literal

// ✅ RICHTIG: JSON-Wert mit text() wrappen backgroundColor: if emp.backgroundColor then text(emp.backgroundColor) else &quot;#888888&quot; end

// ❌ FALSCH: Typ-Mischung bei Farb-Properties fontColor: if record.Color then record.Color else &quot;#000000&quot; end

// ✅ RICHTIG: Konsistente text() Konvertierung fontColor: if record.Color then text(record.Color) else &quot;#000000&quot; end</code></pre>

Wann text() verwenden: - Wenn ein JSON-Objekt-Property in einem if/else mit einem String-Fallback verwendet wird - Wenn JSON-Werte mit String-Literalen verkettet werden - Wenn JSON-Properties an Widget-Properties übergeben werden, die Strings erwarten

Faustregel: Wenn der else-Zweig ein String-Literal wie "#888888" ist, muss der if-Zweig auch einen String via text() zurückgeben.

7. Array-Handling & Dynamic Content

Array-Funktion vs. Layout-Blocks

Ninox bietet die `array()`-Funktion zum Zusammenführen von Arrays, aber in Widget-Kontexten ist die Verwendung von verschachtelten Layouts oft sauberer und flexibler.

⚠️ WICHTIG: array() ist meist unnötig

Blöcke können direkt nebeneinander gesetzt werden - array() ist nur nötig wenn tatsächlich Arrays kombiniert werden müssen.

// ❌ Unnötig: array() mit if-else
blocks: array(
    if condition then [{ value: widget1 }] else [] end,
    for item in items do { value: widget2 } end
)

// ✅ Besser: Direkt nebeneinander, kein array(), kein else blocks: [ if condition then { value: widget1 } end, for item in items do { value: widget2 } end ]</code></pre>

Merke: Ninox filtert null-Werte automatisch aus Arrays. Daher: - Kein else [] nötig bei bedingten Blöcken - Kein array() nötig um Blöcke zu kombinieren - Einfach Blöcke/Schleifen kommasepariert auflisten

❌ Vermeiden: Verschachtelte array() Aufrufe

// Problematisch: Schwer zu lesen, Verschachtelungstiefe begrenzt
blocks: array(typeBadges, array(teilnehmerBadges, durationBadges))

✅ Bevorzugt: Wrapper-Layouts mit direkten Blocks

// Sauber: Jede Badge-Gruppe hat eigenes Layout
blocks: [
    {
        value: arcCustomLayout({
            uniqueId: "types-wrapper",
            direction: "horizontal",
            gap: "6px",
            blocks: typeBadges  // Direkt einsetzen
        })
    },
    {
        value: arcCustomLayout({
            uniqueId: "teilnehmer-wrapper",
            direction: "horizontal",
            gap: "4px",
            blocks: teilnehmerBadges  // Direkt einsetzen
        })
    },
    if durationValue then
        {
            value: arcCustomBadge({ ... })
        }
    end
]

Vorteile: - Bessere Lesbarkeit und Wartbarkeit - Individuelle Styling-Kontrolle pro Gruppe (Gap, Ausrichtung) - Keine Limit-Probleme bei array() (Ninox erlaubt nur 2-3 verschachtelte Arrays) - Leichteres Debugging

Wann array() nutzen: - Wenn nur 2 einfache Arrays kombiniert werden müssen - Bei flachen Strukturen ohne weitere Styling-Anforderungen - Beispiel: actionsLeft: array(typeBadges, teilnehmerBadges)

Array-Merge (Referenzen) – 3 oder mehr Arrays

Die Ninox-Funktion array() akzeptiert nur 2 Argumente. Um 3 oder mehr Arrays zusammenzuführen, muss die Funktion mehrfach sequentiell aufgerufen werden.

// ✅ RICHTIG: Sequentielles array() für 3+ Arrays
let listCombined := array(list1, list2);
listCombined := array(listCombined, list3);

Offizielle Ninox-Dokumentation: „If you need to merge more than 2 arrays, just execute the function as often as needed.“ (<a href="https://docs.ninox.com/en/script/functions-overview/functions/array">array() Function</a>)

concat() und join() sind KEINE Alternative – sie liefern Strings, keine Arrays. Ein Workaround wie split(concat(ar1) + ", " + concat(ar2), ",") wandelt alle Werte in Text um und führt zu Typverlust (Zahlen werden zu Strings; bei Werten mit Komma im Text entstehen falsche Splits). Für echte Array-Merges immer array() verwenden.

Referenzen: - <a href="https://docs.ninox.com/en/script/functions-overview/functions/array">Ninox array()</a> – offizielle Dokumentation - <a href="https://forum.ninox.com/t/x2hr89p">Forum: How can I add two arrays?</a> – concat/join liefern Strings - <a href="https://forum.ninox.com/t/h7hr822">Forum: Some User Defined Array Functions</a> – aMerge-Workaround (textbasiert, nur für String-Arrays geeignet)

Code-Referenz im Projekt: projects/desktop-calendar-planning/desktop-calendar-planning.arctemplate Zeilen 2439–2440

⚠️ KRITISCH: Multiple for-Schleifen in blocks

Ninox erlaubt KEINE zwei for-Schleifen direkt nebeneinander in einem blocks-Array. Jede for-Schleife braucht ein eigenes Wrapper-Layout.

// ❌ FEHLER: Zwei for-Schleifen direkt nebeneinander
blocks: [
    for sel in data.selects do { value: arcCustomSelect({...}) } end,
    for btn in data.buttons do { value: arcCustomButton({...}) } end
]

// ✅ RICHTIG: Jede for-Schleife in eigenem Wrapper-Layout blocks: [{ width: &quot;fraction&quot;, value: arcCustomLayout({ uniqueId: &quot;selects-wrapper&quot;, direction: &quot;horizontal&quot;, blocks: for sel in data.selects do { value: arcCustomSelect({...}) } end }) }, { width: &quot;auto&quot;, value: arcCustomLayout({ uniqueId: &quot;buttons-wrapper&quot;, direction: &quot;horizontal&quot;, blocks: for btn in data.buttons do { value: arcCustomButton({...}) } end }) }]</code></pre>

Grund: Ninox interpretiert zwei for-Schleifen nacheinander als Syntaxfehler. Das Wrapper-Layout kapselt jede Schleife sauber ab.

⚠️ KRITISCH: Sections mit unterschiedlichen Daten – for-Schleife, kein statisches Array

Bei mehreren Sections (z.B. "Heute", "Morgen", "Nächste 7 Tage"), wo jede Section eigene Listen mit section-spezifischen Daten hat (z.B. Terminkarten mit unterschiedlichen Teilnehmern pro Karte), muss eine for-Schleife über die Sections verwendet werden.

// ❌ FALSCH: Statisches Array – Daten einer Section können in andere "leaken"
blocks: [
    dashboardSection({ sectionId: "heute", appointments: sectionHeuteList }),
    dashboardSection({ sectionId: "morgen", appointments: sectionMorgenList }),
    ...
]

// ✅ RICHTIG: for-Schleife stellt pro Section eigenen Auswertungskontext sicher blocks: for section in sections[visible = true and cnt(list) &gt; 0] do { width: &quot;100%&quot;, value: dashboardSection({ sectionId: section.sectionId, appointments: for item in section.list do { value: listCard({ participants: item.participants, ... }) } end }) } end</code></pre>

Grund: Ohne for-Schleife kann Ninox Referenzen falsch auflösen – z.B. Teilnehmer-Badges von "Heute"-Karten erscheinen in "Morgen"-Karten. Die for-Schleife erzeugt pro Section einen sauberen Auswertungskontext, sodass jede Karte die richtigen Daten aus ihrer eigenen Section/Liste erhält.

⚠️ KRITISCH: if/else mit einzelnem Block vs. for-Schleife

Wenn ein if-Zweig einen einzelnen Block und der else-Zweig eine for-Schleife enthält, muss der einzelne Block in [{...}] (eckige Klammern) gesetzt werden.

// ❌ FEHLER: Unterschiedliche Typen (Object vs. Array)
if isEmpty then
    { value: "Leer" }  // Object
else
    for item in items do { value: item } end  // Array
end

// ✅ RICHTIG: Beide als Array if isEmpty then [{ value: &quot;Leer&quot; }] // Array mit einem Element else for item in items do { value: item } end // Array end</code></pre>

Grund: Ninox erwartet bei if/else gleiche Typen. Die for-Schleife gibt ein Array zurück, also muss der einzelne Block auch als Array [{...}] definiert werden.

⚠️ KRITISCH: if direkt in Arrays OHNE extra { Bei bedingten Blöcken innerhalb von blocks: [...] darf das if NICHT in ein extra { } gewrappt werden.

// ❌ FALSCH: Extra { vor if
blocks: [{
        width: "100%",
        value: "immer sichtbar"
    }, {  // ← Dieses extra { ist FALSCH!
        if condition then
            {
                width: "100%",
                value: "bedingt"
            }
        end
    }  // ← Und dieses schließende } ist auch FALSCH!
]

// ✅ RICHTIG: if direkt im Array, kein extra Wrapper blocks: [{ width: &quot;100%&quot;, value: &quot;immer sichtbar&quot; }, if condition then { width: &quot;100%&quot;, value: &quot;bedingt&quot; } end ]</code></pre>

Grund: Das if...then...end selbst gibt das Block-Objekt zurück (oder null). Ein Wrapping in { } erzeugt eine ungültige Objekt-Struktur, die Ninox nicht parsen kann.

Bedingte Blöcke - else-Zweig oft unnötig

Widget-`blocks`-Arrays ignorieren automatisch `null`/leere Werte. Daher sind leere `else`-Blöcke meist unnötig.

❌ Verbose: Leere else-Blöcke

blocks: [
    if condition then
        { /* ... */ }
    else
        {
            width: "0px",
            height: "0px",
            value: ""
        }
    end
]

✅ Clean: Ohne else

blocks: [
    if condition then
        { /* ... */ }
    end
]

Ninox filtert automatisch: - null Werte - Leere Arrays [] - Undefined/falsy Ergebnisse

Dynamisch vs. Statisch

Vermeide hardcoded, starre Lösungen. Nutze immer Schleifen und Arrays, um flexibel auf Datenstrukturen zu reagieren.

❌ Statisch: Nur erste 2 Elemente

blocks: [
    if count(teilnehmer) > 0 then
        { value: arcCustomBadge({ value: item(teilnehmer, 0).value }) }
    end,
    if count(teilnehmer) > 1 then
        { value: arcCustomBadge({ value: item(teilnehmer, 1).value }) }
    end
]

✅ Dynamisch: Alle Elemente

// Vorbereitung (Top-Level oder in Funktion):
let teilnehmerBadges := if teilnehmer then
    for t in teilnehmer do
        {
            width: "auto",
            height: "auto",
            value: arcCustomBadge({
                uniqueId: "badge-" + t.value,
                value: t.value,
                fontColor: if t.color then text(t.color) else "#FFFFFF" end,
                backgroundColor: if t.backgroundColor then text(t.backgroundColor) else "#3388FF" end
            })
        }
    end
else
    []
end;

// Verwendung im Layout: blocks: [ { value: arcCustomLayout({ uniqueId: &quot;teilnehmer-wrapper&quot;, blocks: teilnehmerBadges // Dynamisch, alle Elemente }) } ]</code></pre>

Vorteile der dynamischen Lösung: - Funktioniert mit beliebig vielen Elementen (1, 5, 100...) - Keine Code-Duplizierung - Änderungen am Badge-Styling nur an einer Stelle - Wiederverwendbar (Variable kann mehrfach genutzt werden)

Pattern: Wiederverwendbare Badge-Arrays

Definiere Badge-Arrays als Variablen, um sie in mehreren Layouts zu verwenden (z.B. Card-Footer UND Popover-Header):
function myComponent(data : any) do
    // Einmal definieren
    let typeBadges := if data.types then
        for typeItem in data.types do
            {
                width: "auto",
                height: "auto",
                value: arcCustomBadge({
                    uniqueId: data.id + "-type-" + typeItem.value,
                    value: text(typeItem.value),
                    fontColor: if typeItem.color then text(typeItem.color) else "#A114A8" end,
                    backgroundColor: if typeItem.backgroundColor then text(typeItem.backgroundColor) else "#FDDEFF" end
                })
            }
        end
    else
        []
    end;
    
    // Mehrfach verwenden
    arcCustomLayout({
        blocks: [
            { value: arcCustomLayout({ blocks: typeBadges }) },  // In Card
            { value: arcCustomLayout({ blocks: typeBadges }) }   // In Popover
        ]
    })
end

8. UI Patterns & Component Architecture

Tab-basierte Navigation

Für komplexe UI-Komponenten mit mehreren Content-Bereichen (z.B. Popovers, Modals):

Tab-Konfiguration

tabs: [{
    id: "termin",
    title: "Termin",
    hasComment: false,  // Flag für Badge-Display
    contentBlock: arcCustomLayout({
        uniqueId: "tab-termin-content",
        height: "100%",
        scrollSettings: { scrollY: true },
        blocks: [/* Content */]
    })
}, {
    id: "kommentare",
    title: "Kommentare",
    hasComment: currentRecord.CommentField != null and text(currentRecord.CommentField) != "",
    contentBlock: arcCustomLayout({
        uniqueId: "tab-kommentare-content",
        height: "100%",
        scrollSettings: { scrollY: true },
        blocks: [/* Content */]
    })
}]

Tab-Navigation Rendering

// Tab-Buttons mit Badge-Support
blocks: for tab in popoverData.tabs do
    {
        width: "auto",
        height: "auto",
        value: arcCustomButton({
            uniqueId: popoverData.id + "-tab-" + tab.id,
            title: tab.title,
            backgroundColor: if popoverData.activeTab = tab.id then "#333333" else "#EBEDF5" end,
            fontColor: if popoverData.activeTab = tab.id then "#FFFFFF" else "#818599" end,
            showBadge: if tab.id = "kommentare" and tab.hasComment then true else false end,
            badgeTitle: "1",
            badgeColor: "#FFFFFF",
            badgeBackground: "#FF3B30",
            actions: [{
                type: "update",
                recordId: Nr,
                field: popoverData.tabActionField,
                value: tab.id
            }]
        })
    }
end

Dynamic Tab Content Display

// Content-Bereich: Dynamisches Rendering basierend auf activeTab
{
    width: "100%",
    height: "fraction",  // Nimmt verfügbaren Platz
    value: let activeTabData := first(for tab in popoverData.tabs do
        if tab.id = popoverData.activeTab then
            tab
        end
    end);
    if activeTabData and activeTabData.contentBlock then
        html(activeTabData.contentBlock)  // ⚠️ html() Wrapper erforderlich!
    else
        arcCustomLayout({
            uniqueId: popoverData.id + "-empty-content",
            blocks: [{
                value: arcCustomText({
                    value: "Kein Content verfügbar.",
                    fontColor: "#999999"
                })
            }]
        })
    end
}

Wichtig: html() Wrapper ist erforderlich, wenn contentBlock als any Type übergeben wird, um Type-Kompatibilität zwischen Widget-Return und erwartetem Layout zu gewährleisten.

Footer sollten NICHT Teil des scrollbaren Tab-Contents sein, sondern als separater Block am Ende positioniert werden.

❌ Problematisch: Footer im Content

contentBlock: arcCustomLayout({
    blocks: [
        { /* Content */ },
        { /* Footer Buttons */ }  // Scrollt mit → schlecht
    ]
})

✅ Richtig: Footer als separates Property

// 1. Footer als separates Property im Data-Objekt
{
    tabs: [...],
    footerBlock: arcCustomLayout({
        uniqueId: "popover-footer",
        height: "auto",  // Footer passt sich Inhalt an
        gap: "10px",
        paddingY: "10px",
        blocks: [
            if phoneNumber then
                { value: arcCustomButton({ title: "Anrufen", ... }) }
            end,
            if mapsUrl then
                { value: arcCustomButton({ title: "In Google Maps öffnen", ... }) }
            end
        ]
    })
}
// 2. Footer im Popover-Layout (nach Content)
blocks: [
    { /* Header */ },
    { /* Tabs */ },
    { 
        width: "100%",
        height: "fraction",  // Content scrollbar
        value: /* Tab Content */
    },
    if popoverData.footerBlock then
        {
            width: "100%",
            height: "auto",  // Footer bleibt unten
            value: html(popoverData.footerBlock)
        }
    end
]

Layout-Struktur mit Footer

┌─────────────────────────────┐
│ Popover Header (auto)       │
├─────────────────────────────┤
│ Tabs Navigation (auto)      │
├─────────────────────────────┤
│ Tab Content (fraction)      │ ← scrollY: true
│   - Scrollbarer Bereich     │
│   - height: "fraction"      │
├─────────────────────────────┤
│ Footer (auto)               │ ← Immer sichtbar
│  [Action Button 1]          │
│  [Action Button 2]          │
└─────────────────────────────┘

Vorteile: - Footer bleibt immer sichtbar (kein Scrollen nötig) - Klare Trennung zwischen Content und Actions - Bessere UX für Action-Buttons (z.B. "Anrufen", "In Maps öffnen") - Unabhängiges Styling für Footer-Bereich

Pattern-Zusammenfassung: 1. Content-Bereich: height: "fraction" + scrollY: true 2. Footer-Bereich: height: "auto" + außerhalb des scrollbaren Content 3. Footer als footerBlock Property im Data-Objekt definieren 4. Footer mit html(popoverData.footerBlock) rendern

Pagination für Listen/Kanban

Bei großen Datenmengen (z.B. Inbox mit vielen Mails) kann das Widget crashen. Lösung: arcCustomPagination mit slice().

Pagination-Variablen oben definieren

let maxEntriesPerPage := 20;

// Pro Liste/Swimlane let listAll := (select Items where ...); let totalCards := cnt(listAll); let currentPage := if page_field then number(page_field) else 1 end; let totalPages := ceil(totalCards / maxEntriesPerPage); let listPaginated := slice(listAll, maxEntriesPerPage * (currentPage - 1), maxEntriesPerPage * currentPage);</code></pre>

Pagination in Config-Objekt

let swimlaneConfig := [{
    id: "inbox",
    title: "Inbox",
    showPagination: totalCards > maxEntriesPerPage,  // Nur wenn nötig
    pagination: {
        currentPage: currentPage,
        totalPages: totalPages,
        totalCards: totalCards,
        fieldId: fieldId(Nr, "page_inbox")  // Number-Feld für aktuelle Seite
    },
    sections: [{
        title: "Items",
        cards: listPaginated.[{...}]  // Paginierte Liste verwenden
    }]
}];

Pagination Widget am Ende der Swimlane

if swimlane.showPagination then
    {
        width: "100%",
        height: "auto",
        alignX: "center",
        value: arcCustomPagination({
            uniqueId: "pagination-" + text(swimlane.id),
            editable: true,
            recordId: Nr,
            fieldId: text(swimlane.pagination.fieldId),
            direction: "horizontal",
            title: swimlane.pagination.currentPage,
            value: swimlane.pagination.currentPage,
            total: swimlane.pagination.totalPages,
            totalPrefix: " von "
        })
    }
end

Wichtig: - showPagination als boolean für konditionale Anzeige - slice(list, start, end) für die paginierte Teilmenge - Separates Number-Feld pro Swimlane/Liste (z.B. page_inbox, page_bearbeiten) - editable: true erlaubt direkte Seiteneingabe

Pagination bei Filterwechsel zurücksetzen

⚠️ Problem: Wenn ein Filter gewechselt wird (z.B. Postfach), kann die aktuelle Seite höher sein als die neue Gesamtseitenzahl → leere Anzeige.

Lösung: Im Ninox Trigger "Nach Änderung" des Filterfelds die Seite zurücksetzen:

// Trigger: Nach Änderung von "selectedPostfach"
'Aktuelle Seite' := 1

Gilt für alle Filter die die Listengröße beeinflussen: - Postfach/Mailbox Auswahl - Status Filter - Kategorie Filter - Bearbeiter Filter

Empty States für Listen/Swimlanes

Wenn eine Liste/Swimlane leer ist, sollte ein Fallback-Text angezeigt werden.

Pattern: Bedingte Section-Anzeige + Empty State

⚠️ WICHTIG: Bei if/else mit unterschiedlichen Block-Typen (einzelner Block vs. for-Schleife) muss der einzelne Block in [{...}] (eckige Klammern) gesetzt werden, damit Ninox beide als Arrays erkennt.

// Config: emptyMessage pro Swimlane
emptyMessage: "Keine Einträge im Posteingang",

// Rendering: if/else Pattern (NICHT if...end, for...end nebeneinander!) let totalSectionCards := sum(for sec in swimlane.sections do count(sec.cards) end); if totalSectionCards = 0 then [{ // ← Eckige Klammern um einzelnen Block! width: &quot;100%&quot;, height: &quot;100%&quot;, value: arcCustomLayout({ uniqueId: &quot;empty-&quot; + swimlaneId, alignX: &quot;center&quot;, alignY: &quot;center&quot;, // ← Für vertikale Zentrierung braucht man Layout! width: &quot;100%&quot;, height: &quot;100%&quot;, blocks: [{ value: arcCustomText({ value: text(swimlane.emptyMessage), fontColor: &quot;#999999&quot; }) }] }) }] else for section in swimlane.sections do if count(section.cards) &gt; 0 then { /* Section mit Title und Cards */ } end end end // ← Ein end für das gesamte if/else</code></pre>

Warum [{...}]? Ninox erwartet bei if/else gleiche Typen. Die for-Schleife gibt ein Array zurück, also muss der einzelne Block auch als Array [{...}] definiert werden.

Warum Layout für vertikale Zentrierung? Blocks haben nur alignX für horizontale Ausrichtung. Für alignY (vertikale Zentrierung) muss ein arcCustomLayout Wrapper verwendet werden.

Reset-Button in Select ausblenden

Manche Selects haben "Alle" bereits als Record-Option und dürfen nicht auf null zurückgesetzt werden.

// Config: hideReset pro Filter
filters: [{
    placeholder: "Posteingang",
    hideReset: true,  // Kein Reset-Button
    items: [...]
}, {
    placeholder: "Status",
    hideReset: false,  // Reset-Button "Alle" anzeigen
    items: [...]
}]

// Rendering: reset.hide aus Config lesen reset: { title: &quot;Alle&quot;, value: &quot;&quot;, hide: if filter.hideReset then true else false end }</code></pre>

9. Responsive Design: isMobile Pattern mit ninoxApp()

Plattform-Erkennung

Verwende ninoxApp() um zwischen Mobile und Desktop/Tablet zu unterscheiden:

let isMobile := ninoxApp() = "iphone" or ninoxApp() = "android";

Rückgabewerte von ninoxApp(): "web", "mac", "iphone", "ipad", "android", "tab", "server".

Desktop Layout Shell

Auf Desktop/Tablet: Zweispaltiges Layout: - Linke Sidebar (fixe Breite, z.B. 370px): Mobile-Ansichten als Sidebar adaptiert - Rechter Content-Bereich (width: "fraction"): Desktop-spezifischer Content mit Header (fixe Höhe), Content (fraction), Footer (auto)

isMobile Parameter in Funktionen

isMobile über das Data-Objekt an alle Funktionen weiterreichen, die plattformspezifische Anpassungen benötigen:

--- In Funktion: Styles bedingt setzen ---
styles: if data.isMobile then
    "overflow-x: hidden; max-width: 400px; min-height: 700px;position:relative;"
else
    "overflow-x: hidden; min-height: 700px;position:relative;"
end,

Views-Architektur (Einheitliche Navigation)

Zwei-Ebenen-Navigationssystem: 1. activeView (aus State abgeleitet): Bestimmt den Top-Level-Kontext (dashboard, ticket, customer) 2. activeTab (aus helper_activeTab): Wählt den Tab innerhalb der aktuellen View

Alle Navigationsaktionen schreiben in ein einziges Feld helper_activeTab über tabActionField.

--- activeView wird aus globalem State abgeleitet, nicht in einem Feld gespeichert ---
let activeView := if helper_currentCustomer then
        "customer"
    else
        if currentTicket then "ticket" else "dashboard" end
    end;

--- Alle Tabs nutzen das gleiche Action-Feld --- let tabActionField := fieldId(Nr, &quot;helper_activeTab&quot;); let activeTabValue := if helper_activeTab then helper_activeTab else &quot;&quot; end;</code></pre>

View-Funktionen mit Lazy Content Loading

Kritisch für Performance: Schweren Content in View-Funktionen mit if data.active then ... end Guards wrappen. Das verhindert, dass Ninox teure Widget-Berechnungen für inaktive Views auswertet (da let-Variablen in Ninox eager evaluiert werden).

function viewTicket(data : any) do
    if data.active then
        let activeTab := if data.activeTab then data.activeTab else "ticket" end;
        {
            desktop: {
                sidebar: { content: heavyWidget(), footer: null },
                main: { content: mainWidget(), header: null, footer: footerWidget(), contentSidebar: null }
            },
            mobile: {
                main: mobileWidget(),
                footer: null,
                header: null
            }
        }
    end
end;

Warum Funktionen statt Inline-JSON? : Wenn man let myWidget := expensiveWidget(); auf Top-Level setzt und es im JSON mit if activeView = "ticket" then myWidget end referenziert, evaluiert Ninox myWidget trotzdem. Durch das Wrappen in eine Funktion mit data.active-Check wird die teure Berechnung nur bei aktiver View ausgelöst.

Views-Konfiguration

Views als Array von Config-Objekten organisieren, jedes mit eigenen navTabs und lazy-geladenem content:

let views := [{
        id: "dashboard",
        active: activeView = "dashboard",
        defaultTab: "cockpit",
        navTabs: [{ id: "cockpit", title: "Cockpit", visible: true, active: ..., actionField: tabActionField }, ...],
        content: viewDashboard({ active: activeView = "dashboard", activeTab: activeTabValue, ... })
    }, {
        id: "ticket",
        active: activeView = "ticket",
        navTabs: [{ id: "ticket", title: "Ticket", visible: true, active: ..., actionField: tabActionField }, ...],
        content: viewTicket({ active: activeView = "ticket", activeTab: activeTabValue, ... })
    }];

Generisches Rendering aus Views-Config

Aktuelle View auflösen und plattformspezifischen Content rendern:

let currentView := first(for v in views do if v.active then v end end);
let currentNavTabs := if currentView then currentView.navTabs else [] end;
let viewContent := if currentView then currentView.content else null end;

if isMobile then if viewContent and viewContent.mobile and viewContent.mobile.main then html(viewContent.mobile.main) end else desktopLayout({ sidebarContent: viewContent.desktop.sidebar.content, rightHeader: desktopTicketHeader({ tabs: currentNavTabs, tabRecordId: Nr, ... }), rightContent: viewContent.desktop.main.content, rightFooter: viewContent.desktop.main.footer }) end</code></pre>

Jede View-Funktion gibt sowohl desktop- als auch mobile-Content-Strukturen zurück. Der generische Renderer wählt die richtige basierend auf isMobile.

10. Helper-Feld Namenskonvention

Konsistentes Namensschema für Helper-Felder:

PatternBeispielVerwendung
helper_current{Entity}helper_currentCustomerAktuell ausgewählter/aktiver Datensatz (ID)
helper_active{Feature}helper_activeViewAktiver Tab/View/Modus
helper_selected{Entity}helper_selectedTicketAusgewähltes Element für Detail/Popover
trigger_{action}trigger_deleteBoolean-Trigger-Felder für Ninox-Aktionen

Plattform-spezifische Präfixe wie desktop_ oder mobile_ sowie kontext-spezifische wie popover_ oder filter_ in Feldnamen vermeiden. Generische Namen verwenden, damit dasselbe Feld plattformübergreifend funktioniert.

Refactoring-Learnings (Detail-/List-Views)

Skeleton-first-Architektur: Layout-Shell (panelLayout, fullLayout) zuerst mit Platzhaltern bauen, dann reale Komponenten schrittweise integrieren. Vermeidet "alter Code läuft weiter" und ermöglicht sauberes Testen.

Shared Data Objects: Ein einziges ticketData (oder currentTicket) Objekt wenn ein Ticket ausgewählt ist. Alle Sub-Views (Details, Notizen, Kommentare, Historie) konsumieren daraus. Einmal berechnen, überall wiederverwenden.

View-Level-Variablen-Scoping: Variablen wie ticketHeader, ticketSidebarContent, ticketFooter innerhalb von if activeView = "ticket" then ... end werden nur bei aktiver View evaluiert. Verhindert eager evaluation schwerer Widgets für inaktive Views.

Generische Komponenten-Namen: ticketSidebarHeader statt mobileTicketHeader oder popoverHeader. Dieselbe Komponente dient Desktop-Sidebar und Mobile-Panel; später für andere Entitäten wiederverwendbar.

Array-Projektion für Sections: list[visible = true and count(list) > 0].[{ block }] direkt in blocks für effiziente Iteration. Query innerhalb if hasSearchFilter then ... else [] end für Performance, wenn eine "Später"-Section nur bei Suche relevant ist.

Select-Items-Syntax: Für arcCustomSelect (select Table).[{ title: ..., active: ..., value: ... }] verwenden, nicht for m in select ... do { id, title } end. Das active-Flag prüft contains(numbers(current.Field), number(Nr)) bei Multi-Select.

arcCustomSelect currentValue bei Verknüpfungsfeldern: Wenn das Select-Feld eine Relation ist, muss currentValue das Anzeigefeld der verknüpften Tabelle nutzen, nicht text(record.Relation). Beispiel: currentValue: if record.Kategorie then text(record.Kategorie.Titel) else "" end.

Conditional Tab main für Detail-Views: Wenn ein Tab sowohl Listen- als auch Detail-Ansicht hat, die main-Property conditional machen: main: if detailActive then { showNav: false, headerLeft: backBtn, headerRight: actionBtn, content: detailContent, contentRight: detailSidebar, footer: detailFooter } else { showNav: true, content: listContent, ... } end. Nutzt die desktopMainLayout-Slots (headerLeft, headerRight, contentRight, footer) statt alles in eine einzige Content-Funktion zu packen.

Detail-View-Rendering auf Tab-Ebene, nicht in Content-Funktionen: Nicht innerhalb von untersuchungenContent() zwischen Liste und Detail branchen. Content-Funktionen auf eine Aufgabe fokussieren (Liste ODER Detail) und die Tab-Definition entscheiden lassen, was gerendert wird. Ermöglicht korrekte Nutzung von Sidebar/Footer/Header-Slots.

11. Dokumentation für LLMs

Wenn Code generiert wird, muss er: 1. Kompilierbar in Ninox sein (kein echtes JS). 2. Die Widget-Referenz strikt befolgen.