Detail-Tabellen (1:n) — Referenz¶
kc:childTable macht aus einem JSON-Schema-Feld vom Typ array eine
echte relationale 1:n-Beziehung mit eigener Datenbank-Tabelle. Typische
Anwendungen: Bestellpositionen unter einer Bestellung, Belege unter einer
Reisekostenabrechnung, Ansprechpartner unter einem Kunden.
Einstieg: Klick-Tutorial · JSON-Tutorial.
Schema-Grundform¶
{
"positionen": {
"type": "array",
"title": "Positionen",
"kc:colspan": "full",
"kc:childTable": {
"initialRows": 1,
"properties": {
"artikel": { "type": "string", "title": "Artikel" },
"menge": { "type": "number", "title": "Menge", "kc:decimals": 0 },
"preis": { "type": "number", "title": "Preis", "kc:decimals": 2 },
"summe": { "type": "number", "title": "Summe", "kc:decimals": 2,
"kc:calc": { "type": "arithmetic", "op": "multiply",
"operands": ["menge", "preis"] } }
}
}
}
}
Die Spalten verstehen im Wesentlichen denselben Eigenschaftssatz wie
normale Felder: kc:decimals, kc:readonly, kc:required, kc:lookup,
kc:picker, kc:calc, format, pattern/kc:patternMode, kc:align
usw.
Im Formular-Editor pflegst du die Spalten in den Eigenschaften der Detail-Tabelle (Bereich Felder → Detailzeilen-Spalten, + Spalte); ein Doppelklick auf eine Spalte in der Vorschau öffnet deren eigene Eigenschaften.
Einstellungen der Detail-Tabelle¶
Schlüssel (in kc:childTable) |
Bedeutung |
|---|---|
properties |
die Spalten — JSON-Schema-Form, auch verschachtelte Detail-Tabellen |
initialRows |
so viele leere Zeilen erhält ein neuer Datensatz (Standard 0) |
keepEmptyRows |
true = komplett leere Zeilen beim Speichern behalten (Standard: verwerfen) |
layout |
"block" zeigt jede Zeile als eigenen Formularblock statt als Tabellenzeile |
blockTitle |
Überschrift je Block, z. B. "Position ${index}" (1-basiert) |
blockLayout |
Raster im Block: vertical, horizontal-2, horizontal-3 |
blockCollapsible |
true = Blöcke lassen sich auf- und zuklappen (Akkordeon) |
collapsibleSubRows |
true = verschachtelte Unter-Tabellen je Zeile einklappbar |
Am Feld selbst (neben kc:childTable) wirken zusätzlich die üblichen
Feld-Eigenschaften, z. B. kc:colspan: "full" für die volle Breite.
Einzelne Spalten lassen sich mit kc:colWidthFactor breiter oder schmaler
stellen (relativ zur gleichmäßigen Verteilung).
Block-Modus auf schmalen Bildschirmen
Unter 768 px Breite (Telefon, schmales Fenster) zeigt KCForms jede Detail-Tabelle automatisch im Block-Modus. Das Schema ändert sich dabei nicht.
Wie das in der Datenbank liegt¶
Beim Speichern der Formular-Definition (und bei jedem Backend-Start) legt KCForms für jede Detail-Tabelle eine eigene Tabelle an:
form_<formid>_<feldname>
parent_id FK auf form_<formid>.id, ON DELETE CASCADE
lfd_nr laufende Nummer innerhalb des Datensatzes
<je Spalte eine eigene Spalte>
PRIMARY KEY (parent_id, lfd_nr)
| Formular-ID | Feldname | Tabelle |
|---|---|---|
bestellung |
positionen |
form_bestellung_positionen |
reisekosten |
belege |
form_reisekosten_belege |
Bindestriche und andere Sonderzeichen der Formular-ID werden zu
Unterstrichen (kosten-im-wirtschaftsjahr → form_kosten_im_wirtschaftsjahr).
Die Spaltentypen leitet KCForms aus der Spalte ab:
| Spalte | Datenbank (MySQL-Schreibweise) |
|---|---|
type: integer |
BIGINT |
type: number |
DECIMAL mit kc:decimals Nachkommastellen (Standard 4) |
type: boolean |
BOOLEAN |
kc:widget: textarea |
LONGTEXT |
Text mit kc:maxLength |
VARCHAR(n), über 4 000 Zeichen LONGTEXT |
format: date |
DATE |
format: date-time |
TIMESTAMP |
| sonstiger Text | VARCHAR(255) |
Ältere Datumsspalten
Vor dem Stand vom 19.09.2026 legte KCForms Datumsspalten in Detail-Tabellen als Text
(VARCHAR) an. Solche bestehenden Spalten bleiben unverändert und
funktionieren weiter; nur neu hinzukommende Datumsspalten werden DATE
bzw. TIMESTAMP. Wer eine alte Spalte umstellen will, macht das per SQL
(Werte stehen dort als JJJJ-MM-TT).
Neue Spalten legt KCForms beim Speichern im Formular-Editor und beim Start
automatisch per ALTER TABLE … ADD an.
Laden und Speichern¶
Beim Laden eines Datensatzes liest KCForms die Kopfzeile aus
form_<formid> und für jede Detail-Tabelle die zugehörigen Zeilen. Im
Formular und in ctx.form stehen sie als Liste unter dem Feldnamen.
Beim Speichern schreibt KCForms die Kopfzeile und ersetzt dann für
jede Detail-Tabelle alle Zeilen des Datensatzes: alte löschen, aktuelle
in Reihenfolge neu einfügen (lfd_nr = Position).
Keine Fremdschlüssel auf einzelne Zeilen
Weil die Zeilen bei jedem Speichern neu geschrieben werden, lohnt sich ein Fremdschlüssel von außen auf eine einzelne Zeile nicht. Wer auf Zeilen verweisen muss, verweist auf den Kopf-Datensatz.
Leere Zeilen
Komplett leere Zeilen verwirft KCForms beim Speichern — etwa per
initialRows vorbelegte, die niemand ausgefüllt hat. Als leer gilt eine
Zeile, wenn keine Eingabespalte einen Wert hat und keine Unter-Tabelle
eine gefüllte Zeile; berechnete und schreibgeschützte Spalten zählen
dabei nicht mit, ein nicht angehaktes Kästchen auch nicht. Sollen leere
Zeilen erhalten bleiben: keepEmptyRows: true in kc:childTable
(im Formular-Editor: Dataset-Eigenschaften der Tabelle → Leere Zeilen speichern).
Über die REST-Schnittstelle des Formulars gilt dasselbe Format:
POST /api/forms/{id}/submit nimmt die Zeilen als Liste unter dem
Feldnamen entgegen, GET /api/forms/{id}/instances/{instanceId} liefert
sie so zurück.
Vorbelegung: initialRows¶
initialRows füllt einen neuen Datensatz mit der angegebenen Anzahl
leerer Zeilen, damit der Anwender gleich lostippen kann. 0 (Standard)
bedeutet: Die Tabelle startet leer, der Anwender klickt
+ Zeile hinzufügen. Gespeicherte Datensätze behalten ihre tatsächlichen
Zeilen.
Skript-Zugriff¶
Die Zeilen stehen in ctx.form.<feldname> als Liste von Objekten.
Lesen und Ändern bestehender Zeilen funktioniert mit den üblichen
Schleifen:
// onValidate: mindestens eine Position mit Menge
var pos = ctx.form.positionen || [];
var mitMenge = pos.filter(function (p) { return kc.num(p.menge) > 0; });
if (mitMenge.length === 0) {
ctx.addError('positionen', 'Mindestens eine Position mit Menge erforderlich.');
}
// onFieldChange: Zeilenwerte ändern
var pos = ctx.form.positionen || [];
pos.forEach(function (p) {
if (!p) return;
p.hinweis = kc.num(p.menge) > 100 ? 'Großmenge' : '';
});
Einfache Rechnungen (Zeile = Menge × Preis, Summe über alle Zeilen) brauchen kein Skript — dafür gibt es Berechnungsfelder.
Neue Zeilen per Skript
Zeilen hinzufügen (ctx.form.positionen.push({...})) oder die Liste
durch ein neues JavaScript-Array ersetzen funktioniert derzeit nicht
zuverlässig. Bestehende Zeilen ändern geht dagegen problemlos.
Eine objektorientierte Sicht mit Zeilen, Elementen und Unter-Tabellen bietet
ctx.model.
Picker in Spalten¶
Eine Spalte kann einen Picker haben. In der Zelle erscheint dann ein …-Knopf:
"positionen": {
"type": "array",
"kc:childTable": {
"properties": {
"artikelnr": {
"type": "string", "title": "Artikel-Nr.",
"kc:picker": {
"table": "bueromaterial",
"search": ["artikelnr", "bezeichnung"],
"columns": [
{ "column": "artikelnr", "title": "Nr." },
{ "column": "bezeichnung", "title": "Bezeichnung" },
{ "column": "preis", "title": "Preis" }
],
"set": {
"artikelnr": "artikelnr",
"bezeichnung": "bezeichnung",
"preis": "preis"
}
}
},
"bezeichnung": { "type": "string", "kc:readonly": true },
"menge": { "type": "number", "kc:decimals": 0 },
"preis": { "type": "number", "kc:decimals": 2, "kc:readonly": true }
},
"initialRows": 1
}
}
set zielt auf Spalten derselben Zeile — wählt der Anwender in der
3. Zeile einen Artikel, werden bezeichnung und preis in der 3. Zeile
gefüllt.
Auswahllisten in Spalten¶
"belege": {
"type": "array",
"kc:childTable": {
"properties": {
"kategorie": {
"type": "string", "title": "Kategorie",
"kc:lookupOptions": [
{ "key": "FAHRT", "value": "Fahrtkosten" },
{ "key": "VERPFLEGUNG", "value": "Verpflegung" },
{ "key": "UEBERNACHT", "value": "Übernachtung" }
]
},
"betrag": { "type": "number", "kc:decimals": 2 }
}
}
}
Auswahllisten in Spalten werden je Zeile ausgewertet; abhängige Listen innerhalb derselben Zeile funktionieren ebenfalls. Details: Lookups.
Spalten ein- und ausblenden, Zellen sperren¶
Eine Spalte kann verschwinden oder gesperrt sein — der Unterschied liegt darin, worauf sich die Bedingung bezieht.
Ganze Spalte: kc:visible: false blendet sie immer aus,
kc:visibleIf je nach Bedingung. Beide werden gegen die Werte des
Formularkopfs ausgewertet, nicht gegen die Zeile — eine Spalte ist
entweder da oder nicht, eine je Zeile wechselnde Antwort ergäbe eine
Spalte mit Löchern. Der Wert einer ausgeblendeten Spalte bleibt
gespeichert und lässt sich weiterhin als kc:calc-Operand verwenden.
Einzelne Zelle: kc:readonlyIf und kc:requiredIf entscheiden je
Zeile. Sie sehen zuerst die Werte dieser Zeile und darunter die des
Formularkopfs — „Pflicht, wenn in dieser Zeile die Menge über 100 liegt"
lässt sich damit genauso schreiben wie „Pflicht, solange oben die
Rabattaktion läuft". Heißen eine Spalte und ein Feld im Kopf gleich,
gewinnt die Spalte, weil sie die nähere Ebene ist. Ein kc:picker an
einer gesperrten Zelle ist mit gesperrt.
"positionen": {
"type": "array",
"kc:childTable": {
"properties": {
"artikel": { "type": "string", "title": "Artikel" },
"menge": { "type": "integer", "title": "Menge" },
"rabatt": { "type": "number", "title": "Rabatt %",
"kc:visibleIf": "rabattaktion === true" },
"begruendung": { "type": "string", "title": "Begründung",
"kc:readonlyIf": "menge < 10" }
}
}
}
Die Spalte rabatt erscheint nur, wenn im Formularkopf das Feld
rabattaktion angekreuzt ist. Das Feld begruendung lässt sich nur in
den Zeilen ausfüllen, deren menge mindestens 10 beträgt.
Was womit zusammenhängt
kc:visible/kc:visibleIf betreffen die Anzeige im Formular. Ob
eine Spalte gedruckt wird, steuert davon unabhängig
kc:printable/kc:printableIf (siehe Druck) — eine
Spalte, die auf dem Bildschirm ausgeblendet ist, erscheint also
weiterhin im PDF, solange sie nicht auch dort abgeschaltet wird. Das
ist Absicht und gilt für Felder im Kopf genauso.
In der Block-Darstellung (layout: "block", und automatisch auf
schmalen Schirmen) darf kc:visibleIf zusätzlich Spalten derselben
Zeile vergleichen, weil dort jeder Datensatz ein eigenes kleines
Formular ist. In der Tabellen-Darstellung geht das nicht — schreibe
solche Bedingungen also so, dass sie sich auf den Formularkopf
beziehen, wenn das Formular beide Darstellungen benutzen soll.
Validierung¶
Pflichtfelder, Muster und Formatregeln gelten in jeder Zeile wie im
Formularkopf. Ein Pflichtfeld „mindestens eine Zeile" gibt es auf der
Detail-Tabelle nicht — das prüfst du im onValidate-Hook (Beispiel
oben).
Pflichtspalten¶
Eine Spalte wird zur Pflichtspalte über die required-Liste der Tabelle
(im Studio der Haken Pflicht am Spalten-Editor) oder über
kc:requiredIf, wenn sie es nur unter einer Bedingung sein soll:
"positionen": {
"type": "array",
"kc:childTable": {
"required": ["artikel"],
"properties": {
"artikel": { "type": "string", "title": "Artikel" },
"menge": { "type": "integer", "title": "Menge" },
"grund": { "type": "string", "title": "Grund",
"kc:requiredIf": "menge > 100" }
}
}
}
Wo der Stern steht, folgt daraus, für wen die Pflicht gilt: die statische Pflichtspalte trägt ihn im Spaltenkopf, weil sie für jede Zeile gilt; die bedingte Pflicht trägt ihn in der Zelle, und nur solange diese leer ist.
Geprüft wird beim Klick auf Validieren und beim Speichern — also serverseitig, nicht nur im Browser. Die Meldung nennt Zeilennummer und Spaltentitel und erscheint an der Tabelle.
Wann eine Pflichtspalte bewusst schweigt
- Leere Zeilen werden nicht angemahnt. Eine angelegte, nie
ausgefüllte Zeile ist keine Lücke, sondern eine Zeile zu viel — sie
wird beim Speichern ohnehin verworfen (siehe
keepEmptyRows). - Unerfüllbare Zellen ebenso wenig: ausgeblendete Spalten
(
kc:visible: false,kc:visibleIf), perkc:readonly/kc:readonlyIfgesperrte Zellen und Rechenspalten (kc:calc) lassen sich nicht ausfüllen. Eine Pflicht darauf wäre eine Sackgasse: das Formular ließe sich nicht mehr speichern, und niemand könnte etwas dagegen tun.
Druck (PDF)¶
Detail-Tabellen erscheinen im PDF als eigene Tabelle mit Kopfzeile, im
Block-Modus als Blöcke. Die Spaltenreihenfolge folgt properties; Spalten
mit kc:printable: false fehlen, ebenso solche, deren kc:printableIf
nicht zutrifft — ausgewertet gegen die Werte des Formularkopfs (bei einer
Unter-Tabelle gegen die Master-Zeile). kc:align steuert die Ausrichtung in der
Tabelle. Läuft eine Tabelle über mehrere Seiten, wiederholen sich Titel
(„Fortsetzung") und Kopfzeile. Einstellungen wie Umbruch oder Abschneiden
je Spalte: Druck.
Unter-Tabellen in Unter-Tabellen (1:n:m)¶
kc:childTable ist rekursiv: Eine Spalte darf selbst wieder eine
Detail-Tabelle sein — in beliebiger Tiefe. Typische Fälle: Kostenstellen
mit Kostenpositionen, Positionen mit Seriennummern, Belege mit mehreren
Anlagen.
Beispiel-Schema¶
{
"kostenstellen": {
"type": "array",
"title": "Kostenstellen",
"kc:childTable": {
"initialRows": 3,
"collapsibleSubRows": true,
"properties": {
"titel": { "type": "string", "title": "Titel" },
"summe": { "type": "number", "title": "Summe",
"kc:decimals": 2, "kc:readonly": true },
"kostenpositionen": {
"type": "array",
"title": "Kostenpositionen",
"kc:childTable": {
"initialRows": 2,
"properties": {
"wert": { "type": "number", "title": "Wert",
"kc:decimals": 2, "kc:numberSuffix": " EUR" }
}
}
}
}
}
}
}
Tabellennamen je Ebene¶
Jede Ebene erhält eine eigene Tabelle; der Name reiht die Feldnamen
aneinander (Formular-ID kosten-im-wirtschaftsjahr):
| Ebene | Tabelle |
|---|---|
| Kopf | form_kosten_im_wirtschaftsjahr |
| Kostenstellen | form_kosten_im_wirtschaftsjahr_kostenstellen |
| Kostenpositionen | form_kosten_im_wirtschaftsjahr_kostenstellen_kostenpositionen |
Tabellennamen sind auf 64 Zeichen begrenzt. Wird ein Name länger, kürzt
KCForms ihn auf 56 Zeichen und hängt _ plus sieben Zeichen eines Hashwerts
aus dem vollen Pfad an — so bleiben auch gekürzte Namen eindeutig.
Die Verknüpfung zur Elternebene:
parent_id → FK auf form_<formid>.id (immer)
parent_lfd_nr_1 → lfd_nr der Zeile auf Ebene 1
parent_lfd_nr_2 → analog für Ebene 2 …
PRIMARY KEY (parent_id, parent_lfd_nr_1, …, lfd_nr)
Zusammengesetzte Fremdschlüssel sorgen für ON DELETE CASCADE über alle
Ebenen.
Datenformat — Unter-Listen in der Zeile¶
Die Liste der nächsten Ebene steckt in der jeweiligen Zeile:
{
"kostenstellen": [
{
"lfd_nr": 1,
"titel": "Marketing",
"summe": 12500.00,
"kostenpositionen": [
{ "lfd_nr": 1, "wert": 5000.00 },
{ "lfd_nr": 2, "wert": 7500.00 }
]
},
{ "lfd_nr": 2, "titel": "IT", "summe": 0.00, "kostenpositionen": [] }
]
}
Das gilt beim Lesen wie beim Schreiben.
Skript-Zugriff über mehrere Ebenen¶
Geschrieben wird immer direkt in ctx.form — die Unter-Listen stecken in
den Zeilen:
// Summe je Kostenstelle und Gesamtsumme
var stellen = ctx.form.kostenstellen || [];
var gesamt = 0;
for (var i = 0; i < stellen.length; i++) {
var stelle = stellen[i];
if (!stelle) continue;
var sub = 0;
var positionen = stelle.kostenpositionen || [];
for (var j = 0; j < positionen.length; j++) {
if (positionen[j]) sub += kc.num(positionen[j].wert);
}
stelle.summe = sub;
gesamt += sub;
}
ctx.form.gesamt = gesamt;
Lesend erreichst du jede Ebene auch über
ctx.model.children.<feld>.rows[i].subordinates.<unterfeld>.rows[j].
ctx.model ist eine Lese-Sicht
Werte, die ein Skript über ctx.model (….elements.<x>.value)
schreibt, landen nicht in ctx.form und werden nicht gespeichert.
Details: ctx.model.
collapsibleSubRows¶
collapsibleSubRows: true zeigt die Unter-Tabelle jeder Zeile
einklappbar an — eine kompakte Liste, ein Klick klappt die Unter-Tabelle
auf. Das betrifft nur das Web-Formular; im PDF erscheinen Unter-Tabellen
immer vollständig.
Druck bei 1:n:m¶
Unter-Tabellen erscheinen im PDF nach ihrer Elternzeile als eingerückter Block mit eigenem Titel und Kopf. Die Kopfzeile der Elterntabelle zeigt nur die einfachen Spalten; verschachtelte Spalten wandern in den eingerückten Block. Passt der Block auf eine Seite, hält KCForms Elternzeile und Block zusammen.
Beispiel: „Kosten im Wirtschaftsjahr"¶
Das Beispielformular Kosten im Wirtschaftsjahr (Katalog Service,
Ordner Demos/1zunzum) zeigt alle Ebenen samt rekursiver Summierung in
onFieldChange. Installieren unter System-Administration →
Formular-Bausteine → Beispiele.
Stolperfallen¶
type: "array"ohnekc:childTableergibt ein normales Mehrfachwert-Feld (z. B. eine Mehrfachauswahl), keine Tabelle.- Spalten umbenennen: Die neue Spalte kommt beim Speichern dazu, die
alte bleibt in der Datenbank stehen. Bei Bedarf manuell aufräumen, z. B.
im DB-Client:
ALTER TABLE form_xxx_yyy DROP COLUMN …. - Sehr breite Tabellen (mehr als etwa 20 Spalten) werden unübersichtlich.
Besser gruppieren oder den Block-Modus (
layout: "block") nutzen. initialRowsgroßzügig gewählt: Leere Zeilen werden mitgespeichert und lösen Pflichtfeld-Fehler aus, wenn eine Spalte Pflicht ist. Nicht benötigte Zeilen mit × entfernen oderinitialRowssenken.