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

460 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- status: plan -->
<!-- started: 2026-04-29 -->
<!-- component: frontend-nyla | gateway -->
# 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<TableGroupNode[]>([]);
const [activeGroupId, setActiveGroupId] = useState<string | null>(null);
const [pendingGroupTree, setPendingGroupTree] = useState<TableGroupNode[] | null>(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