UI Verbesserungen Gruppierung und Anwendung auf alle Seiten

This commit is contained in:
Ida 2026-04-30 10:46:44 +02:00
parent aff9dcb7bd
commit e7a79a3484
8 changed files with 241 additions and 24 deletions

View file

@ -486,6 +486,56 @@
cursor: pointer;
}
/* Items that live inside a group — subtle tint + left connector */
.tr.groupedItem {
border-left: 3px solid color-mix(in srgb, var(--color-primary, #4a6fa5) 35%, transparent);
}
.tr.groupedItem:hover {
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 8%, var(--color-bg, #fff));
}
/**
* Hierarchy: set `--row-tree-indent` on the <tr> (px). Same row shifts checkbox, actions, and every `.td`.
* Folder rows attach this class from GroupRow.tsx; omit padding on `.folderCell` (inner strip uses `--group-indent`).
*/
.treeRowIndented {
--row-tree-indent: 0px;
}
.treeRowIndented > .selectColumn {
box-sizing: border-box !important;
padding-top: 4px !important;
padding-right: 4px !important;
padding-bottom: 4px !important;
padding-left: calc(4px + var(--row-tree-indent)) !important;
}
.treeRowIndented > .actionsColumn {
box-sizing: border-box !important;
padding-top: 4px !important;
padding-right: 4px !important;
padding-bottom: 4px !important;
padding-left: calc(4px + var(--row-tree-indent)) !important;
}
.treeRowIndented > .td {
box-sizing: border-box !important;
padding-top: 8px !important;
padding-right: 12px !important;
padding-bottom: 8px !important;
padding-left: calc(12px + var(--row-tree-indent)) !important;
}
.treeRowIndented > .folderCell:first-child {
box-sizing: border-box !important;
padding-left: calc(12px + var(--row-tree-indent)) !important;
}
.treeRowIndented > .selectColumn + .folderCell {
padding: 0 !important;
}
/* Selection Column */
.selectColumn {
text-align: center;

View file

@ -76,11 +76,15 @@ import {
} from '../../../utils/attributeTypeMapper';
import type { AttributeType } from '../../../utils/attributeTypeMapper';
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';
import {
GroupFolderRow,
BreadcrumbRow,
GROUP_TREE_INDENT_STEP_PX,
type GroupBulkAction,
} 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. */
@ -3058,17 +3062,23 @@ export function FormGeneratorTable<T extends Record<string, any>>({
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 isGrouped = indentLevel > 0;
const rowBgStyle: React.CSSProperties = isGrouped
? { background: 'color-mix(in srgb, var(--color-primary, #4a6fa5) 4%, transparent)' }
: {};
const ungroupStyle: React.CSSProperties = willUngroup
? { borderLeft: '3px solid #d69e2e', opacity: 0.5 }
? { outline: '2px solid #d69e2e', opacity: 0.5 }
: {};
/** Visual depth inside group tree (= folder depth + 1 for direct children — see `_renderGroup`). */
const leadingIndentPx = isGrouped ? indentLevel * GROUP_TREE_INDENT_STEP_PX : 0;
const treeIndentCss: React.CSSProperties | undefined =
isGrouped ? { ['--row-tree-indent' as string]: `${leadingIndentPx}px` } : undefined;
return (
<tr
key={rowId || index}
className={`${styles.tr} ${selectedIds.has(rowId) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
style={{ ...indentStyle, ...ungroupStyle, display: hiddenByDrag ? 'none' : undefined }}
className={`${styles.tr} ${selectedIds.has(rowId) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''} ${isGrouped ? styles.groupedItem : ''} ${isGrouped ? styles.treeRowIndented : ''}`}
style={{ ...rowBgStyle, ...ungroupStyle, ...(treeIndentCss ?? {}), display: hiddenByDrag ? 'none' : undefined }}
onClick={() => onRowClick?.(row, index)}
draggable={groupingEnabled || rowDraggable}
onDragStart={(e) => {
@ -3111,7 +3121,10 @@ export function FormGeneratorTable<T extends Record<string, any>>({
{...Object.fromEntries(Object.entries(dataAttributes).map(([k, v]) => [`data-${k}`, v]))}
>
{selectable && (
<td className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
<td
className={styles.selectColumn}
style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}
>
<input type="checkbox"
checked={selectedIds.has(_getRowId(row))}
onChange={() => handleRowSelect(row)}
@ -3123,7 +3136,10 @@ export function FormGeneratorTable<T extends Record<string, any>>({
</td>
)}
{hasActionColumn && (
<td className={styles.actionsColumn} style={{ width: `${currentActionsWidth}px`, minWidth: `${defaultActionsWidth}px` }}>
<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) => {
@ -3155,13 +3171,21 @@ export function FormGeneratorTable<T extends Record<string, any>>({
</div>
</td>
)}
{detectedColumns.map(col => {
{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 }}>
<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>
);
@ -3181,9 +3205,25 @@ export function FormGeneratorTable<T extends Record<string, any>>({
const _visibleById = new Map<string, T>();
displayData.forEach(row => _visibleById.set(_getRowId(row), row));
const _collectSelectableSubtreeIds = (n: typeof groupTree[0]): string[] => {
const acc: string[] = [];
for (const id of n.itemIds) {
const r = _visibleById.get(id);
if (!r) continue;
if (isRowSelectable && !isRowSelectable(r)) continue;
acc.push(id);
}
for (const sg of n.subGroups) acc.push(..._collectSelectableSubtreeIds(sg));
return acc;
};
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 subtreeSelectableIds = _collectSelectableSubtreeIds(node);
const subtreeSelectedCount = subtreeSelectableIds.filter(id => selectedIds.has(id)).length;
const subtreeAllSelected = subtreeSelectableIds.length > 0 && subtreeSelectedCount === subtreeSelectableIds.length;
const subtreePartial = subtreeSelectedCount > 0 && subtreeSelectedCount < subtreeSelectableIds.length;
const userCollapsed = expandedGroups.has(`collapsed-${node.id}`);
// Visual expanded state (chevron icon, hover-expand override, drag-collapse override)
@ -3241,7 +3281,24 @@ export function FormGeneratorTable<T extends Record<string, any>>({
<GroupFolderRow
node={node}
depth={depth}
colSpan={_totalColSpan}
tableCells={{
showSelect: selectable,
dataColumnsCount: detectedColumns.length + (hasActionColumn ? 1 : 0),
selectClassName: styles.selectColumn,
selectTdStyle: { width: '40px', minWidth: '40px', maxWidth: '40px' },
}}
subtreeSelect={selectable ? {
checked: subtreeAllSelected,
indeterminate: subtreePartial,
disabled: subtreeSelectableIds.length === 0,
onToggle: () => {
if (subtreeSelectableIds.length === 0) return;
const next = new Set(selectedIds);
if (subtreeAllSelected) subtreeSelectableIds.forEach(id => next.delete(id));
else subtreeSelectableIds.forEach(id => next.add(id));
_notifySelection(next);
},
} : undefined}
visibleCount={visibleIds.length}
isExpanded={isExp}
isEditing={editingGroupId === node.id}

View file

@ -40,6 +40,17 @@
border-left: 3px solid #d69e2e;
}
/* Folder subtree selection (aligned with tbody .tr.selected) */
.groupFolderRow.folderRowSubtreeFull {
background: rgba(124, 109, 216, 0.08);
background: rgba(var(--color-secondary-rgb), 0.08);
}
.groupFolderRow.folderRowSubtreePartial {
background: rgba(124, 109, 216, 0.04);
background: rgba(var(--color-secondary-rgb), 0.04);
}
.folderCell {
padding: 0 !important;
width: 100%;

View file

@ -3,6 +3,7 @@ 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';
@ -18,14 +19,36 @@ export interface GroupBulkAction {
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;
colSpan: 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;
@ -60,6 +83,8 @@ interface GroupFolderRowProps {
export function GroupFolderRow({
node,
depth,
subtreeSelect,
tableCells,
colSpan,
visibleCount,
isExpanded,
@ -87,8 +112,15 @@ export function GroupFolderRow({
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();
@ -96,26 +128,55 @@ export function GroupFolderRow({
}
}, [isEditing]);
const indentPx = depth * 20;
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(' ');
return (
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={{ '--group-indent': `${indentPx}px`, display: hidden ? 'none' : undefined } as React.CSSProperties}
style={{ ...folderStripStyle, display: hidden ? 'none' : undefined } as React.CSSProperties}
draggable={!isEditing}
onDragStart={onGroupDragStart}
onDrag={onGroupDrag}
onDragEnd={onGroupDragEnd}
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
@ -135,7 +196,23 @@ export function GroupFolderRow({
onDragLeave={() => { onItemDragLeave(); onGroupDragLeave(); }}
onDragEnter={(e) => e.preventDefault()}
>
<td colSpan={colSpan} className={styles.folderCell}>
{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 }} />}
@ -143,6 +220,7 @@ export function GroupFolderRow({
{/* Chevron */}
<button
className={`${styles.chevronBtn} ${isExpanded ? styles.chevronOpen : ''}`}
type="button"
onClick={(e) => { e.stopPropagation(); onToggle(); }}
title={isExpanded ? t('Zuklappen') : t('Aufklappen')}
tabIndex={-1}
@ -198,6 +276,7 @@ export function GroupFolderRow({
{bulkActions.map((action, i) => (
<button
key={i}
type="button"
className={`${styles.actionBtn} ${action.variant === 'danger' ? styles.actionBtnDanger : ''}`}
title={action.title}
disabled={!!action.disabled}
@ -213,8 +292,8 @@ export function GroupFolderRow({
{/* ── 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>
<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>
)}
@ -224,6 +303,8 @@ export function GroupFolderRow({
</tr>
</>
);
return folderCells;
}
// ---------------------------------------------------------------------------

View file

@ -14,6 +14,7 @@ import {
deleteFiles as deleteFilesApi,
type FolderInfo,
} from '../api/fileApi';
import type { TableGroupNode } from '../api/connectionApi';
export interface FilePreviewResult {
success: boolean;
@ -73,6 +74,7 @@ export function useUserFiles() {
totalItems: number;
totalPages: number;
} | null>(null);
const [groupTree, setGroupTree] = useState<TableGroupNode[]>([]);
const { request, isLoading: loading, error } = useApiRequest<null, UserFile[]>();
const { checkPermission } = usePermissions();
@ -172,6 +174,9 @@ export function useUserFiles() {
if (data.pagination) {
setPagination(data.pagination);
}
if (Array.isArray((data as any).groupTree)) {
setGroupTree((data as any).groupTree);
}
} else {
// Handle non-paginated response (backward compatibility)
console.log('📋 Processing non-paginated response:', {
@ -325,6 +330,7 @@ export function useUserFiles() {
attributes,
permissions,
pagination,
groupTree,
fetchFileById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded

View file

@ -13,6 +13,7 @@ import {
type AttributeDefinition,
type PaginationParams
} from '../api/promptApi';
import type { TableGroupNode } from '../api/connectionApi';
// Re-export types for backward compatibility
export type { Prompt, AttributeDefinition, PaginationParams };
@ -34,6 +35,7 @@ export function usePrompts() {
totalItems: number;
totalPages: number;
} | null>(null);
const [groupTree, setGroupTree] = useState<TableGroupNode[]>([]);
const { request, isLoading: loading, error } = useApiRequest<null, Prompt[]>();
const { checkPermission } = usePermissions();
@ -99,6 +101,9 @@ export function usePrompts() {
if (data.pagination) {
setPagination(data.pagination);
}
if (Array.isArray((data as any).groupTree)) {
setGroupTree((data as any).groupTree);
}
} else {
// Handle non-paginated response (backward compatibility)
const items = Array.isArray(data) ? data : [];
@ -454,10 +459,11 @@ export function usePrompts() {
attributes,
permissions,
pagination,
groupTree,
fetchPromptById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded // Generic function to ensure attributes are loaded
ensureAttributesLoaded
};
}

View file

@ -57,6 +57,7 @@ export const FilesPage: React.FC = () => {
error,
refetch: tableRefetch,
pagination,
groupTree,
fetchFileById,
updateFileOptimistically,
} = useUserFiles();
@ -556,7 +557,9 @@ export const FilesPage: React.FC = () => {
handleInlineUpdate,
updateOptimistically: updateFileOptimistically,
previewingFiles,
groupTree,
}}
groupingConfig={{ contextKey: 'files', enabled: true }}
emptyMessage={emptyTableMessage}
/>
</div>

View file

@ -34,6 +34,7 @@ export const PromptsPage: React.FC = () => {
loading,
error,
refetch,
groupTree,
fetchPromptById,
updateOptimistically,
} = usePrompts();
@ -236,7 +237,9 @@ export const PromptsPage: React.FC = () => {
handleDelete: handlePromptDelete,
handleInlineUpdate,
updateOptimistically,
groupTree,
}}
groupingConfig={{ contextKey: 'prompts', enabled: true }}
emptyMessage={t('Keine Prompts gefunden')}
/>
</div>