ui-nyla/src/components/FormGenerator/GroupingManager/GroupRow.tsx

374 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

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 fgTableCss from '../FormGeneratorTable/FormGeneratorTable.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;
}
/** Horizontal shift per nesting level — keep in sync with item rows (`FormGeneratorTable`). */
export const GROUP_TREE_INDENT_STEP_PX = 20;
// ---------------------------------------------------------------------------
// GroupFolderRow
// ---------------------------------------------------------------------------
/** Folder row: optional select column, then one merged cell for folder UI (spans actions + data cols — no blank actions column). */
export interface GroupFolderTableCells {
showSelect: boolean;
/** `<td colSpan>` for folder strip = `detectedColumns.length` + (1 if table has an actions column). */
dataColumnsCount: number;
selectClassName: string;
selectTdStyle?: React.CSSProperties;
}
interface GroupFolderRowProps {
node: TableGroupNode;
depth: number;
/** Checkbox for “whole subtree”: select / clear all selectable visible items under this folder. */
subtreeSelect?: {
checked: boolean;
indeterminate: boolean;
disabled: boolean;
onToggle: () => void;
};
/** When set, use split `<td>` layout; omit single-cell colspan. */
tableCells?: GroupFolderTableCells;
/** Legacy single spanning cell — only used when `tableCells` is omitted. */
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,
subtreeSelect,
tableCells,
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 { ConfirmDialog } = useConfirm();
const inputRef = useRef<HTMLInputElement>(null);
const subtreeCbRef = useRef<HTMLInputElement>(null);
const totalCount = node.itemIds.length;
useEffect(() => {
const el = subtreeCbRef.current;
if (!el || !subtreeSelect) return;
el.indeterminate = subtreeSelect.indeterminate;
}, [subtreeSelect?.indeterminate, subtreeSelect?.checked, subtreeSelect]);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
const indentPx = depth * GROUP_TREE_INDENT_STEP_PX;
const _rowClass = [
styles.groupFolderRow,
tableCells ? fgTableCss.treeRowIndented : '',
isDragOver ? styles.dragOver : '',
isDragOverFromGroup ? styles.dragOverGroup : '',
isDraggingOut ? styles.draggingOut : '',
subtreeSelect?.checked && !subtreeSelect?.disabled ? styles.folderRowSubtreeFull : '',
subtreeSelect?.indeterminate && !subtreeSelect?.checked ? styles.folderRowSubtreePartial : '',
].filter(Boolean).join(' ');
const mergedColSpan =
tableCells
? tableCells.dataColumnsCount
: (colSpan ?? 1);
const folderStripStyle =
({
'--group-indent': `${indentPx}px`,
...(tableCells
? { ['--row-tree-indent' as string]: `${depth * GROUP_TREE_INDENT_STEP_PX}px` }
: {}),
}) as React.CSSProperties;
const guardDragDecor = (
e: React.DragEvent,
relay: React.DragEventHandler | undefined,
) => {
const el = e.target as HTMLElement;
if (el.closest('input, button, textarea, label')) {
e.preventDefault();
e.stopPropagation();
return;
}
relay?.(e);
};
const folderCells = (
<>
{typeof document !== 'undefined' && ReactDOM.createPortal(<ConfirmDialog />, document.body)}
<tr
className={_rowClass}
style={{ ...folderStripStyle, display: hidden ? 'none' : undefined } as React.CSSProperties}
draggable={!isEditing}
onDragStart={(e) => guardDragDecor(e, onGroupDragStart)}
onDrag={(e) => guardDragDecor(e, onGroupDrag)}
onDragEnd={(e) => guardDragDecor(e, 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()}
>
{tableCells?.showSelect && (
<td className={tableCells.selectClassName} style={tableCells.selectTdStyle}>
{subtreeSelect && (
<input
ref={subtreeCbRef}
type="checkbox"
checked={subtreeSelect.checked}
disabled={subtreeSelect.disabled}
onChange={(e) => { e.stopPropagation(); subtreeSelect.onToggle(); }}
onClick={(e) => e.stopPropagation()}
title={node.name ? t('Auswahl unter „{name}“', { name: node.name }) : t('Auswahl dieser Gruppe')}
aria-label={node.name ? t('Alle sichtbaren Einträge in „{name}“ auswählen', { name: node.name }) : t('Alle sichtbaren Einträge in dieser Gruppe auswählen')}
/>
)}
</td>
)}
<td colSpan={tableCells ? mergedColSpan : (colSpan ?? 1)} 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 : ''}`}
type="button"
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}
type="button"
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 type="button" onClick={(e) => { e.stopPropagation(); onRename(); }} title={t('Umbenennen')} className={styles.mgmtBtn}><FaPen /></button>
<button type="button" onClick={(e) => { e.stopPropagation(); onAddSub(); }} title={t('Untergruppe erstellen')} className={styles.mgmtBtn}><FaPlus /></button>
</span>
)}
<span style={{ flex: 1 }} />
</div>
</td>
</tr>
</>
);
return folderCells;
}
// ---------------------------------------------------------------------------
// 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>
);
}