# FormGenerator: Persistente Benutzer-Gruppierung ## Beschreibung und Kontext Der `FormGeneratorTable` wird auf vielen Seiten der Plattform genutzt. Nutzer sollen Einträge in benannte, rekursive Gruppen organisieren können — mit persistenter Speicherung und vollständiger Kompatibilität mit Pagination, Suche, Filter und allen Action-Buttons. **Kernprinzip: Grouping ist ein eingebautes Feature von `PaginationParams` und `PaginatedResponse` — kein separater Call, keine eigene Route, kein eigenes API-Modul. Der bestehende `refetch()`-Mechanismus ist der einzige Transport.** --- ## Architektur-Kern: Wie es funktioniert ### Grouping reitet auf dem bestehenden Pagination-Call `PaginationParams` (der JSON-Parameter jedes List-Endpoints) bekommt zwei neue optionale Felder: ``` saveGroupTree → wenn gesetzt: Backend speichert diesen Baum VOR dem Fetch groupId → wenn gesetzt: Backend filtert Items auf Items dieser Gruppe ``` `PaginatedResponse` bekommt ein neues optionales Feld: ``` groupTree → aktueller Gruppen-Baum des Users für diesen Endpoint (immer mitgeliefert) ``` **Ein Aufruf tut damit drei Dinge auf einmal:** 1. Speichert den neuen Gruppen-Baum (wenn `saveGroupTree` gesetzt) 2. Filtert auf eine Gruppe (wenn `groupId` gesetzt) 3. Gibt aktuelle Items + aktuelle Gruppen-Baum zurück ### Ablauf End-to-End ``` Seitenaufruf (erster Load): GET /api/connections/?pagination={"page":1,"pageSize":20} ← { items: [...], pagination: {...}, groupTree: [{id, name, itemIds, subGroups}] } User erstellt Gruppe (lokal sofort sichtbar, dann debounced Save via refetch): GET /api/connections/?pagination={"page":1,"pageSize":20,"saveGroupTree":[{neuerBaum}]} ← { items: [...], pagination: {...}, groupTree: [{neuerBaum, vom Backend bestätigt}] } User betritt Gruppe "Kunden" (id: "g1"): GET /api/connections/?pagination={"page":1,"pageSize":20,"groupId":"g1"} ← { items: [nur Items der Gruppe], pagination: {totalItems: 3, ...}, groupTree: [...] } → Suche, Filter, Sortierung, mode=ids, mode=filterValues — alles funktioniert innerhalb des Gruppen-Scopes, da das Backend die IN-Liste kennt ``` ### Backend: Pro Route genau 2 Zeilen Overhead Der gesamte Grouping-Mechanismus ist in `routeHelpers.py` als shared Helper implementiert. Jede Route die Grouping unterstützen soll, ruft ihn auf: ```python # Anfang der Route-Funktion (BEVOR items gebaut werden): groupCtx = handleGroupingInRequest(paginationParams, interface, "connections") # → speichert saveGroupTree falls vorhanden # → gibt groupIdItemIds zurück falls groupId gesetzt # Items bauen (unverändert)... # Falls Gruppen-Scope aktiv: Items auf Gruppe einschränken items = applyGroupScopeFilter(items, groupCtx.itemIds) # Am Ende: groupTree in Response einbetten return {**result, "groupTree": groupCtx.groupTree} ``` **Kein neues Route-File. Kein neues Interface-File. Keine neue URL.** --- ## Betroffene Module - **Gateway:** - `modules/datamodels/datamodelPagination.py` — `PaginationParams` + `groupId`, `saveGroupTree`; `PaginatedResponse` + `groupTree`; neue Klassen `TableGroupNode` + `TableGrouping` in **dieser Datei** - `modules/interfaces/interfaceDbApp.py` — `AppObjects` um `getTableGrouping(contextKey)` + `upsertTableGrouping(contextKey, rootGroups)` erweitern; neue Tabelle `table_groupings` in `poweron_app` (auto-created) - `modules/routes/routeHelpers.py` — `handleGroupingInRequest(paginationParams, interface, contextKey)` + `applyGroupScopeFilter(items, itemIds)` hinzufügen - Jede List-Route die Grouping unterstützen soll: **2 Zeilen** am Anfang + **1 Feld** in der Response (`groupTree`) - **Frontend:** - `FormGeneratorTable.tsx` — `groupingConfig`-Prop; interner Grouping-State; nutzt `hookData.refetch()` als einzigen Transport - `FormGeneratorControls.tsx` — Gruppen-Toolbar-Button - `FormGenerator/GroupingManager/GroupRow.tsx` — Gruppen-Header-Zeile (neue Komponente) - `FormGenerator/GroupingManager/GroupingManager.tsx` — Seitenpanel (neue Komponente) - **Kein neuer Hook, kein neues API-Modul, keine Änderungen an bestehenden Feature-Hooks** - **DB-Migration:** Nein (Auto-Create via DatabaseConnector) --- ## Datenmodell ### Ergänzungen in `datamodelPagination.py` ```python # --- Grouping-Modelle (neu, in derselben Datei) --- class TableGroupNode(BaseModel): id: str name: str itemIds: List[str] = Field(default_factory=list) subGroups: List['TableGroupNode'] = Field(default_factory=list) order: int = 0 isExpanded: bool = True TableGroupNode.model_rebuild() class TableGrouping(BaseModel): """DB-Tabelle table_groupings in poweron_app.""" id: str userId: str contextKey: str # abgeleitet aus Route-Prefix, z. B. "connections", "prompts", "admin/users" rootGroups: List[TableGroupNode] = Field(default_factory=list) updatedAt: Optional[float] = None # --- Erweiterung PaginationParams (2 neue optionale Felder) --- class PaginationParams(BaseModel): page: int = Field(ge=1) pageSize: int = Field(ge=1, le=1000) sort: List[SortField] = Field(default_factory=list) filters: Optional[Dict[str, Any]] = None # NEU: groupId: Optional[str] = None # Scope: nur Items dieser Gruppe saveGroupTree: Optional[List[Dict[str, Any]]] = None # Persistieren: diesen Baum speichern # --- Erweiterung PaginatedResponse (1 neues optionales Feld) --- class PaginatedResponse(BaseModel, Generic[T]): items: List[T] pagination: Optional[PaginationMetadata] groupTree: Optional[List[TableGroupNode]] = None # NEU — immer mitgeliefert wenn vorhanden model_config = ConfigDict(arbitrary_types_allowed=True) ``` --- ## Backend-Implementierung ### `routeHelpers.py` — neuer shared Helper ```python from dataclasses import dataclass from typing import Optional, Set @dataclass class GroupingContext: groupTree: Optional[list] # Für die Response itemIds: Optional[Set[str]] # Falls groupId gesetzt — IN-Filter-Menge def handleGroupingInRequest( paginationParams: Optional[PaginationParams], interface, # AppObjects contextKey: str, ) -> GroupingContext: """ Zentraler Grouping-Handler — aufgerufen am Anfang jeder List-Route. 1. Falls paginationParams.saveGroupTree gesetzt: → interface.upsertTableGrouping(contextKey, saveGroupTree) → saveGroupTree aus params entfernen (wird nicht weiter verarbeitet) 2. Falls paginationParams.groupId gesetzt: → Gruppe im gespeicherten Baum suchen (rekursiv inkl. Subgruppen) → itemIds der Gruppe (+ alle Subgruppen) als Set zurückgeben → groupId aus params entfernen (wird nicht als normaler Filter verarbeitet) 3. Aktuellen groupTree laden und für Response bereitstellen. Returns: GroupingContext(groupTree, itemIds) """ def applyGroupScopeFilter(items: list, itemIds: Optional[Set[str]]) -> list: """ Wendet den Gruppen-Scope-Filter an. Gibt items unverändert zurück wenn itemIds is None (kein Scope aktiv). Filtert sonst auf item["id"] in itemIds. """ if itemIds is None: return items return [item for item in items if str(item.get("id", "")) in itemIds] ``` ### Route-Erweiterung — Muster (2 + 1 Zeilen) ```python @router.get("/") async def get_connections(request, pagination=None, mode=None, column=None, currentUser=Depends(getCurrentUser)): from modules.routes.routeHelpers import handleGroupingInRequest, applyGroupScopeFilter interface = getInterface(currentUser) CONTEXT_KEY = "connections" # 1. Grouping verarbeiten (speichern falls nötig, Scope auflösen) groupCtx = handleGroupingInRequest(paginationParams, interface, CONTEXT_KEY) # mode=filterValues / mode=ids (unverändert, aber groupId ist bereits aus params entfernt) if mode == "filterValues": ... if mode == "ids": items = _buildEnhancedItems() items = applyGroupScopeFilter(items, groupCtx.itemIds) # Scope auch für ids! return handleIdsInMemory(items, pagination) # Items bauen (unverändert) items = _buildEnhancedItems() # 2. Gruppen-Scope-Filter anwenden items = applyGroupScopeFilter(items, groupCtx.itemIds) # Pagination (unverändert) result = paginateInMemory(items, paginationParams) # 3. groupTree in Response einbetten return {**result.model_dump(), "groupTree": groupCtx.groupTree} ``` **`mode=filterValues` und `mode=ids` funktionieren automatisch korrekt im Gruppen-Scope**, weil `groupId` aus `paginationParams` entfernt wurde und `applyGroupScopeFilter` aufgerufen wird — dadurch beziehen sich Filter-Dropdowns und "Select All Filtered" auf Items der aktuellen Gruppe. --- ## Frontend-Implementierung ### `FormGeneratorTable` — interner Grouping-State ```typescript // Neues Prop: groupingConfig?: { contextKey: string; // Nur für RBAC/Logging — der eigentliche Key wird serverseitig aus dem Endpoint abgeleitet enabled: boolean; } // Interner State (nur in FormGeneratorTable, nicht im Hook): const [groupTree, setGroupTree] = useState([]); const [activeGroupId, setActiveGroupId] = useState(null); const [pendingGroupTree, setPendingGroupTree] = useState(null); ``` ### Datenfluss — `hookData.refetch()` als einziger Transport ```typescript // groupTree kommt aus der normalen refetch-Response: useEffect(() => { if (hookData?.pagination?.groupTree) { setGroupTree(hookData.pagination.groupTree); setPendingGroupTree(null); // Gespeichert — pending löschen } }, [hookData]); // Beim Betreten eines Gruppen-Scopes: const _enterGroup = (groupId: string) => { setActiveGroupId(groupId); hookData.refetch({ ...currentPaginationParams, page: 1, groupId }); }; // Beim Verlassen des Scopes: const _exitGroup = () => { setActiveGroupId(null); hookData.refetch({ ...currentPaginationParams, page: 1, groupId: undefined }); }; // Bei Gruppen-Mutation (erstellen, umbenennen, löschen, Item zuordnen): const _mutateGroupTree = (newTree: TableGroupNode[]) => { setGroupTree(newTree); // Sofort lokal sichtbar (optimistic) setPendingGroupTree(newTree); // Markiert für nächsten Save _debouncedSave(newTree); // Debounced: nach 500ms via refetch speichern }; // Debounced Save: normaler refetch + saveGroupTree im pagination param const _debouncedSave = useMemo(() => debounce((tree: TableGroupNode[]) => { hookData.refetch({ ...currentPaginationParams, saveGroupTree: tree, // Backend speichert und bestätigt }); }, 500), [hookData, currentPaginationParams]); ``` **Kein neues API-Modul. Kein eigener fetch-Call. `hookData.refetch()` ist alles.** ### Render-Struktur ``` Root-View (kein activeGroupId): ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ▼ Kunden (3 Items) [Delete all] [Download all] [+ Subgruppe] [Umbenennen] [×] ▶ Aktive (1) [Delete all] [+ Subgruppe] [Umbenennen] [×] — Item A [Edit] [Delete] [Download] — Item B [Edit] [Delete] [Download] — Item C (Aktive) [Edit] [Delete] [Download] ▶ Intern [5 Items — Gruppe öffnen →] ── Nicht zugeordnet (2) — Item X [Edit] [Delete] [Download] Gruppen-Scope (activeGroupId = "Intern"): ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ← Zurück | Intern (Seite 1/1 · 5 Einträge) [Delete all] [Download all] — Item C [Edit] [Delete] [Download] — Item D [Edit] [Delete] [Download] ... ``` Wenn `activeGroupId` gesetzt: refetch läuft mit `groupId` → Backend filtert → Pagination, Suche, Filter, `mode=ids` — alles auf Gruppe begrenzt. --- ## Pagination, Suche, Filter — vollständig korrekt | Szenario | Was passiert | |----------|-------------| | Root-View, Seite blättern | Normaler refetch. `groupTree` kommt in Response mit. Gruppen-Counter aus `groupNode.itemIds.length`. | | Gruppen-Scope aktiv | `groupId` in PaginationParams → Backend IN-Filter → Totalcount kommt vom Backend (korrekt). | | Suche im Scope | `groupId` + `filters.search` → Backend filtert Items der Gruppe nach Suchtext. | | "Select All Filtered" (mode=ids) | `groupId` in params → `applyGroupScopeFilter` wird VOR `handleIdsInMemory` angewendet → nur IDs der Gruppe werden zurückgegeben. | | Filter-Dropdown (mode=filterValues) | `groupId` in params → `applyGroupScopeFilter` vor `handleFilterValuesInMemory` → Distinct-Werte kommen nur aus Gruppen-Items. | | Gruppen-Baum speichern während Paginieren | `saveGroupTree` + Seiten-Params im gleichen Call → Backend speichert Baum UND gibt aktuelle Seite zurück. | --- ## Entscheidungen | Datum | Entscheidung | Begründung | |-------|-------------|------------| | 2026-04-29 | `saveGroupTree` + `groupId` in `PaginationParams` statt eigener Endpoint | Ein Call, ein Transport, kein zweiter API-Pfad; Grouping ist integraler Bestandteil der Datenabfrage | | 2026-04-29 | `groupTree` in `PaginatedResponse` | Immer synchron mit aktuellen Items; kein separater Lade-Call nötig | | 2026-04-29 | Shared Helper in `routeHelpers.py`, 2 Zeilen pro Route | DRY; gesamte Komplexität an einem Ort; pro Route null Eigenlogik | | 2026-04-29 | Optimistic UI + debounced Save via normalen refetch | UX sofort; kein Flackern; Persistenz ohne Extra-Call | | 2026-04-29 | Modelle in `datamodelPagination.py` | `PaginatedResponse` liegt dort; keine neue Datei; Import-Graph bleibt minimal | | 2026-04-29 | `applyGroupScopeFilter` auch für `mode=ids` und `mode=filterValues` | Filter-Dropdown und Bulk-Select funktionieren korrekt im Gruppen-Scope ohne Sonderbehandlung | | 2026-04-29 | Gruppen-State nur in `FormGeneratorTable`, nicht im Feature-Hook | Keine Änderungen an bestehenden Hooks nötig; Grouping ist transparent für Seiten-Code | --- ## Umsetzungs-Checkliste ### Phase 1: Backend Core - [ ] `datamodelPagination.py`: `TableGroupNode`, `TableGrouping` Klassen hinzufügen - [ ] `datamodelPagination.py`: `PaginationParams` um `groupId` + `saveGroupTree` erweitern - [ ] `datamodelPagination.py`: `PaginatedResponse` um `groupTree` erweitern - [ ] `datamodelPagination.py`: `normalize_pagination_dict` so erweitern, dass `saveGroupTree` und `groupId` korrekt geparst werden - [ ] `interfaceDbApp.py`: `getTableGrouping(contextKey)` + `upsertTableGrouping(contextKey, rootGroups)` zu `AppObjects` hinzufügen - [ ] `routeHelpers.py`: `GroupingContext` Dataclass + `handleGroupingInRequest()` + `applyGroupScopeFilter()` implementieren ### Phase 2: Route-Erweiterungen Pro Route: `handleGroupingInRequest` am Anfang + `applyGroupScopeFilter` vor Pagination + `groupTree` in Response - [ ] `routeDataConnections.py` (inkl. `mode=ids`, `mode=filterValues`) - [ ] `routeDataPrompts.py` - [ ] `routeDataUsers.py` - [ ] `routeDataMandates.py` - [ ] `routeDataFiles.py` - [ ] Weitere nach Bedarf (Trustee, RealEstate, Invitations, …) ### Phase 3: Frontend Typen + FormGeneratorTable Grundgerüst - [ ] TypeScript-Typen `TableGroupNode` direkt in `FormGeneratorTable.tsx` oder `src/types/tableGrouping.ts` - [ ] `groupingConfig`-Prop zu `FormGeneratorTableProps` hinzufügen - [ ] `groupTree` aus `hookData`-Response parsen und in internem State halten - [ ] `activeGroupId`-State + `_enterGroup` / `_exitGroup` - [ ] `pendingGroupTree`-State + `_mutateGroupTree` + debounced Save via `hookData.refetch()` - [ ] `PaginatedResponse` Frontend-Typ um `groupTree` erweitern ### Phase 4: Render-Logik - [ ] `GroupRow`-Komponente: `src/components/FormGenerator/GroupingManager/GroupRow.tsx` - [ ] Render-Algorithmus: Root-View mit Gruppen-Header-Zeilen und DataRows; "Nicht zugeordnet"-Sektion - [ ] Gruppen-Scope-View: Breadcrumb, "Zurück"-Button, normales Table-Layout - [ ] Gedimmte Gruppen ohne sichtbare Items (Root-View) mit "Gruppe öffnen"-Button - [ ] Expand/Collapse je Gruppe (lokal; isExpanded kommt aus `groupTree`) ### Phase 5: Aktionen und Interaktion - [ ] `actionButtons` + `customActions` auf `GroupRow`-Ebene (Batch auf alle Items via `mode=ids` im Scope) - [ ] Delete-Gruppe mit `useConfirm`: "Nur Gruppe" vs. "Gruppe + alle Items löschen" - [ ] "In Gruppe verschieben" als BatchAction bei Multi-Select - [ ] Kontextmenü-Button je DataRow → Gruppen-Dropdown - [ ] Drag-and-Drop DataRow → GroupRow ### Phase 6: GroupingManager Panel - [ ] `GroupingManager`-Komponente: Gruppen-Baum-Panel - [ ] Button "Gruppen" in `FormGeneratorControls` (nur wenn `groupingConfig.enabled`) - [ ] Neue Gruppe erstellen (`usePrompt`) - [ ] Gruppe umbenennen (inline) - [ ] Subgruppe erstellen - [ ] Gruppe löschen (`useConfirm`) - [ ] Reihenfolge Up/Down ### Abschluss - [ ] i18n: alle neuen UI-Texte mit `t('...')` getaggt - [ ] CSS Modules für alle neuen Komponenten - [ ] `b-reference/frontend-nyla/formgenerator.md` aktualisieren - [ ] `b-reference/gateway/architecture.md` — `PaginationParams`/`PaginatedResponse`-Erweiterung dokumentieren --- ## Akzeptanzkriterien | # | Kriterium (Given-When-Then) | Prio | |---|---------------------------|------| | 1 | Given `FormGeneratorTable` mit `groupingConfig.enabled` — When Seite lädt — Then kommt `groupTree` in der normalen List-Response mit; **kein zweiter API-Call** | must | | 2 | Given User erstellt Gruppe — When 500ms nach letzter Änderung — Then ein einziger `refetch` mit `saveGroupTree` wird abgesendet; nach Reload ist Gruppe vorhanden | must | | 3 | Given User klickt Gruppe "Kunden" — When `_enterGroup` aufgerufen — Then refetch mit `groupId`; Backend filtert; Pagination und Totalcount beziehen sich auf die Gruppe | must | | 4 | Given aktiver Gruppen-Scope — When User sucht "Test" — Then `groupId` + Search in einem Call; Backend zeigt nur Treffer in der Gruppe | must | | 5 | Given aktiver Gruppen-Scope — When User klickt "Alle auswählen" (mode=ids) — Then IDs kommen nur aus der Gruppe, nicht aus der Gesamtliste | must | | 6 | Given Filter-Dropdown geöffnet im Gruppen-Scope — When Werte geladen — Then kommen nur aus Items der Gruppe (mode=filterValues korrekt) | should | | 7 | Given `FormGeneratorTable` ohne `groupingConfig` — Then identisches Verhalten wie vor dem Feature | must | | 8 | Given Gruppe mit 5 Items — When User Delete auf Group-Header — Then Confirm → alle 5 Items gelöscht; Gruppe aus Baum entfernt; ein `refetch` mit aktuellem `saveGroupTree` | must | | 9 | Given User zieht DataRow auf Gruppen-Header — Then Item wird zur Gruppe zugeordnet; `_mutateGroupTree` + debounced Save | should | --- ## Testplan | ID | AC | Art | Automatisiert | Repo-Pfad | Status | |----|----|----|--------------|-----------|--------| | T1 | 1, 2 | api | ja | `gateway/tests/test_grouping_helpers.py` | pending | | T2 | 3, 4, 5, 6 | api | ja | `gateway/tests/test_grouping_helpers.py` | pending | | T3 | 7 | component | nein | manuell | pending | | T4 | 8 | api + component | nein | manuell | pending | | T5 | 9 | component | nein | manuell | pending | --- ## Offene Fragen 1. **`scope: 'user' | 'mandate'`** im `TableGrouping`-Modell bereits vorbereiten für späteres mandate-weites Sharing? 2. **CSV-Export** soll Gruppen-Spalte enthalten? 3. **`activeGroupId` im URL-State** (`?group=g1`) für Deep-Links? 4. **Max-Tiefe** konfigurierbar (`groupingConfig.maxDepth`) oder feste Warnung nach 3 Ebenen? --- ## Links - PR: — - Referenz FormGenerator: `b-reference/frontend-nyla/formgenerator.md` - Referenz Gateway-Architektur: `b-reference/gateway/architecture.md` - Referenz DB-Architektur: `b-reference/platform/database-architecture.md` --- ## Abschluss - [ ] `b-reference/frontend-nyla/formgenerator.md` — Grouping-Sektion - [ ] `b-reference/gateway/architecture.md` — `PaginationParams`/`PaginatedResponse` Erweiterung - [ ] TOPICS.md geprüft - [ ] Dieses Dokument → `z-archive/` verschoben