gruppierung fertig gestellt formgenerator

This commit is contained in:
Ida 2026-04-29 18:25:42 +02:00
parent b61544d8b1
commit c8e9304801
12 changed files with 1439 additions and 128 deletions

View file

@ -55,6 +55,19 @@ export interface PaginationParams {
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>;
search?: string;
/** Scope request to items of this group (resolved server-side to itemIds IN-filter). */
groupId?: string;
/** If set, persist this group tree on the backend before fetching (optimistic save). */
saveGroupTree?: TableGroupNode[];
}
export interface TableGroupNode {
id: string;
name: string;
itemIds: string[];
subGroups: TableGroupNode[];
order: number;
isExpanded: boolean;
}
export interface PaginatedResponse<T> {
@ -65,6 +78,8 @@ export interface PaginatedResponse<T> {
totalItems: number;
totalPages: number;
};
/** Current group tree for this (user, contextKey) pair — undefined if no grouping configured. */
groupTree?: TableGroupNode[];
}
export interface CreateConnectionData {
@ -123,6 +138,8 @@ export async function fetchConnections(
if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search;
if (params.groupId) paginationObj.groupId = params.groupId;
if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree;
if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj);

View file

@ -34,6 +34,8 @@ export interface PaginationParams {
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>;
search?: string;
groupId?: string;
saveGroupTree?: any[];
}
export interface PaginatedResponse<T> {
@ -103,6 +105,8 @@ export async function fetchFiles(
if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search;
if (params.groupId) paginationObj.groupId = params.groupId;
if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree;
if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj);

View file

@ -46,6 +46,8 @@ export interface PaginationParams {
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>;
search?: string;
groupId?: string;
saveGroupTree?: any[];
}
export interface PaginatedResponse<T> {
@ -84,6 +86,8 @@ export async function fetchMandates(
if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search;
if (params.groupId) paginationObj.groupId = params.groupId;
if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree;
if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj);

View file

@ -49,6 +49,8 @@ export interface PaginationParams {
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>;
search?: string;
groupId?: string;
saveGroupTree?: any[];
}
export interface PaginatedResponse<T> {
@ -110,6 +112,8 @@ export async function fetchPrompts(
if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search;
if (params.groupId) paginationObj.groupId = params.groupId;
if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree;
if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj);

View file

@ -48,6 +48,8 @@ export interface PaginationParams {
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>;
search?: string;
groupId?: string;
saveGroupTree?: any[];
}
export interface PaginatedResponse<T> {
@ -152,6 +154,8 @@ export async function fetchUsers(
if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search;
if (params.groupId) paginationObj.groupId = params.groupId;
if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree;
if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj);

View file

@ -4,7 +4,7 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './FormGeneratorControls.module.css';
import { Button } from '../../UiComponents/Button';
import { IoIosRefresh } from "react-icons/io";
import { FaTrash, FaDownload } from "react-icons/fa";
import { FaTrash, FaDownload, FaLayerGroup } from "react-icons/fa";
import type { AttributeType } from '../../../utils/attributeTypeMapper';
// Generic field/column config interface
@ -77,6 +77,10 @@ export interface FormGeneratorControlsProps {
onSelectAllFiltered?: () => void;
selectAllFilteredActive?: boolean;
selectAllFilteredLoading?: boolean;
// Grouping
groupingEnabled?: boolean;
onCreateGroup?: () => void;
activeGroupId?: string | null;
}
export function FormGeneratorControls({
@ -110,6 +114,9 @@ export function FormGeneratorControls({
onSelectAllFiltered,
selectAllFilteredActive = false,
selectAllFilteredLoading = false,
groupingEnabled = false,
onCreateGroup,
activeGroupId,
}: FormGeneratorControlsProps) {
const { t } = useLanguage();
@ -212,6 +219,16 @@ export function FormGeneratorControls({
{csvExporting ? t('Exportiere...') : 'CSV'}
</button>
)}
{groupingEnabled && onCreateGroup && (
<button
onClick={onCreateGroup}
className={styles.refreshButton}
title={t('Neue Gruppe erstellen')}
style={{ color: activeGroupId ? 'var(--color-primary, #4a6fa5)' : undefined }}
>
<span className={styles.refreshIcon}><FaLayerGroup /></span>
</button>
)}
{onRefresh && (
<button
onClick={onRefresh}

View file

@ -9,6 +9,7 @@
overflow: hidden;
height: 100%;
max-height: 100%;
position: relative;
}
.title {

View file

@ -75,17 +75,171 @@ import {
isNumberType,
} from '../../../utils/attributeTypeMapper';
import type { AttributeType } from '../../../utils/attributeTypeMapper';
import { FaFilter } from 'react-icons/fa';
import { FaFilter, FaTrash } from 'react-icons/fa';
import type { GroupBulkAction } from '../GroupingManager/GroupRow';
import api from '../../../api';
import { PeriodPicker } from '../../PeriodPicker';
import type { PeriodValue } from '../../PeriodPicker';
import { GroupFolderRow, BreadcrumbRow } from '../GroupingManager/GroupRow';
/** A filter value can be a plain string, null (for empty/missing), or a
* {value, label} object returned by FK-aware filter-values endpoints. */
type FilterValue = string | null | { value: string | null; label: string };
// ---------------------------------------------------------------------------
// Table Grouping types (mirrors backend datamodelPagination.TableGroupNode)
// ---------------------------------------------------------------------------
export interface TableGroupNode {
id: string;
name: string;
itemIds: string[];
subGroups: TableGroupNode[];
order: number;
isExpanded: boolean;
}
export interface GroupingConfig {
/** Unique key identifying this table's group storage on the backend.
* Mirrors the API path segment, e.g. "connections", "prompts", "files/list". */
contextKey: string;
enabled: boolean;
}
const _EMPTY_FILTER_SENTINEL = '__EMPTY__';
// ---------------------------------------------------------------------------
// Pure tree-manipulation helpers (no React dependencies)
// ---------------------------------------------------------------------------
function _genGroupId(): string {
return `g-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
}
function _treeAddRoot(tree: TableGroupNode[], node: TableGroupNode): TableGroupNode[] {
return [...tree, node];
}
function _treeAddSub(tree: TableGroupNode[], parentId: string, node: TableGroupNode): TableGroupNode[] {
return tree.map(n =>
n.id === parentId
? { ...n, subGroups: [...n.subGroups, node] }
: { ...n, subGroups: _treeAddSub(n.subGroups, parentId, node) }
);
}
function _treeUpdate(tree: TableGroupNode[], groupId: string, fn: (n: TableGroupNode) => TableGroupNode): TableGroupNode[] {
return tree.map(n =>
n.id === groupId ? fn(n) : { ...n, subGroups: _treeUpdate(n.subGroups, groupId, fn) }
);
}
function _treeRemove(tree: TableGroupNode[], groupId: string): TableGroupNode[] {
return tree
.filter(n => n.id !== groupId)
.map(n => ({ ...n, subGroups: _treeRemove(n.subGroups, groupId) }));
}
function _treeRemoveItemFromAll(tree: TableGroupNode[], itemId: string): TableGroupNode[] {
return tree.map(n => ({
...n,
itemIds: n.itemIds.filter(id => id !== itemId),
subGroups: _treeRemoveItemFromAll(n.subGroups, itemId),
}));
}
function _treeAddItemToGroup(tree: TableGroupNode[], groupId: string, itemId: string): TableGroupNode[] {
return _treeUpdate(tree, groupId, n => ({
...n,
itemIds: n.itemIds.includes(itemId) ? n.itemIds : [...n.itemIds, itemId],
}));
}
function _treeMoveItemsToGroup(tree: TableGroupNode[], itemIds: string[], groupId: string | null): TableGroupNode[] {
let t = tree;
for (const id of itemIds) t = _treeRemoveItemFromAll(t, id);
if (groupId) for (const id of itemIds) t = _treeAddItemToGroup(t, groupId, id);
return t;
}
function _treeReorder(tree: TableGroupNode[], groupId: string, dir: 'up' | 'down'): TableGroupNode[] {
const _at = (nodes: TableGroupNode[]): TableGroupNode[] => {
const idx = nodes.findIndex(n => n.id === groupId);
if (idx === -1) return nodes.map(n => ({ ...n, subGroups: _at(n.subGroups) }));
const arr = [...nodes];
if (dir === 'up' && idx > 0) [arr[idx - 1], arr[idx]] = [arr[idx], arr[idx - 1]];
if (dir === 'down' && idx < arr.length - 1) [arr[idx], arr[idx + 1]] = [arr[idx + 1], arr[idx]];
return arr;
};
return _at(tree);
}
function _treeFlatten(tree: TableGroupNode[]): Array<{ node: TableGroupNode; depth: number; parentId: string | null }> {
const result: Array<{ node: TableGroupNode; depth: number; parentId: string | null }> = [];
const _walk = (nodes: TableGroupNode[], depth: number, parentId: string | null) => {
nodes.forEach(n => {
result.push({ node: n, depth, parentId });
_walk(n.subGroups, depth + 1, n.id);
});
};
_walk(tree, 0, null);
return result;
}
/** Returns the id of the group that directly contains `itemId`, or null. */
function _treeGetItemDirectGroupId(tree: TableGroupNode[], itemId: string): string | null {
for (const n of tree) {
if (n.itemIds.includes(itemId)) return n.id;
const found = _treeGetItemDirectGroupId(n.subGroups, itemId);
if (found !== null) return found;
}
return null;
}
/** Returns the parent group id of `groupId` (null = root level). Returns null if not found. */
function _treeGetGroupParentId(tree: TableGroupNode[], groupId: string): string | null {
const _search = (nodes: TableGroupNode[], parentId: string | null): string | null | undefined => {
for (const n of nodes) {
if (n.id === groupId) return parentId;
const found = _search(n.subGroups, n.id);
if (found !== undefined) return found;
}
return undefined;
};
const result = _search(tree, null);
return result === undefined ? null : result;
}
/** Returns true if `ancestorId` is an ancestor of (or equal to) `nodeId`. */
function _treeIsAncestor(tree: TableGroupNode[], ancestorId: string, nodeId: string): boolean {
const _check = (nodes: TableGroupNode[]): boolean => {
for (const n of nodes) {
if (n.id === ancestorId) {
const _inSubtree = (subs: TableGroupNode[]): boolean => {
for (const s of subs) {
if (s.id === nodeId) return true;
if (_inSubtree(s.subGroups)) return true;
}
return false;
};
return n.id === nodeId || _inSubtree(n.subGroups);
}
if (_check(n.subGroups)) return true;
}
return false;
};
return _check(tree);
}
/** Extract a group node and re-insert it under a new parent (or at root). */
function _treeMoveGroupToParent(
tree: TableGroupNode[],
groupId: string,
newParentId: string | null,
): TableGroupNode[] {
let extracted: TableGroupNode | null = null;
const _extract = (nodes: TableGroupNode[]): TableGroupNode[] =>
nodes
.filter(n => { if (n.id === groupId) { extracted = { ...n }; return false; } return true; })
.map(n => ({ ...n, subGroups: _extract(n.subGroups) }));
const newTree = _extract(tree);
if (!extracted) return tree;
if (newParentId === null) return [...newTree, extracted];
return _treeUpdate(newTree, newParentId, n => ({
...n,
subGroups: [...n.subGroups, extracted!],
}));
}
/**
* Stringify any cell value for display.
* The backend resolves TextMultilingual to plain strings via resolveText() / get_text().
@ -223,6 +377,8 @@ export interface FormGeneratorTableProps<T = any> {
initialSort?: Array<{ key: string; direction: 'asc' | 'desc' }>;
rowDraggable?: boolean;
onRowDragStart?: (e: React.DragEvent<HTMLTableRowElement>, row: T) => void;
/** Enable persistent user-defined grouping for this table instance. */
groupingConfig?: GroupingConfig;
}
const _FILTER_PAGE_SIZE = 100;
@ -582,12 +738,35 @@ export function FormGeneratorTable<T extends Record<string, any>>({
initialSort,
rowDraggable = false,
onRowDragStart,
groupingConfig,
}: FormGeneratorTableProps<T>) {
const { t, currentLanguage: contextLanguage } = useLanguage();
// When only onDelete is provided, use it for multi-delete too so Delete stays visible with 2+ selected
const onDeleteMultiple = onDeleteMultipleProp ?? (onDelete ? (rows: T[]) => rows.forEach((r) => onDelete(r)) : undefined);
const currentLanguage = useMemo(() => contextLanguage || 'en', [contextLanguage]);
// ── Grouping state ────────────────────────────────────────────────────────
// groupTree: authoritative tree from the last backend response
// activeGroupId: when set, table is in "group scope" (refetch uses groupId filter)
// pendingGroupTree: local mutations not yet confirmed by backend
const [groupTree, setGroupTree] = useState<TableGroupNode[]>([]);
const [activeGroupId, setActiveGroupId] = useState<string | null>(null);
const pendingGroupTreeRef = useRef<TableGroupNode[] | null>(null);
// Debounce timer ref for saving group tree changes
const _groupSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const groupingEnabled = groupingConfig?.enabled === true;
// Sync groupTree from hookData whenever a fresh response arrives
useEffect(() => {
if (!groupingEnabled) return;
const tree = hookDataProp?.groupTree;
if (Array.isArray(tree)) {
setGroupTree(tree);
pendingGroupTreeRef.current = null;
}
}, [hookDataProp?.groupTree, groupingEnabled]);
// ── Optimistic row hiding + adjusted header count ───────────────────────
// We synthesize `removeOptimistically` at the FormGenerator layer so every
// page gets instant delete feedback (row hidden + "N Einträge" decremented)
@ -689,6 +868,197 @@ export function FormGeneratorTable<T extends Record<string, any>>({
pagination: adjustedPagination,
};
}, [hookDataProp, optimisticallyDeletedIds]);
// ── Grouping helpers ──────────────────────────────────────────────────────
/** Mutate the local group tree immediately and schedule a debounced backend save. */
const _mutateGroupTree = useCallback((newTree: TableGroupNode[]) => {
setGroupTree(newTree);
pendingGroupTreeRef.current = newTree;
if (_groupSaveTimerRef.current) clearTimeout(_groupSaveTimerRef.current);
_groupSaveTimerRef.current = setTimeout(() => {
if (!hookData?.refetch) return;
const s = tableStateRef.current;
const params: Record<string, any> = {
page: s.page,
pageSize: s.pageSize,
saveGroupTree: pendingGroupTreeRef.current,
};
if (activeGroupId) params.groupId = activeGroupId;
if (s.search?.trim()) params.search = s.search.trim();
hookData.refetch(params);
}, 500);
}, [hookData, activeGroupId]);
/** Enter a group scope — refetch with groupId filter. */
const _enterGroup = useCallback((groupId: string) => {
setActiveGroupId(groupId);
if (!hookData?.refetch) return;
const s = tableStateRef.current;
hookData.refetch({ page: 1, pageSize: s.pageSize, groupId });
}, [hookData]);
/** Exit group scope — refetch without groupId. */
const _exitGroup = useCallback(() => {
setActiveGroupId(null);
if (!hookData?.refetch) return;
const s = tableStateRef.current;
hookData.refetch({ page: 1, pageSize: s.pageSize });
}, [hookData]);
// Cleanup debounce timer on unmount
useEffect(() => {
return () => { if (_groupSaveTimerRef.current) clearTimeout(_groupSaveTimerRef.current); };
}, []);
// Inline editing: which group's name is currently being edited
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
// Drag-and-drop: item drag
const [draggedRowId, setDraggedRowId] = useState<string | null>(null);
const [dragOverGroupId, setDragOverGroupId] = useState<string | null>(null);
// Drag-and-drop: group drag (dragging a whole group folder)
const [draggedGroupId, setDraggedGroupId] = useState<string | null>(null);
const [dragOverGroupIdFromGroup, setDragOverGroupIdFromGroup] = useState<string | null>(null);
// Visual feedback: item/group is being dragged left past threshold
const [dragWillUngroup, setDragWillUngroup] = useState(false);
// Auto-collapse source group while dragging; hover over a group temporarily expands it
const [dragCollapsedId, setDragCollapsedId] = useState<string | null>(null);
const [dragHoverExpandedId, setDragHoverExpandedId] = useState<string | null>(null);
// Timer to delay the source-group collapse so the drag ghost is captured first
const dragCollapseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Refs for stable document-level handlers (avoid stale closures)
const dragStartXRef = useRef<number>(0);
const dragCurXRef = useRef<number>(0); // updated via document dragover
const draggedRowIdRef = useRef<string | null>(null);
const draggedGroupIdRef = useRef<string | null>(null);
const dragSourceDepthRef = useRef<number>(0);
// True when cursor crossed the left-ungroup threshold AND no onItemDrop/onGroupDrop was called.
// onItemDrop sets this to false (user dropped INTO a group → don't ungroup)
const willUngroupRef = useRef(false);
// Always-current copies of tree + actions for the document-level dragend handler
const groupTreeRef = useRef<TableGroupNode[]>(groupTree);
const groupingActionsRef = useRef<typeof groupingActions | null>(null);
const setExpandedGroupsRef = useRef<((u: (prev: Set<string>) => Set<string>) => void) | null>(null);
const createGroupAndEdit = useCallback((parentId?: string) => {
const id = _genGroupId();
const node: TableGroupNode = { id, name: '', itemIds: [], subGroups: [], order: 0, isExpanded: true };
_mutateGroupTree(parentId ? _treeAddSub(groupTree, parentId, node) : _treeAddRoot(groupTree, node));
setEditingGroupId(id);
}, [groupTree, _mutateGroupTree]);
/** All tree mutations go through here — instant local update + debounced backend save. */
const groupingActions = useMemo(() => ({
renameGroup: (groupId: string, name: string) => {
_mutateGroupTree(_treeUpdate(groupTree, groupId, n => ({ ...n, name })));
},
deleteGroup: (groupId: string) => {
_mutateGroupTree(_treeRemove(groupTree, groupId));
},
moveItemsToGroup: (itemIds: string[], targetGroupId: string | null) => {
_mutateGroupTree(_treeMoveItemsToGroup(groupTree, itemIds, targetGroupId));
},
moveGroupToParent: (groupId: string, newParentId: string | null) => {
// Prevent moving a group into itself or one of its own descendants
if (newParentId && _treeIsAncestor(groupTree, groupId, newParentId)) return;
if (newParentId === groupId) return;
_mutateGroupTree(_treeMoveGroupToParent(groupTree, groupId, newParentId));
},
}), [groupTree, _mutateGroupTree]);
// Keep refs current on every render (no useEffect needed — direct assignment is fine for refs)
groupTreeRef.current = groupTree;
groupingActionsRef.current = groupingActions;
// Document-level drag handlers — run once on mount, use refs for always-fresh values
useEffect(() => {
// Track cursor X while dragging (fires even after source element leaves DOM)
const trackCursorX = (e: DragEvent) => {
if (e.clientX !== 0) {
dragCurXRef.current = e.clientX;
if (draggedRowIdRef.current || draggedGroupIdRef.current) {
// Update the ref AND the visual state
const crossed = dragSourceDepthRef.current > 0 &&
e.clientX < dragStartXRef.current - 50;
// Only set to true here; onItemDrop/onGroupDrop can set it back to false
if (crossed) willUngroupRef.current = true;
else willUngroupRef.current = false;
setDragWillUngroup(crossed);
}
}
};
// On drag end: execute the ungroup move if willUngroupRef is still true.
// willUngroupRef is set to false when user drops INTO a group (onItemDrop/onGroupDrop),
// so this only fires when the item was released outside any group.
const handleDragEnd = () => {
const rowId = draggedRowIdRef.current;
const gId = draggedGroupIdRef.current;
const willUngroup = willUngroupRef.current;
// Clear visual drag state FIRST so the item renders at full opacity in its new position
draggedRowIdRef.current = null;
draggedGroupIdRef.current = null;
dragCurXRef.current = 0;
dragStartXRef.current = 0;
dragSourceDepthRef.current = 0;
willUngroupRef.current = false;
if (dragCollapseTimerRef.current) {
clearTimeout(dragCollapseTimerRef.current);
dragCollapseTimerRef.current = null;
}
setDraggedRowId(null);
setDraggedGroupId(null);
setDragWillUngroup(false);
setDragCollapsedId(null);
setDragHoverExpandedId(null);
setDragOverGroupId(null);
setDragOverGroupIdFromGroup(null);
// Then execute the move action (re-render will see draggedRowId=null → no grey)
if (willUngroup && rowId) {
const tree = groupTreeRef.current;
const actions = groupingActionsRef.current!;
const curGroup = _treeGetItemDirectGroupId(tree, rowId);
if (curGroup !== null) {
const parentGroup = _treeGetGroupParentId(tree, curGroup);
actions.moveItemsToGroup([rowId], parentGroup);
// Keep the source group collapsed after the move
setExpandedGroupsRef.current(prev => {
const next = new Set(prev);
next.add(`collapsed-${curGroup}`);
return next;
});
}
}
if (willUngroup && gId) {
const tree = groupTreeRef.current;
const actions = groupingActionsRef.current!;
const parentId = _treeGetGroupParentId(tree, gId);
const grandParentId = parentId !== null ? _treeGetGroupParentId(tree, parentId) : null;
actions.moveGroupToParent(gId, grandParentId);
// Keep the parent group collapsed after the move
if (parentId) {
setExpandedGroupsRef.current(prev => {
const next = new Set(prev);
next.add(`collapsed-${parentId}`);
return next;
});
}
}
};
// dragover bubbles → regular listener is fine for cursor tracking
// dragend does NOT bubble → must use capture phase to catch it from child elements
document.addEventListener('dragover', trackCursorX);
document.addEventListener('dragend', handleDragEnd, true);
return () => {
document.removeEventListener('dragover', trackCursorX);
document.removeEventListener('dragend', handleDragEnd, true);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // intentionally empty — all mutable values accessed via refs
// Use provided columns from Pydantic attribute definitions
// NO AUTO-DETECTION - columns must come from backend attribute definitions
// Use a ref to cache columns so they persist across data changes (e.g., when filtering)
@ -773,8 +1143,32 @@ export function FormGeneratorTable<T extends Record<string, any>>({
return () => cancelAnimationFrame(id);
}, [openFilterColumn]);
// Grouping: Track expanded groups
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => new Set());
// Grouping: Track expanded/collapsed groups — persisted in localStorage
const _groupExpandKey = groupingConfig?.contextKey
? `porta_group_expanded_${groupingConfig.contextKey}`
: null;
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => {
if (!groupingConfig?.contextKey) return new Set();
try {
const raw = localStorage.getItem(`porta_group_expanded_${groupingConfig.contextKey}`);
return raw ? new Set<string>(JSON.parse(raw)) : new Set();
} catch {
return new Set();
}
});
// Persist on every change
const _setExpandedGroups = useCallback((updater: (prev: Set<string>) => Set<string>) => {
setExpandedGroups(prev => {
const next = updater(prev);
if (_groupExpandKey) {
try { localStorage.setItem(_groupExpandKey, JSON.stringify([...next])); } catch { /* quota */ }
}
return next;
});
}, [_groupExpandKey]);
setExpandedGroupsRef.current = _setExpandedGroups;
const [groupsInitialized, setGroupsInitialized] = useState(false);
// Generate a storage key based on column names for localStorage persistence
@ -855,25 +1249,17 @@ export function FormGeneratorTable<T extends Record<string, any>>({
}));
}
// Log search parameters being sent to backend
console.log('🔍 FormGeneratorTable: Calling backend with pagination params:', {
searchTerm: debouncedSearchTerm,
searchInParams: paginationParams.search,
filters: paginationParams.filters,
sort: paginationParams.sort,
page: paginationParams.page,
pageSize: paginationParams.pageSize,
fullParams: paginationParams
});
// Preserve active group scope so filter/sort/page changes stay within the group
if (groupingEnabled && activeGroupId) {
paginationParams.groupId = activeGroupId;
}
// Call backend refetch with parameters
hookData.refetch(paginationParams).then(() => {
console.log('✅ FormGeneratorTable: Backend refetch completed');
}).catch((error: any) => {
hookData.refetch(paginationParams).catch((error: any) => {
console.error('❌ FormGeneratorTable: Backend refetch failed:', error);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, filters, sortConfigs, currentPage, currentPageSize, supportsBackendPagination, refreshNonce]);
}, [debouncedSearchTerm, filters, sortConfigs, currentPage, currentPageSize, supportsBackendPagination, refreshNonce, activeGroupId]);
// Refs for action buttons containers to detect clicks outside
const actionButtonsRefs = useRef<Map<number, HTMLDivElement>>(new Map());
@ -2184,6 +2570,9 @@ export function FormGeneratorTable<T extends Record<string, any>>({
onSelectAllFiltered={apiEndpoint && supportsBackendPagination && selectable ? handleSelectAllFiltered : undefined}
selectAllFilteredActive={selectAllFilteredActive}
selectAllFilteredLoading={selectAllFilteredLoading}
groupingEnabled={groupingEnabled}
onCreateGroup={groupingEnabled ? () => createGroupAndEdit() : undefined}
activeGroupId={activeGroupId}
/>
)}
@ -2688,118 +3077,358 @@ export function FormGeneratorTable<T extends Record<string, any>>({
);
})
) : (
displayData.map((row, index) => {
const dataAttributes = getRowDataAttributes ? getRowDataAttributes(row, index) : {};
return (
<tr
key={index}
className={`${styles.tr} ${selectedIds.has(_getRowId(row)) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
onClick={() => onRowClick?.(row, index)}
draggable={rowDraggable}
onDragStart={rowDraggable && onRowDragStart ? (e) => onRowDragStart(e, row) : undefined}
{...Object.fromEntries(
Object.entries(dataAttributes).map(([key, value]) => [`data-${key}`, value])
)}
>
{selectable && (
<td className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
<input
type="checkbox"
checked={selectedIds.has(_getRowId(row))}
onChange={() => handleRowSelect(row)}
onClick={(e) => e.stopPropagation()}
disabled={isRowSelectable && !isRowSelectable(row)}
title={
isRowSelectable && !isRowSelectable(row)
? t('Dieses Element kann nicht ausgewählt werden')
: t('Dieses Element auswählen')
}
style={{
opacity: isRowSelectable && !isRowSelectable(row) ? 0.4 : 1,
cursor: isRowSelectable && !isRowSelectable(row) ? 'not-allowed' : 'pointer'
}}
/>
</td>
)}
{hasActionColumn && (
<td
className={styles.actionsColumn}
style={{
width: `${currentActionsWidth}px`,
minWidth: `${defaultActionsWidth}px`
}}
>
<div
ref={(el) => {
if (el) {
actionButtonsRefs.current.set(index, el);
} else {
actionButtonsRefs.current.delete(index);
(() => {
// Total colspan for group/breadcrumb/ungrouped rows
const _totalColSpan = (selectable ? 1 : 0) + (hasActionColumn ? 1 : 0) + detectedColumns.length;
// ── Helper: render a single data row ──────────────────
// indentLevel: visual nesting depth inside a group (0 = ungrouped)
const _renderDataRow = (row: T, index: number, indentLevel = 0, hiddenByDrag = false) => {
const dataAttributes = getRowDataAttributes ? getRowDataAttributes(row, index) : {};
const rowId = _getRowId(row);
const isDragging = draggedRowId === rowId;
const willUngroup = isDragging && dragWillUngroup;
const indentStyle: React.CSSProperties = indentLevel > 0
? { borderLeft: `3px solid color-mix(in srgb, var(--color-primary, #4a6fa5) ${Math.min(indentLevel * 25, 60)}%, transparent)` }
: {};
const ungroupStyle: React.CSSProperties = willUngroup
? { borderLeft: '3px solid #d69e2e', opacity: 0.5 }
: {};
return (
<tr
key={rowId || index}
className={`${styles.tr} ${selectedIds.has(rowId) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
style={{ ...indentStyle, ...ungroupStyle, display: hiddenByDrag ? 'none' : undefined }}
onClick={() => onRowClick?.(row, index)}
draggable={groupingEnabled || rowDraggable}
onDragStart={(e) => {
if (groupingEnabled) {
dragStartXRef.current = e.clientX;
dragCurXRef.current = e.clientX;
draggedRowIdRef.current = rowId;
dragSourceDepthRef.current = indentLevel;
setDragWillUngroup(false);
setDraggedRowId(rowId);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', rowId);
// Delay source-group collapse: browser needs one frame to
// capture the drag ghost before we remove the element from DOM.
if (indentLevel > 0) {
const srcGroup = _treeGetItemDirectGroupId(groupTree, rowId);
if (srcGroup) {
if (dragCollapseTimerRef.current) clearTimeout(dragCollapseTimerRef.current);
dragCollapseTimerRef.current = setTimeout(() => {
setDragCollapsedId(srcGroup);
dragCollapseTimerRef.current = null;
}, 200);
}
}
}
if (rowDraggable && onRowDragStart) onRowDragStart(e, row);
}}
onDragEnd={() => {
// Direct element handler — clears the grey opacity immediately.
// The document capture handler fires first and handles the ungroup action.
setDraggedRowId(null);
setDragWillUngroup(false);
setDragCollapsedId(null);
setDragHoverExpandedId(null);
if (dragCollapseTimerRef.current) {
clearTimeout(dragCollapseTimerRef.current);
dragCollapseTimerRef.current = null;
}
}}
{...Object.fromEntries(Object.entries(dataAttributes).map(([k, v]) => [`data-${k}`, v]))}
>
{selectable && (
<td className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
<input type="checkbox"
checked={selectedIds.has(_getRowId(row))}
onChange={() => handleRowSelect(row)}
onClick={(e) => e.stopPropagation()}
disabled={isRowSelectable && !isRowSelectable(row)}
title={isRowSelectable && !isRowSelectable(row) ? t('Dieses Element kann nicht ausgewählt werden') : t('Dieses Element auswählen')}
style={{ opacity: isRowSelectable && !isRowSelectable(row) ? 0.4 : 1, cursor: isRowSelectable && !isRowSelectable(row) ? 'not-allowed' : 'pointer' }}
/>
</td>
)}
{hasActionColumn && (
<td className={styles.actionsColumn} style={{ width: `${currentActionsWidth}px`, minWidth: `${defaultActionsWidth}px` }}>
<div ref={(el) => { if (el) actionButtonsRefs.current.set(index, el); else actionButtonsRefs.current.delete(index); }}
className={`${styles.actionButtons} ${shouldWrapActionButtons ? styles.actionButtonsWrap : ''}`}>
{actionButtons.map((ab, ai) => {
if (ab.visible && !ab.visible(row, hookData)) return null;
const abTitle = typeof ab.title === 'function' ? ab.title(row) : ab.title;
let dis: boolean | { disabled: boolean; message?: string } = false;
if (ab.disabled) { dis = ab.disabled(row, hookData); }
else if (row._permissions) {
if (ab.type === 'edit' && row._permissions.canUpdate === false) dis = true;
else if (ab.type === 'delete' && row._permissions.canDelete === false) dis = true;
}
const isLd = ab.loading ? ab.loading(row) : false;
const isProc = ab.isProcessing ? ab.isProcessing(row) : false;
const bp = { row, disabled: dis, loading: isLd, className: ab.className, title: abTitle, idField: ab.idField ?? 'id', nameField: ab.nameField ?? 'name', typeField: ab.typeField ?? 'type', contentField: ab.contentField ?? 'content', operationName: ab.operationName, loadingStateName: ab.loadingStateName };
switch (ab.type) {
case 'edit': return <EditActionButton key={`a-${ai}`} {...bp} onEdit={ab.onAction} hookData={hookData} />;
case 'delete': return <DeleteActionButton key={`a-${ai}`} {...bp} containerRef={{ current: actionButtonsRefs.current.get(index) || null }} hookData={hookData} />;
case 'view': return <ViewActionButton key={`a-${ai}`} {...bp} onView={ab.onAction || (() => {})} isViewing={isProc} hookData={hookData} />;
case 'copy': return <CopyActionButton key={`a-${ai}`} {...bp} onCopy={ab.onAction} isCopying={isProc} contentField={ab.contentField} />;
default: return null;
}
})}
{customActions.map((ca) => (
<CustomActionButton key={`ca-${ca.id}`} row={row} id={ca.id} icon={ca.icon}
onClick={ca.onClick} visible={ca.visible} disabled={ca.disabled}
loading={ca.loading} title={ca.title} className={ca.className}
hookData={hookData} idField={ca.idField ?? 'id'} />
))}
</div>
</td>
)}
{detectedColumns.map(col => {
const cv = row[col.key];
const cCls = col.cellClassName ? col.cellClassName(cv, row) : '';
const aStyle = _columnAlignStyle(col);
return (
<td key={col.key} className={`${styles.td} ${cCls}`.trim()}
style={{ width: columnWidths[col.key] || col.width || 150, minWidth: columnWidths[col.key] || col.width || 150, maxWidth: columnWidths[col.key] || col.width || 150, ...aStyle }}>
{formatCellValue(cv, col, row)}
</td>
);
})}
</tr>
);
};
// ── Grouped root-view: file-browser folder layout ──────
if (groupingEnabled && groupTree.length > 0 && !activeGroupId) {
const _groupedIds = new Set<string>();
const _collectIds = (nodes: typeof groupTree) => {
nodes.forEach(n => { n.itemIds.forEach(id => _groupedIds.add(id)); _collectIds(n.subGroups); });
};
_collectIds(groupTree);
const _visibleById = new Map<string, T>();
displayData.forEach(row => _visibleById.set(_getRowId(row), row));
const _renderGroup = (node: typeof groupTree[0], depth: number, inheritedHidden = false): React.ReactNode => {
const visibleIds = node.itemIds.filter(id => _visibleById.has(id));
const groupItems = visibleIds.map(id => _visibleById.get(id)!);
const userCollapsed = expandedGroups.has(`collapsed-${node.id}`);
// Visual expanded state (chevron icon, hover-expand override, drag-collapse override)
const isExp = dragHoverExpandedId === node.id
? true
: (dragCollapsedId === node.id)
? false
: !userCollapsed;
// Whether children should exist in DOM (never remove during drag — it kills drag ops)
// When drag-collapsed: render children but hide with display:none
const shouldRenderChildren = !userCollapsed || dragHoverExpandedId === node.id;
const childrenHiddenByDrag = (dragCollapsedId === node.id && dragHoverExpandedId !== node.id) || inheritedHidden;
// Build bulk actions — same buttons as per-row, wired to all group items
const _bulkActions: GroupBulkAction[] = [];
const _hasDeleteBtn = actionButtons.some(ab => ab.type === 'delete');
if (_hasDeleteBtn && (onDeleteMultiple || onDelete)) {
// Collect all item ids recursively in this group
const _collectAll = (n: typeof node): string[] => [
...n.itemIds,
...n.subGroups.flatMap(_collectAll),
];
const allItemIds = _collectAll(node);
const allVisibleItems = allItemIds
.filter(id => _visibleById.has(id))
.map(id => _visibleById.get(id)!);
_bulkActions.push({
icon: <FaTrash />,
title: t('Alle {n} löschen + Gruppe', { n: String(allItemIds.length) }),
variant: 'danger',
onClick: () => {
if (allVisibleItems.length > 0) {
(onDeleteMultiple ?? ((rows) => rows.forEach(r => onDelete!(r))))(allVisibleItems);
}
// Also remove the group itself
groupingActions.deleteGroup(node.id);
},
disabled: false,
});
}
batchActions.forEach(ba => {
const applicable = ba.isApplicable ? groupItems.filter(ba.isApplicable) : groupItems;
_bulkActions.push({
icon: ba.icon ? React.createElement(ba.icon) : undefined,
title: `${ba.label} (${applicable.length})`,
onClick: () => ba.onClick(applicable),
disabled: applicable.length === 0,
});
});
return (
<React.Fragment key={`g-${node.id}`}>
<GroupFolderRow
node={node}
depth={depth}
colSpan={_totalColSpan}
visibleCount={visibleIds.length}
isExpanded={isExp}
isEditing={editingGroupId === node.id}
isDragOver={dragOverGroupId === node.id}
isDragOverFromGroup={dragOverGroupIdFromGroup === node.id}
bulkActions={_bulkActions}
onToggle={() => {
// Clear any stuck drag-state for this group so the button
// always responds, even if a drag was cancelled mid-way.
if (dragCollapsedId === node.id) setDragCollapsedId(null);
if (dragHoverExpandedId === node.id) setDragHoverExpandedId(null);
_setExpandedGroups(prev => {
const next = new Set(prev);
// Toggle purely on persistent state, not on `isExp`
// (which can be overridden by drag state).
if (prev.has(`collapsed-${node.id}`)) {
next.delete(`collapsed-${node.id}`); // expand
} else {
next.add(`collapsed-${node.id}`); // collapse
}
return next;
});
}}
className={`${styles.actionButtons} ${shouldWrapActionButtons ? styles.actionButtonsWrap : ''}`}
>
{actionButtons.map((actionButton, actionIndex) => {
if (actionButton.visible && !actionButton.visible(row, hookData)) return null;
const actionTitle = typeof actionButton.title === 'function'
? actionButton.title(row)
: actionButton.title;
let disabledResult: boolean | { disabled: boolean; message?: string } = false;
if (actionButton.disabled) {
disabledResult = actionButton.disabled(row, hookData);
} else if (row._permissions) {
if (actionButton.type === 'edit' && row._permissions.canUpdate === false) {
disabledResult = true;
} else if (actionButton.type === 'delete' && row._permissions.canDelete === false) {
disabledResult = true;
onEditCommit={(name) => {
if (name.trim()) {
groupingActions.renameGroup(node.id, name.trim());
} else {
groupingActions.deleteGroup(node.id);
}
setEditingGroupId(null);
}}
onEditCancel={() => {
if (!node.name) groupingActions.deleteGroup(node.id);
setEditingGroupId(null);
}}
onRename={() => setEditingGroupId(node.id)}
onAddSub={() => createGroupAndEdit(node.id)}
// Item drag handlers
onItemDragOver={(e) => {
e.preventDefault();
setDragOverGroupId(node.id);
// Hover-expand: open this group while dragging over it
setDragHoverExpandedId(node.id);
}}
onItemDrop={(e) => {
e.preventDefault();
// Cancel ungroup: user explicitly dropped INTO a group
willUngroupRef.current = false;
setDragWillUngroup(false);
const id = draggedRowId ?? e.dataTransfer.getData('text/plain');
if (id) groupingActions.moveItemsToGroup([id], node.id);
// The document dragend capture handler will reset state
}}
onItemDragLeave={() => {
if (dragOverGroupId === node.id) setDragOverGroupId(null);
// Collapse again when pointer leaves this group
if (dragHoverExpandedId === node.id) setDragHoverExpandedId(null);
}}
// Group drag handlers (this row is draggable)
onGroupDragStart={(e) => {
dragStartXRef.current = e.clientX;
dragCurXRef.current = e.clientX;
draggedGroupIdRef.current = node.id;
dragSourceDepthRef.current = depth;
setDragWillUngroup(false);
e.dataTransfer.setData('application/porta-group', node.id);
e.dataTransfer.effectAllowed = 'move';
setDraggedGroupId(node.id);
// Delay parent-group collapse to let the drag ghost be captured first
if (depth > 0) {
const parentId = _treeGetGroupParentId(groupTree, node.id);
if (parentId) {
if (dragCollapseTimerRef.current) clearTimeout(dragCollapseTimerRef.current);
dragCollapseTimerRef.current = setTimeout(() => {
setDragCollapsedId(parentId);
dragCollapseTimerRef.current = null;
}, 200);
}
}
const isLoading = actionButton.loading ? actionButton.loading(row) : false;
const isProcessing = actionButton.isProcessing ? actionButton.isProcessing(row) : false;
const baseProps = {
row, disabled: disabledResult, loading: isLoading,
className: actionButton.className, title: actionTitle,
idField: actionButton.idField ?? 'id', nameField: actionButton.nameField ?? 'name',
typeField: actionButton.typeField ?? 'type', contentField: actionButton.contentField ?? 'content',
operationName: actionButton.operationName, loadingStateName: actionButton.loadingStateName
};
switch (actionButton.type) {
case 'edit':
return <EditActionButton key={`action-${actionIndex}`} {...baseProps} onEdit={actionButton.onAction} hookData={hookData} />;
case 'delete':
return <DeleteActionButton key={`action-${actionIndex}`} {...baseProps} containerRef={{ current: actionButtonsRefs.current.get(index) || null }} hookData={hookData} />;
case 'view':
return <ViewActionButton key={`action-${actionIndex}`} {...baseProps} onView={actionButton.onAction || (() => {})} isViewing={isProcessing} hookData={hookData} />;
case 'copy':
return <CopyActionButton key={`action-${actionIndex}`} {...baseProps} onCopy={actionButton.onAction} isCopying={isProcessing} contentField={actionButton.contentField} />;
default:
return null;
}}
onGroupDragEnd={() => { /* handled by document dragend */ }}
onGroupDragOver={(e) => {
// Don't highlight self or own descendants
const gId = draggedGroupId ?? e.dataTransfer.getData('application/porta-group');
if (gId && !_treeIsAncestor(groupTree, gId, node.id) && gId !== node.id) {
e.preventDefault();
setDragOverGroupIdFromGroup(node.id);
setDragHoverExpandedId(node.id);
}
})}
{customActions.map((customAction) => (
<CustomActionButton key={`custom-${customAction.id}`} row={row} id={customAction.id} icon={customAction.icon}
onClick={customAction.onClick} visible={customAction.visible} disabled={customAction.disabled}
loading={customAction.loading} title={customAction.title} className={customAction.className}
hookData={hookData} idField={customAction.idField ?? 'id'} />
))}
</div>
</td>
)}
{detectedColumns.map(column => {
const cellValue = row[column.key];
const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : '';
const combinedClassName = `${styles.td} ${customClassName}`.trim();
const alignStyle = _columnAlignStyle(column);
return (
<td key={column.key} className={combinedClassName}
style={{ width: columnWidths[column.key] || column.width || 150, minWidth: columnWidths[column.key] || column.width || 150, maxWidth: columnWidths[column.key] || column.width || 150, ...alignStyle }}>
{formatCellValue(cellValue, column, row)}
</td>
);
})}
</tr>
);
})
}}
onGroupDrop={(e) => {
e.preventDefault();
// Cancel ungroup: user explicitly dropped INTO a group
willUngroupRef.current = false;
setDragWillUngroup(false);
const gId = draggedGroupId ?? e.dataTransfer.getData('application/porta-group');
if (gId) groupingActions.moveGroupToParent(gId, node.id);
// The document dragend capture handler will reset state
}}
onGroupDrag={undefined}
onGroupDragLeave={() => {
if (dragOverGroupIdFromGroup === node.id) setDragOverGroupIdFromGroup(null);
if (dragHoverExpandedId === node.id) setDragHoverExpandedId(null);
}}
isDraggingOut={draggedGroupId === node.id && dragWillUngroup}
hidden={inheritedHidden}
/>
{shouldRenderChildren && visibleIds.map(id => {
const row = _visibleById.get(id);
if (!row) return null;
return _renderDataRow(row, displayData.indexOf(row), depth + 1, childrenHiddenByDrag);
})}
{shouldRenderChildren && node.subGroups.map(sub => _renderGroup(sub, depth + 1, childrenHiddenByDrag))}
</React.Fragment>
);
};
const _ungroupedRows = displayData.filter(row => !_groupedIds.has(_getRowId(row)));
return (
<>
{groupTree.map(node => _renderGroup(node, 0))}
{_ungroupedRows.map((row, idx) => _renderDataRow(row, displayData.indexOf(row) !== -1 ? displayData.indexOf(row) : idx))}
</>
);
}
// ── Scope view (activeGroupId set) — breadcrumb + flat rows ──
if (groupingEnabled && activeGroupId) {
const _activeGroup = (() => {
const _find = (nodes: typeof groupTree): typeof groupTree[0] | null => {
for (const n of nodes) {
if (n.id === activeGroupId) return n;
const found = _find(n.subGroups);
if (found) return found;
}
return null;
};
return _find(groupTree);
})();
const _totalScopeItems = hookData?.pagination?.totalItems ?? displayData.length;
return (
<>
<BreadcrumbRow
groupName={_activeGroup?.name ?? activeGroupId}
totalItems={_totalScopeItems}
colSpan={_totalColSpan}
onBack={_exitGroup}
/>
{displayData.map((row, idx) => _renderDataRow(row, idx))}
</>
);
}
// ── Default: no grouping or empty group tree ───────────
return displayData.map((row, index) => _renderDataRow(row, index));
})()
)}
{!loading && displayData.length === 0 && (
<tr>
@ -2815,6 +3444,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
</table>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,328 @@
/* ---------------------------------------------------------------------------
GroupFolderRow file-browser-style folder rows in the data table
--------------------------------------------------------------------------- */
.groupFolderRow {
background: var(--color-surface, #eef0f2);
border-bottom: 1px solid var(--color-border, #d4d9e0);
transition: background 0.12s;
user-select: none;
}
.groupFolderRow:hover {
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 8%, var(--color-surface, #eef0f2));
}
.groupFolderRow.dragOver {
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 18%, var(--color-surface, #eef0f2));
outline: 2px dashed var(--color-primary, #4a6fa5);
outline-offset: -2px;
}
/* Drop zone when another GROUP is dragged onto this group */
.groupFolderRow.dragOverGroup {
background: color-mix(in srgb, #d69e2e 18%, var(--color-surface, #eef0f2));
outline: 2px dashed #d69e2e;
outline-offset: -2px;
}
/* Cursor hint while dragging a group row */
.groupFolderRow[draggable="true"] {
cursor: grab;
}
.groupFolderRow[draggable="true"]:active {
cursor: grabbing;
}
/* Visual feedback: group is being dragged leftward to pop out */
.groupFolderRow.draggingOut {
opacity: 0.5;
border-left: 3px solid #d69e2e;
}
.folderCell {
padding: 0 !important;
width: 100%;
}
.separator {
display: inline-block;
width: 1px;
height: 18px;
background: var(--color-border, #d4d9e0);
margin: 0 4px;
flex-shrink: 0;
}
.folderInner {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 10px 5px 0;
min-height: 34px;
width: 100%;
box-sizing: border-box;
}
.indent {
display: inline-block;
flex-shrink: 0;
}
/* Expand/collapse chevron button */
.chevronBtn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.12s;
flex-shrink: 0;
border-radius: 3px;
width: 20px;
height: 20px;
}
.chevronBtn:hover {
background: var(--color-primary-light, rgba(74,111,165,0.12));
}
/* Pure-CSS triangle arrow */
.chevronArrow {
display: inline-block;
width: 0;
height: 0;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 6px solid var(--color-text-secondary, #64748b);
transition: transform 0.15s;
flex-shrink: 0;
}
.chevronBtn:hover .chevronArrow {
border-left-color: var(--color-primary, #4a6fa5);
}
.chevronOpen .chevronArrow {
transform: rotate(90deg);
}
/* Folder icon (SVG via react-icons) */
.folderIcon {
font-size: 14px;
flex-shrink: 0;
line-height: 1;
margin-right: 2px;
color: var(--color-primary, #4a6fa5);
display: inline-flex;
align-items: center;
}
/* Group name text */
.groupName {
font-size: 13px;
font-weight: 500;
color: var(--color-text, #2d3748);
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
max-width: 300px;
}
.unnamed {
color: var(--color-text-secondary, #94a3b8);
font-style: italic;
font-weight: 400;
}
/* Inline name input when editing */
.nameInput {
font-size: 13px;
font-weight: 500;
border: 1px solid var(--color-primary, #4a6fa5);
border-radius: 4px;
padding: 2px 8px;
outline: none;
background: var(--color-bg, #fff);
color: var(--color-text, #2d3748);
min-width: 160px;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary, #4a6fa5) 20%, transparent);
}
/* Item count badge */
.badge {
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 15%, transparent);
color: var(--color-primary, #4a6fa5);
border-radius: 10px;
padding: 0 7px;
font-size: 11px;
font-weight: 500;
line-height: 18px;
flex-shrink: 0;
margin-left: 4px;
}
/* Drop hint text */
.dropHint {
font-size: 11px;
font-style: italic;
color: var(--color-primary, #4a6fa5);
margin-left: 4px;
animation: pulse 1s ease-in-out infinite alternate;
}
@keyframes pulse {
from { opacity: 0.6; }
to { opacity: 1.0; }
}
/* ── Bulk item action buttons (same type as per-row action buttons) ── */
.actions {
display: flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
margin-right: 4px;
}
.actionBtn {
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #d4d9e0);
cursor: pointer;
padding: 3px 8px;
border-radius: 5px;
font-size: 12px;
color: var(--color-text-secondary, #64748b);
transition: background 0.1s, color 0.1s, border-color 0.1s;
line-height: 1;
display: inline-flex;
align-items: center;
gap: 4px;
height: 24px;
}
.actionBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.actionBtn:not(:disabled):hover {
background: var(--color-surface, #eef0f2);
color: var(--color-text, #2d3748);
border-color: var(--color-primary, #4a6fa5);
}
.actionBtnDanger:not(:disabled):hover {
background: color-mix(in srgb, #e53e3e 10%, transparent);
color: #c53030;
border-color: #c53030;
}
/* ── Group management buttons (rename / add-sub / delete-group) ── */
.mgmtActions {
display: flex;
align-items: center;
gap: 1px;
flex-shrink: 0;
border-left: 1px solid var(--color-border, #d4d9e0);
padding-left: 6px;
margin-left: 2px;
}
.mgmtBtn {
background: none;
border: none;
cursor: pointer;
padding: 3px 5px;
border-radius: 3px;
font-size: 11px;
color: var(--color-text-secondary, #94a3b8);
transition: background 0.1s, color 0.1s;
display: inline-flex;
align-items: center;
height: 22px;
}
.mgmtBtn:hover {
background: var(--color-border, #d4d9e0);
color: var(--color-text, #2d3748);
}
.mgmtBtnDanger:hover {
background: color-mix(in srgb, #e53e3e 12%, transparent);
color: #c53030;
}
/* ---------------------------------------------------------------------------
Breadcrumb row
--------------------------------------------------------------------------- */
.breadcrumbRow {
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 6%, var(--color-bg, #fff));
}
.breadcrumbCell {
padding: 8px 14px !important;
}
.breadcrumbInner {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.backButton {
background: none;
border: none;
cursor: pointer;
color: var(--color-primary, #4a6fa5);
font-size: 13px;
padding: 2px 8px;
border-radius: 5px;
transition: background 0.1s;
}
.backButton:hover {
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 12%, transparent);
}
.breadcrumbSep {
color: var(--color-text-secondary, #94a3b8);
}
.breadcrumbCurrent {
font-weight: 600;
color: var(--color-text, #2d3748);
}
/* ---------------------------------------------------------------------------
Ungrouped section row
--------------------------------------------------------------------------- */
.ungroupedRow {
background: var(--color-bg, #f8f9fa);
transition: background 0.12s, outline 0.12s;
}
/* Drop target: item or group dragged back to root */
.ungroupedDragOver {
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 10%, var(--color-bg, #f8f9fa));
outline: 2px dashed var(--color-primary, #4a6fa5);
outline-offset: -2px;
}
.ungroupedCell {
display: flex !important;
align-items: center;
gap: 6px;
padding: 5px 14px !important;
font-size: 12px;
color: var(--color-text-secondary, #94a3b8);
font-style: italic;
border-top: 1px dashed var(--color-border, #d4d9e0);
}

View file

@ -0,0 +1,293 @@
import React, { useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { useConfirm } from '../../../hooks/useConfirm';
import styles from './GroupRow.module.css';
import type { TableGroupNode } from '../FormGeneratorTable/FormGeneratorTable';
import { FaFolder, FaFolderOpen, FaList, FaPen, FaPlus } from 'react-icons/fa';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface GroupBulkAction {
icon?: React.ReactNode;
title?: string;
variant?: 'default' | 'danger';
onClick: () => void;
disabled?: boolean;
}
// ---------------------------------------------------------------------------
// GroupFolderRow
// ---------------------------------------------------------------------------
interface GroupFolderRowProps {
node: TableGroupNode;
depth: number;
colSpan: number;
visibleCount: number;
isExpanded: boolean;
isEditing: boolean;
/** True while an ITEM is dragged over this row (drop item into group). */
isDragOver: boolean;
/** True while a GROUP is dragged over this row (nest group inside). */
isDragOverFromGroup: boolean;
bulkActions?: GroupBulkAction[];
onToggle: () => void;
onEditCommit: (name: string) => void;
onEditCancel: () => void;
onRename: () => void;
onAddSub: () => void;
// Item drag-drop
onItemDragOver: (e: React.DragEvent) => void;
onItemDrop: (e: React.DragEvent) => void;
onItemDragLeave: () => void;
// Group drag (this row is draggable)
onGroupDragStart: (e: React.DragEvent) => void;
onGroupDragEnd: () => void;
onGroupDrag?: (e: React.DragEvent) => void;
/** True while this group is being dragged leftward to pop out one level */
isDraggingOut?: boolean;
/** Hide this row via display:none (keeps it in DOM so drag operations don't break) */
hidden?: boolean;
// Group drop (another group dropped onto this)
onGroupDragOver: (e: React.DragEvent) => void;
onGroupDrop: (e: React.DragEvent) => void;
onGroupDragLeave: () => void;
}
export function GroupFolderRow({
node,
depth,
colSpan,
visibleCount,
isExpanded,
isEditing,
isDragOver,
isDragOverFromGroup,
isDraggingOut,
hidden,
bulkActions = [],
onToggle,
onEditCommit,
onEditCancel,
onRename,
onAddSub,
onItemDragOver,
onItemDrop,
onItemDragLeave,
onGroupDragStart,
onGroupDragEnd,
onGroupDrag,
onGroupDragOver,
onGroupDrop,
onGroupDragLeave,
}: GroupFolderRowProps) {
const { t } = useLanguage();
const { confirm, ConfirmDialog } = useConfirm();
const inputRef = useRef<HTMLInputElement>(null);
const totalCount = node.itemIds.length;
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
const indentPx = depth * 20;
const _rowClass = [
styles.groupFolderRow,
isDragOver ? styles.dragOver : '',
isDragOverFromGroup ? styles.dragOverGroup : '',
isDraggingOut ? styles.draggingOut : '',
].filter(Boolean).join(' ');
return (
<>
{typeof document !== 'undefined' && ReactDOM.createPortal(<ConfirmDialog />, document.body)}
<tr
className={_rowClass}
style={{ '--group-indent': `${indentPx}px`, display: hidden ? 'none' : undefined } as React.CSSProperties}
draggable={!isEditing}
onDragStart={onGroupDragStart}
onDrag={onGroupDrag}
onDragEnd={onGroupDragEnd}
// item drag-over
onDragOver={(e) => {
// distinguish item vs group drag via dataTransfer type
if (e.dataTransfer.types.includes('application/porta-group')) {
onGroupDragOver(e);
} else {
onItemDragOver(e);
}
}}
onDrop={(e) => {
if (e.dataTransfer.types.includes('application/porta-group')) {
onGroupDrop(e);
} else {
onItemDrop(e);
}
}}
onDragLeave={() => { onItemDragLeave(); onGroupDragLeave(); }}
onDragEnter={(e) => e.preventDefault()}
>
<td colSpan={colSpan} className={styles.folderCell}>
<div className={styles.folderInner}>
{/* Indent */}
{indentPx > 0 && <span className={styles.indent} style={{ width: indentPx }} />}
{/* Chevron */}
<button
className={`${styles.chevronBtn} ${isExpanded ? styles.chevronOpen : ''}`}
onClick={(e) => { e.stopPropagation(); onToggle(); }}
title={isExpanded ? t('Zuklappen') : t('Aufklappen')}
tabIndex={-1}
>
<span className={styles.chevronArrow} />
</button>
{/* Folder icon */}
<span className={styles.folderIcon}>
{isExpanded ? <FaFolderOpen /> : <FaFolder />}
</span>
{/* Name / inline input */}
{isEditing ? (
<input
ref={inputRef}
defaultValue={node.name}
className={styles.nameInput}
placeholder={t('Gruppenname…')}
onKeyDown={(e) => {
if (e.key === 'Enter') onEditCommit(e.currentTarget.value);
if (e.key === 'Escape') onEditCancel();
}}
onBlur={(e) => onEditCommit(e.target.value)}
/>
) : (
<span className={styles.groupName} onClick={(e) => { e.stopPropagation(); onToggle(); }}>
{node.name || <em className={styles.unnamed}>{t('(Unbenannt)')}</em>}
</span>
)}
{/* Item count badge */}
{!isEditing && (
<span className={styles.badge}>
{visibleCount < totalCount && totalCount > 0
? `${visibleCount} / ${totalCount}`
: String(totalCount)}
</span>
)}
{/* Drop hint */}
{(isDragOver || isDragOverFromGroup) && (
<span className={styles.dropHint}>
{isDragOverFromGroup ? t('Als Untergruppe ablegen') : t('Hierher ziehen')}
</span>
)}
{/* ── Bulk actions (delete all, custom batch) right after badge ── */}
{!isEditing && bulkActions.length > 0 && (
<>
<span className={styles.separator} />
<span className={styles.actions}>
{bulkActions.map((action, i) => (
<button
key={i}
className={`${styles.actionBtn} ${action.variant === 'danger' ? styles.actionBtnDanger : ''}`}
title={action.title}
disabled={!!action.disabled}
onClick={(e) => { e.stopPropagation(); if (!action.disabled) action.onClick(); }}
>
{action.icon}
</button>
))}
</span>
</>
)}
{/* ── Group management: rename / add-subgroup ── */}
{!isEditing && (
<span className={styles.mgmtActions}>
<button onClick={(e) => { e.stopPropagation(); onRename(); }} title={t('Umbenennen')} className={styles.mgmtBtn}><FaPen /></button>
<button onClick={(e) => { e.stopPropagation(); onAddSub(); }} title={t('Untergruppe erstellen')} className={styles.mgmtBtn}><FaPlus /></button>
</span>
)}
<span style={{ flex: 1 }} />
</div>
</td>
</tr>
</>
);
}
// ---------------------------------------------------------------------------
// BreadcrumbRow
// ---------------------------------------------------------------------------
interface BreadcrumbRowProps {
groupName: string;
totalItems: number;
colSpan: number;
onBack: () => void;
}
export function BreadcrumbRow({ groupName, totalItems, colSpan, onBack }: BreadcrumbRowProps) {
const { t } = useLanguage();
return (
<tr className={styles.breadcrumbRow}>
<td colSpan={colSpan} className={styles.breadcrumbCell}>
<div className={styles.breadcrumbInner}>
<button className={styles.backButton} onClick={onBack}>
{t('Alle anzeigen')}
</button>
<span className={styles.breadcrumbSep}></span>
<span className={styles.breadcrumbCurrent}>{groupName}</span>
{totalItems > 0 && (
<span style={{ color: 'var(--color-text-secondary, #94a3b8)', fontSize: '11px' }}>
({totalItems} {t('Einträge')})
</span>
)}
</div>
</td>
</tr>
);
}
// ---------------------------------------------------------------------------
// UngroupedRow — also a drop zone for removing items/groups from groups
// ---------------------------------------------------------------------------
interface UngroupedRowProps {
count: number;
colSpan: number;
isDragOver?: boolean;
onDragOver?: (e: React.DragEvent) => void;
onDrop?: (e: React.DragEvent) => void;
onDragLeave?: () => void;
}
export function UngroupedRow({ count, colSpan, isDragOver, onDragOver, onDrop, onDragLeave }: UngroupedRowProps) {
const { t } = useLanguage();
return (
<tr
className={`${styles.ungroupedRow} ${isDragOver ? styles.ungroupedDragOver : ''}`}
onDragOver={onDragOver}
onDrop={onDrop}
onDragLeave={onDragLeave}
onDragEnter={(e) => e.preventDefault()}
>
<td colSpan={colSpan} className={styles.ungroupedCell}>
<span className={styles.folderIcon}><FaList /></span>
{t('Nicht zugeordnet')}
<span className={styles.badge}>{count}</span>
{isDragOver && <span className={styles.dropHint}>{t('Aus Gruppe entfernen')}</span>}
</td>
</tr>
);
}

View file

@ -22,6 +22,7 @@ import {
// Re-export types for backward compatibility
export type { Connection, AttributeDefinition, PaginationParams, CreateConnectionData, ConnectResponse };
export type { TableGroupNode } from '../api/connectionApi';
// Hook for managing connections
export function useConnections() {
@ -34,6 +35,7 @@ export function useConnections() {
totalItems: number;
totalPages: number;
} | null>(null);
const [groupTree, setGroupTree] = useState<import('../api/connectionApi').TableGroupNode[]>([]);
const [isConnecting, setIsConnecting] = useState(false);
const [connectError, setConnectError] = useState<string | null>(null);
const { request, isLoading, error } = useApiRequest<any, any>();
@ -101,6 +103,9 @@ export function useConnections() {
if (data.pagination) {
setPagination(data.pagination);
}
if (Array.isArray(data.groupTree)) {
setGroupTree(data.groupTree);
}
} else {
// Handle non-paginated response (backward compatibility)
const items = Array.isArray(data) ? data : [];
@ -826,7 +831,8 @@ export function useConnections() {
// Additional methods for FormGenerator
updateOptimistically,
handleInlineUpdate,
fetchConnectionById
fetchConnectionById,
groupTree,
};
}

View file

@ -44,6 +44,7 @@ export const ConnectionsPage: React.FC = () => {
refreshMicrosoftToken,
refreshGoogleToken,
isConnecting,
groupTree,
} = useConnections();
const [editingConnection, setEditingConnection] = useState<Connection | null>(null);
@ -469,7 +470,9 @@ export const ConnectionsPage: React.FC = () => {
handleDelete: deleteConnection,
handleInlineUpdate,
updateOptimistically,
groupTree,
}}
groupingConfig={{ contextKey: 'connections', enabled: true }}
emptyMessage={t('Keine Verbindungen gefunden')}
/>
</div>