# 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) ```tsx import adminStyles from '../../admin/Admin.module.css'; export const MyPage: React.FC = () => (
...
); ``` 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: ```tsx 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 `
` 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) => 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 ```typescript interface BatchAction { label: string; icon?: React.ReactNode; loading?: boolean; onClick: (selectedRows: T[]) => void | Promise; 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](../platform-core/fk-label-resolution.md)): | 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 ```python @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` | 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: ```python 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: ```typescript { 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: ```python 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: ```typescript { 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) ```typescript // FALSCH — hardcoded Labels gehoeren NICHT in die Page formatter: (v: boolean) => v ? Ja : Nein 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 ```typescript interface TreeNodeProvider { rootKey: string; loadChildren(parentId: string | null, ownership: Ownership): Promise[]>; canCreate?(parentId: string | null): boolean; canRename?(node: TreeNode): boolean; canDelete?(node: TreeNode): boolean; canMove?(source: TreeNode, target: TreeNode | null): boolean; canPatchScope?(node: TreeNode): boolean; canPatchNeutralize?(node: TreeNode): boolean; createChild?(parentId: string | null, name: string): Promise>; renameNode?(id: string, newName: string): Promise; deleteNodes?(ids: string[]): Promise; moveNodes?(ids: string[], targetParentId: string | null): Promise; patchScope?(ids: string[], scope: ScopeValue, cascadeChildren?: boolean): Promise; patchNeutralize?(ids: string[], neutralize: boolean): Promise; 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) ```typescript 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; } interface TreeNode { // ... 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` | 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 |