21 KiB
FormGenerator -- Referenz
Uebersicht
Der FormGenerator besteht aus mehreren Komponenten:
| Komponente | Datei | Zweck |
|---|---|---|
FormGeneratorTable |
components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx |
Generische Datentabelle mit Pagination, Sortierung, Filterung, Suche, Selektion, Bulk-Actions |
FormGeneratorControls |
components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx |
Toolbar: Suche, Pagination, Batch-Action-Buttons, "Select All Filtered"-Banner |
FormGeneratorForm |
components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx |
Dynamische Formulare aus Backend-Attribut-Definitionen |
FormGeneratorList |
components/FormGenerator/FormGeneratorList/FormGeneratorList.tsx |
Listen-Darstellung |
FormGeneratorReport |
components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx |
Berichte / Report-Layouts (nicht: es gibt kein separates FormGeneratorChart) |
FormGeneratorTree |
components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx |
Generische Baumkomponente mit Provider-Pattern, Multiselect, DnD, Inline-Editing, Scope/Neutralize, Batch-Actions |
Diese Referenz dokumentiert FormGeneratorTable (inkl. Backend-API-Pattern) und FormGeneratorTree.
Page Layout Chain (Pflicht)
FormGeneratorTable rendert intern mit flex:1; min-height:0; overflow:hidden. Damit die Tabelle die volle Seitenbreite/-hoehe ausnuetzt und nicht abgeschnitten wird, muss die Eltern-Hierarchie eine bounded height chain liefern. Andernfalls passiert eines davon:
- Tabelle ist 0 px hoch (kollabiert) -- nur Toolbar sichtbar.
- Tabelle waechst ueber den Viewport hinaus, horizontaler Scroll fehlt -- letzte Spalten werden abgeschnitten.
- Page-Container hat
max-width-> Tabelle ist auf z.B. 800 px begrenzt.
Korrektes Pattern (verwendet PromptsPage, TrusteePositionsView, TrusteeDataTablesView)
import adminStyles from '../../admin/Admin.module.css';
export const MyPage: React.FC = () => (
<div className={`${adminStyles.adminPage} ${adminStyles.adminPageFill}`}>
<div className={adminStyles.pageHeader}>...</div>
<div className={adminStyles.tableContainer}>
<FormGeneratorTable {...props} />
</div>
</div>
);
Die drei CSS-Klassen aus ui-nyla/src/pages/admin/Admin.module.css:
| Klasse | Wirkung |
|---|---|
.adminPage |
display:flex; flex-direction:column; width:100%; box-sizing:border-box; flex:0 0 auto (Default fuer Standard-Seiten ohne Tabelle) |
.adminPageFill |
flex:1 1 auto; min-height:0; overflow:hidden -- aktiviert die bounded height chain |
.tableContainer |
flex:1; min-height:0; overflow:hidden; display:flex; flex-direction:column -- direktes Eltern-Element der Tabelle |
FeatureView/MainLayout liefern bereits height:100% mit min-height:0 -- Page-Komponenten brauchen also nur adminPage adminPageFill und einen tableContainer direkt um die FormGeneratorTable.
Bei Tabs / Wrapper-Komponenten
Wenn die Tabelle in einem Tab-Wrapper (z.B. TrusteeDataTab) gemountet wird, muss jeder Wrapper die Flex-Chain weiterreichen. Pattern:
const _rootStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
width: '100%',
};
const _tableWrapStyle: React.CSSProperties = {
flex: 1,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
width: '100%',
};
Eine Toolbar oberhalb der Tabelle bekommt flexShrink: 0, der Tabellenwrapper flex: 1; minHeight: 0.
Anti-Patterns (verursachen das "Tabelle abgeschnitten"-Bug)
- Outer-Wrapper mit
max-width: 800px(z.B..expenseImportSectionausTrusteeViews.module.css) -- begrenzt die Breite. - Outer
<div>ohneflex:1; min-height:0-- Tabelle kollabiert auf 0 px Hoehe. .adminPageohne.adminPageFill-- Default istflex:0 0 auto, das innere.tableContainer flex:1hat keinen Platz.- Padding auf einem Zwischen-Wrapper -- bricht das Box-Sizing der Chain.
FormGeneratorTable -- Props
Datenanbindung
| Prop | Typ | Beschreibung |
|---|---|---|
data |
T[] |
Anzuzeigende Datensaetze (aktuelle Seite) |
columns |
ColumnConfig[] |
Spaltendefinitionen (key, label, type, sortable, filterable, searchable, filterOptions, width) |
apiEndpoint |
string? |
Backend-Endpunkt fuer automatische Filter-/ID-Abfragen |
hookData |
{ refetch, pagination, fetchFilterValues? } |
Daten-Hook mit Refetch-Callback und Pagination-Metadata |
supportsBackendPagination |
boolean |
Aktiviert serverseitige Pagination, Sortierung und Filterung |
Interaktion
| Prop | Typ | Beschreibung |
|---|---|---|
selectable |
boolean |
Aktiviert Zeilen-Selektion (Checkboxen) |
onSelectionChange |
(selectedIds: Set<string>) => void |
Callback bei Aenderung der Selektion |
idField |
string |
Feld fuer eindeutige ID (Default: "id") |
isRowSelectable |
(row: T) => boolean |
Bestimmt, ob eine Zeile selektierbar ist |
batchActions |
BatchAction[] |
Bulk-Aktionen fuer selektierte Zeilen |
actionButtons |
ActionButton[] |
Zeilen-Aktionen (Edit, Delete etc.) |
BatchAction Interface
interface BatchAction<T> {
label: string;
icon?: React.ReactNode;
loading?: boolean;
onClick: (selectedRows: T[]) => void | Promise<void>;
isApplicable?: (row: T) => boolean;
}
isApplicable ermoeglicht pro Action zu bestimmen, welche der selektierten Zeilen fuer diese Aktion qualifizieren. Der Button zeigt dann (applicableCount/totalSelected) und ist disabled wenn applicableCount === 0.
Unified Filter API
Prinzip
Alle Daten-Endpunkte unterstuetzen drei Modi ueber Query-Parameter auf dem gleichen Endpunkt:
| Modus | Query-Parameter | Response | Zweck |
|---|---|---|---|
| Normal (default) | pagination={...} |
{ items: T[], pagination: PaginationMetadata } |
Paginierte Datenliste |
filterValues |
mode=filterValues&column=xxx&pagination={crossFilters} |
string[] |
Distinct-Werte fuer Spaltenfilter-Dropdown |
ids |
mode=ids&pagination={filters} |
string[] |
Alle IDs der gefilterten Datensaetze |
Kein separater /filter-values-Endpunkt. Alle alten /filter-values-Sub-Pfade wurden entfernt (Stand 2026-04-13).
Cross-Filtering
Bei mode=filterValues werden alle aktiven Filter ausser dem angeforderten Column-Filter angewendet (Cross-Filtering). So zeigt z.B. der Status-Filter nur Werte an, die bei den aktuell gesetzten Mandant- und Typ-Filtern vorkommen.
Beispiele
# Normale Liste (Seite 1, 20 Eintraege)
GET /api/users/?pagination={"page":1,"pageSize":20,"sort":[{"field":"name","direction":"asc"}]}
# Filter-Werte fuer Spalte "status" (mit Cross-Filter auf mandateLabel)
GET /api/users/?mode=filterValues&column=status&pagination={"filters":{"mandateLabel":"Demo AG"}}
# Alle IDs der gefilterten Ansicht (fuer "Select All Filtered")
GET /api/users/?mode=ids&pagination={"filters":{"status":"active"},"search":"admin"}
Backend-Implementierung
Zentrale Hilfsfunktionen in platform-core/modules/routes/routeHelpers.py (FK-Label-Aufloesung: siehe FK Label Resolution):
| Funktion | Zweck |
|---|---|
handleFilterValuesMode(db, modelClass, column, ...) |
SQL-basierte Distinct-Werte via DB-Connector |
handleIdsMode(db, modelClass, ...) |
SQL-basierte ID-Liste via DB-Connector |
handleFilterValuesInMemory(items, column, ...) |
In-Memory Distinct-Werte (fuer enriched/aggregierte Listen) |
handleIdsInMemory(items, ...) |
In-Memory ID-Liste |
parseCrossFilterPagination(column, paginationJson) |
Parsed Pagination-JSON und entfernt den angeforderten Column-Filter |
_applyFiltersAndSort(items, paginationParams) |
In-Memory Filterung und Sortierung |
_extractDistinctValues(items, column, ...) |
Distinct-Werte aus Item-Liste extrahieren |
paginateInMemory(items, paginationParams) |
In-Memory Paginierung |
Route-Integration Pattern
@router.get("/", response_model=PaginatedResponse[MyModel])
def list_items(
request: Request,
pagination: Optional[str] = Query(None),
mode: Optional[str] = Query(None, description="'filterValues' or 'ids'"),
column: Optional[str] = Query(None, description="Column key"),
context: RequestContext = Depends(getRequestContext),
):
if mode in ("filterValues", "ids"):
from modules.routes.routeHelpers import handleFilterValuesInMemory, handleIdsInMemory
items = _buildFullList(context)
if mode == "filterValues":
if not column:
raise HTTPException(status_code=400, detail="column required")
return handleFilterValuesInMemory(items, column, pagination)
return handleIdsInMemory(items, pagination)
# Normal paginated list...
Selektion
ID-basierte Selektion
Die Selektion arbeitet mit IDs (nicht Zeilen-Indizes). Das idField-Prop bestimmt, welches Feld als eindeutiger Schluessel verwendet wird (Default: "id").
| State | Typ | Beschreibung |
|---|---|---|
selectedIds |
Set<string> |
Menge der selektierten IDs |
Selektion-Reset
Die Selektion wird automatisch zurueckgesetzt bei:
- Seitenwechsel (currentPage)
- Seitengroesse-Aenderung (pageSize)
- Filter-Aenderung
- Suchbegriff-Aenderung
- Sortier-Aenderung
Select All Filtered
Wenn apiEndpoint und supportsBackendPagination gesetzt sind, zeigt die Toolbar einen "Alle X Eintraege auswaehlen"-Button. Dieser ruft GET {apiEndpoint}?mode=ids&pagination={currentFilters} auf und fuegt alle zurueckgegebenen IDs zur Selektion hinzu.
Ein "Auswahl aufheben"-Button erscheint, wenn "Select All Filtered" aktiv ist.
Row-Level Delete
Wenn eine Zeile geloescht wird (via actionButtons Delete), wird deren ID automatisch aus selectedIds entfernt.
FilterValuesList (Spaltenfilter-Dropdown)
Wenn ein filterbarer Spalten-Header geklickt wird, laedt das Dropdown die Distinct-Werte:
column.filterOptions(statische Enum, page-defined) -- wird direkt verwendet, kein Backend-Callcolumn.options(kommt ausfrontend_optionsder Pydantic-Felder, automatisch viaresolveColumnTypes) -- wird direkt verwendethookData.fetchFilterValues(columnKey, crossFilters)-- falls im Hook bereitgestelltGET {apiEndpoint}?mode=filterValues&column=xxx&pagination={currentFilters}-- automatisch
Bei mehr als 10 Werten erscheint ein Suchfeld (Client-seitige Filterung der geladenen Werte).
Boolean-Spalten rendern als "Ja"/"Nein". Datum-Spalten rendern als Range-Picker (Von/Bis, type date/timestamp/time). Zahlenspalten (integer/int/number/float) rendern als Operatorauswahl (=, >, >=, <, <=, Zwischen) plus Wertfeld(er) und Anwenden; Filter-Payload: { operator, value } bzw. { operator: "between", value: { from, to } } (kompatibel mit routeHelpers._matchesFilter / SQL-Pagination).
Cell-Rendering: KEINE hardcoded Labels in Pages
Regel: Pages duerfen Value->Label-Mappings NICHT im Frontend hardcoden (kein formatter: (v) => v ? 'Ja' : 'Nein', kein _STATUS_LABELS[v], kein scopeLabels[v]). Daten und Display-Labels kommen ausschliesslich aus dem Backend-Modell.
Pattern fuer Booleans
Im Pydantic-Feld:
active: bool = Field(
default=True,
json_schema_extra={
"frontend_type": "checkbox",
"label": "Aktiv",
"frontend_format_labels": ["Ja", "-", "Nein"], # [TRUE, NULL, FALSE]
},
)
Im Frontend-Page reicht:
{ key: 'active', sortable: true, filterable: true }
Kein formatter, kein hardcoded Ja/Nein. FormGeneratorTable.renderBooleanCell greift automatisch und nutzt die i18n-aufgeloesten Labels.
Pattern fuer Enums (select-Felder)
Im Pydantic-Feld:
status: str = Field(
default="running",
json_schema_extra={
"frontend_type": "select",
"label": "Status",
"frontend_options": [
{"value": "running", "label": "Läuft"},
{"value": "completed", "label": "Abgeschlossen"},
{"value": "failed", "label": "Fehlgeschlagen"},
],
},
)
Im Frontend-Page reicht:
{ key: 'status', sortable: true, filterable: true }
resolveColumnTypes merged options automatisch in ColumnConfig.options. FormGeneratorTable resolved Cell-Value -> Label und Filter-Dropdown-Labels aus diesen Options. Der i18nRegistry.resolveText uebersetzt die Labels server-seitig basierend auf der Sprache des Requests.
Pattern fuer Header-Labels
column.label ist optional. resolveColumnTypes zieht das Label aus dem Backend-Attribut (label aus json_schema_extra). Page setzt label nur, um den Backend-Default zu ueberschreiben.
Anti-Pattern (nicht erlaubt)
// FALSCH — hardcoded Labels gehoeren NICHT in die Page
formatter: (v: boolean) => v ? <span>Ja</span> : <span>Nein</span>
formatter: (v: string) => statusMap[v] ?? v
filterLabelResolver: (v: string) => myLabels[v]
Wenn das Backend keine frontend_options/frontend_format_labels setzt: Pydantic-Modell erweitern, NICHT die Page hacken.
Migrierte Endpunkte (Stand 2026-04-13)
Alle folgenden Endpunkte unterstuetzen mode=filterValues und mode=ids:
Core Routes
| Route-Datei | Endpunkt | Methode |
|---|---|---|
routeDataUsers.py |
GET /api/users/ |
SQL + In-Memory (je nach Scope) |
routeDataConnections.py |
GET /api/connections/ |
In-Memory |
routeDataPrompts.py |
GET /api/prompts/ |
In-Memory |
routeInvitations.py |
GET /api/invitations/ |
In-Memory |
routeSubscription.py |
GET /api/subscriptions/admin/all |
In-Memory |
routeAdminFeatures.py |
GET /api/admin/features/instances |
In-Memory |
routeAdminFeatures.py |
GET /api/admin/features/templates/roles |
In-Memory |
routeAdminFeatures.py |
GET /api/admin/features/instances/{id}/users |
In-Memory |
routeAdminRbacRules.py |
GET /api/admin/rbac/roles |
In-Memory |
routeDataMandates.py |
GET /api/mandates/ |
SQL + In-Memory (je nach Scope) |
routeDataMandates.py |
GET /api/mandates/{id}/users |
In-Memory |
routeDataFiles.py |
GET /api/files/list |
SQL + In-Memory Fallback |
routeWorkflowDashboard.py |
GET /api/automation/dashboard/ (Runs) |
Enriched + SQL |
routeWorkflowDashboard.py |
GET /api/automation/dashboard/workflows |
Enriched + SQL |
routeBilling.py |
GET /api/billing/view/users/transactions |
Billing-Interface |
Feature Routes
| Route-Datei | Endpunkt | Methode |
|---|---|---|
routeFeatureTrustee.py |
GET /api/trustee/{id}/documents |
RBAC SQL + In-Memory Fallback |
routeFeatureTrustee.py |
GET /api/trustee/{id}/positions |
RBAC SQL + In-Memory Fallback |
routeFeatureRealEstate.py |
GET /api/realestate/{id}/projects |
In-Memory |
routeFeatureRealEstate.py |
GET /api/realestate/{id}/parcels |
In-Memory |
FormGeneratorTree
FormGeneratorTree ist die generische Baumkomponente der FormGenerator-Familie.
Sie ersetzt die fruehere FolderTree-Komponente und folgt einem Provider-Pattern,
bei dem die Datenlogik (API-Aufrufe, CRUD-Operationen) in austauschbare
TreeNodeProvider-Implementierungen ausgelagert wird.
Dateien
| Datei | Inhalt |
|---|---|
FormGeneratorTree.tsx |
Haupt-Komponente (Rendering, Selection, DnD, Inline-Editing) |
FormGeneratorTree.module.css |
Styles |
types.ts |
TreeNode, TreeNodeProvider, TreeBatchAction, FormGeneratorTreeProps |
providers/FolderFileProvider.tsx |
Referenz-Provider fuer Files + Folders |
TreeNodeProvider-Interface
interface TreeNodeProvider<T = any> {
rootKey: string;
loadChildren(parentId: string | null, ownership: Ownership): Promise<TreeNode<T>[]>;
canCreate?(parentId: string | null): boolean;
canRename?(node: TreeNode<T>): boolean;
canDelete?(node: TreeNode<T>): boolean;
canMove?(source: TreeNode<T>, target: TreeNode<T> | null): boolean;
canPatchScope?(node: TreeNode<T>): boolean;
canPatchNeutralize?(node: TreeNode<T>): boolean;
createChild?(parentId: string | null, name: string): Promise<TreeNode<T>>;
renameNode?(id: string, newName: string): Promise<void>;
deleteNodes?(ids: string[]): Promise<void>;
moveNodes?(ids: string[], targetParentId: string | null): Promise<void>;
patchScope?(ids: string[], scope: ScopeValue, cascadeChildren?: boolean): Promise<void>;
patchNeutralize?(ids: string[], neutralize: boolean): Promise<void>;
getBatchActions?(): TreeBatchAction[];
}
Die can*-Methoden steuern, welche Affordances pro Knoten sichtbar sind.
Alle Mutations-Methoden sind optional -- ein read-only Provider implementiert nur
loadChildren.
Built-in Features
- Expand/Collapse mit Lazy-Load via
loadChildren - Multiselect mit Shift/Ctrl; getrennt zwischen
ownership='own'und'shared' - Cascade-Selection: Folder selektieren -> alle eigenen Children selektiert
- DnD (
application/x-poweron-tree-items); aussharednur lesende Drops - Inline-Rename (Doppelklick / F2) -- nur fuer eigene Knoten
- Scope/Neutralize Icons: interaktiv fuer eigene, Indikator fuer geteilte
- Mixed-State (generisch):
scope,neutralizeund beliebigeextraActions[*].valueduerfen den Wert'mixed'haben (vom Provider/Backend gesetzt). Der Tree rendert dann ein uniformes Symbol (\u25E9) statt des typspezifischen Icons. - Pending-Spinner (generisch): waehrend einer asynchronen Provider-Aktion
(
patchScope,patchNeutralize, oderextraActions[*].onClick) zeigt der Tree einen kleinen rotierenden Spinner ueber dem jeweiligen Button. - extraActions (generisch): Knoten koennen eine Liste zusaetzlicher Aktionen
mitbringen (
TreeNode.extraActions: NodeAction[]). Der Tree rendert sie als Icon-Buttons mit Tooltip; er kennt deren Semantik nicht und reicht nur Klicks in den Pending-Spinner-Wrapper. - Batch-Actions mit
typeFilter(z.B. separate Delete-Buttons fuer Ordner vs. Dateien) - Refresh-Button im Section-Header
- Confirmation-Dialoge vor Delete-Aktionen
Generische Erweiterungen (Mixed, Pending, extraActions)
interface NodeAction {
key: string; // eindeutig pro Node
icon: React.ReactNode; // Standard-Icon (durch mixed/spinner ueberschrieben)
tooltip: string;
value?: boolean | string | 'mixed';
disabled?: boolean;
onClick?: () => Promise<void> | void;
}
interface TreeNode<T> {
// ...
scope?: ScopeValue | 'mixed';
neutralize?: boolean | 'mixed';
extraActions?: NodeAction[];
}
Rendering-Regeln:
| Zustand | Anzeige |
|---|---|
value !== 'mixed', kein Pending |
typspezifisches Icon (Scope-Emoji, Schloss, Action-Icon) |
value === 'mixed' |
\u25E9 mit Klasse flagMixed (uniform fuer alle Flags) |
| Pending (Promise laeuft) | rotierender Spinner mit Klasse flagSpinner |
Diese Features sind domaen-frei und werden von beiden FormGeneratorTree-Konsumenten
(FilesTab.tsx und UnifiedDataBar/SourcesTab.tsx) genutzt.
Props (FormGeneratorTreeProps)
| Prop | Typ | Beschreibung |
|---|---|---|
provider |
TreeNodeProvider<T> |
Daten- und Operations-Provider |
ownership |
'own' | 'shared' |
Bestimmt Tree-Kontext (CRUD vs. read-only) |
title |
string? |
Section-Header |
compact |
boolean? |
Kompakte Darstellung (z.B. in UDB) |
collapsible |
boolean? |
Section kollabierbar |
defaultCollapsed |
boolean? |
Initial kollabiert |
selectable |
boolean? |
Default true. Bei false keine Checkboxes, kein Batch-Toolbar (UDB-Sources-Modus) |
emptyMessage |
string? |
Anzeige bei leerem Tree |
onNodeClick |
(node) => void |
Callback bei Knoten-Klick |
onSelectionChange |
(selectedIds) => void |
Callback bei Selektions-Aenderung |
onRefresh |
() => void |
Callback nach internem Refresh (z.B. fuer Tabellen-Sync) |
Verwendung
Aktuell in drei Kontexten eingebunden, jeweils mit eigenem Provider:
UnifiedDataBar/FilesTab.tsx: Zwei Sektionen (Eigene / Geteilt mit mir), Provider:FolderFileProviderUnifiedDataBar/SourcesTab.tsx: Zwei Sektionen (Persoenliche Quellen / Mandanten-Daten), Provider:createUdbPersonalProvider+createUdbFeatureProviderauscomponents/UnifiedDataBar/sources/pages/basedata/FilesPage.tsx: Split-View mit Tree links undFormGeneratorTablerechts; Tree-Selektion filtert Tabelle nach Ordner
Tree vs. Table-with-Grouping
| Kriterium | FormGeneratorTree |
FormGeneratorTable mit groupingConfig |
|---|---|---|
| Persistenz | Knoten sind persistente Entitaeten (z.B. Folders) | Gruppen sind UI-only JSON (TableGrouping) |
| CRUD | Create/Rename/Delete/Move pro Knoten | Kein CRUD auf Gruppen-Ebene |
| RBAC | Scope/Neutralize pro Knoten, Owner-Guards | Keine pro-Gruppen-Berechtigungen |
| Einsatz | Hierarchische Datenstrukturen | Flache Listen mit visueller Kategorisierung |