20 KiB
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:
- Speichert den neuen Gruppen-Baum (wenn
saveGroupTreegesetzt) - Filtert auf eine Gruppe (wenn
groupIdgesetzt) - 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:
# 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 KlassenTableGroupNode+TableGroupingin dieser Dateimodules/interfaces/interfaceDbApp.py—AppObjectsumgetTableGrouping(contextKey)+upsertTableGrouping(contextKey, rootGroups)erweitern; neue Tabelletable_groupingsinpoweron_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; nutzthookData.refetch()als einzigen TransportFormGeneratorControls.tsx— Gruppen-Toolbar-ButtonFormGenerator/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
# --- 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
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)
@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
// 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<TableGroupNode[]>([]);
const [activeGroupId, setActiveGroupId] = useState<string | null>(null);
const [pendingGroupTree, setPendingGroupTree] = useState<TableGroupNode[] | null>(null);
Datenfluss — hookData.refetch() als einziger Transport
// 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,TableGroupingKlassen hinzufügendatamodelPagination.py:PaginationParamsumgroupId+saveGroupTreeerweiterndatamodelPagination.py:PaginatedResponseumgroupTreeerweiterndatamodelPagination.py:normalize_pagination_dictso erweitern, dasssaveGroupTreeundgroupIdkorrekt geparst werdeninterfaceDbApp.py:getTableGrouping(contextKey)+upsertTableGrouping(contextKey, rootGroups)zuAppObjectshinzufügenrouteHelpers.py:GroupingContextDataclass +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.pyrouteDataUsers.pyrouteDataMandates.pyrouteDataFiles.py- Weitere nach Bedarf (Trustee, RealEstate, Invitations, …)
Phase 3: Frontend Typen + FormGeneratorTable Grundgerüst
- TypeScript-Typen
TableGroupNodedirekt inFormGeneratorTable.tsxodersrc/types/tableGrouping.ts groupingConfig-Prop zuFormGeneratorTablePropshinzufügengroupTreeaushookData-Response parsen und in internem State haltenactiveGroupId-State +_enterGroup/_exitGrouppendingGroupTree-State +_mutateGroupTree+ debounced Save viahookData.refetch()PaginatedResponseFrontend-Typ umgroupTreeerweitern
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+customActionsaufGroupRow-Ebene (Batch auf alle Items viamode=idsim 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 wenngroupingConfig.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/ui-nyla/formgenerator.mdaktualisierenb-reference/platform-core/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 | platform-core/tests/test_grouping_helpers.py |
pending |
| T2 | 3, 4, 5, 6 | api | ja | platform-core/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
scope: 'user' | 'mandate'imTableGrouping-Modell bereits vorbereiten für späteres mandate-weites Sharing?- CSV-Export soll Gruppen-Spalte enthalten?
activeGroupIdim URL-State (?group=g1) für Deep-Links?- Max-Tiefe konfigurierbar (
groupingConfig.maxDepth) oder feste Warnung nach 3 Ebenen?
Links
- PR: —
- Referenz FormGenerator:
b-reference/ui-nyla/formgenerator.md - Referenz Gateway-Architektur:
b-reference/platform-core/architecture.md - Referenz DB-Architektur:
b-reference/platform/database-architecture.md
Abschluss
- Dieses Dokument Status →
done (superseded)(2026-05-15). Implementierung folgte anderem Ansatz (View-basiertes GroupLayout). - Eintrag in
c-work/_CHANGELOG.md(2026-05-12, FormGeneratorTable Grouping Refactor).