fixes udb, outlook, workflow
This commit is contained in:
parent
c702740714
commit
1c2a196192
25 changed files with 1879 additions and 69 deletions
|
|
@ -22,6 +22,7 @@ import {
|
||||||
archiveVersion,
|
archiveVersion,
|
||||||
createTemplateFromWorkflow,
|
createTemplateFromWorkflow,
|
||||||
copyTemplate,
|
copyTemplate,
|
||||||
|
importWorkflowFromFile,
|
||||||
type NodeType,
|
type NodeType,
|
||||||
type NodeTypeCategory,
|
type NodeTypeCategory,
|
||||||
type Automation2Graph,
|
type Automation2Graph,
|
||||||
|
|
@ -122,6 +123,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
instanceId,
|
instanceId,
|
||||||
mandateId: mandateId || '',
|
mandateId: mandateId || '',
|
||||||
featureInstanceId: instanceId,
|
featureInstanceId: instanceId,
|
||||||
|
surface: 'graphEditor',
|
||||||
}), [instanceId, mandateId]);
|
}), [instanceId, mandateId]);
|
||||||
const [versions, setVersions] = useState<AutoVersion[]>([]);
|
const [versions, setVersions] = useState<AutoVersion[]>([]);
|
||||||
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
|
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
|
||||||
|
|
@ -722,6 +724,10 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
hideTabs={['chats']}
|
hideTabs={['chats']}
|
||||||
onFileSelect={onFileSelect}
|
onFileSelect={onFileSelect}
|
||||||
onSourcesChanged={onSourcesChanged}
|
onSourcesChanged={onSourcesChanged}
|
||||||
|
onWorkflowImportedFromFile={async (workflowId) => {
|
||||||
|
await loadWorkflows();
|
||||||
|
handleWorkflowSelect(workflowId);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -771,6 +777,21 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
getCategoryIcon={getCategoryIcon}
|
getCategoryIcon={getCategoryIcon}
|
||||||
onSelectionChange={setSelectedNode}
|
onSelectionChange={setSelectedNode}
|
||||||
highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined}
|
highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined}
|
||||||
|
onExternalDrop={async (mime, payload) => {
|
||||||
|
if (mime !== 'application/json+workflow' || !instanceId) return false;
|
||||||
|
const p = payload as { files?: Array<{ id: string }> } | undefined;
|
||||||
|
const fileId = p?.files?.[0]?.id;
|
||||||
|
if (!fileId) return false;
|
||||||
|
try {
|
||||||
|
const result = await importWorkflowFromFile(request, instanceId, { fileId });
|
||||||
|
await loadWorkflows();
|
||||||
|
if (result?.workflow?.id) handleWorkflowSelect(result.workflow.id);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`${LOG} workflow drop import failed`, e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{configurableSelected && selectedNode && (
|
{configurableSelected && selectedNode && (
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,11 @@ interface FlowCanvasProps {
|
||||||
getCategoryIcon: (category: string) => React.ReactNode;
|
getCategoryIcon: (category: string) => React.ReactNode;
|
||||||
onSelectionChange?: (node: CanvasNode | null) => void;
|
onSelectionChange?: (node: CanvasNode | null) => void;
|
||||||
highlightedNodeIds?: Record<string, string>;
|
highlightedNodeIds?: Record<string, string>;
|
||||||
|
/** Wenn ein Drop mit einer registrierten externen MIME-Type ankommt
|
||||||
|
* (z. B. ``application/json+workflow`` aus der UDB-FilesTab),
|
||||||
|
* wird dieser Callback statt der Node-Type-Drop-Logik aufgerufen.
|
||||||
|
* Liefert `true` zurück, wenn der Drop als "verarbeitet" gilt. */
|
||||||
|
onExternalDrop?: (mime: string, payload: unknown) => Promise<boolean> | boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HIGHLIGHT_COLORS: Record<string, string> = {
|
const HIGHLIGHT_COLORS: Record<string, string> = {
|
||||||
|
|
@ -162,6 +167,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
getCategoryIcon,
|
getCategoryIcon,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
highlightedNodeIds,
|
highlightedNodeIds,
|
||||||
|
onExternalDrop,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -256,8 +262,31 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
}, [connections]);
|
}, [connections]);
|
||||||
|
|
||||||
const handleDrop = useCallback(
|
const handleDrop = useCallback(
|
||||||
(e: React.DragEvent) => {
|
async (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
// 1) externe Drop-Targets (z. B. ``application/json+workflow`` aus UDB-FilesTab)
|
||||||
|
if (onExternalDrop) {
|
||||||
|
const reservedMimes = new Set([
|
||||||
|
'application/json',
|
||||||
|
'application/tree-items',
|
||||||
|
'application/file-id',
|
||||||
|
'application/file-ids',
|
||||||
|
'application/folder-id',
|
||||||
|
]);
|
||||||
|
for (const mime of Array.from(e.dataTransfer.types)) {
|
||||||
|
if (!mime.startsWith('application/') || reservedMimes.has(mime)) continue;
|
||||||
|
const raw = e.dataTransfer.getData(mime);
|
||||||
|
if (!raw) continue;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(raw);
|
||||||
|
const handled = await onExternalDrop(mime, payload);
|
||||||
|
if (handled) return;
|
||||||
|
} catch {
|
||||||
|
// andere Drag-Source → ignorieren, Standard-Pfad versuchen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2) Standard: Node-Type aus der NodeSidebar
|
||||||
const raw = e.dataTransfer.getData('application/json');
|
const raw = e.dataTransfer.getData('application/json');
|
||||||
if (!raw || !containerRef.current) return;
|
if (!raw || !containerRef.current) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -269,7 +298,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
onDropNodeType(type, Math.max(0, x), Math.max(0, y));
|
onDropNodeType(type, Math.max(0, x), Math.max(0, y));
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
},
|
},
|
||||||
[onDropNodeType, panOffset, zoom]
|
[onDropNodeType, onExternalDrop, panOffset, zoom]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleHandleMouseDown = useCallback(
|
const handleHandleMouseDown = useCallback(
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,11 @@
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import type { CanvasNode } from './FlowCanvas';
|
import type { CanvasNode } from './FlowCanvas';
|
||||||
import type { NodeType, NodeTypeParameter } from '../../../api/workflowApi';
|
import type { NodeType, NodeTypeParameter, PortSchema } from '../../../api/workflowApi';
|
||||||
import type { ApiRequestFunction } from '../../../api/workflowApi';
|
import type { ApiRequestFunction } from '../../../api/workflowApi';
|
||||||
import { getLabel } from '../nodes/shared/utils';
|
import { getLabel } from '../nodes/shared/utils';
|
||||||
import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers';
|
import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers';
|
||||||
|
import { useAutomation2DataFlow } from '../context/Automation2DataFlowContext';
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
|
@ -72,12 +73,21 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
||||||
[onParametersChange]
|
[onParametersChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const dataFlow = useAutomation2DataFlow();
|
||||||
|
const portTypeCatalog: Record<string, PortSchema> = (dataFlow?.portTypeCatalog as Record<string, PortSchema> | undefined) ?? {};
|
||||||
|
|
||||||
if (!node || !nodeType) return null;
|
if (!node || !nodeType) return null;
|
||||||
|
|
||||||
const isTrigger = node.type.startsWith('trigger.');
|
const isTrigger = node.type.startsWith('trigger.');
|
||||||
const showNameField = onNodeUpdate && !isTrigger;
|
const showNameField = onNodeUpdate && !isTrigger;
|
||||||
const parameters = nodeType.parameters || [];
|
const parameters = nodeType.parameters || [];
|
||||||
|
|
||||||
|
const inputPortDefs = nodeType.inputPorts ?? {};
|
||||||
|
const outputPortDefs = nodeType.outputPorts ?? {};
|
||||||
|
const inputPortEntries = Object.entries(inputPortDefs);
|
||||||
|
const outputPortEntries = Object.entries(outputPortDefs);
|
||||||
|
const hasPortInfo = inputPortEntries.length > 0 || outputPortEntries.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.nodeConfigPanel}>
|
<div className={styles.nodeConfigPanel}>
|
||||||
{showNameField && (
|
{showNameField && (
|
||||||
|
|
@ -101,6 +111,45 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
||||||
{getLabel(nodeType.description, language)}
|
{getLabel(nodeType.description, language)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{hasPortInfo && (
|
||||||
|
<details className={styles.nodeConfigPorts ?? ''} style={{ margin: '0 0 0.75rem', fontSize: '0.75rem' }}>
|
||||||
|
<summary style={{ cursor: 'pointer', color: 'var(--text-secondary, #666)', fontWeight: 600, padding: '0.25rem 0' }}>
|
||||||
|
{t('Datenfluss (Eingabe / Ausgabe)')}
|
||||||
|
</summary>
|
||||||
|
{inputPortEntries.length > 0 && (
|
||||||
|
<div style={{ marginTop: '0.4rem' }}>
|
||||||
|
<div style={{ color: 'var(--text-secondary, #666)', fontWeight: 600, marginBottom: 2 }}>
|
||||||
|
{'\u2B07'} {t('Eingabe')}
|
||||||
|
</div>
|
||||||
|
{inputPortEntries.map(([idx, def]) => (
|
||||||
|
<_PortFieldList
|
||||||
|
key={`in-${idx}`}
|
||||||
|
portIndex={Number(idx)}
|
||||||
|
schemaNames={def?.accepts ?? []}
|
||||||
|
catalog={portTypeCatalog}
|
||||||
|
emptyLabel={t('keine Felder')}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{outputPortEntries.length > 0 && (
|
||||||
|
<div style={{ marginTop: '0.4rem' }}>
|
||||||
|
<div style={{ color: 'var(--text-secondary, #666)', fontWeight: 600, marginBottom: 2 }}>
|
||||||
|
{'\u2B06'} {t('Ausgabe')}
|
||||||
|
</div>
|
||||||
|
{outputPortEntries.map(([idx, def]) => (
|
||||||
|
<_PortFieldList
|
||||||
|
key={`out-${idx}`}
|
||||||
|
portIndex={Number(idx)}
|
||||||
|
schemaNames={def?.schema ? [def.schema] : []}
|
||||||
|
catalog={portTypeCatalog}
|
||||||
|
emptyLabel={t('keine Felder')}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
{parameters.map((param: NodeTypeParameter) => {
|
{parameters.map((param: NodeTypeParameter) => {
|
||||||
const frontendType = param.frontendType || 'text';
|
const frontendType = param.frontendType || 'text';
|
||||||
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
|
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
|
||||||
|
|
@ -120,3 +169,53 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface _PortFieldListProps {
|
||||||
|
portIndex: number;
|
||||||
|
schemaNames: string[];
|
||||||
|
catalog: Record<string, PortSchema>;
|
||||||
|
emptyLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _PortFieldList: React.FC<_PortFieldListProps> = ({ portIndex, schemaNames, catalog, emptyLabel }) => {
|
||||||
|
if (!schemaNames.length) return null;
|
||||||
|
return (
|
||||||
|
<div style={{ marginLeft: 4, marginBottom: 4 }}>
|
||||||
|
<div style={{ color: '#888', fontSize: '0.7rem' }}>
|
||||||
|
{`#${portIndex} `}{schemaNames.join(' | ')}
|
||||||
|
</div>
|
||||||
|
{schemaNames.map((name) => {
|
||||||
|
const schema = catalog[name];
|
||||||
|
const fields = schema?.fields ?? [];
|
||||||
|
if (name === 'Transit') {
|
||||||
|
return (
|
||||||
|
<div key={name} style={{ marginLeft: 8, color: '#999', fontStyle: 'italic', fontSize: '0.7rem' }}>
|
||||||
|
{'\u00B7 Transit (durchgereichte Daten)'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!fields.length) {
|
||||||
|
return (
|
||||||
|
<div key={name} style={{ marginLeft: 8, color: '#bbb', fontSize: '0.7rem' }}>
|
||||||
|
{`\u00B7 ${emptyLabel}`}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ul key={name} style={{ margin: '2px 0 4px 16px', padding: 0, listStyle: 'none' }}>
|
||||||
|
{fields.map((f) => (
|
||||||
|
<li key={f.name} style={{ fontSize: '0.7rem', lineHeight: 1.4, color: '#555' }}>
|
||||||
|
<span style={{ fontFamily: 'monospace', color: '#222' }}>{f.name}</span>
|
||||||
|
<span style={{ color: '#999' }}>{`: ${f.type}`}</span>
|
||||||
|
{!f.required && <span style={{ color: '#bbb' }}>{' (optional)'}</span>}
|
||||||
|
{f.description && (
|
||||||
|
<div style={{ color: '#888', marginLeft: 4 }}>{f.description}</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,16 @@
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Visueller Hint für Custom-Drag-Sources (z. B. Workflow-Files):
|
||||||
|
* pulst dezent beim Hover, um zu signalisieren "hier kann ich woanders hingezogen werden". */
|
||||||
|
@keyframes _customDragPulse {
|
||||||
|
0%, 100% { box-shadow: inset 0 0 0 0 transparent; }
|
||||||
|
50% { box-shadow: inset 2px 0 0 0 var(--color-primary, #F25843); }
|
||||||
|
}
|
||||||
|
.treeNode.hasCustomDrag:hover {
|
||||||
|
animation: _customDragPulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.chevron {
|
.chevron {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,18 @@ import { usePrompt, type PromptOptions } from '../../hooks/usePrompt';
|
||||||
import styles from './FolderTree.module.css';
|
import styles from './FolderTree.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import {
|
||||||
|
type FileAction,
|
||||||
|
type FileActionContext,
|
||||||
|
type FileActionTarget,
|
||||||
|
type UdbSurface,
|
||||||
|
resolveActionLabel,
|
||||||
|
} from './actions/types';
|
||||||
|
import { useFileActions, runAction, type ResolvedActions } from './actions/registry';
|
||||||
|
import { useViewMode } from './actions/useViewMode';
|
||||||
|
import { usePointerLongPress } from './actions/usePointerLongPress';
|
||||||
|
import { FileActionContextMenu } from './actions/FileActionContextMenu';
|
||||||
|
import { FileActionBottomSheet } from './actions/FileActionBottomSheet';
|
||||||
|
|
||||||
/* ── Public types ──────────────────────────────────────────────────────── */
|
/* ── Public types ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
|
@ -80,6 +92,11 @@ export interface FolderTreeProps {
|
||||||
onFolderScopeChange?: (folderId: string, newScope: string) => void;
|
onFolderScopeChange?: (folderId: string, newScope: string) => void;
|
||||||
onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void;
|
onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void;
|
||||||
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
|
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
|
||||||
|
/** Optionale Custom-Aktionen (Plugin-Slot, siehe `actions/types.ts`).
|
||||||
|
* Built-in Aktionen funktionieren auch ohne dieses Prop unverändert. */
|
||||||
|
customActions?: FileAction[];
|
||||||
|
/** Aufruf-Surface (z. B. ``'graphEditor'``) — wird in Predicates der Custom-Actions gespiegelt. */
|
||||||
|
udbContext?: UdbSurface;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Helpers ───────────────────────────────────────────────────────────── */
|
/* ── Helpers ───────────────────────────────────────────────────────────── */
|
||||||
|
|
@ -148,6 +165,28 @@ function _computeFlatList(
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _matchesShortcut(e: KeyboardEvent, shortcut: string): boolean {
|
||||||
|
const parts = shortcut.toLowerCase().split('+').map(p => p.trim());
|
||||||
|
const wantMod = parts.includes('mod');
|
||||||
|
const wantShift = parts.includes('shift');
|
||||||
|
const wantAlt = parts.includes('alt');
|
||||||
|
const wantCtrl = parts.includes('ctrl') && !wantMod;
|
||||||
|
const key = parts.find(p => !['mod', 'shift', 'alt', 'ctrl'].includes(p));
|
||||||
|
if (!key) return false;
|
||||||
|
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
|
||||||
|
const modOk = wantMod ? (isMac ? e.metaKey : e.ctrlKey) : true;
|
||||||
|
const ctrlOk = wantCtrl ? e.ctrlKey : (wantMod ? true : !e.ctrlKey);
|
||||||
|
const shiftOk = wantShift === e.shiftKey;
|
||||||
|
const altOk = wantAlt === e.altKey;
|
||||||
|
const keyOk = e.key.toLowerCase() === key || e.code.toLowerCase() === `key${key}`;
|
||||||
|
return modOk && ctrlOk && shiftOk && altOk && keyOk;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _windowConfirm(_title: string, body: string): boolean {
|
||||||
|
if (typeof window === 'undefined') return true;
|
||||||
|
return window.confirm(body);
|
||||||
|
}
|
||||||
|
|
||||||
function _fileIcon(mime?: string): string {
|
function _fileIcon(mime?: string): string {
|
||||||
if (!mime) return '\uD83D\uDCC4';
|
if (!mime) return '\uD83D\uDCC4';
|
||||||
if (mime.startsWith('image/')) return '\uD83D\uDDBC\uFE0F';
|
if (mime.startsWith('image/')) return '\uD83D\uDDBC\uFE0F';
|
||||||
|
|
@ -186,6 +225,21 @@ interface SelectionCtx {
|
||||||
onScopeChange?: (fileId: string, newScope: string) => void;
|
onScopeChange?: (fileId: string, newScope: string) => void;
|
||||||
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
|
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
|
||||||
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
|
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
|
||||||
|
/** Action-System Pipeline. Wenn vorhanden, rendert das FileItem zusätzlich
|
||||||
|
* Right-Click-Menu, Long-Press-Sheet und Custom-Inline-Icons. */
|
||||||
|
actions?: {
|
||||||
|
actionCtx: FileActionContext;
|
||||||
|
/** Liefert pro Target die nach Kanal sortierten/gefilterten Aktionen. */
|
||||||
|
resolveFor: (target: FileActionTarget) => ResolvedActions;
|
||||||
|
/** Öffnet das Right-Click-Menu am angegebenen Viewport-Punkt. */
|
||||||
|
openMenu: (anchor: { x: number; y: number }, target: FileActionTarget, title?: string) => void;
|
||||||
|
/** Öffnet das Bottom-Sheet (Mobile Long-Press). */
|
||||||
|
openSheet: (target: FileActionTarget, title?: string) => void;
|
||||||
|
/** Custom-Drag-MIME-Types, die zusätzlich ans dataTransfer gehängt werden. */
|
||||||
|
applyDragPayload: (e: React.DragEvent, target: FileActionTarget) => void;
|
||||||
|
};
|
||||||
|
/** Inline-Rename-Trigger des FolderTree (für die Built-in `core.rename`-Action). */
|
||||||
|
registerInlineRename: (fileId: string, fn: () => void) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Stable trio (chat | scope | neutralize) ──────────────────────────────
|
/* ── Stable trio (chat | scope | neutralize) ──────────────────────────────
|
||||||
|
|
@ -276,6 +330,37 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
|
||||||
const isSelected = sel.selectedItemIds.has(file.id);
|
const isSelected = sel.selectedItemIds.has(file.id);
|
||||||
const multiSelected = sel.selectedItemIds.size > 1;
|
const multiSelected = sel.selectedItemIds.size > 1;
|
||||||
|
|
||||||
|
const _beginRename = useCallback(() => {
|
||||||
|
setRenameValue(file.fileName);
|
||||||
|
setRenaming(true);
|
||||||
|
}, [file.fileName]);
|
||||||
|
useEffect(() => {
|
||||||
|
sel.registerInlineRename(file.id, _beginRename);
|
||||||
|
}, [file.id, _beginRename, sel]);
|
||||||
|
|
||||||
|
const _buildActionTarget = useCallback((): FileActionTarget => {
|
||||||
|
return { files: [file], folders: [] };
|
||||||
|
}, [file]);
|
||||||
|
|
||||||
|
const _onContextMenu = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (!sel.actions) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
sel.actions.openMenu({ x: e.clientX, y: e.clientY }, _buildActionTarget(), file.fileName);
|
||||||
|
}, [sel.actions, _buildActionTarget, file.fileName]);
|
||||||
|
|
||||||
|
const _longPressHandlers = usePointerLongPress(
|
||||||
|
useCallback(() => {
|
||||||
|
if (!sel.actions) return;
|
||||||
|
sel.actions.openSheet(_buildActionTarget(), file.fileName);
|
||||||
|
}, [sel.actions, _buildActionTarget, file.fileName]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const inlineCustomActions = useMemo(() => {
|
||||||
|
if (!sel.actions) return [];
|
||||||
|
return sel.actions.resolveFor(_buildActionTarget()).inline;
|
||||||
|
}, [sel.actions, _buildActionTarget]);
|
||||||
|
|
||||||
const _handleRename = useCallback(async () => {
|
const _handleRename = useCallback(async () => {
|
||||||
const trimmed = renameValue.trim();
|
const trimmed = renameValue.trim();
|
||||||
if (trimmed && trimmed !== file.fileName && sel.onRenameFile) {
|
if (trimmed && trimmed !== file.fileName && sel.onRenameFile) {
|
||||||
|
|
@ -310,11 +395,15 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
|
||||||
styles.fileNode,
|
styles.fileNode,
|
||||||
isSelected ? styles.multiSelected : '',
|
isSelected ? styles.multiSelected : '',
|
||||||
dragging ? styles.dragging : '',
|
dragging ? styles.dragging : '',
|
||||||
|
inlineCustomActions.length > 0 ? styles.hasCustomDrag : '',
|
||||||
].filter(Boolean).join(' ')}
|
].filter(Boolean).join(' ')}
|
||||||
onClick={(e) => sel.onItemClick(file.id, 'file', e)}
|
onClick={(e) => sel.onItemClick(file.id, 'file', e)}
|
||||||
|
onContextMenu={_onContextMenu}
|
||||||
|
{..._longPressHandlers}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
sel.onItemDragStart(e, file.id, 'file', file.fileName);
|
sel.onItemDragStart(e, file.id, 'file', file.fileName);
|
||||||
|
if (sel.actions) sel.actions.applyDragPayload(e, _buildActionTarget());
|
||||||
setDragging(true);
|
setDragging(true);
|
||||||
}}
|
}}
|
||||||
onDragEnd={() => setDragging(false)}
|
onDragEnd={() => setDragging(false)}
|
||||||
|
|
@ -345,8 +434,27 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className={styles.actions}>
|
<span className={styles.actions}>
|
||||||
|
{!multiSelected && inlineCustomActions.slice(0, 3).map((a) => {
|
||||||
|
const Icon = a.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={a.id}
|
||||||
|
className={`${styles.actionBtn} ${a.danger ? styles.danger : ''}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (sel.actions) {
|
||||||
|
void runAction(a, _buildActionTarget(), sel.actions.actionCtx);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={resolveActionLabel(a, _buildActionTarget())}
|
||||||
|
style={a.iconColor ? { color: a.iconColor } : undefined}
|
||||||
|
>
|
||||||
|
<Icon size={12} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{sel.onRenameFile && !multiSelected && (
|
{sel.onRenameFile && !multiSelected && (
|
||||||
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title={t('Umbenennen')}>
|
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); _beginRename(); }} title={t('Umbenennen')}>
|
||||||
<FaPen />
|
<FaPen />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -674,8 +782,25 @@ export default function FolderTree({
|
||||||
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
|
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
|
||||||
onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder,
|
onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder,
|
||||||
onScopeChange, onNeutralizeToggle, onFolderScopeChange, onFolderNeutralizeToggle, onSendToChat,
|
onScopeChange, onNeutralizeToggle, onFolderScopeChange, onFolderNeutralizeToggle, onSendToChat,
|
||||||
|
customActions, udbContext,
|
||||||
}: FolderTreeProps) {
|
}: FolderTreeProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const viewMode = useViewMode();
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inlineRenameRegistryRef = useRef<Map<string, () => void>>(new Map());
|
||||||
|
const _registerInlineRename = useCallback((fileId: string, fn: () => void) => {
|
||||||
|
inlineRenameRegistryRef.current.set(fileId, fn);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [menuState, setMenuState] = useState<{
|
||||||
|
anchor: { x: number; y: number };
|
||||||
|
target: FileActionTarget;
|
||||||
|
title?: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [sheetState, setSheetState] = useState<{
|
||||||
|
target: FileActionTarget;
|
||||||
|
title?: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(new Set());
|
const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(new Set());
|
||||||
const [internalSelectedIds, setInternalSelectedIds] = useState<Set<string>>(new Set());
|
const [internalSelectedIds, setInternalSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
@ -799,6 +924,60 @@ export default function FolderTree({
|
||||||
return ids;
|
return ids;
|
||||||
}, [tree]);
|
}, [tree]);
|
||||||
|
|
||||||
|
const _beginInlineRename = useCallback((fileId: string) => {
|
||||||
|
const fn = inlineRenameRegistryRef.current.get(fileId);
|
||||||
|
if (fn) fn();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const actionCtx: FileActionContext = useMemo(() => ({
|
||||||
|
viewMode,
|
||||||
|
udbContext,
|
||||||
|
}), [viewMode, udbContext]);
|
||||||
|
|
||||||
|
const fileActions = useFileActions(actionCtx, customActions, {
|
||||||
|
onRenameFile,
|
||||||
|
onDeleteFile,
|
||||||
|
onDeleteFiles,
|
||||||
|
onDeleteFolders,
|
||||||
|
onSendToChat,
|
||||||
|
t,
|
||||||
|
beginInlineRename: _beginInlineRename,
|
||||||
|
});
|
||||||
|
|
||||||
|
const _openMenu = useCallback(
|
||||||
|
(anchor: { x: number; y: number }, target: FileActionTarget, title?: string) => {
|
||||||
|
setMenuState({ anchor, target, title });
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const _openSheet = useCallback((target: FileActionTarget, title?: string) => {
|
||||||
|
setSheetState({ target, title });
|
||||||
|
}, []);
|
||||||
|
const _closeMenu = useCallback(() => setMenuState(null), []);
|
||||||
|
const _closeSheet = useCallback(() => setSheetState(null), []);
|
||||||
|
|
||||||
|
const _applyDragPayload = useCallback(
|
||||||
|
(e: React.DragEvent, target: FileActionTarget) => {
|
||||||
|
const drag = fileActions.forTarget(target).drag;
|
||||||
|
for (const a of drag) {
|
||||||
|
if (!a.dragMime) continue;
|
||||||
|
try {
|
||||||
|
e.dataTransfer.setData(
|
||||||
|
a.dragMime,
|
||||||
|
JSON.stringify({
|
||||||
|
actionId: a.id,
|
||||||
|
files: target.files.map((f) => ({ id: f.id, name: f.fileName })),
|
||||||
|
folders: target.folders.map((f) => ({ id: f.id, name: f.name })),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// dataTransfer.setData kann in seltenen Fällen werfen (read-only) — nicht fatal.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fileActions],
|
||||||
|
);
|
||||||
|
|
||||||
const sel: SelectionCtx = useMemo(() => {
|
const sel: SelectionCtx = useMemo(() => {
|
||||||
const selFileIds = Array.from(selectedItemIds).filter(id => allFileIds.has(id));
|
const selFileIds = Array.from(selectedItemIds).filter(id => allFileIds.has(id));
|
||||||
const selFolderIds = Array.from(selectedItemIds).filter(id => allFolderIds.has(id));
|
const selFolderIds = Array.from(selectedItemIds).filter(id => allFolderIds.has(id));
|
||||||
|
|
@ -815,8 +994,55 @@ export default function FolderTree({
|
||||||
onScopeChange,
|
onScopeChange,
|
||||||
onNeutralizeToggle,
|
onNeutralizeToggle,
|
||||||
onSendToChat,
|
onSendToChat,
|
||||||
|
actions: {
|
||||||
|
actionCtx,
|
||||||
|
resolveFor: fileActions.forTarget,
|
||||||
|
openMenu: _openMenu,
|
||||||
|
openSheet: _openSheet,
|
||||||
|
applyDragPayload: _applyDragPayload,
|
||||||
|
},
|
||||||
|
registerInlineRename: _registerInlineRename,
|
||||||
};
|
};
|
||||||
}, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onScopeChange, onNeutralizeToggle, onSendToChat]);
|
}, [
|
||||||
|
selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart,
|
||||||
|
onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders,
|
||||||
|
onScopeChange, onNeutralizeToggle, onSendToChat,
|
||||||
|
actionCtx, fileActions.forTarget, _openMenu, _openSheet, _applyDragPayload, _registerInlineRename,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Tastenkürzel — nur dispatchen wenn FolderTree den Fokus enthält und es nicht aus
|
||||||
|
// einem Input/Editable-Element kommt (sonst kollidiert F2/Delete mit Inline-Rename).
|
||||||
|
useEffect(() => {
|
||||||
|
const _onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
const root = containerRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
const active = document.activeElement as HTMLElement | null;
|
||||||
|
if (!active || !root.contains(active)) return;
|
||||||
|
const tag = active.tagName.toLowerCase();
|
||||||
|
if (tag === 'input' || tag === 'textarea' || active.isContentEditable) return;
|
||||||
|
|
||||||
|
const selFileIds = Array.from(selectedItemIds).filter(id => allFileIds.has(id));
|
||||||
|
const selFolderIds = Array.from(selectedItemIds).filter(id => allFolderIds.has(id));
|
||||||
|
if (selFileIds.length + selFolderIds.length === 0) return;
|
||||||
|
|
||||||
|
const allFiles = (files ?? []).filter(f => selFileIds.includes(f.id));
|
||||||
|
// Folder-Ziele für Shortcuts kommen aktuell nicht vor — Built-in `core.delete`
|
||||||
|
// operiert auf der Selection. Für diese Iteration genügt das.
|
||||||
|
const target: FileActionTarget = { files: allFiles, folders: [] };
|
||||||
|
const resolved = fileActions.forTarget(target).shortcut;
|
||||||
|
|
||||||
|
for (const a of resolved) {
|
||||||
|
if (!a.shortcut) continue;
|
||||||
|
if (_matchesShortcut(e, a.shortcut)) {
|
||||||
|
e.preventDefault();
|
||||||
|
void runAction(a, target, actionCtx, _windowConfirm);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', _onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', _onKeyDown);
|
||||||
|
}, [selectedItemIds, allFileIds, allFolderIds, files, fileActions, actionCtx]);
|
||||||
|
|
||||||
// Root drop handler: items dropped on the empty area go to root (null)
|
// Root drop handler: items dropped on the empty area go to root (null)
|
||||||
const _handleRootDrop = useCallback(async (e: React.DragEvent) => {
|
const _handleRootDrop = useCallback(async (e: React.DragEvent) => {
|
||||||
|
|
@ -853,8 +1079,17 @@ export default function FolderTree({
|
||||||
onSelect(null);
|
onSelect(null);
|
||||||
}, [_setSelection, onSelect]);
|
}, [_setSelection, onSelect]);
|
||||||
|
|
||||||
|
const menuActions = useMemo(
|
||||||
|
() => (menuState ? fileActions.forTarget(menuState.target).menu : []),
|
||||||
|
[menuState, fileActions],
|
||||||
|
);
|
||||||
|
const sheetActions = useMemo(
|
||||||
|
() => (sheetState ? fileActions.forTarget(sheetState.target).sheet : []),
|
||||||
|
[sheetState, fileActions],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.folderTree}>
|
<div className={styles.folderTree} ref={containerRef} tabIndex={-1}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '2px 4px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '2px 4px' }}>
|
||||||
<span
|
<span
|
||||||
className={`${styles.treeNode} ${isRootSelected ? styles.selected : ''} ${rootDropOver ? styles.dropTarget : ''}`}
|
className={`${styles.treeNode} ${isRootSelected ? styles.selected : ''} ${rootDropOver ? styles.dropTarget : ''}`}
|
||||||
|
|
@ -910,6 +1145,26 @@ export default function FolderTree({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<PromptDialog />
|
<PromptDialog />
|
||||||
|
{menuState && (
|
||||||
|
<FileActionContextMenu
|
||||||
|
anchor={menuState.anchor}
|
||||||
|
actions={menuActions}
|
||||||
|
target={menuState.target}
|
||||||
|
ctx={actionCtx}
|
||||||
|
onClose={_closeMenu}
|
||||||
|
title={menuState.title}
|
||||||
|
confirm={_windowConfirm}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<FileActionBottomSheet
|
||||||
|
open={sheetState !== null}
|
||||||
|
actions={sheetActions}
|
||||||
|
target={sheetState?.target ?? { files: [], folders: [] }}
|
||||||
|
ctx={actionCtx}
|
||||||
|
onClose={_closeSheet}
|
||||||
|
title={sheetState?.title}
|
||||||
|
confirm={_windowConfirm}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
/* Bottom-Sheet für FolderTree Long-Press (Mobile). */
|
||||||
|
|
||||||
|
@keyframes _slideUp {
|
||||||
|
from { transform: translateY(100%); }
|
||||||
|
to { transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes _fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
animation: _fadeIn 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1001;
|
||||||
|
background: var(--color-bg-elevated, #ffffff);
|
||||||
|
border-top-left-radius: 16px;
|
||||||
|
border-top-right-radius: 16px;
|
||||||
|
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.18);
|
||||||
|
padding: 8px 0 calc(8px + env(safe-area-inset-bottom, 0px));
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: _slideUp 0.18s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle {
|
||||||
|
width: 36px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--color-border, rgba(0, 0, 0, 0.18));
|
||||||
|
margin: 4px auto 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
padding: 4px 16px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary, #222);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
border-bottom: 1px solid var(--color-border, rgba(0, 0, 0, 0.06));
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-primary, #222);
|
||||||
|
text-align: left;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:active {
|
||||||
|
background: var(--color-bg-hover, rgba(25, 118, 210, 0.10));
|
||||||
|
}
|
||||||
|
|
||||||
|
.item.danger {
|
||||||
|
color: var(--color-error, #d32f2f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
font-size: 17px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-secondary, #999);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
83
src/components/FolderTree/actions/FileActionBottomSheet.tsx
Normal file
83
src/components/FolderTree/actions/FileActionBottomSheet.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
/**
|
||||||
|
* FileActionBottomSheet — Long-Press Action-Sheet für Mobile.
|
||||||
|
*
|
||||||
|
* Slide-Up von unten, 48 px Touch-Targets, ESC + Backdrop schließen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
type FileAction,
|
||||||
|
type FileActionContext,
|
||||||
|
type FileActionTarget,
|
||||||
|
resolveActionLabel,
|
||||||
|
} from './types';
|
||||||
|
import { runAction } from './registry';
|
||||||
|
import styles from './FileActionBottomSheet.module.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
actions: FileAction[];
|
||||||
|
target: FileActionTarget;
|
||||||
|
ctx: FileActionContext;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
confirm?: (title: string, body: string) => boolean | Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileActionBottomSheet: React.FC<Props> = ({
|
||||||
|
open,
|
||||||
|
actions,
|
||||||
|
target,
|
||||||
|
ctx,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
confirm,
|
||||||
|
}) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const _onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', _onKey);
|
||||||
|
return () => window.removeEventListener('keydown', _onKey);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const _handleClick = async (action: FileAction) => {
|
||||||
|
onClose();
|
||||||
|
await runAction(action, target, ctx, confirm);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.backdrop} onClick={onClose} />
|
||||||
|
<div className={styles.sheet} role="dialog" aria-modal="true" aria-label={title}>
|
||||||
|
<div className={styles.handle} aria-hidden="true" />
|
||||||
|
{title && <div className={styles.title}>{title}</div>}
|
||||||
|
{actions.length === 0 ? (
|
||||||
|
<div className={styles.empty}>—</div>
|
||||||
|
) : (
|
||||||
|
actions.map((a) => {
|
||||||
|
const Icon = a.icon;
|
||||||
|
const cls = a.danger ? `${styles.item} ${styles.danger}` : styles.item;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={a.id}
|
||||||
|
type="button"
|
||||||
|
className={cls}
|
||||||
|
onClick={() => _handleClick(a)}
|
||||||
|
style={a.iconColor ? { color: a.iconColor } : undefined}
|
||||||
|
>
|
||||||
|
<span className={styles.icon}>
|
||||||
|
<Icon size={17} />
|
||||||
|
</span>
|
||||||
|
<span className={styles.label}>{resolveActionLabel(a, target)}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
/* Context-Menu für FolderTree (Right-Click).
|
||||||
|
* Floating, ARIA-menu, Backdrop-Click + ESC schließen. */
|
||||||
|
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1001;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 320px;
|
||||||
|
background: var(--color-bg-elevated, #ffffff);
|
||||||
|
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.12));
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-primary, #222);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 4px 12px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary, #888);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
margin: 4px 0;
|
||||||
|
background: var(--color-border, rgba(0, 0, 0, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:hover,
|
||||||
|
.item:focus-visible {
|
||||||
|
background: var(--color-bg-hover, rgba(25, 118, 210, 0.08));
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item.danger {
|
||||||
|
color: var(--color-error, #d32f2f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item.danger:hover,
|
||||||
|
.item.danger:focus-visible {
|
||||||
|
background: var(--color-bg-error, rgba(211, 47, 47, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-secondary, #999);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", monospace;
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary, #999);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
146
src/components/FolderTree/actions/FileActionContextMenu.tsx
Normal file
146
src/components/FolderTree/actions/FileActionContextMenu.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
/**
|
||||||
|
* FileActionContextMenu — Floating Right-Click-Menu für FolderTree.
|
||||||
|
*
|
||||||
|
* Wird vom FolderTree gemountet wenn `onContextMenu` auf einer Zeile feuert.
|
||||||
|
* Schließt sich bei Backdrop-Klick, ESC oder nach Aktion-Dispatch.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
type FileAction,
|
||||||
|
type FileActionContext,
|
||||||
|
type FileActionTarget,
|
||||||
|
resolveActionLabel,
|
||||||
|
} from './types';
|
||||||
|
import { runAction } from './registry';
|
||||||
|
import styles from './FileActionContextMenu.module.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Sichtbar/positioniert. ``null`` → nicht gemountet. */
|
||||||
|
anchor: { x: number; y: number } | null;
|
||||||
|
actions: FileAction[];
|
||||||
|
target: FileActionTarget;
|
||||||
|
ctx: FileActionContext;
|
||||||
|
/** Wird aufgerufen sobald das Menü schließen soll (Backdrop, ESC, nach Action). */
|
||||||
|
onClose: () => void;
|
||||||
|
/** Optional: Header-Label (z. B. Dateiname). */
|
||||||
|
title?: string;
|
||||||
|
/** Optionaler Confirm-Provider (z. B. browser native ``window.confirm``). */
|
||||||
|
confirm?: (title: string, body: string) => boolean | Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileActionContextMenu: React.FC<Props> = ({
|
||||||
|
anchor,
|
||||||
|
actions,
|
||||||
|
target,
|
||||||
|
ctx,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
confirm,
|
||||||
|
}) => {
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!anchor) return;
|
||||||
|
const _onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', _onKey);
|
||||||
|
return () => window.removeEventListener('keydown', _onKey);
|
||||||
|
}, [anchor, onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!anchor || !menuRef.current) return;
|
||||||
|
menuRef.current.focus();
|
||||||
|
}, [anchor]);
|
||||||
|
|
||||||
|
if (!anchor) return null;
|
||||||
|
|
||||||
|
const adjusted = _adjustToViewport(anchor, menuRef.current);
|
||||||
|
|
||||||
|
const _handleClick = async (action: FileAction) => {
|
||||||
|
onClose();
|
||||||
|
await runAction(action, target, ctx, confirm);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={styles.backdrop}
|
||||||
|
onClick={onClose}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className={styles.menu}
|
||||||
|
role="menu"
|
||||||
|
tabIndex={-1}
|
||||||
|
style={{ left: adjusted.x, top: adjusted.y }}
|
||||||
|
>
|
||||||
|
{title && <div className={styles.header}>{title}</div>}
|
||||||
|
{actions.length === 0 ? (
|
||||||
|
<div className={styles.empty}>—</div>
|
||||||
|
) : (
|
||||||
|
actions.map((a, idx) => {
|
||||||
|
const Icon = a.icon;
|
||||||
|
const isDangerCls = a.danger ? `${styles.item} ${styles.danger}` : styles.item;
|
||||||
|
return (
|
||||||
|
<React.Fragment key={a.id}>
|
||||||
|
{idx > 0 && a.danger && actions[idx - 1] && !actions[idx - 1].danger && (
|
||||||
|
<div className={styles.divider} />
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className={isDangerCls}
|
||||||
|
onClick={() => _handleClick(a)}
|
||||||
|
style={a.iconColor ? { color: a.iconColor } : undefined}
|
||||||
|
>
|
||||||
|
<span className={styles.icon}>
|
||||||
|
<Icon size={13} />
|
||||||
|
</span>
|
||||||
|
<span className={styles.label}>{resolveActionLabel(a, target)}</span>
|
||||||
|
{a.shortcut && <span className={styles.shortcut}>{_formatShortcut(a.shortcut)}</span>}
|
||||||
|
</button>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function _adjustToViewport(
|
||||||
|
anchor: { x: number; y: number },
|
||||||
|
menu: HTMLDivElement | null,
|
||||||
|
): { x: number; y: number } {
|
||||||
|
if (!menu) return anchor;
|
||||||
|
const rect = menu.getBoundingClientRect();
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const margin = 4;
|
||||||
|
let x = anchor.x;
|
||||||
|
let y = anchor.y;
|
||||||
|
if (x + rect.width + margin > vw) x = Math.max(margin, vw - rect.width - margin);
|
||||||
|
if (y + rect.height + margin > vh) y = Math.max(margin, vh - rect.height - margin);
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
function _formatShortcut(s: string): string {
|
||||||
|
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
|
||||||
|
return s
|
||||||
|
.split('+')
|
||||||
|
.map((part) => {
|
||||||
|
const k = part.trim().toLowerCase();
|
||||||
|
if (k === 'mod') return isMac ? '\u2318' : 'Ctrl';
|
||||||
|
if (k === 'shift') return isMac ? '\u21E7' : 'Shift';
|
||||||
|
if (k === 'alt') return isMac ? '\u2325' : 'Alt';
|
||||||
|
if (k === 'ctrl') return 'Ctrl';
|
||||||
|
return k.length === 1 ? k.toUpperCase() : part;
|
||||||
|
})
|
||||||
|
.join(isMac ? '' : '+');
|
||||||
|
}
|
||||||
218
src/components/FolderTree/actions/registry.ts
Normal file
218
src/components/FolderTree/actions/registry.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
/**
|
||||||
|
* useFileActions — zentraler Registry-Hook für FolderTree Aktionen.
|
||||||
|
*
|
||||||
|
* Liefert eine einheitliche, gefilterte und sortierte Aktion-Liste, die das
|
||||||
|
* `FolderTree`-Inneres an Right-Click-Menü, Long-Press-Sheet, Tastenkürzel und
|
||||||
|
* Drag-Source dispatched. Built-in-Aktionen (Rename, Delete, Send-to-Chat)
|
||||||
|
* werden aus den vorhandenen FolderTree-Callbacks abgeleitet, damit existierende
|
||||||
|
* Aufrufer nichts ändern müssen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { FaPen, FaTrash, FaCommentDots } from 'react-icons/fa';
|
||||||
|
import {
|
||||||
|
type FileAction,
|
||||||
|
type FileActionContext,
|
||||||
|
type FileActionTarget,
|
||||||
|
resolveActionLabel,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
/** Callback-Bündel mit den heutigen `FolderTreeProps`-Handlern.
|
||||||
|
* Optional, weil nicht jeder Aufrufer alle Built-ins anbietet. */
|
||||||
|
export interface BuiltinCallbacks {
|
||||||
|
onRenameFile?: (fileId: string, newName: string) => Promise<void>;
|
||||||
|
onDeleteFile?: (fileId: string) => Promise<void>;
|
||||||
|
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
|
||||||
|
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
|
||||||
|
onSendToChat?: (
|
||||||
|
items: Array<{ id: string; type: 'file' | 'folder'; name: string }>,
|
||||||
|
) => void;
|
||||||
|
/** Translator (i18n) — typischerweise `t` aus dem LanguageContext. */
|
||||||
|
t?: (key: string, vars?: Record<string, string>) => string;
|
||||||
|
/** Inline-Rename-Trigger (Eingabefeld in der Zeile). Wird vom FolderTree
|
||||||
|
* intern bereitgestellt — nicht vom Aufrufer. */
|
||||||
|
beginInlineRename?: (fileId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sortierte, gefilterte Aktionsliste pro Kanal. */
|
||||||
|
export interface ResolvedActions {
|
||||||
|
inline: FileAction[];
|
||||||
|
menu: FileAction[];
|
||||||
|
sheet: FileAction[];
|
||||||
|
shortcut: FileAction[];
|
||||||
|
drag: FileAction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const _IDENTITY: BuiltinCallbacks['t'] = (s) => s;
|
||||||
|
|
||||||
|
/** Built-in-Definitionen, die aus den heute hartcodierten Callbacks abgeleitet werden.
|
||||||
|
* Diese erscheinen NUR in den neuen Kanälen (Menu, Sheet, Shortcut) — die Inline-Icons
|
||||||
|
* werden weiterhin direkt vom FolderTree-Renderer gezeichnet, damit die bestehende
|
||||||
|
* "Stable-Trio + dynamische Aktionen"-Logik unangetastet bleibt. */
|
||||||
|
function _buildBuiltins(cb: BuiltinCallbacks): FileAction[] {
|
||||||
|
const t = cb.t ?? _IDENTITY;
|
||||||
|
const list: FileAction[] = [];
|
||||||
|
|
||||||
|
if (cb.onSendToChat) {
|
||||||
|
list.push({
|
||||||
|
id: 'core.sendToChat',
|
||||||
|
label: t('In Chat senden'),
|
||||||
|
icon: FaCommentDots,
|
||||||
|
scope: 'multi',
|
||||||
|
channels: ['menu', 'sheet'],
|
||||||
|
sortOrder: 100,
|
||||||
|
handler: ({ files, folders }) => {
|
||||||
|
const items = [
|
||||||
|
...files.map((f) => ({ id: f.id, type: 'file' as const, name: f.fileName })),
|
||||||
|
...folders.map((f) => ({ id: f.id, type: 'folder' as const, name: f.name })),
|
||||||
|
];
|
||||||
|
if (items.length > 0) cb.onSendToChat!(items);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cb.onRenameFile && cb.beginInlineRename) {
|
||||||
|
list.push({
|
||||||
|
id: 'core.rename',
|
||||||
|
label: t('Umbenennen'),
|
||||||
|
icon: FaPen,
|
||||||
|
scope: 'file',
|
||||||
|
channels: ['menu', 'sheet', 'shortcut'],
|
||||||
|
shortcut: 'F2',
|
||||||
|
sortOrder: 110,
|
||||||
|
predicate: ({ files, folders }) => files.length === 1 && folders.length === 0,
|
||||||
|
handler: ({ files }) => {
|
||||||
|
if (files.length === 1) cb.beginInlineRename!(files[0].id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cb.onDeleteFile || cb.onDeleteFiles || cb.onDeleteFolders) {
|
||||||
|
list.push({
|
||||||
|
id: 'core.delete',
|
||||||
|
label: ({ files, folders }) =>
|
||||||
|
files.length + folders.length > 1
|
||||||
|
? t('{count} Einträge löschen', { count: String(files.length + folders.length) })
|
||||||
|
: t('Löschen'),
|
||||||
|
icon: FaTrash,
|
||||||
|
scope: 'multi',
|
||||||
|
channels: ['menu', 'sheet', 'shortcut'],
|
||||||
|
shortcut: 'Delete',
|
||||||
|
danger: true,
|
||||||
|
sortOrder: 200,
|
||||||
|
predicate: ({ files, folders }) => files.length > 0 || folders.length > 0,
|
||||||
|
confirm: {
|
||||||
|
title: t('Löschen bestätigen'),
|
||||||
|
body: ({ files, folders }) =>
|
||||||
|
files.length + folders.length > 1
|
||||||
|
? t('{count} Einträge löschen?', {
|
||||||
|
count: String(files.length + folders.length),
|
||||||
|
})
|
||||||
|
: t('Diesen Eintrag löschen?'),
|
||||||
|
},
|
||||||
|
handler: async ({ files, folders }) => {
|
||||||
|
if (folders.length > 0 && cb.onDeleteFolders) {
|
||||||
|
await cb.onDeleteFolders(folders.map((f) => f.id));
|
||||||
|
}
|
||||||
|
if (files.length > 1 && cb.onDeleteFiles) {
|
||||||
|
await cb.onDeleteFiles(files.map((f) => f.id));
|
||||||
|
} else if (files.length === 1) {
|
||||||
|
if (cb.onDeleteFile) await cb.onDeleteFile(files[0].id);
|
||||||
|
else if (cb.onDeleteFiles) await cb.onDeleteFiles([files[0].id]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zentrale Registry-Hook.
|
||||||
|
*
|
||||||
|
* @param ctx Aktueller Aufruf-Kontext (View-Mode, Mandant, …).
|
||||||
|
* @param customs Vom Aufrufer registrierte Custom-Actions (Plugin-Slot).
|
||||||
|
* @param builtins Callback-Bündel der Built-in-Aktionen (aus FolderTreeProps abgeleitet).
|
||||||
|
*
|
||||||
|
* Die Rückgabe ist memoized und pro Kanal vorgefiltert; ein `Predicate`-Check
|
||||||
|
* pro Target erfolgt zusätzlich erst beim Render der jeweiligen Zeile/Sheet.
|
||||||
|
*/
|
||||||
|
export function useFileActions(
|
||||||
|
ctx: FileActionContext,
|
||||||
|
customs: FileAction[] | undefined,
|
||||||
|
builtins: BuiltinCallbacks,
|
||||||
|
): {
|
||||||
|
/** Alle Aktionen (gemerged + sortiert), unfiltered nach Predicate. */
|
||||||
|
all: FileAction[];
|
||||||
|
/** Liefert die für ein konkretes Target sichtbaren Aktionen, gruppiert nach Kanal. */
|
||||||
|
forTarget: (target: FileActionTarget) => ResolvedActions;
|
||||||
|
} {
|
||||||
|
const all = useMemo(() => {
|
||||||
|
const merged = [..._buildBuiltins(builtins), ...(customs ?? [])];
|
||||||
|
merged.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.sortOrder ?? 1000) - (b.sortOrder ?? 1000) || a.id.localeCompare(b.id),
|
||||||
|
);
|
||||||
|
return merged;
|
||||||
|
// We intentionally depend on each callback identity so re-renders pick up
|
||||||
|
// updated handlers (closures over instanceId etc.).
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [
|
||||||
|
customs,
|
||||||
|
builtins.onRenameFile,
|
||||||
|
builtins.onDeleteFile,
|
||||||
|
builtins.onDeleteFiles,
|
||||||
|
builtins.onDeleteFolders,
|
||||||
|
builtins.onSendToChat,
|
||||||
|
builtins.beginInlineRename,
|
||||||
|
builtins.t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const forTarget = useMemo(() => {
|
||||||
|
return (target: FileActionTarget): ResolvedActions => {
|
||||||
|
const _matches = (a: FileAction): boolean => {
|
||||||
|
if (a.scope === 'file' && (target.files.length !== 1 || target.folders.length > 0))
|
||||||
|
return false;
|
||||||
|
if (a.scope === 'folder' && (target.folders.length !== 1 || target.files.length > 0))
|
||||||
|
return false;
|
||||||
|
if (a.scope === 'multi' && target.files.length + target.folders.length === 0)
|
||||||
|
return false;
|
||||||
|
if (a.predicate && !a.predicate(target, ctx)) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const matched = all.filter(_matches);
|
||||||
|
return {
|
||||||
|
inline: matched.filter((a) => a.channels.includes('inline')),
|
||||||
|
menu: matched.filter((a) => a.channels.includes('menu')),
|
||||||
|
sheet: matched.filter((a) => a.channels.includes('sheet')),
|
||||||
|
shortcut: matched.filter((a) => a.channels.includes('shortcut')),
|
||||||
|
drag: matched.filter((a) => a.channels.includes('drop')),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}, [all, ctx]);
|
||||||
|
|
||||||
|
return { all, forTarget };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hilfs-Dispatcher: führt Confirm + Handler aus, fängt Fehler ab und loggt sie.
|
||||||
|
* Der eigentliche Confirm-Dialog wird vom Renderer (Context-Menu/Sheet) bereitgestellt
|
||||||
|
* — dieser Helper bleibt UI-frei und ist von außerhalb React aufrufbar. */
|
||||||
|
export async function runAction(
|
||||||
|
action: FileAction,
|
||||||
|
target: FileActionTarget,
|
||||||
|
ctx: FileActionContext,
|
||||||
|
confirmFn?: (label: string, body: string) => boolean | Promise<boolean>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (action.confirm && confirmFn) {
|
||||||
|
const ok = await confirmFn(action.confirm.title, action.confirm.body(target));
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await action.handler(target, ctx);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[FileAction] ${action.id} failed`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { resolveActionLabel };
|
||||||
87
src/components/FolderTree/actions/types.ts
Normal file
87
src/components/FolderTree/actions/types.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
/**
|
||||||
|
* Action-Modell für FolderTree (UDB Action System).
|
||||||
|
*
|
||||||
|
* Eine `FileAction` ist die kanonische Beschreibung einer Aktion, die der User
|
||||||
|
* auf eine Datei oder einen Ordner anwenden kann. Dieselbe Definition rendert
|
||||||
|
* sich automatisch in mehreren Kanälen:
|
||||||
|
* - inline → Icon-Button am rechten Zeilenrand
|
||||||
|
* - menu → Eintrag im Right-Click-Context-Menu
|
||||||
|
* - sheet → Eintrag im Long-Press Bottom-Sheet (Mobile)
|
||||||
|
* - shortcut → Tastenkürzel solange FolderTree Fokus hat
|
||||||
|
* - drop → Drag-Source: hängt eine zusätzliche MIME ans dataTransfer
|
||||||
|
*
|
||||||
|
* Vorhandene Built-in-Aktionen (Rename, Delete, Send-to-Chat) bleiben hinter
|
||||||
|
* dem System bestehen; wenn der Aufrufer keine `customActions` mitliefert,
|
||||||
|
* verhält sich `FolderTree` 1:1 wie zuvor.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type React from 'react';
|
||||||
|
import type { FileNode, FolderNode } from '../FolderTree';
|
||||||
|
|
||||||
|
export type FileActionScope = 'file' | 'folder' | 'multi';
|
||||||
|
export type FileActionChannel = 'inline' | 'menu' | 'sheet' | 'shortcut' | 'drop';
|
||||||
|
|
||||||
|
/** UDB-Aufruf-Kontext — Aufrufer-Sites identifizieren sich, damit Predicates
|
||||||
|
* pro Surface entscheiden können (z. B. "nur im Graph-Editor sichtbar"). */
|
||||||
|
export type UdbSurface =
|
||||||
|
| 'workspace'
|
||||||
|
| 'graphEditor'
|
||||||
|
| 'trustee'
|
||||||
|
| 'standalone'
|
||||||
|
| 'sharepoint';
|
||||||
|
|
||||||
|
export interface FileActionContext {
|
||||||
|
mandateId?: string;
|
||||||
|
featureInstanceId?: string;
|
||||||
|
viewMode: 'desktop' | 'mobile';
|
||||||
|
udbContext?: UdbSurface;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileActionTarget {
|
||||||
|
files: FileNode[];
|
||||||
|
folders: FolderNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileActionConfirm {
|
||||||
|
title: string;
|
||||||
|
body: (target: FileActionTarget) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileAction {
|
||||||
|
/** Global eindeutige Aktion-ID, namespace-prefixed (z. B. ``workflow.openInEditor``). */
|
||||||
|
id: string;
|
||||||
|
/** Anzeige-Label (statisch oder als Funktion vom Target abgeleitet). */
|
||||||
|
label: string | ((target: FileActionTarget) => string);
|
||||||
|
/** Icon-Komponente (react-icons-Style), bekommt optional `size`-Prop. */
|
||||||
|
icon: React.ComponentType<{ size?: number }>;
|
||||||
|
/** Optionale Tönung des Icons (CSS color string). */
|
||||||
|
iconColor?: string;
|
||||||
|
/** Was ist das Target — einzelne Datei, Ordner, oder Mehrfach-Selektion. */
|
||||||
|
scope: FileActionScope;
|
||||||
|
/** Über welche UI-Kanäle wird die Aktion angeboten. */
|
||||||
|
channels: FileActionChannel[];
|
||||||
|
/** Pure, billig — entscheidet ob die Aktion für das aktuelle Target sichtbar ist. */
|
||||||
|
predicate?: (target: FileActionTarget, ctx: FileActionContext) => boolean;
|
||||||
|
/** Async oder sync. Fehler werden vom Renderer geloggt; Toasts macht der Aufrufer. */
|
||||||
|
handler: (target: FileActionTarget, ctx: FileActionContext) => Promise<void> | void;
|
||||||
|
/** Tastenkürzel, z. B. `mod+e`. ``mod`` = Cmd auf Mac, Ctrl sonst. */
|
||||||
|
shortcut?: string;
|
||||||
|
/** Wenn gesetzt → Bestätigungs-Dialog vor `handler`. */
|
||||||
|
confirm?: FileActionConfirm;
|
||||||
|
/** MIME-Type für Drag-Source: wird zusätzlich ans `dataTransfer` gehängt. */
|
||||||
|
dragMime?: string;
|
||||||
|
/** Sortier-Reihenfolge — kleinere Werte zuerst (Built-ins liegen bei 100, 110, 120…). */
|
||||||
|
sortOrder?: number;
|
||||||
|
/** Visuell als gefährliche/destruktive Aktion markieren (rote Tönung). */
|
||||||
|
danger?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolver-Helper: liest das Label eines `FileAction` aus, egal ob String oder Funktion. */
|
||||||
|
export function resolveActionLabel(action: FileAction, target: FileActionTarget): string {
|
||||||
|
return typeof action.label === 'function' ? action.label(target) : action.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hilfs-Konstruktor: baut ein leeres Target. */
|
||||||
|
export function emptyTarget(): FileActionTarget {
|
||||||
|
return { files: [], folders: [] };
|
||||||
|
}
|
||||||
75
src/components/FolderTree/actions/usePointerLongPress.ts
Normal file
75
src/components/FolderTree/actions/usePointerLongPress.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Long-Press-Erkennung über Pointer-Events.
|
||||||
|
*
|
||||||
|
* Liefert Handler die direkt auf `<div>` etc. gespreaded werden können.
|
||||||
|
* Ein "Long-Press" feuert nach `thresholdMs` (Default 500 ms) wenn der Pointer
|
||||||
|
* sich nicht weiter als `moveTolerance` Pixel bewegt hat.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface LongPressOptions {
|
||||||
|
thresholdMs?: number;
|
||||||
|
moveTolerance?: number;
|
||||||
|
/** Wenn ``true``, werden auch Maus-Events behandelt (für Desktop-Smoke-Tests). */
|
||||||
|
includeMouse?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LongPressHandlers {
|
||||||
|
onPointerDown: (e: React.PointerEvent) => void;
|
||||||
|
onPointerMove: (e: React.PointerEvent) => void;
|
||||||
|
onPointerUp: (e: React.PointerEvent) => void;
|
||||||
|
onPointerCancel: (e: React.PointerEvent) => void;
|
||||||
|
onPointerLeave: (e: React.PointerEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePointerLongPress(
|
||||||
|
callback: (e: React.PointerEvent) => void,
|
||||||
|
options: LongPressOptions = {},
|
||||||
|
): LongPressHandlers {
|
||||||
|
const { thresholdMs = 500, moveTolerance = 8, includeMouse = false } = options;
|
||||||
|
const timerRef = useRef<number | null>(null);
|
||||||
|
const startPosRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const firedRef = useRef(false);
|
||||||
|
|
||||||
|
const _clear = useCallback(() => {
|
||||||
|
if (timerRef.current !== null) {
|
||||||
|
window.clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
startPosRef.current = null;
|
||||||
|
firedRef.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onPointerDown = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
if (!includeMouse && e.pointerType === 'mouse') return;
|
||||||
|
_clear();
|
||||||
|
startPosRef.current = { x: e.clientX, y: e.clientY };
|
||||||
|
firedRef.current = false;
|
||||||
|
timerRef.current = window.setTimeout(() => {
|
||||||
|
firedRef.current = true;
|
||||||
|
callback(e);
|
||||||
|
}, thresholdMs);
|
||||||
|
},
|
||||||
|
[callback, includeMouse, thresholdMs, _clear],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPointerMove = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
if (timerRef.current === null || !startPosRef.current) return;
|
||||||
|
const dx = e.clientX - startPosRef.current.x;
|
||||||
|
const dy = e.clientY - startPosRef.current.y;
|
||||||
|
if (Math.abs(dx) > moveTolerance || Math.abs(dy) > moveTolerance) _clear();
|
||||||
|
},
|
||||||
|
[moveTolerance, _clear],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
onPointerDown,
|
||||||
|
onPointerMove,
|
||||||
|
onPointerUp: _clear,
|
||||||
|
onPointerCancel: _clear,
|
||||||
|
onPointerLeave: _clear,
|
||||||
|
};
|
||||||
|
}
|
||||||
25
src/components/FolderTree/actions/useViewMode.ts
Normal file
25
src/components/FolderTree/actions/useViewMode.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den aktuellen View-Mode (`'desktop' | 'mobile'`) basierend auf
|
||||||
|
* Viewport-Breite + Touch-Heuristik. Mobile = Breite < 768 px ODER
|
||||||
|
* Touch-Primary-Pointer ohne Maus.
|
||||||
|
*/
|
||||||
|
export function useViewMode(): 'desktop' | 'mobile' {
|
||||||
|
const [mode, setMode] = useState<'desktop' | 'mobile'>(() => _detect());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const _onResize = () => setMode(_detect());
|
||||||
|
window.addEventListener('resize', _onResize);
|
||||||
|
return () => window.removeEventListener('resize', _onResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _detect(): 'desktop' | 'mobile' {
|
||||||
|
if (typeof window === 'undefined') return 'desktop';
|
||||||
|
const isNarrow = window.matchMedia('(max-width: 768px)').matches;
|
||||||
|
const isCoarse = window.matchMedia('(pointer: coarse)').matches;
|
||||||
|
return isNarrow || isCoarse ? 'mobile' : 'desktop';
|
||||||
|
}
|
||||||
|
|
@ -65,6 +65,7 @@ import {
|
||||||
CustomActionButton
|
CustomActionButton
|
||||||
} from '../ActionButtons';
|
} from '../ActionButtons';
|
||||||
import { formatUnixTimestamp } from '../../../utils/time';
|
import { formatUnixTimestamp } from '../../../utils/time';
|
||||||
|
import { applyFrontendFormat } from '../../../utils/applyFrontendFormat';
|
||||||
import { FormGeneratorControls } from '../FormGeneratorControls';
|
import { FormGeneratorControls } from '../FormGeneratorControls';
|
||||||
import { CopyableTruncatedValue } from '../../UiComponents/CopyableTruncatedValue';
|
import { CopyableTruncatedValue } from '../../UiComponents/CopyableTruncatedValue';
|
||||||
import {
|
import {
|
||||||
|
|
@ -112,6 +113,13 @@ export interface ColumnConfig {
|
||||||
cellClassName?: (value: any, row: any) => string; // For custom cell styling
|
cellClassName?: (value: any, row: any) => string; // For custom cell styling
|
||||||
fkSource?: string; // API endpoint for FK resolution (e.g., "/api/users/")
|
fkSource?: string; // API endpoint for FK resolution (e.g., "/api/users/")
|
||||||
fkDisplayField?: string; // Which field of FK target to display (e.g., "username", "name", "roleLabel")
|
fkDisplayField?: string; // Which field of FK target to display (e.g., "username", "name", "roleLabel")
|
||||||
|
// Backend-provided render hints (gateway/.../attributeUtils.py).
|
||||||
|
// Excel-style format string applied by ``applyFrontendFormat`` to numeric/int
|
||||||
|
// values, e.g. "R:#'###.00", "M:b" (bytes), "L:0.000". Empty = default rendering.
|
||||||
|
frontendFormat?: string;
|
||||||
|
// Pre-translated label tokens for binary/categorical cells, e.g. ["Ja", "-", "Nein"].
|
||||||
|
// Resolved server-side via i18n so the FE never needs another translation hop.
|
||||||
|
frontendFormatLabels?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FormGeneratorTableProps<T = any> {
|
export interface FormGeneratorTableProps<T = any> {
|
||||||
|
|
@ -1721,6 +1729,17 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
return '-';
|
return '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backend render hints take priority for binary cells when explicit
|
||||||
|
// ``frontendFormatLabels`` are provided -- this is how the LLM/user
|
||||||
|
// overrides the default ✓/✗ tri-state with meaningful labels like
|
||||||
|
// ["Ja", "-", "Nein"] or ["aktiv", "?", "inaktiv"]. We still defer to the
|
||||||
|
// inline-editable boolean renderer when no labels are configured so the
|
||||||
|
// existing checkbox UX is preserved.
|
||||||
|
if (column.frontendFormatLabels && (typeof value === 'boolean' || (column.type && isCheckboxType(column.type)))) {
|
||||||
|
const formatted = applyFrontendFormat(value, column.frontendFormat, column.frontendFormatLabels, column.type, currentLanguage);
|
||||||
|
return formatted.text;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle boolean/checkbox fields with inline editing support
|
// Handle boolean/checkbox fields with inline editing support
|
||||||
if (column.type && isCheckboxType(column.type)) {
|
if (column.type && isCheckboxType(column.type)) {
|
||||||
return renderBooleanCell(value, column, row);
|
return renderBooleanCell(value, column, row);
|
||||||
|
|
@ -1894,6 +1913,15 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
return value ? '✓' : '✗';
|
return value ? '✓' : '✗';
|
||||||
case 'number':
|
case 'number':
|
||||||
|
case 'float':
|
||||||
|
case 'integer':
|
||||||
|
case 'int':
|
||||||
|
// Honor backend ``frontendFormat`` (e.g. "R:#'###.00", "M:b") if present.
|
||||||
|
// Without a format hint we keep the existing default locale rendering so
|
||||||
|
// existing tables continue to look the same.
|
||||||
|
if (column.frontendFormat || column.frontendFormatLabels) {
|
||||||
|
return applyFrontendFormat(value, column.frontendFormat, column.frontendFormatLabels, column.type, currentLanguage).text;
|
||||||
|
}
|
||||||
return typeof value === 'number' ? value.toLocaleString() : value;
|
return typeof value === 'number' ? value.toLocaleString() : value;
|
||||||
default:
|
default:
|
||||||
return String(value);
|
return String(value);
|
||||||
|
|
@ -2427,9 +2455,17 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : '';
|
const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : '';
|
||||||
const combinedClassName = `${styles.td} ${customClassName}`.trim();
|
const combinedClassName = `${styles.td} ${customClassName}`.trim();
|
||||||
const isNumeric = column.type === 'number' || column.type === 'float' || column.type === 'integer';
|
const isNumeric = column.type === 'number' || column.type === 'float' || column.type === 'integer';
|
||||||
|
const formatAlign = column.frontendFormat && column.frontendFormat[1] === ':' ? column.frontendFormat[0] : '';
|
||||||
|
const alignStyle: React.CSSProperties = formatAlign === 'R'
|
||||||
|
? { textAlign: 'right' }
|
||||||
|
: formatAlign === 'M'
|
||||||
|
? { textAlign: 'center' }
|
||||||
|
: formatAlign === 'L'
|
||||||
|
? { textAlign: 'left' }
|
||||||
|
: isNumeric ? { textAlign: 'right' } : {};
|
||||||
return (
|
return (
|
||||||
<td key={column.key} className={combinedClassName}
|
<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, ...(isNumeric ? { textAlign: 'right' } : {}) }}>
|
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)}
|
{formatCellValue(cellValue, column, row)}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
|
|
@ -2543,9 +2579,19 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : '';
|
const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : '';
|
||||||
const combinedClassName = `${styles.td} ${customClassName}`.trim();
|
const combinedClassName = `${styles.td} ${customClassName}`.trim();
|
||||||
const isNumeric = column.type === 'number' || column.type === 'float' || column.type === 'integer';
|
const isNumeric = column.type === 'number' || column.type === 'float' || column.type === 'integer';
|
||||||
|
// ``frontendFormat`` may carry an explicit alignment prefix
|
||||||
|
// ("L:", "M:", "R:") that overrides the numeric default.
|
||||||
|
const formatAlign = column.frontendFormat && column.frontendFormat[1] === ':' ? column.frontendFormat[0] : '';
|
||||||
|
const alignStyle: React.CSSProperties = formatAlign === 'R'
|
||||||
|
? { textAlign: 'right' }
|
||||||
|
: formatAlign === 'M'
|
||||||
|
? { textAlign: 'center' }
|
||||||
|
: formatAlign === 'L'
|
||||||
|
? { textAlign: 'left' }
|
||||||
|
: isNumeric ? { textAlign: 'right' } : {};
|
||||||
return (
|
return (
|
||||||
<td key={column.key} className={combinedClassName}
|
<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, ...(isNumeric ? { textAlign: 'right' } : {}) }}>
|
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)}
|
{formatCellValue(cellValue, column, row)}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,17 @@
|
||||||
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
||||||
|
import { FaFileImport } from 'react-icons/fa';
|
||||||
import type { UdbContext } from './UnifiedDataBar';
|
import type { UdbContext } from './UnifiedDataBar';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import FolderTree from '../../components/FolderTree/FolderTree';
|
import FolderTree from '../../components/FolderTree/FolderTree';
|
||||||
import type { FileNode } from '../../components/FolderTree/FolderTree';
|
import type { FileNode } from '../../components/FolderTree/FolderTree';
|
||||||
|
import type { FileAction } from '../../components/FolderTree/actions/types';
|
||||||
import { useFileContext } from '../../contexts/FileContext';
|
import { useFileContext } from '../../contexts/FileContext';
|
||||||
|
import { useApiRequest } from '../../hooks/useApi';
|
||||||
|
import {
|
||||||
|
importWorkflowFromFile,
|
||||||
|
WORKFLOW_FILE_EXTENSION,
|
||||||
|
} from '../../api/workflowApi';
|
||||||
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
import styles from './FilesTab.module.css';
|
import styles from './FilesTab.module.css';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
|
||||||
|
|
@ -11,10 +19,16 @@ interface FilesTabProps {
|
||||||
context: UdbContext;
|
context: UdbContext;
|
||||||
onFileSelect?: (fileId: string, fileName?: string) => void;
|
onFileSelect?: (fileId: string, fileName?: string) => void;
|
||||||
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
|
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
|
||||||
|
/** Wird aufgerufen, wenn ein ``.workflow.json``-File via Custom-Action in
|
||||||
|
* den Graph-Editor importiert wurde. Aktivierung im Editor (Refresh-Liste,
|
||||||
|
* Auto-Select) bleibt Aufgabe des Aufrufers. */
|
||||||
|
onWorkflowImported?: (workflowId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat }) => {
|
const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat, onWorkflowImported }) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const { showSuccess, showError } = useToast();
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
@ -179,6 +193,48 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
||||||
}
|
}
|
||||||
}, [refreshFolders, refreshTreeFiles]);
|
}, [refreshFolders, refreshTreeFiles]);
|
||||||
|
|
||||||
|
const _customActions: FileAction[] = useMemo(() => {
|
||||||
|
if (context.surface !== 'graphEditor') return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'workflow.openInEditor',
|
||||||
|
label: t('In Graph-Editor laden'),
|
||||||
|
icon: FaFileImport,
|
||||||
|
scope: 'file',
|
||||||
|
channels: ['inline', 'menu', 'sheet', 'drop'],
|
||||||
|
dragMime: 'application/json+workflow',
|
||||||
|
sortOrder: 50,
|
||||||
|
predicate: ({ files }) =>
|
||||||
|
files.length === 1 &&
|
||||||
|
files[0].fileName.toLowerCase().endsWith(WORKFLOW_FILE_EXTENSION),
|
||||||
|
handler: async ({ files }) => {
|
||||||
|
const file = files[0];
|
||||||
|
if (!context.instanceId || !file) return;
|
||||||
|
try {
|
||||||
|
const result = await importWorkflowFromFile(request, context.instanceId, {
|
||||||
|
fileId: file.id,
|
||||||
|
});
|
||||||
|
const warnings = result?.warnings ?? [];
|
||||||
|
const wfId = result?.workflow?.id;
|
||||||
|
if (warnings.length > 0) {
|
||||||
|
showSuccess(
|
||||||
|
t('Workflow importiert ({n} Warnungen). Aktivierung manuell.', {
|
||||||
|
n: String(warnings.length),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showSuccess(t('Workflow importiert (deaktiviert).'));
|
||||||
|
}
|
||||||
|
if (wfId && onWorkflowImported) onWorkflowImported(wfId);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
showError(t('Import fehlgeschlagen: {msg}', { msg }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [context.surface, context.instanceId, t, request, showSuccess, showError, onWorkflowImported]);
|
||||||
|
|
||||||
const _onFolderScopeChange = useCallback(async (folderId: string, newScope: string) => {
|
const _onFolderScopeChange = useCallback(async (folderId: string, newScope: string) => {
|
||||||
try {
|
try {
|
||||||
await api.patch(`/api/files/folders/${folderId}/scope`, { scope: newScope });
|
await api.patch(`/api/files/folders/${folderId}/scope`, { scope: newScope });
|
||||||
|
|
@ -282,6 +338,8 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
||||||
onFolderScopeChange={_onFolderScopeChange}
|
onFolderScopeChange={_onFolderScopeChange}
|
||||||
onFolderNeutralizeToggle={_onFolderNeutralizeToggle}
|
onFolderNeutralizeToggle={_onFolderNeutralizeToggle}
|
||||||
onSendToChat={onSendToChat}
|
onSendToChat={onSendToChat}
|
||||||
|
customActions={_customActions}
|
||||||
|
udbContext={context.surface}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{_fileNodes.length === 0 && (
|
{_fileNodes.length === 0 && (
|
||||||
|
|
|
||||||
|
|
@ -1161,7 +1161,9 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 4,
|
gap: 4,
|
||||||
paddingLeft: depth * 16 + 4,
|
// Compensate the 3px borderLeft on active rows with -3px paddingLeft so
|
||||||
|
// the row content stays at exactly the same x-position as inactive rows.
|
||||||
|
paddingLeft: (depth * 16 + 4) - (ds ? 3 : 0),
|
||||||
paddingRight: 4,
|
paddingRight: 4,
|
||||||
paddingTop: 3,
|
paddingTop: 3,
|
||||||
paddingBottom: 3,
|
paddingBottom: 3,
|
||||||
|
|
@ -1406,7 +1408,10 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = (props) => {
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 4,
|
display: 'flex', alignItems: 'center', gap: 4,
|
||||||
paddingLeft: 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
|
// Compensate the 3px borderLeft on active wildcard rows with -3px
|
||||||
|
// paddingLeft so the row content stays at the same x-position.
|
||||||
|
paddingLeft: wildcardFds ? 1 : 4,
|
||||||
|
paddingRight: 4, paddingTop: 3, paddingBottom: 3,
|
||||||
cursor: 'pointer', borderRadius: 3,
|
cursor: 'pointer', borderRadius: 3,
|
||||||
background: wildcardFds
|
background: wildcardFds
|
||||||
? (hovered ? '#ede7f6' : '#7b1fa208')
|
? (hovered ? '#ede7f6' : '#7b1fa208')
|
||||||
|
|
@ -1585,6 +1590,7 @@ interface _GroupFolderViewProps extends _FeatureActionContext {
|
||||||
|
|
||||||
const _GroupFolderView: React.FC<_GroupFolderViewProps> = (props) => {
|
const _GroupFolderView: React.FC<_GroupFolderViewProps> = (props) => {
|
||||||
const { featureNode, objectKey, label, items, pathSegments, depth, inheritedScope, inheritedNeutralize, ...ctx } = props;
|
const { featureNode, objectKey, label, items, pathSegments, depth, inheritedScope, inheritedNeutralize, ...ctx } = props;
|
||||||
|
const { t } = useLanguage();
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
const segments = [...pathSegments, `g:${objectKey}`];
|
const segments = [...pathSegments, `g:${objectKey}`];
|
||||||
|
|
@ -1592,17 +1598,45 @@ const _GroupFolderView: React.FC<_GroupFolderViewProps> = (props) => {
|
||||||
const expanded = ctx.featureExpandedPaths.has(pathKey);
|
const expanded = ctx.featureExpandedPaths.has(pathKey);
|
||||||
const chevron = expanded ? '\u25BE' : '\u25B8';
|
const chevron = expanded ? '\u25BE' : '\u25B8';
|
||||||
|
|
||||||
|
// Container-wildcard objectKey: matches every record/table inside this group.
|
||||||
|
// Pattern lives in the backend workspaceContext-resolver -- the trailing `.*`
|
||||||
|
// is treated as a glob-prefix so a single FDS row drives chat/scope/neutralize
|
||||||
|
// for every child without having to add each one individually.
|
||||||
|
const containerObjectKey = `data.feature.${featureNode.featureCode}.group:${objectKey}.*`;
|
||||||
|
const wildcardFds = ctx.featureDataSources.find(
|
||||||
|
f => f.featureInstanceId === featureNode.featureInstanceId && f.objectKey === containerObjectKey,
|
||||||
|
);
|
||||||
|
const _chatPayload = {
|
||||||
|
featureInstanceId: featureNode.featureInstanceId,
|
||||||
|
featureCode: featureNode.featureCode,
|
||||||
|
objectKey: containerObjectKey,
|
||||||
|
label,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
onClick={() => ctx.onToggleFeaturePath(pathKey)}
|
onClick={() => ctx.onToggleFeaturePath(pathKey)}
|
||||||
onMouseEnter={() => setHovered(true)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onMouseLeave={() => setHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.dataTransfer.setData('application/feature-source', JSON.stringify(_chatPayload));
|
||||||
|
e.dataTransfer.setData('text/plain', label);
|
||||||
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 4,
|
display: 'flex', alignItems: 'center', gap: 4,
|
||||||
paddingLeft: depth * 16 + 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
|
// Compensate the 3px border on active wildcard rows so the row
|
||||||
|
// content stays at the same x-position whether or not it's active.
|
||||||
|
paddingLeft: (depth * 16 + 4) - (wildcardFds ? 3 : 0),
|
||||||
|
paddingRight: 4, paddingTop: 3, paddingBottom: 3,
|
||||||
cursor: 'pointer', borderRadius: 3,
|
cursor: 'pointer', borderRadius: 3,
|
||||||
background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent',
|
background: wildcardFds
|
||||||
|
? (hovered ? '#ede7f6' : '#7b1fa208')
|
||||||
|
: (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'),
|
||||||
|
borderLeft: wildcardFds ? '3px solid #7b1fa2' : undefined,
|
||||||
transition: 'background 0.1s', userSelect: 'none',
|
transition: 'background 0.1s', userSelect: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -1617,6 +1651,52 @@ const _GroupFolderView: React.FC<_GroupFolderViewProps> = (props) => {
|
||||||
}}>
|
}}>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); ctx.onSendToChat?.(_chatPayload); }}
|
||||||
|
style={{
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
fontSize: 14, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
|
||||||
|
opacity: wildcardFds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2',
|
||||||
|
}}
|
||||||
|
title={t('Container in Chat senden')}
|
||||||
|
>
|
||||||
|
{'\u{1F4AC}'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (wildcardFds) { ctx.onCycleScope(wildcardFds); return; }
|
||||||
|
const newId = await ctx.onAddFeatureTable(
|
||||||
|
featureNode,
|
||||||
|
{ objectKey: containerObjectKey, tableName: '*', label, fields: [] } as FeatureTableNode,
|
||||||
|
);
|
||||||
|
if (newId) {
|
||||||
|
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(inheritedScope || 'personal') }); } catch {}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: wildcardFds ? 1 : 0.35 }}
|
||||||
|
title={wildcardFds ? `${t('Bereich')}: ${wildcardFds.scope}` : (inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Scope setzen'))}
|
||||||
|
>
|
||||||
|
{_SCOPE_ICONS[wildcardFds?.scope || inheritedScope || 'personal']}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (wildcardFds) { ctx.onToggleNeutralize(wildcardFds); return; }
|
||||||
|
const newId = await ctx.onAddFeatureTable(
|
||||||
|
featureNode,
|
||||||
|
{ objectKey: containerObjectKey, tableName: '*', label, fields: [] } as FeatureTableNode,
|
||||||
|
);
|
||||||
|
if (newId) {
|
||||||
|
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: true }); } catch {}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: (wildcardFds?.neutralize ?? inheritedNeutralize) ? 1 : 0.35 }}
|
||||||
|
title={(wildcardFds?.neutralize ?? inheritedNeutralize) ? t('Neutralisierung an') : t('Neutralisierung aus')}
|
||||||
|
>
|
||||||
|
{'\uD83D\uDD12'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expanded && items.length > 0 && (
|
{expanded && items.length > 0 && (
|
||||||
|
|
@ -1672,17 +1752,45 @@ const _ParentGroupView: React.FC<_ParentGroupViewProps> = (props) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Container-wildcard objectKey for the parent group: matches every record in
|
||||||
|
// ``table`` so a single FDS row drives chat/scope/neutralize for the whole list.
|
||||||
|
const containerObjectKey = `data.feature.${featureNode.featureCode}.${table.tableName}.*`;
|
||||||
|
const wildcardFds = ctx.featureDataSources.find(
|
||||||
|
f => f.featureInstanceId === featureNode.featureInstanceId
|
||||||
|
&& f.tableName === table.tableName
|
||||||
|
&& !f.recordFilter
|
||||||
|
&& f.objectKey === containerObjectKey,
|
||||||
|
);
|
||||||
|
const _chatPayload = {
|
||||||
|
featureInstanceId: featureNode.featureInstanceId,
|
||||||
|
featureCode: featureNode.featureCode,
|
||||||
|
tableName: table.tableName,
|
||||||
|
objectKey: containerObjectKey,
|
||||||
|
label: table.label || table.tableName,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
onClick={_onToggle}
|
onClick={_onToggle}
|
||||||
onMouseEnter={() => setHovered(true)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onMouseLeave={() => setHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.dataTransfer.setData('application/feature-source', JSON.stringify(_chatPayload));
|
||||||
|
e.dataTransfer.setData('text/plain', _chatPayload.label);
|
||||||
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 4,
|
display: 'flex', alignItems: 'center', gap: 4,
|
||||||
paddingLeft: depth * 16 + 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
|
paddingLeft: (depth * 16 + 4) - (wildcardFds ? 3 : 0),
|
||||||
|
paddingRight: 4, paddingTop: 3, paddingBottom: 3,
|
||||||
cursor: 'pointer', borderRadius: 3,
|
cursor: 'pointer', borderRadius: 3,
|
||||||
background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent',
|
background: wildcardFds
|
||||||
|
? (hovered ? '#ede7f6' : '#7b1fa208')
|
||||||
|
: (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'),
|
||||||
|
borderLeft: wildcardFds ? '3px solid #7b1fa2' : undefined,
|
||||||
transition: 'background 0.1s', userSelect: 'none',
|
transition: 'background 0.1s', userSelect: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -1701,6 +1809,54 @@ const _ParentGroupView: React.FC<_ParentGroupViewProps> = (props) => {
|
||||||
+{childTables.length} {t('Tabellen')}
|
+{childTables.length} {t('Tabellen')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); ctx.onSendToChat?.(_chatPayload); }}
|
||||||
|
style={{
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
fontSize: 14, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
|
||||||
|
opacity: wildcardFds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2',
|
||||||
|
}}
|
||||||
|
title={t('Container in Chat senden')}
|
||||||
|
>
|
||||||
|
{'\u{1F4AC}'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (wildcardFds) { ctx.onCycleScope(wildcardFds); return; }
|
||||||
|
const newId = await ctx.onAddFeatureTable(
|
||||||
|
featureNode,
|
||||||
|
{ ...table, objectKey: containerObjectKey } as FeatureTableNode,
|
||||||
|
{ labelOverride: _chatPayload.label },
|
||||||
|
);
|
||||||
|
if (newId) {
|
||||||
|
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(inheritedScope || 'personal') }); } catch {}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: wildcardFds ? 1 : 0.35 }}
|
||||||
|
title={wildcardFds ? `${t('Bereich')}: ${wildcardFds.scope}` : (inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Scope setzen'))}
|
||||||
|
>
|
||||||
|
{_SCOPE_ICONS[wildcardFds?.scope || inheritedScope || 'personal']}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (wildcardFds) { ctx.onToggleNeutralize(wildcardFds); return; }
|
||||||
|
const newId = await ctx.onAddFeatureTable(
|
||||||
|
featureNode,
|
||||||
|
{ ...table, objectKey: containerObjectKey } as FeatureTableNode,
|
||||||
|
{ labelOverride: _chatPayload.label },
|
||||||
|
);
|
||||||
|
if (newId) {
|
||||||
|
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: true }); } catch {}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: (wildcardFds?.neutralize ?? inheritedNeutralize) ? 1 : 0.35 }}
|
||||||
|
title={(wildcardFds?.neutralize ?? inheritedNeutralize) ? t('Neutralisierung an') : t('Neutralisierung aus')}
|
||||||
|
>
|
||||||
|
{'\uD83D\uDD12'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expanded && records && records.length > 0 && (
|
{expanded && records && records.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,23 @@ import styles from './UnifiedDataBar.module.css';
|
||||||
|
|
||||||
export type UdbTab = 'chats' | 'files' | 'sources';
|
export type UdbTab = 'chats' | 'files' | 'sources';
|
||||||
|
|
||||||
|
/** Aufruf-Surface, in der die UDB gerade lebt. Wird an `FolderTree.udbContext`
|
||||||
|
* weitergereicht, damit Custom-Actions (z. B. `workflow.openInEditor`) sich
|
||||||
|
* pro Surface registrieren können. */
|
||||||
|
export type UdbSurface =
|
||||||
|
| 'workspace'
|
||||||
|
| 'graphEditor'
|
||||||
|
| 'trustee'
|
||||||
|
| 'standalone'
|
||||||
|
| 'sharepoint';
|
||||||
|
|
||||||
export interface UdbContext {
|
export interface UdbContext {
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
mandateId?: string;
|
mandateId?: string;
|
||||||
featureInstanceId?: string;
|
featureInstanceId?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
/** Optionales Surface-Tag, hilft Custom-Actions zu entscheiden, wann sie sichtbar sind. */
|
||||||
|
surface?: UdbSurface;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddToChat_FileItem {
|
export interface AddToChat_FileItem {
|
||||||
|
|
@ -44,6 +56,9 @@ interface UnifiedDataBarProps {
|
||||||
onSendToChat_Files?: (items: AddToChat_FileItem[]) => void;
|
onSendToChat_Files?: (items: AddToChat_FileItem[]) => void;
|
||||||
onSendToChat_FeatureSource?: (params: AddToChat_FeatureSource) => void;
|
onSendToChat_FeatureSource?: (params: AddToChat_FeatureSource) => void;
|
||||||
onAttachDataSource?: (dsId: string) => void;
|
onAttachDataSource?: (dsId: string) => void;
|
||||||
|
/** Wird aufgerufen, sobald aus der UDB-FilesTab ein Workflow-File in den
|
||||||
|
* Graph-Editor importiert wurde (Action `workflow.openInEditor`). */
|
||||||
|
onWorkflowImportedFromFile?: (workflowId: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,6 +87,7 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
|
||||||
onSendToChat_Files,
|
onSendToChat_Files,
|
||||||
onSendToChat_FeatureSource,
|
onSendToChat_FeatureSource,
|
||||||
onAttachDataSource,
|
onAttachDataSource,
|
||||||
|
onWorkflowImportedFromFile,
|
||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -116,6 +132,7 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
|
||||||
context={context}
|
context={context}
|
||||||
onFileSelect={onFileSelect}
|
onFileSelect={onFileSelect}
|
||||||
onSendToChat={onSendToChat_Files}
|
onSendToChat={onSendToChat_Files}
|
||||||
|
onWorkflowImported={onWorkflowImportedFromFile}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{currentTab === 'sources' && !hideTabs?.includes('sources') && (
|
{currentTab === 'sources' && !hideTabs?.includes('sources') && (
|
||||||
|
|
|
||||||
|
|
@ -66,12 +66,19 @@ export function useConfirm() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={_handleCancel}
|
// Backdrop intentionally has NO onClick handler: this confirm dialog
|
||||||
|
// must only close via the explicit Cancel/Confirm buttons or Escape.
|
||||||
|
// Accidental outside-clicks should NOT dismiss a decision the user
|
||||||
|
// hasn't made yet. (UX policy for all modal dialogs in PORTA.)
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed', inset: 0, zIndex: 10000,
|
position: 'fixed', inset: 0, zIndex: 10000,
|
||||||
background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(2px)',
|
background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(2px)',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') _handleCancel();
|
||||||
|
}}
|
||||||
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
|
|
||||||
|
|
@ -73,12 +73,19 @@ export function usePrompt() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={_handleCancel}
|
// Backdrop intentionally has NO onClick handler: this dialog must only
|
||||||
|
// close via the explicit Cancel button, the Escape key on the input,
|
||||||
|
// or the Confirm button. Clicking outside the dialog should NOT
|
||||||
|
// dismiss the user's input. (UX policy for all modal forms in PORTA.)
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed', inset: 0, zIndex: 10000,
|
position: 'fixed', inset: 0, zIndex: 10000,
|
||||||
background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(2px)',
|
background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(2px)',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') _handleCancel();
|
||||||
|
}}
|
||||||
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,8 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
maxWidth: attr.maxWidth || 400,
|
maxWidth: attr.maxWidth || 400,
|
||||||
fkSource: (attr as any).fkSource,
|
fkSource: (attr as any).fkSource,
|
||||||
fkDisplayField: (attr as any).fkDisplayField,
|
fkDisplayField: (attr as any).fkDisplayField,
|
||||||
|
frontendFormat: (attr as any).frontendFormat,
|
||||||
|
frontendFormatLabels: (attr as any).frontendFormatLabels,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (attr.name === 'userId') {
|
if (attr.name === 'userId') {
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,8 @@ export const FilesPage: React.FC = () => {
|
||||||
maxWidth: attr.maxWidth || 400,
|
maxWidth: attr.maxWidth || 400,
|
||||||
fkSource: (attr as any).fkSource,
|
fkSource: (attr as any).fkSource,
|
||||||
fkDisplayField: (attr as any).fkDisplayField,
|
fkDisplayField: (attr as any).fkDisplayField,
|
||||||
|
frontendFormat: (attr as any).frontendFormat,
|
||||||
|
frontendFormatLabels: (attr as any).frontendFormatLabels,
|
||||||
}));
|
}));
|
||||||
cols.push({
|
cols.push({
|
||||||
key: 'sysCreatedBy',
|
key: 'sysCreatedBy',
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,8 @@ export const PromptsPage: React.FC = () => {
|
||||||
maxWidth: attr.name === 'content' ? 500 : attr.maxWidth || 400,
|
maxWidth: attr.name === 'content' ? 500 : attr.maxWidth || 400,
|
||||||
fkSource: (attr as any).fkSource,
|
fkSource: (attr as any).fkSource,
|
||||||
fkDisplayField: (attr as any).fkDisplayField,
|
fkDisplayField: (attr as any).fkDisplayField,
|
||||||
|
frontendFormat: (attr as any).frontendFormat,
|
||||||
|
frontendFormatLabels: (attr as any).frontendFormatLabels,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Add sysCreatedBy column with FK resolution to show username
|
// Add sysCreatedBy column with FK resolution to show username
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,13 @@ interface TabDef {
|
||||||
Wrapper: React.FC<{ instanceId: string }>;
|
Wrapper: React.FC<{ instanceId: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TabGroupDef {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
tabs: TabDef[];
|
||||||
|
}
|
||||||
|
|
||||||
function _buildApiEndpoint(instanceId: string, suffix: string): string {
|
function _buildApiEndpoint(instanceId: string, suffix: string): string {
|
||||||
return `/api/trustee/${instanceId}/${suffix}`;
|
return `/api/trustee/${instanceId}/${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
@ -136,21 +143,53 @@ const _DataAccountBalancesWrapper = _makeReadOnlyWrapper(useTrusteeDataAccountBa
|
||||||
const _AccountingConfigsWrapper = _makeReadOnlyWrapper(useTrusteeAccountingConfigs, 'accounting/configs');
|
const _AccountingConfigsWrapper = _makeReadOnlyWrapper(useTrusteeAccountingConfigs, 'accounting/configs');
|
||||||
const _AccountingSyncsWrapper = _makeReadOnlyWrapper(useTrusteeAccountingSyncs, 'accounting/syncs');
|
const _AccountingSyncsWrapper = _makeReadOnlyWrapper(useTrusteeAccountingSyncs, 'accounting/syncs');
|
||||||
|
|
||||||
function _buildTabs(t: (k: string) => string): TabDef[] {
|
// Group structure mirrors `DATA_OBJECTS` in `gateway/modules/features/trustee/mainTrustee.py`
|
||||||
|
// (UDB folders): Stammdaten · Lokale Daten · Konfiguration · Daten aus Buchhaltungssystem.
|
||||||
|
// "Stammdaten" is page-only (Organisation/Rolle/Zugriff/Vertrag are admin tables that
|
||||||
|
// don't appear in the UDB because the feature instance IS the organisation).
|
||||||
|
function _buildTabGroups(t: (k: string) => string): TabGroupDef[] {
|
||||||
return [
|
return [
|
||||||
{ id: 'organisations', entityName: 'TrusteeOrganisation', label: t('Organisation'), icon: '\uD83C\uDFE2', color: '#1976d2', readOnly: false, Wrapper: _OrganisationsWrapper },
|
{
|
||||||
{ id: 'roles', entityName: 'TrusteeRole', label: t('Rolle'), icon: '\uD83D\uDC65', color: '#0277bd', readOnly: false, Wrapper: _RolesWrapper },
|
id: 'master',
|
||||||
{ id: 'access', entityName: 'TrusteeAccess', label: t('Zugriff'), icon: '\uD83D\uDD11', color: '#0288d1', readOnly: false, Wrapper: _AccessWrapper },
|
label: t('Stammdaten'),
|
||||||
{ id: 'contracts', entityName: 'TrusteeContract', label: t('Vertrag'), icon: '\uD83D\uDCDC', color: '#00796b', readOnly: false, Wrapper: _ContractsWrapper },
|
color: '#1976d2',
|
||||||
{ id: 'documents', entityName: 'TrusteeDocument', label: t('Dokument'), icon: '\uD83D\uDCC4', color: '#388e3c', readOnly: false, Wrapper: _DocumentsWrapper },
|
tabs: [
|
||||||
{ id: 'positions', entityName: 'TrusteePosition', label: t('Position'), icon: '\uD83D\uDCCA', color: '#43a047', readOnly: false, Wrapper: _PositionsWrapper },
|
{ id: 'organisations', entityName: 'TrusteeOrganisation', label: t('Organisation'), icon: '\uD83C\uDFE2', color: '#1976d2', readOnly: false, Wrapper: _OrganisationsWrapper },
|
||||||
{ id: 'accounts', entityName: 'TrusteeDataAccount', label: t('Konten (Sync)'), icon: '\uD83D\uDCD2', color: '#f57c00', readOnly: true, Wrapper: _DataAccountsWrapper },
|
{ id: 'roles', entityName: 'TrusteeRole', label: t('Rolle'), icon: '\uD83D\uDC65', color: '#0277bd', readOnly: false, Wrapper: _RolesWrapper },
|
||||||
{ id: 'journal-entries', entityName: 'TrusteeDataJournalEntry', label: t('Buchungen (Sync)'), icon: '\uD83D\uDCDD', color: '#ef6c00', readOnly: true, Wrapper: _DataJournalEntriesWrapper },
|
{ id: 'access', entityName: 'TrusteeAccess', label: t('Zugriff'), icon: '\uD83D\uDD11', color: '#0288d1', readOnly: false, Wrapper: _AccessWrapper },
|
||||||
{ id: 'journal-lines', entityName: 'TrusteeDataJournalLine', label: t('Buchungszeilen (Sync)'), icon: '\uD83D\uDCC3', color: '#e65100', readOnly: true, Wrapper: _DataJournalLinesWrapper },
|
{ id: 'contracts', entityName: 'TrusteeContract', label: t('Vertrag'), icon: '\uD83D\uDCDC', color: '#00796b', readOnly: false, Wrapper: _ContractsWrapper },
|
||||||
{ id: 'contacts', entityName: 'TrusteeDataContact', label: t('Kontakte (Sync)'), icon: '\uD83D\uDC64', color: '#c2185b', readOnly: true, Wrapper: _DataContactsWrapper },
|
],
|
||||||
{ id: 'account-balances', entityName: 'TrusteeDataAccountBalance', label: t('Kontosalden (Sync)'), icon: '\uD83D\uDCB0', color: '#ad1457', readOnly: true, Wrapper: _DataAccountBalancesWrapper },
|
},
|
||||||
{ id: 'accounting-configs', entityName: 'TrusteeAccountingConfig', label: t('Buchhaltungs-Konfiguration'), icon: '\u2699\uFE0F', color: '#5e35b1', readOnly: true, Wrapper: _AccountingConfigsWrapper },
|
{
|
||||||
{ id: 'accounting-syncs', entityName: 'TrusteeAccountingSync', label: t('Buchhaltungs-Synchronisation'), icon: '\uD83D\uDD04', color: '#3949ab', readOnly: true, Wrapper: _AccountingSyncsWrapper },
|
id: 'localData',
|
||||||
|
label: t('Lokale Daten'),
|
||||||
|
color: '#388e3c',
|
||||||
|
tabs: [
|
||||||
|
{ id: 'documents', entityName: 'TrusteeDocument', label: t('Dokument'), icon: '\uD83D\uDCC4', color: '#388e3c', readOnly: false, Wrapper: _DocumentsWrapper },
|
||||||
|
{ id: 'positions', entityName: 'TrusteePosition', label: t('Position'), icon: '\uD83D\uDCCA', color: '#43a047', readOnly: false, Wrapper: _PositionsWrapper },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'config',
|
||||||
|
label: t('Konfiguration'),
|
||||||
|
color: '#5e35b1',
|
||||||
|
tabs: [
|
||||||
|
{ id: 'accounting-configs', entityName: 'TrusteeAccountingConfig', label: t('Buchhaltungs-Verbindung'), icon: '\u2699\uFE0F', color: '#5e35b1', readOnly: true, Wrapper: _AccountingConfigsWrapper },
|
||||||
|
{ id: 'accounting-syncs', entityName: 'TrusteeAccountingSync', label: t('Sync-Protokoll'), icon: '\uD83D\uDD04', color: '#3949ab', readOnly: true, Wrapper: _AccountingSyncsWrapper },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'accountingData',
|
||||||
|
label: t('Daten aus Buchhaltungssystem'),
|
||||||
|
color: '#ef6c00',
|
||||||
|
tabs: [
|
||||||
|
{ id: 'accounts', entityName: 'TrusteeDataAccount', label: t('Kontenplan'), icon: '\uD83D\uDCD2', color: '#f57c00', readOnly: true, Wrapper: _DataAccountsWrapper },
|
||||||
|
{ id: 'journal-entries', entityName: 'TrusteeDataJournalEntry', label: t('Buchungen'), icon: '\uD83D\uDCDD', color: '#ef6c00', readOnly: true, Wrapper: _DataJournalEntriesWrapper },
|
||||||
|
{ id: 'journal-lines', entityName: 'TrusteeDataJournalLine', label: t('Buchungszeilen'), icon: '\uD83D\uDCC3', color: '#e65100', readOnly: true, Wrapper: _DataJournalLinesWrapper },
|
||||||
|
{ id: 'contacts', entityName: 'TrusteeDataContact', label: t('Kontakte'), icon: '\uD83D\uDC64', color: '#c2185b', readOnly: true, Wrapper: _DataContactsWrapper },
|
||||||
|
{ id: 'account-balances', entityName: 'TrusteeDataAccountBalance', label: t('Kontosalden'), icon: '\uD83D\uDCB0', color: '#ad1457', readOnly: true, Wrapper: _DataAccountBalancesWrapper },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,16 +202,16 @@ export const TrusteeDataTablesView: React.FC = () => {
|
||||||
const instanceId = useInstanceId();
|
const instanceId = useInstanceId();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
const tabs = useMemo(() => _buildTabs(t), [t]);
|
const tabGroups = useMemo(() => _buildTabGroups(t), [t]);
|
||||||
const visibleTabs = tabs;
|
const visibleTabs = useMemo(() => tabGroups.flatMap((g) => g.tabs), [tabGroups]);
|
||||||
|
|
||||||
const requestedTab = searchParams.get('tab');
|
const requestedTab = searchParams.get('tab');
|
||||||
const activeTab = useMemo(() => {
|
const activeTab = useMemo(() => {
|
||||||
if (requestedTab && visibleTabs.some((tab) => tab.id === requestedTab)) {
|
if (requestedTab && visibleTabs.some((tab) => tab.id === requestedTab)) {
|
||||||
return requestedTab;
|
return requestedTab;
|
||||||
}
|
}
|
||||||
return visibleTabs[0]?.id || tabs[0].id;
|
return visibleTabs[0]?.id || '';
|
||||||
}, [requestedTab, visibleTabs, tabs]);
|
}, [requestedTab, visibleTabs]);
|
||||||
|
|
||||||
const _setActiveTab = useCallback((tabId: string) => {
|
const _setActiveTab = useCallback((tabId: string) => {
|
||||||
setSearchParams({ tab: tabId }, { replace: true });
|
setSearchParams({ tab: tabId }, { replace: true });
|
||||||
|
|
@ -217,47 +256,84 @@ export const TrusteeDataTablesView: React.FC = () => {
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: '0.25rem',
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
marginBottom: '1rem',
|
marginBottom: '1rem',
|
||||||
borderBottom: '2px solid var(--border-color, #e0e0e0)',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{visibleTabs.map((tab) => (
|
{tabGroups.map((group) => (
|
||||||
<button
|
<div
|
||||||
key={tab.id}
|
key={group.id}
|
||||||
onClick={() => _setActiveTab(tab.id)}
|
|
||||||
style={{
|
style={{
|
||||||
padding: '0.625rem 1rem',
|
display: 'grid',
|
||||||
border: 'none',
|
gridTemplateColumns: '11rem 1fr',
|
||||||
borderBottom: activeTab === tab.id ? `3px solid ${tab.color}` : '3px solid transparent',
|
alignItems: 'center',
|
||||||
background: 'transparent',
|
gap: '0.75rem',
|
||||||
color: activeTab === tab.id ? 'var(--text-primary, #1a1a1a)' : 'var(--text-secondary, #666)',
|
|
||||||
fontWeight: activeTab === tab.id ? 600 : 400,
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
marginBottom: '-2px',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ marginRight: '0.375rem' }}>{tab.icon}</span>
|
<div
|
||||||
{tab.label}
|
style={{
|
||||||
{tab.readOnly && (
|
fontSize: '0.6875rem',
|
||||||
<span
|
fontWeight: 600,
|
||||||
style={{
|
textTransform: 'uppercase',
|
||||||
marginLeft: '0.375rem',
|
letterSpacing: '0.05em',
|
||||||
fontSize: '0.6875rem',
|
color: group.color,
|
||||||
color: 'var(--text-secondary, #888)',
|
whiteSpace: 'nowrap',
|
||||||
fontWeight: 400,
|
overflow: 'hidden',
|
||||||
}}
|
textOverflow: 'ellipsis',
|
||||||
title={t('Nur lesen – Daten kommen aus dem Sync.')}
|
borderLeft: `3px solid ${group.color}`,
|
||||||
>
|
paddingLeft: '0.5rem',
|
||||||
({t('read-only')})
|
}}
|
||||||
</span>
|
title={group.label}
|
||||||
)}
|
>
|
||||||
</button>
|
{group.label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.25rem',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{group.tabs.map((tab) => {
|
||||||
|
const isActive = activeTab === tab.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => _setActiveTab(tab.id)}
|
||||||
|
title={tab.readOnly ? t('Nur lesen – Daten kommen aus dem Sync.') : tab.label}
|
||||||
|
style={{
|
||||||
|
padding: '0.375rem 0.75rem',
|
||||||
|
border: `1px solid ${isActive ? tab.color : 'var(--border-color, #e0e0e0)'}`,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: isActive ? `${tab.color}15` : 'transparent',
|
||||||
|
color: isActive ? tab.color : 'var(--text-secondary, #555)',
|
||||||
|
fontWeight: isActive ? 600 : 400,
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.375rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">{tab.icon}</span>
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
{tab.readOnly && (
|
||||||
|
<span
|
||||||
|
aria-label={t('Nur lesen')}
|
||||||
|
style={{ fontSize: '0.75rem', opacity: 0.7, lineHeight: 1 }}
|
||||||
|
>
|
||||||
|
{'\uD83D\uDD12'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,8 @@ export const TrusteeDataTab: React.FC<TrusteeDataTabProps> = ({
|
||||||
maxWidth: attr.maxWidth || 400,
|
maxWidth: attr.maxWidth || 400,
|
||||||
fkSource: attr.fkSource,
|
fkSource: attr.fkSource,
|
||||||
fkDisplayField: attr.fkDisplayField,
|
fkDisplayField: attr.fkDisplayField,
|
||||||
|
frontendFormat: attr.frontendFormat,
|
||||||
|
frontendFormatLabels: attr.frontendFormatLabels,
|
||||||
}));
|
}));
|
||||||
}, [attributes, hiddenColumns]);
|
}, [attributes, hiddenColumns]);
|
||||||
|
|
||||||
|
|
|
||||||
181
src/utils/applyFrontendFormat.ts
Normal file
181
src/utils/applyFrontendFormat.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
// Copyright (c) 2026 Patrick Motsch
|
||||||
|
// All rights reserved.
|
||||||
|
//
|
||||||
|
// Central frontend formatter for backend ``frontend_format`` / ``frontend_format_labels``
|
||||||
|
// hints (see gateway/modules/shared/attributeUtils.py). Applied by FormGeneratorTable
|
||||||
|
// for numeric, int and binary cells. Pure function so it can be unit-tested in isolation.
|
||||||
|
//
|
||||||
|
// Format string syntax (Excel-inspired, stays simple on purpose):
|
||||||
|
// <ALIGN>:<PATTERN>
|
||||||
|
// ALIGN ∈ { L, M, R } -- left / middle / right alignment hint
|
||||||
|
// PATTERN may contain literal text wrapped in @...@ (e.g. "@CHF@ #'###.00")
|
||||||
|
//
|
||||||
|
// Numeric patterns:
|
||||||
|
// - "#'###.00" Swiss thousands separator + 2 decimals 1'444'555.67
|
||||||
|
// - "0.000" Force 3 decimals, no thousands separator 4.556
|
||||||
|
// - "0" Integer, no decimals 12
|
||||||
|
// - "b" Auto-scale Byte units (B/KB/MB/GB/TB) 12.3 MB
|
||||||
|
// - "@CHF@ #'###.00" → "CHF 1'234.50" (literal text via @...@)
|
||||||
|
//
|
||||||
|
// Binary (boolean) values use ``frontendFormatLabels`` as a 3-tuple
|
||||||
|
// [trueLabel, neutralLabel, falseLabel]. ``neutralLabel`` is rendered for
|
||||||
|
// ``null``/``undefined`` -- pass "" or "-" if you want to hide it.
|
||||||
|
|
||||||
|
export type RenderAlign = 'left' | 'right' | 'center';
|
||||||
|
|
||||||
|
export interface AppliedFormat {
|
||||||
|
/** Display string ready for the cell. */
|
||||||
|
text: string;
|
||||||
|
/** Alignment hint for the cell, if the format specified one. */
|
||||||
|
align?: RenderAlign;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _ALIGN_MAP: Record<string, RenderAlign> = {
|
||||||
|
L: 'left',
|
||||||
|
M: 'center',
|
||||||
|
R: 'right',
|
||||||
|
};
|
||||||
|
|
||||||
|
const _BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split "ALIGN:PATTERN" into its parts. Returns ``[alignChar, pattern]``,
|
||||||
|
* with ``alignChar`` being ``""`` if no align prefix is present.
|
||||||
|
*/
|
||||||
|
function _splitAlign(format: string): [string, string] {
|
||||||
|
if (format.length >= 2 && format[1] === ':' && _ALIGN_MAP[format[0]] !== undefined) {
|
||||||
|
return [format[0], format.slice(2)];
|
||||||
|
}
|
||||||
|
return ['', format];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the literal-text segment ``@…@`` if present, returning
|
||||||
|
* ``[prefix, numericPattern, suffix]``. The literal segment is dropped from
|
||||||
|
* the numeric pattern so the rest can be parsed as a number format. Only the
|
||||||
|
* first literal block is recognised (good enough for ``@CHF@ #'###.00`` and
|
||||||
|
* ``#'###.00 @CHF@`` cases).
|
||||||
|
*/
|
||||||
|
function _extractLiteral(pattern: string): { prefix: string; numericPattern: string; suffix: string } {
|
||||||
|
const match = pattern.match(/^([^@]*)@([^@]*)@(.*)$/);
|
||||||
|
if (!match) {
|
||||||
|
return { prefix: '', numericPattern: pattern, suffix: '' };
|
||||||
|
}
|
||||||
|
const [, before, literal, after] = match;
|
||||||
|
if (!after.trim() && before.trim()) {
|
||||||
|
return { prefix: '', numericPattern: before.trim(), suffix: literal };
|
||||||
|
}
|
||||||
|
return { prefix: literal, numericPattern: after.trim() || before.trim(), suffix: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format ``value`` as bytes auto-scaled to the largest unit ``< 1024``.
|
||||||
|
* Locale-formatted to one decimal for KB+, integer for raw B.
|
||||||
|
*/
|
||||||
|
function _formatBytes(value: number, locale: string): string {
|
||||||
|
const sign = value < 0 ? '-' : '';
|
||||||
|
let abs = Math.abs(value);
|
||||||
|
let unitIdx = 0;
|
||||||
|
while (abs >= 1024 && unitIdx < _BYTE_UNITS.length - 1) {
|
||||||
|
abs /= 1024;
|
||||||
|
unitIdx += 1;
|
||||||
|
}
|
||||||
|
const decimals = unitIdx === 0 ? 0 : 1;
|
||||||
|
const formatted = abs.toLocaleString(locale, {
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: decimals,
|
||||||
|
});
|
||||||
|
return `${sign}${formatted} ${_BYTE_UNITS[unitIdx]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a numeric value following ``pattern``. Supported patterns:
|
||||||
|
* - "b" byte units
|
||||||
|
* - "#'###.00" thousands separator + N decimals (digits after the dot)
|
||||||
|
* - "0.000" N decimals, no thousands separator
|
||||||
|
* - "0" integer
|
||||||
|
* Falls back to ``toLocaleString`` for unknown patterns so we never break the cell.
|
||||||
|
*/
|
||||||
|
function _formatNumeric(value: number, pattern: string, locale: string): string {
|
||||||
|
if (!pattern) return value.toLocaleString(locale);
|
||||||
|
if (pattern === 'b' || pattern === 'B') return _formatBytes(value, locale);
|
||||||
|
const decimalsMatch = pattern.match(/[.,](0+)\s*$/);
|
||||||
|
const decimals = decimalsMatch ? decimalsMatch[1].length : 0;
|
||||||
|
const useThousands = pattern.includes("'") || pattern.includes('#');
|
||||||
|
return value.toLocaleString(locale, {
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: decimals,
|
||||||
|
useGrouping: useThousands,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply backend render hints to an arbitrary value.
|
||||||
|
*
|
||||||
|
* - ``type === 'binary'`` (or boolean value) renders the i18n-resolved label
|
||||||
|
* tuple from ``formatLabels``.
|
||||||
|
* - Numeric/int values are formatted by ``_formatNumeric`` according to
|
||||||
|
* the ``ALIGN:PATTERN`` format string.
|
||||||
|
* - If ``format`` is empty, the value is rendered with ``toLocaleString`` for
|
||||||
|
* numbers and ``String(value)`` for everything else (no format == no change).
|
||||||
|
*/
|
||||||
|
export function applyFrontendFormat(
|
||||||
|
value: unknown,
|
||||||
|
format: string | undefined,
|
||||||
|
formatLabels: string[] | undefined,
|
||||||
|
type: string | undefined,
|
||||||
|
locale: string = 'de-CH',
|
||||||
|
): AppliedFormat {
|
||||||
|
const [alignChar, pattern] = format ? _splitAlign(format) : ['', ''];
|
||||||
|
const align = _ALIGN_MAP[alignChar];
|
||||||
|
|
||||||
|
// Boolean / binary rendering with i18n-resolved labels
|
||||||
|
if (type === 'binary' || type === 'boolean' || typeof value === 'boolean') {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
const neutral = formatLabels && formatLabels.length >= 2 ? formatLabels[1] : '-';
|
||||||
|
return { text: neutral, align };
|
||||||
|
}
|
||||||
|
if (formatLabels && formatLabels.length >= 1) {
|
||||||
|
const trueLabel = formatLabels[0] ?? '';
|
||||||
|
const falseLabel = formatLabels[2] ?? formatLabels[formatLabels.length - 1] ?? '';
|
||||||
|
return { text: value ? trueLabel : falseLabel, align };
|
||||||
|
}
|
||||||
|
return { text: value ? '✓' : '✗', align };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return { text: '-', align };
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeric = typeof value === 'number'
|
||||||
|
? value
|
||||||
|
: (typeof value === 'string' && value.trim() !== '' && !isNaN(Number(value))
|
||||||
|
? Number(value)
|
||||||
|
: NaN);
|
||||||
|
|
||||||
|
if (Number.isFinite(numeric) && (type === 'number' || type === 'float' || type === 'integer' || type === 'int' || pattern || typeof value === 'number')) {
|
||||||
|
if (!pattern) {
|
||||||
|
return { text: numeric.toLocaleString(locale), align };
|
||||||
|
}
|
||||||
|
const { prefix, numericPattern, suffix } = _extractLiteral(pattern);
|
||||||
|
const numStr = _formatNumeric(numeric, numericPattern, locale);
|
||||||
|
const text = `${prefix ? `${prefix} ` : ''}${numStr}${suffix ? ` ${suffix}` : ''}`.trim();
|
||||||
|
return { text, align };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text: String(value), align };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience: returns just the formatted string. Use this when you only
|
||||||
|
* need the text (e.g. CSV export) and the alignment is irrelevant.
|
||||||
|
*/
|
||||||
|
export function applyFrontendFormatText(
|
||||||
|
value: unknown,
|
||||||
|
format: string | undefined,
|
||||||
|
formatLabels: string[] | undefined,
|
||||||
|
type: string | undefined,
|
||||||
|
locale: string = 'de-CH',
|
||||||
|
): string {
|
||||||
|
return applyFrontendFormat(value, format, formatLabels, type, locale).text;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue