gruppierung fertig gestellt formgenerator
This commit is contained in:
parent
b61544d8b1
commit
c8e9304801
12 changed files with 1439 additions and 128 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
overflow: hidden;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.title {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
328
src/components/FormGenerator/GroupingManager/GroupRow.module.css
Normal file
328
src/components/FormGenerator/GroupingManager/GroupRow.module.css
Normal 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);
|
||||
}
|
||||
293
src/components/FormGenerator/GroupingManager/GroupRow.tsx
Normal file
293
src/components/FormGenerator/GroupingManager/GroupRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue