wiki/b-reference/ui-nyla/formgenerator.md
2026-06-02 09:42:12 +02:00

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. .expenseImportSection aus TrusteeViews.module.css) -- begrenzt die Breite.
  • Outer <div> ohne flex:1; min-height:0 -- Tabelle kollabiert auf 0 px Hoehe.
  • .adminPage ohne .adminPageFill -- Default ist flex:0 0 auto, das innere .tableContainer flex:1 hat 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:

  1. column.filterOptions (statische Enum, page-defined) -- wird direkt verwendet, kein Backend-Call
  2. column.options (kommt aus frontend_options der Pydantic-Felder, automatisch via resolveColumnTypes) -- wird direkt verwendet
  3. hookData.fetchFilterValues(columnKey, crossFilters) -- falls im Hook bereitgestellt
  4. GET {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); aus shared nur lesende Drops
  • Inline-Rename (Doppelklick / F2) -- nur fuer eigene Knoten
  • Scope/Neutralize Icons: interaktiv fuer eigene, Indikator fuer geteilte
  • Mixed-State (generisch): scope, neutralize und beliebige extraActions[*].value duerfen 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, oder extraActions[*].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: FolderFileProvider
  • UnifiedDataBar/SourcesTab.tsx: Zwei Sektionen (Persoenliche Quellen / Mandanten-Daten), Provider: createUdbPersonalProvider + createUdbFeatureProvider aus components/UnifiedDataBar/sources/
  • pages/basedata/FilesPage.tsx: Split-View mit Tree links und FormGeneratorTable rechts; 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