wiki/c-work/1-plan/2026-04-formgenerator-grouping.md
2026-05-05 14:52:20 +02:00

20 KiB
Raw Blame History

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:

# 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.pyPaginationParams + groupId, saveGroupTree; PaginatedResponse + groupTree; neue Klassen TableGroupNode + TableGrouping in dieser Datei
    • modules/interfaces/interfaceDbApp.pyAppObjects um getTableGrouping(contextKey) + upsertTableGrouping(contextKey, rootGroups) erweitern; neue Tabelle table_groupings in poweron_app (auto-created)
    • modules/routes/routeHelpers.pyhandleGroupingInRequest(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.tsxgroupingConfig-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

# --- 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, 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.mdPaginationParams/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?

  • 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.mdPaginationParams/PaginatedResponse Erweiterung
  • TOPICS.md geprüft
  • Dieses Dokument → z-archive/ verschoben