Merge pull request #86 from valueonag/int
All checks were successful
Deploy Nyla / deploy (push) Successful in 2s
All checks were successful
Deploy Nyla / deploy (push) Successful in 2s
Int
This commit is contained in:
commit
4f2745cc2e
25 changed files with 2831 additions and 2490 deletions
|
|
@ -324,11 +324,67 @@ export async function postKnowledgeStop(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RagLimits {
|
||||||
|
maxItems?: number;
|
||||||
|
maxBytes?: number;
|
||||||
|
maxFileSize?: number;
|
||||||
|
maxDepth?: number;
|
||||||
|
// ClickUp variant
|
||||||
|
maxTasks?: number;
|
||||||
|
maxWorkspaces?: number;
|
||||||
|
maxListsPerWorkspace?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataSourceSettings {
|
||||||
|
ragLimits?: RagLimits;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CostEstimate {
|
||||||
|
estimatedTokens: number;
|
||||||
|
estimatedUsd: number;
|
||||||
|
basis: {
|
||||||
|
kind: string;
|
||||||
|
limits: Record<string, number>;
|
||||||
|
assumptions: Record<string, any>;
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
sourceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchDataSourceSettings(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
dataSourceId: string,
|
||||||
|
settings: DataSourceSettings
|
||||||
|
): Promise<{ sourceId: string; settings: DataSourceSettings; updated: boolean }> {
|
||||||
|
return await request({
|
||||||
|
url: `/api/datasources/${dataSourceId}/settings`,
|
||||||
|
method: 'patch',
|
||||||
|
data: { settings }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDataSourceCostEstimate(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
dataSourceId: string
|
||||||
|
): Promise<CostEstimate> {
|
||||||
|
return await request({
|
||||||
|
url: `/api/datasources/${dataSourceId}/cost-estimate`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatchFlagResponse {
|
||||||
|
sourceId: string;
|
||||||
|
resetDescendantIds: string[];
|
||||||
|
updatedAncestors: { id: string; [key: string]: any }[];
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
export async function patchDataSourceRagIndex(
|
export async function patchDataSourceRagIndex(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
dataSourceId: string,
|
dataSourceId: string,
|
||||||
ragIndexEnabled: boolean
|
ragIndexEnabled: boolean | null
|
||||||
): Promise<{ sourceId: string; ragIndexEnabled: boolean; updated: boolean }> {
|
): Promise<PatchFlagResponse> {
|
||||||
return await request({
|
return await request({
|
||||||
url: `/api/datasources/${dataSourceId}/rag-index`,
|
url: `/api/datasources/${dataSourceId}/rag-index`,
|
||||||
method: 'patch',
|
method: 'patch',
|
||||||
|
|
@ -345,9 +401,13 @@ export interface RagDataSourceDto {
|
||||||
label: string;
|
label: string;
|
||||||
path: string;
|
path: string;
|
||||||
sourceType: string;
|
sourceType: string;
|
||||||
ragIndexEnabled: boolean;
|
/** Three-state inherit semantics on backend; UI reads as effective boolean from RAG inventory aggregator. */
|
||||||
neutralize: boolean;
|
ragIndexEnabled: boolean | null;
|
||||||
|
neutralize: boolean | null;
|
||||||
lastIndexed: number | null;
|
lastIndexed: number | null;
|
||||||
|
/** Distinct files indexed for this DataSource (one row per source document). */
|
||||||
|
fileCount: number;
|
||||||
|
/** Embedding-sized text fragments (one per ContentChunk row, ~400 tokens each). */
|
||||||
chunkCount: number;
|
chunkCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -358,8 +418,14 @@ export interface RagConnectionDto {
|
||||||
knowledgeIngestionEnabled: boolean;
|
knowledgeIngestionEnabled: boolean;
|
||||||
preferences: KnowledgePreferences;
|
preferences: KnowledgePreferences;
|
||||||
dataSources: RagDataSourceDto[];
|
dataSources: RagDataSourceDto[];
|
||||||
|
totalFiles: number;
|
||||||
totalChunks: number;
|
totalChunks: number;
|
||||||
runningJobs: { jobId: string; progress: number; progressMessage: string }[];
|
runningJobs: {
|
||||||
|
jobId: string;
|
||||||
|
progress: number;
|
||||||
|
/** Already translated server-side. */
|
||||||
|
progressMessage: string;
|
||||||
|
}[];
|
||||||
lastError?: { jobId: string; errorMessage: string; finishedAt: number | null } | null;
|
lastError?: { jobId: string; errorMessage: string; finishedAt: number | null } | null;
|
||||||
lastSuccess?: {
|
lastSuccess?: {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
|
|
@ -369,12 +435,51 @@ export interface RagConnectionDto {
|
||||||
skippedPolicy: number;
|
skippedPolicy: number;
|
||||||
failed: number;
|
failed: number;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
|
/** Name of the first budget that bit (e.g. "maxBytes", "maxItems", "maxTasks"); null if walk completed naturally. */
|
||||||
|
stoppedAtLimit?: string | null;
|
||||||
|
/** Effective limits used by the walker, for showing the value next to the limit name. */
|
||||||
|
limits?: Record<string, number>;
|
||||||
|
bytesProcessed?: number;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RagFeatureDataSourceDto {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
tableName: string;
|
||||||
|
featureCode: string;
|
||||||
|
ragIndexEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RagFeatureInstanceDto {
|
||||||
|
featureInstanceId: string;
|
||||||
|
featureCode: string;
|
||||||
|
label: string;
|
||||||
|
mandateId: string;
|
||||||
|
fileCount: number;
|
||||||
|
chunkCount: number;
|
||||||
|
statusCounts: Record<string, number>;
|
||||||
|
dataSources: RagFeatureDataSourceDto[];
|
||||||
|
ragEnabled: boolean;
|
||||||
|
runningJobs?: {
|
||||||
|
jobId: string;
|
||||||
|
progress: number;
|
||||||
|
progressMessage: string;
|
||||||
|
}[];
|
||||||
|
lastError?: { jobId: string; errorMessage: string; finishedAt: number | null } | null;
|
||||||
|
lastSuccess?: {
|
||||||
|
jobId: string;
|
||||||
|
finishedAt: number | null;
|
||||||
|
indexed: number;
|
||||||
|
skippedDuplicate: number;
|
||||||
|
failed: number;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RagInventoryDto {
|
export interface RagInventoryDto {
|
||||||
connections: RagConnectionDto[];
|
connections: RagConnectionDto[];
|
||||||
totals: { chunks: number; bytes?: number };
|
featureInstances?: RagFeatureInstanceDto[];
|
||||||
|
totals: { files: number; chunks: number; bytes?: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RagActiveJobDto {
|
export interface RagActiveJobDto {
|
||||||
|
|
@ -383,6 +488,7 @@ export interface RagActiveJobDto {
|
||||||
connectionLabel?: string;
|
connectionLabel?: string;
|
||||||
jobType: string;
|
jobType: string;
|
||||||
progress: number | null;
|
progress: number | null;
|
||||||
|
/** Already translated server-side. */
|
||||||
progressMessage: string;
|
progressMessage: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -864,7 +864,14 @@ export async function syncPositionsToAccounting(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
positionIds: string[],
|
positionIds: string[],
|
||||||
opts?: { pollMs?: number; onProgress?: (progress: number, message?: string | null) => void }
|
opts?: {
|
||||||
|
pollMs?: number;
|
||||||
|
/**
|
||||||
|
* `message` is already translated server-side by the job route handler
|
||||||
|
* (`resolveJobMessage`). Render it 1:1; never feed it through `t()`.
|
||||||
|
*/
|
||||||
|
onProgress?: (progress: number, message?: string | null) => void;
|
||||||
|
}
|
||||||
): Promise<{ total: number; success: number; skipped?: number; errors: number; results: any[] }> {
|
): Promise<{ total: number; success: number; skipped?: number; errors: number; results: any[] }> {
|
||||||
const submission = await request({
|
const submission = await request({
|
||||||
url: `${_getTrusteeBaseUrl(instanceId)}/accounting/sync`,
|
url: `${_getTrusteeBaseUrl(instanceId)}/accounting/sync`,
|
||||||
|
|
|
||||||
|
|
@ -472,6 +472,29 @@
|
||||||
opacity: 0.35;
|
opacity: 0.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Generic mixed-state indicator (children have differing effective values) */
|
||||||
|
.flagMixed {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary, #475569);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Generic pending spinner during async action */
|
||||||
|
.flagSpinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 2px solid var(--color-border, #cbd5e1);
|
||||||
|
border-top-color: var(--color-primary, #2563eb);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: flagSpin 0.7s linear infinite;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes flagSpin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
/* Loading */
|
/* Loading */
|
||||||
.loadingState {
|
.loadingState {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { usePrompt } from '../../../hooks/usePrompt';
|
||||||
import type {
|
import type {
|
||||||
TreeNode,
|
TreeNode,
|
||||||
TreeNodeProvider,
|
TreeNodeProvider,
|
||||||
|
NodeAction,
|
||||||
FormGeneratorTreeProps,
|
FormGeneratorTreeProps,
|
||||||
Ownership,
|
Ownership,
|
||||||
ScopeValue,
|
ScopeValue,
|
||||||
|
|
@ -30,10 +31,33 @@ const _SCOPE_EMOJIS: Record<string, string> = {
|
||||||
global: '\uD83C\uDF10',
|
global: '\uD83C\uDF10',
|
||||||
};
|
};
|
||||||
|
|
||||||
const _NEUTRALIZE_EMOJI = '\uD83D\uDD12';
|
const _NEUTRALIZE_ON_EMOJI = '\uD83D\uDD12'; // closed padlock
|
||||||
|
const _NEUTRALIZE_OFF_EMOJI = '\uD83D\uDD13'; // open padlock
|
||||||
|
const _RAG_ON_EMOJI = '\uD83E\uDDE0'; // brain
|
||||||
|
const _RAG_OFF_EMOJI = '\uD83E\uDDE0'; // brain (greyed via CSS filter when off)
|
||||||
|
|
||||||
function _nextScope(current: ScopeValue | undefined): ScopeValue {
|
/** CSS for the OFF-state of a boolean flag button. We desaturate the colour
|
||||||
const idx = SCOPE_ORDER.indexOf(current ?? 'personal');
|
* emoji and dim it so the on/off transition is obvious at a glance, even
|
||||||
|
* when the on/off glyph itself is similar (e.g. brain vs greyed-brain). */
|
||||||
|
const _OFF_STATE_STYLE: React.CSSProperties = {
|
||||||
|
filter: 'grayscale(1)',
|
||||||
|
opacity: 0.45,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Uniform symbol for any flag whose effective value is 'mixed' across children. */
|
||||||
|
const _MIXED_SYMBOL = '\u25E9';
|
||||||
|
|
||||||
|
/** Internal action keys reserved by the tree for the built-in flag buttons. */
|
||||||
|
const _ACTION_SCOPE = '__scope__';
|
||||||
|
const _ACTION_NEUTRALIZE = '__neutralize__';
|
||||||
|
const _ACTION_RAG = '__rag__';
|
||||||
|
|
||||||
|
/** Shared empty set; avoids spurious renders when pendingActions has no entry. */
|
||||||
|
const _EMPTY_SET: Set<string> = new Set();
|
||||||
|
|
||||||
|
function _nextScope(current: ScopeValue | 'mixed' | undefined): ScopeValue {
|
||||||
|
if (current === 'mixed' || current === undefined) return 'personal';
|
||||||
|
const idx = SCOPE_ORDER.indexOf(current);
|
||||||
return SCOPE_ORDER[(idx + 1) % SCOPE_ORDER.length];
|
return SCOPE_ORDER[(idx + 1) % SCOPE_ORDER.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,6 +84,15 @@ function _buildChildMap<T>(nodes: TreeNode<T>[]): Map<string | '__root__', TreeN
|
||||||
}
|
}
|
||||||
for (const [, children] of map) {
|
for (const [, children] of map) {
|
||||||
children.sort((a, b) => {
|
children.sort((a, b) => {
|
||||||
|
const aOrd = a.displayOrder;
|
||||||
|
const bOrd = b.displayOrder;
|
||||||
|
if (aOrd !== undefined && bOrd !== undefined) {
|
||||||
|
if (aOrd !== bOrd) return aOrd - bOrd;
|
||||||
|
} else if (aOrd !== undefined) {
|
||||||
|
return -1;
|
||||||
|
} else if (bOrd !== undefined) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
if (a.type === 'folder' && b.type !== 'folder') return -1;
|
if (a.type === 'folder' && b.type !== 'folder') return -1;
|
||||||
if (a.type !== 'folder' && b.type === 'folder') return 1;
|
if (a.type !== 'folder' && b.type === 'folder') return 1;
|
||||||
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
||||||
|
|
@ -126,6 +159,8 @@ interface TreeNodeRowProps<T = any> {
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
ownership: Ownership;
|
ownership: Ownership;
|
||||||
compact: boolean;
|
compact: boolean;
|
||||||
|
selectable: boolean;
|
||||||
|
pendingActions: Set<string>;
|
||||||
provider: TreeNodeProvider<T>;
|
provider: TreeNodeProvider<T>;
|
||||||
onToggleExpand: (id: string) => void;
|
onToggleExpand: (id: string) => void;
|
||||||
onToggleSelect: (id: string, e: React.MouseEvent) => void;
|
onToggleSelect: (id: string, e: React.MouseEvent) => void;
|
||||||
|
|
@ -138,6 +173,9 @@ interface TreeNodeRowProps<T = any> {
|
||||||
onSendToChat?: (node: TreeNode<T>) => void;
|
onSendToChat?: (node: TreeNode<T>) => void;
|
||||||
onCycleScope: (node: TreeNode<T>) => void;
|
onCycleScope: (node: TreeNode<T>) => void;
|
||||||
onToggleNeutralize: (node: TreeNode<T>) => void;
|
onToggleNeutralize: (node: TreeNode<T>) => void;
|
||||||
|
onToggleRagIndex: (node: TreeNode<T>) => void;
|
||||||
|
onCreateChild?: (parentId: string) => void;
|
||||||
|
onExtraAction: (nodeId: string, action: NodeAction) => void;
|
||||||
onDragStart: (e: React.DragEvent, node: TreeNode<T>) => void;
|
onDragStart: (e: React.DragEvent, node: TreeNode<T>) => void;
|
||||||
onDragOver: (e: React.DragEvent, node: TreeNode<T>) => void;
|
onDragOver: (e: React.DragEvent, node: TreeNode<T>) => void;
|
||||||
onDragLeave: (e: React.DragEvent) => void;
|
onDragLeave: (e: React.DragEvent) => void;
|
||||||
|
|
@ -154,6 +192,8 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
||||||
isDragging,
|
isDragging,
|
||||||
ownership,
|
ownership,
|
||||||
compact,
|
compact,
|
||||||
|
selectable,
|
||||||
|
pendingActions,
|
||||||
provider,
|
provider,
|
||||||
onToggleExpand,
|
onToggleExpand,
|
||||||
onToggleSelect,
|
onToggleSelect,
|
||||||
|
|
@ -166,6 +206,9 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
||||||
onSendToChat,
|
onSendToChat,
|
||||||
onCycleScope,
|
onCycleScope,
|
||||||
onToggleNeutralize,
|
onToggleNeutralize,
|
||||||
|
onToggleRagIndex,
|
||||||
|
onCreateChild,
|
||||||
|
onExtraAction,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
onDragOver,
|
onDragOver,
|
||||||
onDragLeave,
|
onDragLeave,
|
||||||
|
|
@ -231,6 +274,12 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
||||||
const canDelete = isOwn && provider.canDelete?.(node);
|
const canDelete = isOwn && provider.canDelete?.(node);
|
||||||
const canPatchScope = isOwn && provider.canPatchScope?.(node);
|
const canPatchScope = isOwn && provider.canPatchScope?.(node);
|
||||||
const canPatchNeutralize = isOwn && provider.canPatchNeutralize?.(node);
|
const canPatchNeutralize = isOwn && provider.canPatchNeutralize?.(node);
|
||||||
|
const canPatchRagIndex = isOwn && provider.canPatchRagIndex?.(node);
|
||||||
|
const canCreateChild =
|
||||||
|
isOwn &&
|
||||||
|
!!provider.createChild &&
|
||||||
|
node.type === 'folder' &&
|
||||||
|
(provider.canCreate ? provider.canCreate(node.id) : true);
|
||||||
|
|
||||||
const rowClasses = [
|
const rowClasses = [
|
||||||
styles.nodeRow,
|
styles.nodeRow,
|
||||||
|
|
@ -263,17 +312,19 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
||||||
>
|
>
|
||||||
<div className={styles.indentSpacer} style={{ width: depth * INDENT_PX }} />
|
<div className={styles.indentSpacer} style={{ width: depth * INDENT_PX }} />
|
||||||
|
|
||||||
<input
|
{selectable && (
|
||||||
type="checkbox"
|
<input
|
||||||
className={styles.nodeCheckbox}
|
type="checkbox"
|
||||||
checked={isSelected}
|
className={styles.nodeCheckbox}
|
||||||
onChange={() => {}}
|
checked={isSelected}
|
||||||
onClick={(e) => {
|
onChange={() => {}}
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
onToggleSelect(node.id, e as unknown as React.MouseEvent);
|
e.stopPropagation();
|
||||||
}}
|
onToggleSelect(node.id, e as unknown as React.MouseEvent);
|
||||||
tabIndex={-1}
|
}}
|
||||||
/>
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasChildren ? (
|
{hasChildren ? (
|
||||||
<span
|
<span
|
||||||
|
|
@ -316,6 +367,17 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className={styles.nodeActionsHover}>
|
<div className={styles.nodeActionsHover}>
|
||||||
|
{canCreateChild && onCreateChild && (
|
||||||
|
<button
|
||||||
|
className={styles.emojiBtn}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onCreateChild(node.id); }}
|
||||||
|
title="Neuer Unterordner"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{'\u2795'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{canRename && (
|
{canRename && (
|
||||||
<button
|
<button
|
||||||
className={styles.emojiBtn}
|
className={styles.emojiBtn}
|
||||||
|
|
@ -327,7 +389,7 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{node.type !== 'folder' && (
|
{node.type !== 'folder' && provider.downloadNode && (
|
||||||
<button
|
<button
|
||||||
className={styles.emojiBtn}
|
className={styles.emojiBtn}
|
||||||
onClick={(e) => { e.stopPropagation(); onDownload(node); }}
|
onClick={(e) => { e.stopPropagation(); onDownload(node); }}
|
||||||
|
|
@ -352,6 +414,49 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.nodeActionsPersistent}>
|
<div className={styles.nodeActionsPersistent}>
|
||||||
|
{/* Order (left-to-right): extraActions (e.g. settings) -> RAG -> sendToChat -> scope -> neutralize. */}
|
||||||
|
{node.extraActions?.map((action) => (
|
||||||
|
<button
|
||||||
|
key={action.key}
|
||||||
|
className={`${styles.emojiBtn} ${action.disabled ? styles.emojiBtnReadonly : ''}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!action.disabled) onExtraAction(node.id, action);
|
||||||
|
}}
|
||||||
|
title={action.tooltip}
|
||||||
|
tabIndex={-1}
|
||||||
|
disabled={action.disabled}
|
||||||
|
>
|
||||||
|
{pendingActions.has(action.key)
|
||||||
|
? <span className={styles.flagSpinner} />
|
||||||
|
: action.value === 'mixed'
|
||||||
|
? <span className={styles.flagMixed}>{_MIXED_SYMBOL}</span>
|
||||||
|
: action.icon}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{node.ragIndexEnabled !== undefined && (
|
||||||
|
<button
|
||||||
|
className={`${styles.emojiBtn} ${canPatchRagIndex ? '' : styles.emojiBtnReadonly}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (canPatchRagIndex) onToggleRagIndex(node);
|
||||||
|
}}
|
||||||
|
title={node.ragIndexEnabled === 'mixed'
|
||||||
|
? 'Gemischt - Klick setzt explizit'
|
||||||
|
: node.ragIndexEnabled ? 'RAG-Indexierung an' : 'RAG-Indexierung aus'}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{pendingActions.has(_ACTION_RAG)
|
||||||
|
? <span className={styles.flagSpinner} />
|
||||||
|
: node.ragIndexEnabled === 'mixed'
|
||||||
|
? <span className={styles.flagMixed}>{_MIXED_SYMBOL}</span>
|
||||||
|
: node.ragIndexEnabled === true
|
||||||
|
? _RAG_ON_EMOJI
|
||||||
|
: <span style={_OFF_STATE_STYLE}>{_RAG_OFF_EMOJI}</span>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{onSendToChat && (
|
{onSendToChat && (
|
||||||
<button
|
<button
|
||||||
className={styles.emojiBtn}
|
className={styles.emojiBtn}
|
||||||
|
|
@ -373,10 +478,14 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (canPatchScope) onCycleScope(node);
|
if (canPatchScope) onCycleScope(node);
|
||||||
}}
|
}}
|
||||||
title={`Scope: ${node.scope}`}
|
title={node.scope === 'mixed' ? 'Gemischt - Klick setzt explizit' : `Scope: ${node.scope}`}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{_SCOPE_EMOJIS[node.scope] ?? _SCOPE_EMOJIS.personal}
|
{pendingActions.has(_ACTION_SCOPE)
|
||||||
|
? <span className={styles.flagSpinner} />
|
||||||
|
: node.scope === 'mixed'
|
||||||
|
? <span className={styles.flagMixed}>{_MIXED_SYMBOL}</span>
|
||||||
|
: (_SCOPE_EMOJIS[node.scope] ?? _SCOPE_EMOJIS.personal)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -387,11 +496,18 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (canPatchNeutralize) onToggleNeutralize(node);
|
if (canPatchNeutralize) onToggleNeutralize(node);
|
||||||
}}
|
}}
|
||||||
title={node.neutralize ? 'Neutralisiert' : 'Nicht neutralisiert'}
|
title={node.neutralize === 'mixed'
|
||||||
|
? 'Gemischt - Klick setzt explizit'
|
||||||
|
: node.neutralize ? 'Neutralisiert' : 'Nicht neutralisiert'}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
style={{ opacity: node.neutralize ? 1 : 0.35 }}
|
|
||||||
>
|
>
|
||||||
{_NEUTRALIZE_EMOJI}
|
{pendingActions.has(_ACTION_NEUTRALIZE)
|
||||||
|
? <span className={styles.flagSpinner} />
|
||||||
|
: node.neutralize === 'mixed'
|
||||||
|
? <span className={styles.flagMixed}>{_MIXED_SYMBOL}</span>
|
||||||
|
: node.neutralize === true
|
||||||
|
? _NEUTRALIZE_ON_EMOJI
|
||||||
|
: <span style={_OFF_STATE_STYLE}>{_NEUTRALIZE_OFF_EMOJI}</span>}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -413,6 +529,8 @@ export function FormGeneratorTree<T = any>({
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onSendToChat,
|
onSendToChat,
|
||||||
allowCreateFolder = true,
|
allowCreateFolder = true,
|
||||||
|
selectable = true,
|
||||||
|
refreshAfterAction = false,
|
||||||
className,
|
className,
|
||||||
}: FormGeneratorTreeProps<T>) {
|
}: FormGeneratorTreeProps<T>) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -428,11 +546,103 @@ export function FormGeneratorTree<T = any>({
|
||||||
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
||||||
const [draggingIds, setDraggingIds] = useState<Set<string>>(new Set());
|
const [draggingIds, setDraggingIds] = useState<Set<string>>(new Set());
|
||||||
const [filterText, setFilterText] = useState('');
|
const [filterText, setFilterText] = useState('');
|
||||||
|
/** Map of nodeId -> set of action keys currently pending (for spinner rendering). */
|
||||||
|
const [pendingActions, setPendingActions] = useState<Map<string, Set<string>>>(new Map());
|
||||||
const lastSelectedIdRef = useRef<string | null>(null);
|
const lastSelectedIdRef = useRef<string | null>(null);
|
||||||
const treeContentRef = useRef<HTMLDivElement>(null);
|
const treeContentRef = useRef<HTMLDivElement>(null);
|
||||||
|
/** Tracks node ids for which auto-expand has already fired (one-shot). */
|
||||||
|
const autoExpandedRef = useRef<Set<string>>(new Set());
|
||||||
|
/** Stable ref to the current flatEntries so _refreshVisibleAttributes can
|
||||||
|
* read visible IDs without being in the dependency array. */
|
||||||
|
const flatEntriesRef = useRef<FlatEntry<T>[]>([]);
|
||||||
|
|
||||||
|
/** Deduplicating node append: merges `incoming` into `prev` by id. */
|
||||||
|
const _mergeNodes = useCallback(
|
||||||
|
(prev: TreeNode<T>[], incoming: TreeNode<T>[]): TreeNode<T>[] => {
|
||||||
|
if (incoming.length === 0) return prev;
|
||||||
|
const existingIds = new Set(prev.map((n) => n.id));
|
||||||
|
const unique = incoming.filter((n) => !existingIds.has(n.id));
|
||||||
|
if (unique.length === 0) return prev;
|
||||||
|
return [...prev, ...unique];
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
/** After a toggle, collect all currently visible node IDs and ask the
|
||||||
|
* provider for their updated attributes. Patches only attribute fields
|
||||||
|
* (neutralize, scope, ragIndexEnabled) on existing nodes — no structural
|
||||||
|
* reload. Falls back to full refetch if provider doesn't implement
|
||||||
|
* refreshAttributes. */
|
||||||
|
const _refreshVisibleAttributes = useCallback(async () => {
|
||||||
|
if (provider.refreshAttributes) {
|
||||||
|
const visibleIds = flatEntriesRef.current.map((e) => e.node.id);
|
||||||
|
if (visibleIds.length === 0) return;
|
||||||
|
const attrs = await provider.refreshAttributes(visibleIds);
|
||||||
|
setNodes((prev) =>
|
||||||
|
prev.map((n) => {
|
||||||
|
const update = attrs.get(n.id);
|
||||||
|
if (!update) return n;
|
||||||
|
const patched: Partial<typeof n> = {};
|
||||||
|
if (n.neutralize !== undefined && update.neutralize !== undefined) patched.neutralize = update.neutralize;
|
||||||
|
if (n.scope !== undefined && update.scope !== undefined) patched.scope = update.scope;
|
||||||
|
if (n.ragIndexEnabled !== undefined && update.ragIndexEnabled !== undefined) patched.ragIndexEnabled = update.ragIndexEnabled;
|
||||||
|
if (Object.keys(patched).length === 0) return n;
|
||||||
|
return { ...n, ...patched };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const expandedList: (string | null)[] = [null, ...Array.from(expandedIds)];
|
||||||
|
const fetched = await Promise.all(
|
||||||
|
expandedList.map((p) => provider.loadChildren(p, ownership)),
|
||||||
|
);
|
||||||
|
const refetchedParents = new Set(expandedList.map((p) => p ?? '__null__'));
|
||||||
|
setNodes((prev) => {
|
||||||
|
const keepers = prev.filter((n) => {
|
||||||
|
const key = n.parentId ?? '__null__';
|
||||||
|
return !refetchedParents.has(key);
|
||||||
|
});
|
||||||
|
return [...keepers, ...fetched.flat()];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [expandedIds, provider, ownership]);
|
||||||
|
|
||||||
|
/** Wrap any async action with pending-state tracking so the tree can show
|
||||||
|
* a spinner over the corresponding button. Generic — no domain knowledge.
|
||||||
|
* When `refreshAfterAction` is enabled, the spinner stays on until the
|
||||||
|
* refreshed attributes have been written into state. */
|
||||||
|
const _runAction = useCallback(
|
||||||
|
async (nodeId: string, actionKey: string, fn: () => Promise<void> | void) => {
|
||||||
|
setPendingActions((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const current = new Set(next.get(nodeId) ?? []);
|
||||||
|
current.add(actionKey);
|
||||||
|
next.set(nodeId, current);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
if (refreshAfterAction || provider.refreshAttributes) {
|
||||||
|
await _refreshVisibleAttributes();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setPendingActions((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const current = new Set(next.get(nodeId) ?? []);
|
||||||
|
current.delete(actionKey);
|
||||||
|
if (current.size === 0) next.delete(nodeId);
|
||||||
|
else next.set(nodeId, current);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[refreshAfterAction, _refreshVisibleAttributes],
|
||||||
|
);
|
||||||
|
|
||||||
const _loadRoot = useCallback(async () => {
|
const _loadRoot = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
autoExpandedRef.current.clear();
|
||||||
|
setExpandedIds(new Set());
|
||||||
try {
|
try {
|
||||||
const rootNodes = await provider.loadChildren(null, ownership);
|
const rootNodes = await provider.loadChildren(null, ownership);
|
||||||
setNodes(rootNodes);
|
setNodes(rootNodes);
|
||||||
|
|
@ -448,6 +658,45 @@ export function FormGeneratorTree<T = any>({
|
||||||
_loadRoot();
|
_loadRoot();
|
||||||
}, [_loadRoot]);
|
}, [_loadRoot]);
|
||||||
|
|
||||||
|
/** Auto-expand nodes with `defaultExpanded=true` from backend, one-shot per id.
|
||||||
|
* Fetches children first, then sets expandedIds + merges atomically so the
|
||||||
|
* expanded arrow never appears without visible children. */
|
||||||
|
useEffect(() => {
|
||||||
|
const targets = nodes.filter(
|
||||||
|
(n) => n.defaultExpanded === true && !autoExpandedRef.current.has(n.id),
|
||||||
|
);
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
const targetIds = targets.map((t) => t.id);
|
||||||
|
for (const id of targetIds) autoExpandedRef.current.add(id);
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const childMap = _buildChildMap(nodes);
|
||||||
|
const toFetch = targetIds.filter((id) => {
|
||||||
|
const existing = childMap.get(id);
|
||||||
|
return !existing || existing.length === 0;
|
||||||
|
});
|
||||||
|
if (toFetch.length > 0) {
|
||||||
|
const results = await Promise.all(
|
||||||
|
toFetch.map((id) =>
|
||||||
|
provider.loadChildren(id, ownership).catch(() => [] as TreeNode<T>[]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (cancelled) return;
|
||||||
|
const flat = results.flat();
|
||||||
|
if (flat.length > 0) {
|
||||||
|
setNodes((prev) => _mergeNodes(prev, flat));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cancelled) return;
|
||||||
|
setExpandedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const id of targetIds) next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [nodes, provider, ownership, _mergeNodes]);
|
||||||
|
|
||||||
const flatEntriesRaw = useMemo(() => _flatten(nodes, expandedIds), [nodes, expandedIds]);
|
const flatEntriesRaw = useMemo(() => _flatten(nodes, expandedIds), [nodes, expandedIds]);
|
||||||
|
|
||||||
const flatEntries = useMemo(() => {
|
const flatEntries = useMemo(() => {
|
||||||
|
|
@ -468,6 +717,8 @@ export function FormGeneratorTree<T = any>({
|
||||||
return flatEntriesRaw.filter((e) => matchIds.has(e.node.id));
|
return flatEntriesRaw.filter((e) => matchIds.has(e.node.id));
|
||||||
}, [flatEntriesRaw, filterText, nodes]);
|
}, [flatEntriesRaw, filterText, nodes]);
|
||||||
|
|
||||||
|
flatEntriesRef.current = flatEntries;
|
||||||
|
|
||||||
const _updateSelection = useCallback(
|
const _updateSelection = useCallback(
|
||||||
(newSelection: Set<string>) => {
|
(newSelection: Set<string>) => {
|
||||||
setSelectedIds(newSelection);
|
setSelectedIds(newSelection);
|
||||||
|
|
@ -479,32 +730,39 @@ export function FormGeneratorTree<T = any>({
|
||||||
const _handleToggleExpand = useCallback(
|
const _handleToggleExpand = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
const wasExpanded = expandedIds.has(id);
|
const wasExpanded = expandedIds.has(id);
|
||||||
setExpandedIds((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(id)) {
|
|
||||||
next.delete(id);
|
|
||||||
} else {
|
|
||||||
next.add(id);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
const node = nodes.find((n) => n.id === id);
|
if (wasExpanded) {
|
||||||
if (node && !wasExpanded) {
|
// Collapse: remove all descendants from nodes state and expandedIds.
|
||||||
const childMap = _buildChildMap(nodes);
|
const descendantIds = new Set<string>();
|
||||||
const existingChildren = childMap.get(id);
|
const _collectDescendants = (parentId: string) => {
|
||||||
if (!existingChildren || existingChildren.length === 0) {
|
for (const n of nodes) {
|
||||||
const childNodes = await provider.loadChildren(id, ownership);
|
if (n.parentId === parentId && !descendantIds.has(n.id)) {
|
||||||
if (childNodes.length > 0) {
|
descendantIds.add(n.id);
|
||||||
setNodes((prev) => [...prev, ...childNodes]);
|
_collectDescendants(n.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
_collectDescendants(id);
|
||||||
|
setExpandedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(id);
|
||||||
|
for (const did of descendantIds) next.delete(did);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setNodes((prev) => prev.filter((n) => !descendantIds.has(n.id)));
|
||||||
|
} else {
|
||||||
|
// Expand: load children from backend (always fresh).
|
||||||
|
setExpandedIds((prev) => new Set([...prev, id]));
|
||||||
|
const childNodes = await provider.loadChildren(id, ownership);
|
||||||
|
if (childNodes.length > 0) {
|
||||||
|
setNodes((prev) => _mergeNodes(prev, childNodes));
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
_scrollExpandedNodeToCenter(id);
|
_scrollExpandedNodeToCenter(id);
|
||||||
}, 50);
|
}, 50);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[nodes, expandedIds, provider, ownership],
|
[nodes, expandedIds, provider, ownership, _mergeNodes],
|
||||||
);
|
);
|
||||||
|
|
||||||
const _scrollExpandedNodeToCenter = useCallback((nodeId: string) => {
|
const _scrollExpandedNodeToCenter = useCallback((nodeId: string) => {
|
||||||
|
|
@ -523,6 +781,10 @@ export function FormGeneratorTree<T = any>({
|
||||||
|
|
||||||
const _handleToggleSelect = useCallback(
|
const _handleToggleSelect = useCallback(
|
||||||
(id: string, e: React.MouseEvent) => {
|
(id: string, e: React.MouseEvent) => {
|
||||||
|
if (!selectable) {
|
||||||
|
setFocusedId(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const newSelection = new Set(selectedIds);
|
const newSelection = new Set(selectedIds);
|
||||||
|
|
||||||
if (e.shiftKey && lastSelectedIdRef.current) {
|
if (e.shiftKey && lastSelectedIdRef.current) {
|
||||||
|
|
@ -566,7 +828,7 @@ export function FormGeneratorTree<T = any>({
|
||||||
lastSelectedIdRef.current = id;
|
lastSelectedIdRef.current = id;
|
||||||
_updateSelection(newSelection);
|
_updateSelection(newSelection);
|
||||||
},
|
},
|
||||||
[selectedIds, flatEntries, nodes, ownership, _updateSelection],
|
[selectable, selectedIds, flatEntries, nodes, ownership, _updateSelection],
|
||||||
);
|
);
|
||||||
|
|
||||||
const _handleNodeClick = useCallback(
|
const _handleNodeClick = useCallback(
|
||||||
|
|
@ -603,18 +865,23 @@ export function FormGeneratorTree<T = any>({
|
||||||
onRefresh?.();
|
onRefresh?.();
|
||||||
}, [_loadRoot, _updateSelection, onRefresh]);
|
}, [_loadRoot, _updateSelection, onRefresh]);
|
||||||
|
|
||||||
const _handleNewFolder = useCallback(async () => {
|
/** Create a new folder under `parentId`. `null` = legacy top-level (the
|
||||||
|
* provider may map this to its own visible root, e.g. a synth-root). */
|
||||||
|
const _createFolderAt = useCallback(async (parentId: string | null) => {
|
||||||
if (ownership !== 'own' || !provider.createChild || !allowCreateFolder) return;
|
if (ownership !== 'own' || !provider.createChild || !allowCreateFolder) return;
|
||||||
const parentId = _resolveNewFolderParentId(selectedIds, nodes);
|
|
||||||
if (provider.canCreate && !provider.canCreate(parentId)) return;
|
if (provider.canCreate && !provider.canCreate(parentId)) return;
|
||||||
const name = await prompt('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' });
|
const name = await prompt('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' });
|
||||||
const trimmed = name?.trim();
|
const trimmed = name?.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
try {
|
try {
|
||||||
const newNode = await provider.createChild(parentId, trimmed);
|
const newNode = await provider.createChild(parentId, trimmed);
|
||||||
setNodes((prev) => [...prev, newNode]);
|
setNodes((prev) => _mergeNodes(prev, [newNode]));
|
||||||
if (parentId) {
|
// The provider may have re-parented `newNode` (e.g. onto a synth-root)
|
||||||
setExpandedIds((prev) => new Set(prev).add(parentId));
|
// when `parentId === null`; expand whichever parent the resulting node
|
||||||
|
// actually points at, so the new folder is visible.
|
||||||
|
const visibleParent = newNode.parentId ?? null;
|
||||||
|
if (visibleParent) {
|
||||||
|
setExpandedIds((prev) => new Set(prev).add(visibleParent));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await _handleRefresh();
|
await _handleRefresh();
|
||||||
|
|
@ -623,12 +890,15 @@ export function FormGeneratorTree<T = any>({
|
||||||
ownership,
|
ownership,
|
||||||
provider,
|
provider,
|
||||||
allowCreateFolder,
|
allowCreateFolder,
|
||||||
selectedIds,
|
|
||||||
nodes,
|
|
||||||
prompt,
|
prompt,
|
||||||
_handleRefresh,
|
_handleRefresh,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const _handleNewFolder = useCallback(async () => {
|
||||||
|
const parentId = _resolveNewFolderParentId(selectedIds, nodes);
|
||||||
|
await _createFolderAt(parentId);
|
||||||
|
}, [_createFolderAt, selectedIds, nodes]);
|
||||||
|
|
||||||
const _handleDelete = useCallback(
|
const _handleDelete = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
const node = nodes.find((n) => n.id === id);
|
const node = nodes.find((n) => n.id === id);
|
||||||
|
|
@ -659,29 +929,43 @@ export function FormGeneratorTree<T = any>({
|
||||||
async (node: TreeNode<T>) => {
|
async (node: TreeNode<T>) => {
|
||||||
const newScope = _nextScope(node.scope);
|
const newScope = _nextScope(node.scope);
|
||||||
const isFolder = node.type === 'folder';
|
const isFolder = node.type === 'folder';
|
||||||
await provider.patchScope?.([node.id], newScope, isFolder);
|
await _runAction(node.id, _ACTION_SCOPE, async () => {
|
||||||
setNodes((prev) => {
|
await provider.patchScope?.([node.id], newScope, isFolder);
|
||||||
if (!isFolder) return prev.map((n) => (n.id === node.id ? { ...n, scope: newScope } : n));
|
|
||||||
const descendantIds = new Set(_collectDescendantIds(node.id, prev));
|
|
||||||
descendantIds.add(node.id);
|
|
||||||
return prev.map((n) => descendantIds.has(n.id) ? { ...n, scope: newScope } : n);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[provider],
|
[provider, _runAction],
|
||||||
);
|
);
|
||||||
|
|
||||||
const _handleToggleNeutralize = useCallback(
|
const _handleToggleNeutralize = useCallback(
|
||||||
async (node: TreeNode<T>) => {
|
async (node: TreeNode<T>) => {
|
||||||
const newValue = !node.neutralize;
|
const newValue = node.neutralize === 'mixed' ? false : !node.neutralize;
|
||||||
await provider.patchNeutralize?.([node.id], newValue);
|
await _runAction(node.id, _ACTION_NEUTRALIZE, async () => {
|
||||||
setNodes((prev) => {
|
await provider.patchNeutralize?.([node.id], newValue);
|
||||||
if (node.type !== 'folder') return prev.map((n) => (n.id === node.id ? { ...n, neutralize: newValue } : n));
|
|
||||||
const descendantIds = new Set(_collectDescendantIds(node.id, prev));
|
|
||||||
descendantIds.add(node.id);
|
|
||||||
return prev.map((n) => descendantIds.has(n.id) ? { ...n, neutralize: newValue } : n);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[provider],
|
[provider, _runAction],
|
||||||
|
);
|
||||||
|
|
||||||
|
const _handleToggleRagIndex = useCallback(
|
||||||
|
async (node: TreeNode<T>) => {
|
||||||
|
const newValue = node.ragIndexEnabled === 'mixed' ? false : !node.ragIndexEnabled;
|
||||||
|
await _runAction(node.id, _ACTION_RAG, async () => {
|
||||||
|
await provider.patchRagIndex?.([node.id], newValue);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[provider, _runAction],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Generic dispatcher for provider-defined extraActions. Tree knows nothing
|
||||||
|
* about the action semantics; it only manages the pending spinner. */
|
||||||
|
const _handleExtraAction = useCallback(
|
||||||
|
async (nodeId: string, action: NodeAction) => {
|
||||||
|
if (!action.onClick) return;
|
||||||
|
await _runAction(nodeId, action.key, async () => {
|
||||||
|
await action.onClick!();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[_runAction],
|
||||||
);
|
);
|
||||||
|
|
||||||
const _handleDragStart = useCallback(
|
const _handleDragStart = useCallback(
|
||||||
|
|
@ -700,10 +984,14 @@ export function FormGeneratorTree<T = any>({
|
||||||
e.dataTransfer.setData('application/tree-items', JSON.stringify(chatPayload));
|
e.dataTransfer.setData('application/tree-items', JSON.stringify(chatPayload));
|
||||||
e.dataTransfer.setData('text/plain', chatPayload.map((p) => p.name).join(', '));
|
e.dataTransfer.setData('text/plain', chatPayload.map((p) => p.name).join(', '));
|
||||||
|
|
||||||
|
if (provider.customizeDragData) {
|
||||||
|
provider.customizeDragData(node, e.dataTransfer);
|
||||||
|
}
|
||||||
|
|
||||||
e.dataTransfer.effectAllowed = 'copyMove';
|
e.dataTransfer.effectAllowed = 'copyMove';
|
||||||
setDraggingIds(new Set(dragIds));
|
setDraggingIds(new Set(dragIds));
|
||||||
},
|
},
|
||||||
[selectedIds, nodes, provider.rootKey],
|
[selectedIds, nodes, provider],
|
||||||
);
|
);
|
||||||
|
|
||||||
const _handleDragOver = useCallback(
|
const _handleDragOver = useCallback(
|
||||||
|
|
@ -948,7 +1236,7 @@ export function FormGeneratorTree<T = any>({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedIds.size > 0 && batchActions.length > 0 && (
|
{selectable && selectedIds.size > 0 && batchActions.length > 0 && (
|
||||||
<div className={styles.batchToolbar}>
|
<div className={styles.batchToolbar}>
|
||||||
<span className={styles.batchCount}>{selectedIds.size} selected</span>
|
<span className={styles.batchCount}>{selectedIds.size} selected</span>
|
||||||
{batchActions.map((action: TreeBatchAction) => {
|
{batchActions.map((action: TreeBatchAction) => {
|
||||||
|
|
@ -1009,6 +1297,8 @@ export function FormGeneratorTree<T = any>({
|
||||||
isDragging={draggingIds.has(entry.node.id)}
|
isDragging={draggingIds.has(entry.node.id)}
|
||||||
ownership={ownership}
|
ownership={ownership}
|
||||||
compact={compact}
|
compact={compact}
|
||||||
|
selectable={selectable}
|
||||||
|
pendingActions={pendingActions.get(entry.node.id) ?? _EMPTY_SET}
|
||||||
provider={provider}
|
provider={provider}
|
||||||
onToggleExpand={_handleToggleExpand}
|
onToggleExpand={_handleToggleExpand}
|
||||||
onToggleSelect={_handleToggleSelect}
|
onToggleSelect={_handleToggleSelect}
|
||||||
|
|
@ -1021,6 +1311,9 @@ export function FormGeneratorTree<T = any>({
|
||||||
onSendToChat={onSendToChat}
|
onSendToChat={onSendToChat}
|
||||||
onCycleScope={_handleCycleScope}
|
onCycleScope={_handleCycleScope}
|
||||||
onToggleNeutralize={_handleToggleNeutralize}
|
onToggleNeutralize={_handleToggleNeutralize}
|
||||||
|
onToggleRagIndex={_handleToggleRagIndex}
|
||||||
|
onCreateChild={allowCreateFolder ? _createFolderAt : undefined}
|
||||||
|
onExtraAction={_handleExtraAction}
|
||||||
onDragStart={_handleDragStart}
|
onDragStart={_handleDragStart}
|
||||||
onDragOver={_handleDragOver}
|
onDragOver={_handleDragOver}
|
||||||
onDragLeave={_handleDragLeave}
|
onDragLeave={_handleDragLeave}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
// Copyright (c) 2026 Patrick Motsch
|
// Copyright (c) 2026 Patrick Motsch
|
||||||
// All rights reserved.
|
// All rights reserved.
|
||||||
|
|
||||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
import React from 'react';
|
||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
import { render, screen, waitFor, within, fireEvent } from '@testing-library/react';
|
import { render, screen, waitFor, within, fireEvent } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { FormGeneratorTree } from '../FormGeneratorTree';
|
import { FormGeneratorTree } from '../FormGeneratorTree';
|
||||||
import type { TreeNode, TreeNodeProvider, TreeBatchAction } from '../types';
|
import type { TreeNode, TreeNodeProvider, TreeBatchAction } from '../types';
|
||||||
|
|
||||||
|
// Silence unused-import warnings for React (needed only for the JSX/UMD type
|
||||||
|
// resolution under tsconfig.app fallback paths).
|
||||||
|
void React;
|
||||||
|
|
||||||
const { mockPrompt } = vi.hoisted(() => ({
|
const { mockPrompt } = vi.hoisted(() => ({
|
||||||
mockPrompt: vi.fn(() => Promise.resolve('NeuOrdner')),
|
mockPrompt: vi.fn(() => Promise.resolve('NeuOrdner')),
|
||||||
}));
|
}));
|
||||||
|
|
@ -17,6 +23,27 @@ vi.mock('../../../../hooks/usePrompt', () => ({
|
||||||
PromptDialog: () => null,
|
PromptDialog: () => null,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../providers/language/LanguageContext', () => ({
|
||||||
|
useLanguage: () => ({
|
||||||
|
t: (key: string, vars?: Record<string, string>) => {
|
||||||
|
if (!vars) return key;
|
||||||
|
let out = key;
|
||||||
|
for (const [k, v] of Object.entries(vars)) out = out.replace(`{${k}}`, String(v));
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
availableLanguages: ['de'],
|
||||||
|
language: 'de',
|
||||||
|
setLanguage: () => {},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../hooks/useConfirm', () => ({
|
||||||
|
useConfirm: () => ({
|
||||||
|
confirm: () => Promise.resolve(true),
|
||||||
|
ConfirmDialog: () => null,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Fixtures
|
// Fixtures
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -68,7 +95,7 @@ const _orphanFile: TreeNode = {
|
||||||
function _createMockProvider(nodes: TreeNode[]): TreeNodeProvider {
|
function _createMockProvider(nodes: TreeNode[]): TreeNodeProvider {
|
||||||
return {
|
return {
|
||||||
rootKey: 'test',
|
rootKey: 'test',
|
||||||
loadChildren: vi.fn(async (parentId) =>
|
loadChildren: vi.fn(async (parentId: string | null): Promise<TreeNode[]> =>
|
||||||
nodes.filter((n) => n.parentId === parentId),
|
nodes.filter((n) => n.parentId === parentId),
|
||||||
),
|
),
|
||||||
canCreate: vi.fn(() => true),
|
canCreate: vi.fn(() => true),
|
||||||
|
|
@ -77,6 +104,7 @@ function _createMockProvider(nodes: TreeNode[]): TreeNodeProvider {
|
||||||
canMove: vi.fn(() => true),
|
canMove: vi.fn(() => true),
|
||||||
canPatchScope: vi.fn((node) => node.ownership === 'own'),
|
canPatchScope: vi.fn((node) => node.ownership === 'own'),
|
||||||
canPatchNeutralize: vi.fn((node) => node.ownership === 'own'),
|
canPatchNeutralize: vi.fn((node) => node.ownership === 'own'),
|
||||||
|
canPatchRagIndex: vi.fn((node) => node.ownership === 'own'),
|
||||||
createChild: vi.fn(async (parentId, name) => ({
|
createChild: vi.fn(async (parentId, name) => ({
|
||||||
id: 'new-1',
|
id: 'new-1',
|
||||||
name,
|
name,
|
||||||
|
|
@ -90,6 +118,7 @@ function _createMockProvider(nodes: TreeNode[]): TreeNodeProvider {
|
||||||
moveNodes: vi.fn(async () => {}),
|
moveNodes: vi.fn(async () => {}),
|
||||||
patchScope: vi.fn(async () => {}),
|
patchScope: vi.fn(async () => {}),
|
||||||
patchNeutralize: vi.fn(async () => {}),
|
patchNeutralize: vi.fn(async () => {}),
|
||||||
|
patchRagIndex: vi.fn(async () => {}),
|
||||||
getBatchActions: vi.fn(() => []),
|
getBatchActions: vi.fn(() => []),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -119,7 +148,7 @@ describe('FormGeneratorTree', () => {
|
||||||
|
|
||||||
it('shows loading spinner while loading', () => {
|
it('shows loading spinner while loading', () => {
|
||||||
const provider = _createMockProvider([]);
|
const provider = _createMockProvider([]);
|
||||||
provider.loadChildren = vi.fn(() => new Promise(() => {})); // never resolves
|
provider.loadChildren = vi.fn(() => new Promise<TreeNode[]>(() => {})); // never resolves
|
||||||
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||||
|
|
||||||
const tree = screen.getByRole('tree');
|
const tree = screen.getByRole('tree');
|
||||||
|
|
@ -607,6 +636,7 @@ describe('FormGeneratorTree', () => {
|
||||||
expect(provider.patchScope).toHaveBeenCalledWith(
|
expect(provider.patchScope).toHaveBeenCalledWith(
|
||||||
['f1'],
|
['f1'],
|
||||||
'featureInstance',
|
'featureInstance',
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -780,4 +810,396 @@ describe('FormGeneratorTree', () => {
|
||||||
expect(screen.queryByText('Delete All')).not.toBeInTheDocument();
|
expect(screen.queryByText('Delete All')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mixed-state rendering (generic, used by UDB Sources)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Mixed-state rendering', () => {
|
||||||
|
const _mixedFolder: TreeNode = {
|
||||||
|
id: 'mx1',
|
||||||
|
name: 'Mixed Folder',
|
||||||
|
type: 'folder',
|
||||||
|
parentId: null,
|
||||||
|
ownership: 'own',
|
||||||
|
scope: 'mixed',
|
||||||
|
neutralize: 'mixed',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('renders mixed symbol for scope and neutralize when value is "mixed"', async () => {
|
||||||
|
const provider = _createMockProvider([_mixedFolder]);
|
||||||
|
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Mixed Folder')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Both scope and neutralize buttons share the same mixed tooltip
|
||||||
|
const mixedBtns = screen.getAllByTitle('Gemischt - Klick setzt explizit');
|
||||||
|
expect(mixedBtns).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking mixed scope cycles deterministically to "personal"', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const provider = _createMockProvider([_mixedFolder]);
|
||||||
|
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Mixed Folder')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const scopeBtn = screen.getAllByTitle('Gemischt - Klick setzt explizit')[0];
|
||||||
|
await user.click(scopeBtn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(provider.patchScope).toHaveBeenCalledWith(['mx1'], 'personal', true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Generic extraActions slot (used by UDB Sources for RAG toggle + settings)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('extraActions', () => {
|
||||||
|
it('renders extraActions buttons and calls onClick', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onClick = vi.fn();
|
||||||
|
const _actionNode: TreeNode = {
|
||||||
|
id: 'a1',
|
||||||
|
name: 'Action Node',
|
||||||
|
type: 'item',
|
||||||
|
parentId: null,
|
||||||
|
ownership: 'own',
|
||||||
|
extraActions: [
|
||||||
|
{ key: 'foo', icon: 'F', tooltip: 'Foo Action', onClick },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const provider = _createMockProvider([_actionNode]);
|
||||||
|
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Action Node')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByTitle('Foo Action'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onClick).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders mixed symbol for extraAction with value="mixed"', async () => {
|
||||||
|
const _mixedActionNode: TreeNode = {
|
||||||
|
id: 'a2',
|
||||||
|
name: 'Mixed Action Node',
|
||||||
|
type: 'item',
|
||||||
|
parentId: null,
|
||||||
|
ownership: 'own',
|
||||||
|
extraActions: [
|
||||||
|
{ key: 'rag', icon: 'R', tooltip: 'RAG', value: 'mixed' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const provider = _createMockProvider([_mixedActionNode]);
|
||||||
|
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Mixed Action Node')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const btn = screen.getByTitle('RAG');
|
||||||
|
expect(btn.textContent).not.toBe('R'); // icon replaced by mixed symbol
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// RAG-Index Toggle (third built-in flag)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('RAG-Index toggle', () => {
|
||||||
|
const _ownFolderRag: TreeNode = {
|
||||||
|
..._ownFolder,
|
||||||
|
ragIndexEnabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('renders RAG button when ragIndexEnabled is defined', async () => {
|
||||||
|
const provider = _createMockProvider([_ownFolderRag]);
|
||||||
|
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByTitle('RAG-Indexierung aus')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking RAG button calls provider.patchRagIndex with toggled value', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const provider = _createMockProvider([_ownFolderRag]);
|
||||||
|
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByTitle('RAG-Indexierung aus'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(provider.patchRagIndex).toHaveBeenCalledWith(['f1'], true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides RAG button when ragIndexEnabled is undefined (synthetic containers)', async () => {
|
||||||
|
const provider = _createMockProvider([_ownFolder]);
|
||||||
|
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.queryByTitle('RAG-Indexierung aus')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTitle('RAG-Indexierung an')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders mixed symbol when ragIndexEnabled is "mixed"', async () => {
|
||||||
|
const _mixedRag: TreeNode = { ..._ownFolderRag, ragIndexEnabled: 'mixed' };
|
||||||
|
const provider = _createMockProvider([_mixedRag]);
|
||||||
|
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
const mixedBtns = screen.getAllByTitle('Gemischt - Klick setzt explizit');
|
||||||
|
expect(mixedBtns.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixed RAG cycles deterministically to false on click', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const _mixedRag: TreeNode = {
|
||||||
|
..._ownFolder,
|
||||||
|
scope: 'personal',
|
||||||
|
neutralize: false,
|
||||||
|
ragIndexEnabled: 'mixed',
|
||||||
|
};
|
||||||
|
const provider = _createMockProvider([_mixedRag]);
|
||||||
|
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const ragBtn = screen.getByTitle('Gemischt - Klick setzt explizit');
|
||||||
|
await user.click(ragBtn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(provider.patchRagIndex).toHaveBeenCalledWith(['f1'], false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// displayOrder (provider-controlled sorting)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('displayOrder', () => {
|
||||||
|
it('siblings with displayOrder render in numeric ascending order', async () => {
|
||||||
|
const _b: TreeNode = {
|
||||||
|
id: 'b', name: 'Mandanten-Daten', type: 'folder',
|
||||||
|
parentId: null, ownership: 'own', displayOrder: 1,
|
||||||
|
};
|
||||||
|
const _a: TreeNode = {
|
||||||
|
id: 'a', name: 'Persoenliche Quellen', type: 'folder',
|
||||||
|
parentId: null, ownership: 'own', displayOrder: 0,
|
||||||
|
};
|
||||||
|
const provider = _createMockProvider([_b, _a]);
|
||||||
|
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Persoenliche Quellen')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = screen.getAllByRole('treeitem');
|
||||||
|
expect(items[0]).toHaveTextContent('Persoenliche Quellen');
|
||||||
|
expect(items[1]).toHaveTextContent('Mandanten-Daten');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('node with displayOrder renders before sibling without (regardless of name)', async () => {
|
||||||
|
const _withOrder: TreeNode = {
|
||||||
|
id: 'wo', name: 'Zzz', type: 'folder',
|
||||||
|
parentId: null, ownership: 'own', displayOrder: 0,
|
||||||
|
};
|
||||||
|
const _withoutOrder: TreeNode = {
|
||||||
|
id: 'no', name: 'Aaa', type: 'folder',
|
||||||
|
parentId: null, ownership: 'own',
|
||||||
|
};
|
||||||
|
const provider = _createMockProvider([_withoutOrder, _withOrder]);
|
||||||
|
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Zzz')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = screen.getAllByRole('treeitem');
|
||||||
|
expect(items[0]).toHaveTextContent('Zzz');
|
||||||
|
expect(items[1]).toHaveTextContent('Aaa');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('siblings without displayOrder fall back to folder-first / alphabetic', async () => {
|
||||||
|
const _file: TreeNode = {
|
||||||
|
id: 'fi', name: 'aaa.txt', type: 'file',
|
||||||
|
parentId: null, ownership: 'own',
|
||||||
|
};
|
||||||
|
const _folder: TreeNode = {
|
||||||
|
id: 'fo', name: 'zzz', type: 'folder',
|
||||||
|
parentId: null, ownership: 'own',
|
||||||
|
};
|
||||||
|
const provider = _createMockProvider([_file, _folder]);
|
||||||
|
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('aaa.txt')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = screen.getAllByRole('treeitem');
|
||||||
|
expect(items[0]).toHaveTextContent('zzz');
|
||||||
|
expect(items[1]).toHaveTextContent('aaa.txt');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// defaultExpanded (auto-expand hint from provider)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('defaultExpanded', () => {
|
||||||
|
it('auto-expands a node carrying defaultExpanded=true and loads its children', async () => {
|
||||||
|
const _root: TreeNode = {
|
||||||
|
id: 'root', name: 'Root', type: 'folder',
|
||||||
|
parentId: null, ownership: 'own', defaultExpanded: true,
|
||||||
|
};
|
||||||
|
const _child: TreeNode = {
|
||||||
|
id: 'child', name: 'Child', type: 'folder',
|
||||||
|
parentId: 'root', ownership: 'own',
|
||||||
|
};
|
||||||
|
const provider = _createMockProvider([_root, _child]);
|
||||||
|
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Root')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
// Without auto-expand the child would be hidden until clicking the chevron.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Child')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(provider.loadChildren).toHaveBeenCalledWith('root', 'own');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not auto-expand a node without defaultExpanded', async () => {
|
||||||
|
const _root: TreeNode = {
|
||||||
|
id: 'root', name: 'Root', type: 'folder',
|
||||||
|
parentId: null, ownership: 'own',
|
||||||
|
};
|
||||||
|
const _child: TreeNode = {
|
||||||
|
id: 'child', name: 'Child', type: 'folder',
|
||||||
|
parentId: 'root', ownership: 'own',
|
||||||
|
};
|
||||||
|
const provider = _createMockProvider([_root, _child]);
|
||||||
|
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Root')).toBeInTheDocument());
|
||||||
|
// Child must NOT appear without manual expand.
|
||||||
|
expect(screen.queryByText('Child')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// refreshAfterAction (backend-authoritative mode)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('refreshAfterAction', () => {
|
||||||
|
it('refetches null + expanded parents after a flag toggle', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const provider = _createMockProvider([_ownFolder]);
|
||||||
|
render(
|
||||||
|
<FormGeneratorTree
|
||||||
|
provider={provider}
|
||||||
|
ownership="own"
|
||||||
|
refreshAfterAction
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialLoadCalls = (provider.loadChildren as ReturnType<typeof vi.fn>).mock.calls.length;
|
||||||
|
|
||||||
|
const neutralizeBtn = screen.getByTitle('Nicht neutralisiert');
|
||||||
|
await user.click(neutralizeBtn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(provider.patchNeutralize).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// After action, at least one extra loadChildren(null, 'own') happened.
|
||||||
|
const newCalls = (provider.loadChildren as ReturnType<typeof vi.fn>).mock.calls;
|
||||||
|
expect(newCalls.length).toBeGreaterThan(initialLoadCalls);
|
||||||
|
expect(newCalls.some(c => c[0] === null && c[1] === 'own')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT refetch when refreshAfterAction is false (default)', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const provider = _createMockProvider([_ownFolder]);
|
||||||
|
render(<FormGeneratorTree provider={provider} ownership="own" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialLoadCalls = (provider.loadChildren as ReturnType<typeof vi.fn>).mock.calls.length;
|
||||||
|
|
||||||
|
const neutralizeBtn = screen.getByTitle('Nicht neutralisiert');
|
||||||
|
await user.click(neutralizeBtn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(provider.patchNeutralize).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const newCalls = (provider.loadChildren as ReturnType<typeof vi.fn>).mock.calls.length;
|
||||||
|
expect(newCalls).toBe(initialLoadCalls);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// selectable=false (UDB Sources mode)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('selectable=false', () => {
|
||||||
|
it('hides checkboxes when selectable=false', async () => {
|
||||||
|
const provider = _createMockProvider([_ownFolder]);
|
||||||
|
const { container } = render(
|
||||||
|
<FormGeneratorTree provider={provider} ownership="own" selectable={false} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('input[type="checkbox"]')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides batch-action toolbar when selectable=false', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const action: TreeBatchAction = {
|
||||||
|
key: 'del',
|
||||||
|
label: 'Delete',
|
||||||
|
onClick: vi.fn(),
|
||||||
|
};
|
||||||
|
const provider = _createMockProvider([_ownFolder]);
|
||||||
|
provider.getBatchActions = vi.fn(() => [action]);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormGeneratorTree provider={provider} ownership="own" selectable={false} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('treeitem', { name: /My Folder/i }));
|
||||||
|
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,27 @@ vi.mock('../../../../hooks/usePrompt', () => ({
|
||||||
PromptDialog: () => null,
|
PromptDialog: () => null,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../providers/language/LanguageContext', () => ({
|
||||||
|
useLanguage: () => ({
|
||||||
|
t: (key: string, vars?: Record<string, string>) => {
|
||||||
|
if (!vars) return key;
|
||||||
|
let out = key;
|
||||||
|
for (const [k, v] of Object.entries(vars)) out = out.replace(`{${k}}`, String(v));
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
availableLanguages: ['de'],
|
||||||
|
language: 'de',
|
||||||
|
setLanguage: () => {},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../hooks/useConfirm', () => ({
|
||||||
|
useConfirm: () => ({
|
||||||
|
confirm: () => Promise.resolve(true),
|
||||||
|
ConfirmDialog: () => null,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Fixtures
|
// Fixtures
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ interface FolderData {
|
||||||
name: string;
|
name: string;
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
scope?: ScopeValue;
|
scope?: ScopeValue;
|
||||||
neutralize?: boolean;
|
neutralize?: boolean | 'mixed';
|
||||||
contextOrphan?: boolean;
|
contextOrphan?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,6 +52,33 @@ function _mapFileToNode(file: FileData, ownership: Ownership): TreeNode {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stable synthetic root id per ownership scope. The real top-level
|
||||||
|
* folders/files attach their `parentId` to this id once we re-parent them
|
||||||
|
* in `loadChildren`. The id stays inside the FE provider; the backend
|
||||||
|
* never sees it. */
|
||||||
|
const _SYNTH_ROOT_ID = (ownership: Ownership): string => `__filesRoot:${ownership}`;
|
||||||
|
|
||||||
|
/** Build the synthetic root node. Its only job is to:
|
||||||
|
* - act as a drop-target for moving items back to top-level,
|
||||||
|
* - expose a global neutralize/scope toggle that cascades to every
|
||||||
|
* top-level descendant.
|
||||||
|
* Its scope/neutralize values are intentionally `undefined` (= "no own
|
||||||
|
* state") — the icons render an indeterminate state and a click sets the
|
||||||
|
* intent on every owned descendant. */
|
||||||
|
function _makeSyntheticRoot(ownership: Ownership): TreeNode {
|
||||||
|
return {
|
||||||
|
id: _SYNTH_ROOT_ID(ownership),
|
||||||
|
name: '/',
|
||||||
|
type: 'folder',
|
||||||
|
parentId: null,
|
||||||
|
ownership,
|
||||||
|
icon: <FaFolder style={{ color: '#666' }} />,
|
||||||
|
defaultExpanded: true,
|
||||||
|
scope: 'personal',
|
||||||
|
neutralize: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createFolderFileProvider(): TreeNodeProvider {
|
export function createFolderFileProvider(): TreeNodeProvider {
|
||||||
const ownerParam = (ownership: Ownership) => (ownership === 'own' ? 'me' : 'shared');
|
const ownerParam = (ownership: Ownership) => (ownership === 'own' ? 'me' : 'shared');
|
||||||
const typeMap = new Map<string, 'folder' | 'file'>();
|
const typeMap = new Map<string, 'folder' | 'file'>();
|
||||||
|
|
@ -66,22 +93,74 @@ export function createFolderFileProvider(): TreeNodeProvider {
|
||||||
return typeMap.get(id) === 'file';
|
return typeMap.get(id) === 'file';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** When a batch contains the synthetic root id, expand it into the set of
|
||||||
|
* every owned top-level folder + file id. The backend doesn't know the
|
||||||
|
* synthetic root, so we must materialize it client-side. Folder patches
|
||||||
|
* are sent with `cascadeChildren=true` (handled by patchScope) so the
|
||||||
|
* whole subtree is covered without enumerating every descendant here. */
|
||||||
|
async function _expandSyntheticRoots(ids: string[]): Promise<string[]> {
|
||||||
|
const synthIds = ids.filter((id) => id.startsWith('__filesRoot:'));
|
||||||
|
if (synthIds.length === 0) return ids;
|
||||||
|
const out = new Set<string>(ids.filter((id) => !id.startsWith('__filesRoot:')));
|
||||||
|
for (const synthId of synthIds) {
|
||||||
|
const ownership: Ownership = synthId.endsWith(':shared') ? 'shared' : 'own';
|
||||||
|
const owner = ownership === 'own' ? 'me' : 'shared';
|
||||||
|
try {
|
||||||
|
const foldersRes = await api.get('/api/files/folders/tree', { params: { owner } });
|
||||||
|
const allFolders: FolderData[] = foldersRes.data ?? [];
|
||||||
|
for (const f of allFolders) {
|
||||||
|
if ((f.parentId ?? null) === null) out.add(f.id);
|
||||||
|
}
|
||||||
|
const paginationParam = JSON.stringify({ filters: { folderId: null }, pageSize: 500 });
|
||||||
|
const filesRes = await api.get('/api/files/list', { params: { pagination: paginationParam } });
|
||||||
|
const data = filesRes.data;
|
||||||
|
const rawFiles: FileData[] = (data && typeof data === 'object' && 'items' in data)
|
||||||
|
? (Array.isArray(data.items) ? data.items : [])
|
||||||
|
: (Array.isArray(data) ? data : []);
|
||||||
|
for (const f of rawFiles) {
|
||||||
|
if ((f.folderId ?? null) === null) out.add(f.id);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[FolderFileProvider] synthetic-root expansion failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(out);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rootKey: 'files',
|
rootKey: 'files',
|
||||||
|
|
||||||
async loadChildren(parentId, ownership) {
|
async loadChildren(parentId, ownership) {
|
||||||
|
// Synthetic root: when the tree asks for top-level (parentId=null),
|
||||||
|
// we return ONE container ("/") instead of the real items. The real
|
||||||
|
// top-level items are then loaded as children of that container the
|
||||||
|
// next time the tree resolves it (auto-expanded via defaultExpanded).
|
||||||
|
if (parentId === null) {
|
||||||
|
return [_makeSyntheticRoot(ownership)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const synthRootId = _SYNTH_ROOT_ID(ownership);
|
||||||
|
// Backend uses `null` for top-level parents; the FE layer remaps the
|
||||||
|
// synthetic root id back to null before talking to the API.
|
||||||
|
const apiParentId = parentId === synthRootId ? null : parentId;
|
||||||
|
|
||||||
const owner = ownerParam(ownership);
|
const owner = ownerParam(ownership);
|
||||||
const nodes: TreeNode[] = [];
|
const nodes: TreeNode[] = [];
|
||||||
|
|
||||||
const foldersRes = await api.get('/api/files/folders/tree', { params: { owner } });
|
const foldersRes = await api.get('/api/files/folders/tree', { params: { owner } });
|
||||||
const allFolders: FolderData[] = foldersRes.data ?? [];
|
const allFolders: FolderData[] = foldersRes.data ?? [];
|
||||||
const childFolders = allFolders.filter((f) => (f.parentId ?? null) === parentId);
|
const childFolders = allFolders.filter((f) => (f.parentId ?? null) === apiParentId);
|
||||||
nodes.push(...childFolders.map((f) => _mapFolderToNode(f, ownership)));
|
const folderNodes = childFolders.map((f) => _mapFolderToNode(f, ownership));
|
||||||
|
// Re-parent top-level folders onto the synthetic root.
|
||||||
|
if (apiParentId === null) {
|
||||||
|
for (const n of folderNodes) n.parentId = synthRootId;
|
||||||
|
}
|
||||||
|
nodes.push(...folderNodes);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const filters: Record<string, any> = {};
|
const filters: Record<string, any> = {};
|
||||||
if (parentId) {
|
if (apiParentId) {
|
||||||
filters.folderId = parentId;
|
filters.folderId = apiParentId;
|
||||||
}
|
}
|
||||||
const paginationParam = JSON.stringify({ filters, pageSize: 500 });
|
const paginationParam = JSON.stringify({ filters, pageSize: 500 });
|
||||||
const filesRes = await api.get('/api/files/list', {
|
const filesRes = await api.get('/api/files/list', {
|
||||||
|
|
@ -94,12 +173,16 @@ export function createFolderFileProvider(): TreeNodeProvider {
|
||||||
} else if (Array.isArray(data)) {
|
} else if (Array.isArray(data)) {
|
||||||
rawFiles = data;
|
rawFiles = data;
|
||||||
}
|
}
|
||||||
let matched = rawFiles.filter((f) => (f.folderId ?? null) === parentId);
|
let matched = rawFiles.filter((f) => (f.folderId ?? null) === apiParentId);
|
||||||
if (ownership === 'shared') {
|
if (ownership === 'shared') {
|
||||||
const myId = getUserDataCache()?.id;
|
const myId = getUserDataCache()?.id;
|
||||||
if (myId) matched = matched.filter((f) => f.sysCreatedBy !== myId);
|
if (myId) matched = matched.filter((f) => f.sysCreatedBy !== myId);
|
||||||
}
|
}
|
||||||
nodes.push(...matched.map((f) => _mapFileToNode(f, ownership)));
|
const fileNodes = matched.map((f) => _mapFileToNode(f, ownership));
|
||||||
|
if (apiParentId === null) {
|
||||||
|
for (const n of fileNodes) n.parentId = synthRootId;
|
||||||
|
}
|
||||||
|
nodes.push(...fileNodes);
|
||||||
} catch {
|
} catch {
|
||||||
// file list may fail for shared trees; folders still render
|
// file list may fail for shared trees; folders still render
|
||||||
}
|
}
|
||||||
|
|
@ -113,15 +196,22 @@ export function createFolderFileProvider(): TreeNodeProvider {
|
||||||
},
|
},
|
||||||
|
|
||||||
canRename(node) {
|
canRename(node) {
|
||||||
|
// Synthetic "/" root cannot be renamed.
|
||||||
|
if (node.id.startsWith('__filesRoot:')) return false;
|
||||||
return node.ownership === 'own';
|
return node.ownership === 'own';
|
||||||
},
|
},
|
||||||
|
|
||||||
canDelete(node) {
|
canDelete(node) {
|
||||||
|
if (node.id.startsWith('__filesRoot:')) return false;
|
||||||
return node.ownership === 'own';
|
return node.ownership === 'own';
|
||||||
},
|
},
|
||||||
|
|
||||||
canMove(source, target) {
|
canMove(source, target) {
|
||||||
|
// The synthetic root itself never moves.
|
||||||
|
if (source.id.startsWith('__filesRoot:')) return false;
|
||||||
if (source.ownership !== 'own') return false;
|
if (source.ownership !== 'own') return false;
|
||||||
|
// Allow drops onto the synthetic root (= move to top-level).
|
||||||
|
if (target && target.id.startsWith('__filesRoot:')) return true;
|
||||||
if (target && target.type !== 'folder') return false;
|
if (target && target.type !== 'folder') return false;
|
||||||
if (target && target.id === source.id) return false;
|
if (target && target.id === source.id) return false;
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -136,8 +226,23 @@ export function createFolderFileProvider(): TreeNodeProvider {
|
||||||
},
|
},
|
||||||
|
|
||||||
async createChild(parentId, name) {
|
async createChild(parentId, name) {
|
||||||
const res = await api.post('/api/files/folders', { name, parentId });
|
// Creating a folder under "/" means a top-level folder; map back to null
|
||||||
return _mapFolderToNode(res.data, 'own');
|
// for the API. The FE-only synth-root id never travels to the backend.
|
||||||
|
const apiParentId = parentId && parentId.startsWith('__filesRoot:') ? null : parentId;
|
||||||
|
const res = await api.post('/api/files/folders', { name, parentId: apiParentId });
|
||||||
|
const node = _mapFolderToNode(res.data, 'own');
|
||||||
|
// Bind the new folder visually to the parent the user actually clicked.
|
||||||
|
// - explicit synth-root parentId -> attach there ("/" + new top-level folder)
|
||||||
|
// - explicit parent (real folder) -> the API echoes the same parentId
|
||||||
|
// - parentId === null (no clicked parent, e.g. global "+" with no
|
||||||
|
// selection): default to the OWN tree's synth-root so the new folder
|
||||||
|
// shows up inside "/" instead of at the legacy top-level row.
|
||||||
|
if (parentId && parentId.startsWith('__filesRoot:')) {
|
||||||
|
node.parentId = parentId;
|
||||||
|
} else if (parentId === null) {
|
||||||
|
node.parentId = _SYNTH_ROOT_ID('own');
|
||||||
|
}
|
||||||
|
return node;
|
||||||
},
|
},
|
||||||
|
|
||||||
async renameNode(id, newName) {
|
async renameNode(id, newName) {
|
||||||
|
|
@ -156,21 +261,29 @@ export function createFolderFileProvider(): TreeNodeProvider {
|
||||||
},
|
},
|
||||||
|
|
||||||
async moveNodes(ids, targetParentId) {
|
async moveNodes(ids, targetParentId) {
|
||||||
|
// Synth-root drop = move to top-level (folderId/parentId = null).
|
||||||
|
const apiTarget = targetParentId && targetParentId.startsWith('__filesRoot:')
|
||||||
|
? null
|
||||||
|
: targetParentId;
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
ids.map((id) => {
|
ids.map((id) => {
|
||||||
if (_isFile(id)) return api.put(`/api/files/${id}`, { folderId: targetParentId });
|
if (id.startsWith('__filesRoot:')) return Promise.resolve();
|
||||||
return api.post(`/api/files/folders/${id}/move`, { targetParentId });
|
if (_isFile(id)) return api.put(`/api/files/${id}`, { folderId: apiTarget });
|
||||||
|
return api.post(`/api/files/folders/${id}/move`, { targetParentId: apiTarget });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
async patchScope(ids, scope, cascadeChildren) {
|
async patchScope(ids, scope, cascadeChildren) {
|
||||||
|
// Synth-root toggle: cascade across every owned top-level folder/file.
|
||||||
|
const expandedIds = await _expandSyntheticRoots(ids);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
ids.map((id) => {
|
expandedIds.map((id) => {
|
||||||
if (_isFile(id)) return api.patch(`/api/files/${id}/scope`, { scope });
|
if (_isFile(id)) return api.patch(`/api/files/${id}/scope`, { scope });
|
||||||
return api.patch(`/api/files/folders/${id}/scope`, { scope, cascadeChildren });
|
return api.patch(`/api/files/folders/${id}/scope`, { scope, cascadeChildren: true });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
void cascadeChildren;
|
||||||
},
|
},
|
||||||
|
|
||||||
async downloadNode(node) {
|
async downloadNode(node) {
|
||||||
|
|
@ -185,14 +298,28 @@ export function createFolderFileProvider(): TreeNodeProvider {
|
||||||
},
|
},
|
||||||
|
|
||||||
async patchNeutralize(ids, neutralize) {
|
async patchNeutralize(ids, neutralize) {
|
||||||
|
const expandedIds = await _expandSyntheticRoots(ids);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
ids.map((id) => {
|
expandedIds.map((id) => {
|
||||||
if (_isFile(id)) return api.patch(`/api/files/${id}/neutralize`, { neutralize });
|
if (_isFile(id)) return api.patch(`/api/files/${id}/neutralize`, { neutralize });
|
||||||
return api.patch(`/api/files/folders/${id}/neutralize`, { neutralize });
|
return api.patch(`/api/files/folders/${id}/neutralize`, { neutralize });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async refreshAttributes(ids: string[]) {
|
||||||
|
const res = await api.post('/api/files/attributes', { ids });
|
||||||
|
const raw: Record<string, { neutralize?: boolean | 'mixed'; scope?: string | 'mixed' }> = res.data ?? {};
|
||||||
|
const result = new Map<string, { neutralize?: boolean | 'mixed'; scope?: ScopeValue | 'mixed' }>();
|
||||||
|
for (const [id, attrs] of Object.entries(raw)) {
|
||||||
|
result.set(id, {
|
||||||
|
neutralize: attrs.neutralize,
|
||||||
|
scope: attrs.scope as ScopeValue | 'mixed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
getBatchActions(): TreeBatchAction[] {
|
getBatchActions(): TreeBatchAction[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,50 @@ export type Ownership = 'own' | 'shared';
|
||||||
|
|
||||||
export type ScopeValue = 'personal' | 'featureInstance' | 'mandate' | 'global';
|
export type ScopeValue = 'personal' | 'featureInstance' | 'mandate' | 'global';
|
||||||
|
|
||||||
|
/** Generic action button rendered to the right of a tree node.
|
||||||
|
* Tree does not interpret the action key; it only renders icon, tooltip
|
||||||
|
* and a pending spinner while onClick is running. Provider may set
|
||||||
|
* value = 'mixed' to make the tree show the uniform mixed symbol instead
|
||||||
|
* of the icon. */
|
||||||
|
export interface NodeAction {
|
||||||
|
key: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
tooltip: string;
|
||||||
|
value?: boolean | string | 'mixed';
|
||||||
|
disabled?: boolean;
|
||||||
|
onClick?: () => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TreeNode<T = any> {
|
export interface TreeNode<T = any> {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
parentId: string | null;
|
parentId: string | null;
|
||||||
ownership: Ownership;
|
ownership: Ownership;
|
||||||
scope?: ScopeValue;
|
/** Effective scope. 'mixed' means children have differing effective scopes. */
|
||||||
neutralize?: boolean;
|
scope?: ScopeValue | 'mixed';
|
||||||
|
/** Effective neutralize. 'mixed' means children have differing effective values. */
|
||||||
|
neutralize?: boolean | 'mixed';
|
||||||
|
/** Effective RAG-index flag. 'mixed' means children have differing effective values. */
|
||||||
|
ragIndexEnabled?: boolean | 'mixed';
|
||||||
contextOrphan?: boolean;
|
contextOrphan?: boolean;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
children?: TreeNode<T>[];
|
children?: TreeNode<T>[];
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
sizeBytes?: number;
|
sizeBytes?: number;
|
||||||
|
/** Optional sort hint. When defined, the node is placed before any sibling
|
||||||
|
* without a `displayOrder`; among siblings that all carry one, they are
|
||||||
|
* sorted numerically ascending. When omitted, the default folder-first /
|
||||||
|
* alphabetic sort applies. Tree-generic; no domain knowledge required. */
|
||||||
|
displayOrder?: number;
|
||||||
|
/** When true, the tree auto-expands this node the first time it appears in
|
||||||
|
* a load result. Subsequent user interactions (collapse/expand) override
|
||||||
|
* this hint, and re-fetches that re-emit the same id do not re-trigger
|
||||||
|
* auto-expansion. Tree-generic. */
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
/** Generic extra action buttons. Tree renders them as Icon+Tooltip with
|
||||||
|
* pending spinner on click. Tree has no knowledge of action semantics. */
|
||||||
|
extraActions?: NodeAction[];
|
||||||
data?: T;
|
data?: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -37,14 +68,31 @@ export interface TreeNodeProvider<T = any> {
|
||||||
canMove?(source: TreeNode<T>, target: TreeNode<T> | null): boolean;
|
canMove?(source: TreeNode<T>, target: TreeNode<T> | null): boolean;
|
||||||
canPatchScope?(node: TreeNode<T>): boolean;
|
canPatchScope?(node: TreeNode<T>): boolean;
|
||||||
canPatchNeutralize?(node: TreeNode<T>): boolean;
|
canPatchNeutralize?(node: TreeNode<T>): boolean;
|
||||||
|
canPatchRagIndex?(node: TreeNode<T>): boolean;
|
||||||
createChild?(parentId: string | null, name: string): Promise<TreeNode<T>>;
|
createChild?(parentId: string | null, name: string): Promise<TreeNode<T>>;
|
||||||
renameNode?(id: string, newName: string): Promise<void>;
|
renameNode?(id: string, newName: string): Promise<void>;
|
||||||
deleteNodes?(ids: string[]): Promise<void>;
|
deleteNodes?(ids: string[]): Promise<void>;
|
||||||
moveNodes?(ids: string[], targetParentId: string | null): Promise<void>;
|
moveNodes?(ids: string[], targetParentId: string | null): Promise<void>;
|
||||||
patchScope?(ids: string[], scope: ScopeValue, cascadeChildren?: boolean): Promise<void>;
|
patchScope?(ids: string[], scope: ScopeValue, cascadeChildren?: boolean): Promise<void>;
|
||||||
patchNeutralize?(ids: string[], neutralize: boolean): Promise<void>;
|
patchNeutralize?(ids: string[], neutralize: boolean): Promise<void>;
|
||||||
|
patchRagIndex?(ids: string[], ragIndexEnabled: boolean): Promise<void>;
|
||||||
downloadNode?(node: TreeNode<T>): Promise<void>;
|
downloadNode?(node: TreeNode<T>): Promise<void>;
|
||||||
getBatchActions?(): TreeBatchAction[];
|
getBatchActions?(): TreeBatchAction[];
|
||||||
|
/** After a toggle action, the tree collects all currently visible node IDs
|
||||||
|
* and calls this method. The provider asks the backend for the current
|
||||||
|
* attribute values (incl. mixed) of exactly those IDs. The tree then
|
||||||
|
* patches only the attribute fields on existing nodes — no structural
|
||||||
|
* reload. If not implemented, the tree falls back to _refetchAllExpanded. */
|
||||||
|
refreshAttributes?(ids: string[]): Promise<Map<string, {
|
||||||
|
neutralize?: boolean | 'mixed';
|
||||||
|
scope?: ScopeValue | 'mixed';
|
||||||
|
ragIndexEnabled?: boolean | 'mixed';
|
||||||
|
}>>;
|
||||||
|
/** Called during drag-start to let the provider inject domain-specific MIME
|
||||||
|
* types into the DataTransfer (e.g. `application/datasource`). The generic
|
||||||
|
* tree always sets `application/tree-items` and `text/plain`; this hook
|
||||||
|
* adds provider-specific formats on top. */
|
||||||
|
customizeDragData?(node: TreeNode<T>, dataTransfer: DataTransfer): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FormGeneratorTreeProps<T = any> {
|
export interface FormGeneratorTreeProps<T = any> {
|
||||||
|
|
@ -62,5 +110,16 @@ export interface FormGeneratorTreeProps<T = any> {
|
||||||
onSendToChat?: (node: TreeNode<T>) => void;
|
onSendToChat?: (node: TreeNode<T>) => void;
|
||||||
/** When false, hides "Neuer Ordner" (e.g. map from table file permissions). Default true. */
|
/** When false, hides "Neuer Ordner" (e.g. map from table file permissions). Default true. */
|
||||||
allowCreateFolder?: boolean;
|
allowCreateFolder?: boolean;
|
||||||
|
/** When false, hides checkboxes, multi-select keyboard bindings and the
|
||||||
|
* batch-action toolbar. Default true (backward compatible). */
|
||||||
|
selectable?: boolean;
|
||||||
|
/** When true, after every flag-toggle / extra-action the tree refetches
|
||||||
|
* children for `null` and every currently expanded id, then atomically
|
||||||
|
* replaces the affected nodes. Optimistic local-state updates are skipped
|
||||||
|
* in this mode -- the backend is the single source of truth.
|
||||||
|
*
|
||||||
|
* Default `false` for backward-compat with FilesTab and other consumers
|
||||||
|
* that rely on the optimistic-update path. */
|
||||||
|
refreshAfterAction?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ interface _RagJob {
|
||||||
connectionLabel?: string;
|
connectionLabel?: string;
|
||||||
jobType: string;
|
jobType: string;
|
||||||
progress: number | null;
|
progress: number | null;
|
||||||
|
/** Already translated server-side (route handler runs `resolveJobMessage`). */
|
||||||
progressMessage: string;
|
progressMessage: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
320
src/components/UnifiedDataBar/DataSourceSettingsModal.tsx
Normal file
320
src/components/UnifiedDataBar/DataSourceSettingsModal.tsx
Normal file
|
|
@ -0,0 +1,320 @@
|
||||||
|
/**
|
||||||
|
* DataSourceSettingsModal
|
||||||
|
*
|
||||||
|
* Single modal for editing DataSource-scoped + Connection-scoped settings
|
||||||
|
* from the UDB tree (Settings ⚙️ icon). Three sections:
|
||||||
|
*
|
||||||
|
* 1. Connection — knowledgeIngestionEnabled master switch + mail/clickup prefs
|
||||||
|
* 2. DataSource RAG-Limits — maxBytes/maxFileSize/maxItems/maxDepth (or clickup variants)
|
||||||
|
* 3. Cost estimate — indicative, non-binding USD figure
|
||||||
|
*
|
||||||
|
* Why a single modal:
|
||||||
|
* - The architectural rule is "no icon inflation in the UDB". One ⚙️ opens
|
||||||
|
* the only place where ANY setting for a node is managed.
|
||||||
|
*
|
||||||
|
* Why both scopes in one modal:
|
||||||
|
* - Editing a DataSource without seeing whether the parent Connection's
|
||||||
|
* master switch is on is confusing. Surface both, with a clear visual
|
||||||
|
* separation between Connection vs. DataSource sections.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { FaTimes, FaToggleOn, FaToggleOff } from 'react-icons/fa';
|
||||||
|
import { useApiRequest } from '../../hooks/useApi';
|
||||||
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import {
|
||||||
|
patchDataSourceSettings,
|
||||||
|
getDataSourceCostEstimate,
|
||||||
|
patchKnowledgeConsent,
|
||||||
|
type RagLimits,
|
||||||
|
type CostEstimate,
|
||||||
|
} from '../../api/connectionApi';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
dataSourceId?: string;
|
||||||
|
connectionId?: string;
|
||||||
|
initialKnowledgeIngestionEnabled?: boolean;
|
||||||
|
initialRagLimits?: RagLimits | null;
|
||||||
|
/**
|
||||||
|
* When false the RAG-Limits and Cost-Estimate sections are hidden.
|
||||||
|
* Only the DataSource-Root (Level 2 in the UDB tree) should show RAG
|
||||||
|
* settings — sub-elements inherit their parent's limits via the walker.
|
||||||
|
*/
|
||||||
|
showRagSection?: boolean;
|
||||||
|
/** Triggered after a successful save so the parent can refetch its lists. */
|
||||||
|
onSaved?: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _CLICKUP_KEYS: (keyof RagLimits)[] = ['maxTasks', 'maxWorkspaces', 'maxListsPerWorkspace'];
|
||||||
|
const _FILES_KEYS: (keyof RagLimits)[] = ['maxItems', 'maxBytes', 'maxFileSize', 'maxDepth'];
|
||||||
|
|
||||||
|
function _isByteLimit(key: keyof RagLimits): boolean {
|
||||||
|
return key === 'maxBytes' || key === 'maxFileSize';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _displayValue(key: keyof RagLimits, value: number | undefined): string {
|
||||||
|
if (value == null) return '';
|
||||||
|
if (_isByteLimit(key)) {
|
||||||
|
return String(Math.round(value / 1024 / 1024));
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _parseInput(key: keyof RagLimits, raw: string): number | null {
|
||||||
|
if (raw == null || raw === '') return null;
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return null;
|
||||||
|
return _isByteLimit(key) ? Math.round(n * 1024 * 1024) : Math.round(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _labelFor(key: keyof RagLimits, t: (k: string) => string): string {
|
||||||
|
switch (key) {
|
||||||
|
case 'maxBytes': return t('Max. Datenvolumen (MB)');
|
||||||
|
case 'maxFileSize': return t('Max. Dateigrösse (MB)');
|
||||||
|
case 'maxItems': return t('Max. Dateien (Anzahl)');
|
||||||
|
case 'maxDepth': return t('Max. Ordnertiefe');
|
||||||
|
case 'maxTasks': return t('Max. Tasks (Anzahl)');
|
||||||
|
case 'maxWorkspaces': return t('Max. Workspaces');
|
||||||
|
case 'maxListsPerWorkspace':return t('Max. Listen pro Workspace');
|
||||||
|
default: return String(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DataSourceSettingsModal: React.FC<Props> = ({
|
||||||
|
open, title, dataSourceId, connectionId,
|
||||||
|
initialKnowledgeIngestionEnabled, initialRagLimits, showRagSection = true,
|
||||||
|
onSaved, onClose,
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
|
||||||
|
const [knowledgeOn, setKnowledgeOn] = useState<boolean>(!!initialKnowledgeIngestionEnabled);
|
||||||
|
const [ragLimits, setRagLimits] = useState<RagLimits>(initialRagLimits || {});
|
||||||
|
const [cost, setCost] = useState<CostEstimate | null>(null);
|
||||||
|
const [costLoading, setCostLoading] = useState<boolean>(false);
|
||||||
|
const [saving, setSaving] = useState<boolean>(false);
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const limitKeys: (keyof RagLimits)[] = useMemo(() => {
|
||||||
|
if (cost?.basis?.kind === 'clickup') return _CLICKUP_KEYS;
|
||||||
|
if (ragLimits.maxTasks != null) return _CLICKUP_KEYS;
|
||||||
|
return _FILES_KEYS;
|
||||||
|
}, [cost, ragLimits]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setKnowledgeOn(!!initialKnowledgeIngestionEnabled);
|
||||||
|
setRagLimits(initialRagLimits || {});
|
||||||
|
setErrorMsg(null);
|
||||||
|
setCost(null);
|
||||||
|
if (!dataSourceId) return;
|
||||||
|
setCostLoading(true);
|
||||||
|
getDataSourceCostEstimate(request, dataSourceId)
|
||||||
|
.then(result => {
|
||||||
|
setCost(result);
|
||||||
|
if (Object.keys(initialRagLimits || {}).length === 0 && result?.basis?.limits) {
|
||||||
|
setRagLimits(result.basis.limits as RagLimits);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
setErrorMsg(typeof err === 'string' ? err : (err?.message || t('Kostenschätzung konnte nicht geladen werden.')));
|
||||||
|
})
|
||||||
|
.finally(() => setCostLoading(false));
|
||||||
|
}, [open, dataSourceId, initialKnowledgeIngestionEnabled, initialRagLimits, request, t]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const _handleConsentToggle = async () => {
|
||||||
|
if (!connectionId) return;
|
||||||
|
const newValue = !knowledgeOn;
|
||||||
|
if (!newValue) {
|
||||||
|
const ok = window.confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?'));
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await patchKnowledgeConsent(request, connectionId, newValue);
|
||||||
|
setKnowledgeOn(newValue);
|
||||||
|
onSaved?.();
|
||||||
|
} catch (err: any) {
|
||||||
|
setErrorMsg(err?.message || t('Master-Switch konnte nicht geändert werden.'));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleLimitChange = (key: keyof RagLimits, raw: string) => {
|
||||||
|
setRagLimits(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
if (raw === '') { delete next[key]; return next; }
|
||||||
|
const parsed = _parseInput(key, raw);
|
||||||
|
if (parsed == null) return prev;
|
||||||
|
next[key] = parsed;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleSaveLimits = async () => {
|
||||||
|
if (!dataSourceId) return;
|
||||||
|
setSaving(true);
|
||||||
|
setErrorMsg(null);
|
||||||
|
try {
|
||||||
|
const cleaned: RagLimits = {};
|
||||||
|
for (const k of limitKeys) {
|
||||||
|
const v = ragLimits[k];
|
||||||
|
if (v != null) cleaned[k] = v;
|
||||||
|
}
|
||||||
|
await patchDataSourceSettings(request, dataSourceId, { ragLimits: cleaned });
|
||||||
|
const refreshed = await getDataSourceCostEstimate(request, dataSourceId);
|
||||||
|
setCost(refreshed);
|
||||||
|
onSaved?.();
|
||||||
|
} catch (err: any) {
|
||||||
|
setErrorMsg(err?.message || t('Speichern fehlgeschlagen.'));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 1000,
|
||||||
|
background: 'rgba(0,0,0,0.45)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
background: '#fff', borderRadius: 8, padding: 0,
|
||||||
|
width: 'min(540px, 92vw)', maxHeight: '85vh', display: 'flex', flexDirection: 'column',
|
||||||
|
boxShadow: '0 12px 40px rgba(0,0,0,0.2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '14px 18px', borderBottom: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
}}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 600 }}>
|
||||||
|
{'\u2699\uFE0F '}{t('Einstellungen')} — {title}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 16, color: '#666' }}
|
||||||
|
title={t('Schliessen')}
|
||||||
|
>
|
||||||
|
<FaTimes />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: 18, overflowY: 'auto' }}>
|
||||||
|
{errorMsg && (
|
||||||
|
<div style={{
|
||||||
|
background: '#fef2f2', color: '#991b1b', padding: '8px 12px',
|
||||||
|
borderRadius: 4, marginBottom: 14, fontSize: 13,
|
||||||
|
}}>{errorMsg}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- Section: Connection --- */}
|
||||||
|
{connectionId && (
|
||||||
|
<section style={{ marginBottom: 22 }}>
|
||||||
|
<h4 style={{ margin: '0 0 8px', fontSize: 12, fontWeight: 700, color: '#666', textTransform: 'uppercase' }}>
|
||||||
|
{t('Verbindung')}
|
||||||
|
</h4>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 0' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 500 }}>{t('Wissensdatenbank aktiv')}</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#777' }}>
|
||||||
|
{t('Master-Schalter — wirkt auf ALLE Datenquellen dieser Verbindung.')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={_handleConsentToggle}
|
||||||
|
disabled={saving}
|
||||||
|
style={{
|
||||||
|
background: 'none', border: 'none', cursor: saving ? 'wait' : 'pointer',
|
||||||
|
fontSize: 22, color: knowledgeOn ? 'var(--primary-color, #F25843)' : '#999',
|
||||||
|
}}
|
||||||
|
title={knowledgeOn ? t('Wissensdatenbank deaktivieren') : t('Wissensdatenbank aktivieren')}
|
||||||
|
>
|
||||||
|
{knowledgeOn ? <FaToggleOn /> : <FaToggleOff />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- Section: RAG Limits (only on DataSource-Root, not sub-elements) --- */}
|
||||||
|
{dataSourceId && showRagSection && (
|
||||||
|
<section style={{ marginBottom: 22 }}>
|
||||||
|
<h4 style={{ margin: '0 0 8px', fontSize: 12, fontWeight: 700, color: '#666', textTransform: 'uppercase' }}>
|
||||||
|
{t('RAG-Indexierungs-Limits')}
|
||||||
|
</h4>
|
||||||
|
<div style={{ fontSize: 11, color: '#777', marginBottom: 10 }}>
|
||||||
|
{t('Walker stoppt bei den ersten erreichten Limit. Defaults greifen, wenn ein Feld leer ist.')}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 120px', gap: 8, alignItems: 'center' }}>
|
||||||
|
{limitKeys.map(key => (
|
||||||
|
<React.Fragment key={key}>
|
||||||
|
<label style={{ fontSize: 13 }}>{_labelFor(key, t)}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={_displayValue(key, ragLimits[key])}
|
||||||
|
onChange={e => _handleLimitChange(key, e.target.value)}
|
||||||
|
placeholder={cost?.basis?.limits?.[key] != null ? _displayValue(key, cost.basis.limits[key]) : ''}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4,
|
||||||
|
textAlign: 'right',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 10, textAlign: 'right' }}>
|
||||||
|
<button
|
||||||
|
onClick={_handleSaveLimits}
|
||||||
|
disabled={saving}
|
||||||
|
style={{
|
||||||
|
background: 'var(--primary-color, #F25843)', color: '#fff', border: 'none',
|
||||||
|
padding: '7px 14px', borderRadius: 4, cursor: saving ? 'wait' : 'pointer', fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? t('Speichern…') : t('Limits speichern')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- Section: Cost estimate (only on DataSource-Root) --- */}
|
||||||
|
{dataSourceId && showRagSection && (
|
||||||
|
<section>
|
||||||
|
<h4 style={{ margin: '0 0 8px', fontSize: 12, fontWeight: 700, color: '#666', textTransform: 'uppercase' }}>
|
||||||
|
{t('Kostenschätzung (indikativ)')}
|
||||||
|
</h4>
|
||||||
|
{costLoading && <div style={{ fontSize: 12, color: '#999' }}>{t('Wird berechnet…')}</div>}
|
||||||
|
{!costLoading && cost && (
|
||||||
|
<div style={{
|
||||||
|
background: '#f9fafb', border: '1px solid #e5e7eb', borderRadius: 4, padding: '10px 12px',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||||
|
<span style={{ fontSize: 13 }}>{t('Voll-Sync (geschätzt)')}</span>
|
||||||
|
<span style={{ fontSize: 16, fontWeight: 600 }}>~ {cost.estimatedUsd.toFixed(4)} USD</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#777', marginTop: 4 }}>
|
||||||
|
~ {cost.estimatedTokens.toLocaleString()} {t('Tokens')} · {cost.basis.notes}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DataSourceSettingsModal;
|
||||||
|
|
@ -34,6 +34,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
||||||
const [ownTreeKey, setOwnTreeKey] = useState(0);
|
const [ownTreeKey, setOwnTreeKey] = useState(0);
|
||||||
const [sharedTreeKey, setSharedTreeKey] = useState(0);
|
const [sharedTreeKey, setSharedTreeKey] = useState(0);
|
||||||
|
|
||||||
|
|
||||||
const _handleNodeClick = useCallback((node: TreeNode) => {
|
const _handleNodeClick = useCallback((node: TreeNode) => {
|
||||||
if (node.type === 'file') {
|
if (node.type === 'file') {
|
||||||
onFileSelect?.(node.id, node.name);
|
onFileSelect?.(node.id, node.name);
|
||||||
|
|
@ -200,6 +201,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
||||||
title={t('Eigene')}
|
title={t('Eigene')}
|
||||||
compact={true}
|
compact={true}
|
||||||
showFilter={true}
|
showFilter={true}
|
||||||
|
refreshAfterAction
|
||||||
onNodeClick={_handleNodeClickWithImport}
|
onNodeClick={_handleNodeClickWithImport}
|
||||||
onSendToChat={_handleSendToChat}
|
onSendToChat={_handleSendToChat}
|
||||||
/>
|
/>
|
||||||
|
|
@ -211,6 +213,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
||||||
compact={true}
|
compact={true}
|
||||||
collapsible={true}
|
collapsible={true}
|
||||||
defaultCollapsed={true}
|
defaultCollapsed={true}
|
||||||
|
refreshAfterAction
|
||||||
emptyMessage={t('Keine geteilten Dateien')}
|
emptyMessage={t('Keine geteilten Dateien')}
|
||||||
onNodeClick={_handleNodeClickWithImport}
|
onNodeClick={_handleNodeClickWithImport}
|
||||||
onSendToChat={_handleSendToChat}
|
onSendToChat={_handleSendToChat}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
.sourcesTab {
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder {
|
|
||||||
padding: 16px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-secondary, #6b7280);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
455
src/components/UnifiedDataBar/UdbSourcesProvider.tsx
Normal file
455
src/components/UnifiedDataBar/UdbSourcesProvider.tsx
Normal file
|
|
@ -0,0 +1,455 @@
|
||||||
|
// Copyright (c) 2026 Patrick Motsch
|
||||||
|
// All rights reserved.
|
||||||
|
/**
|
||||||
|
* UdbSourcesProvider — TreeNodeProvider for the UDB Sources tab.
|
||||||
|
*
|
||||||
|
* Single responsibility: translate the backend tree contract
|
||||||
|
* (POST /api/workspace/{instanceId}/tree/children → nodesByParent map) into
|
||||||
|
* the generic TreeNode shape that FormGeneratorTree consumes, and forward
|
||||||
|
* flag PATCHes to the existing /api/datasources/{id}/{flag} endpoints.
|
||||||
|
*
|
||||||
|
* No effective-value computation, no inheritance logic, no mixed-state math:
|
||||||
|
* the backend is the single source of truth. The provider only:
|
||||||
|
* 1. caches the most recently loaded backend node payload per id, so PATCHes
|
||||||
|
* can resolve the implicit DataSource record (creating it lazily when the
|
||||||
|
* backend reports `canBeAdded=true`),
|
||||||
|
* 2. emits stable display ordering via `displayOrder`,
|
||||||
|
* 3. hides flag affordances on synthetic container nodes (synthRoot,
|
||||||
|
* mandateGroup) by leaving the corresponding TreeNode field undefined.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaLink, FaFolder, FaFile, FaEnvelope,
|
||||||
|
FaCloudUploadAlt, FaCalendarAlt, FaComments, FaUser, FaTable, FaDatabase,
|
||||||
|
FaBuilding,
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import { SiJira } from 'react-icons/si';
|
||||||
|
import api from '../../api';
|
||||||
|
import type { TreeNode, TreeNodeProvider, ScopeValue } from '../FormGenerator/FormGeneratorTree';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Backend contract types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type UdbBackendKind =
|
||||||
|
| 'synthRoot'
|
||||||
|
| 'connection' | 'service' | 'folder' | 'file'
|
||||||
|
| 'mandateGroup' | 'featureNode' | 'fdsTable' | 'fdsRecord' | 'fdsField';
|
||||||
|
|
||||||
|
export interface UdbBackendNode {
|
||||||
|
key: string;
|
||||||
|
kind: UdbBackendKind;
|
||||||
|
parentKey: string | null;
|
||||||
|
label: string;
|
||||||
|
icon?: string;
|
||||||
|
hasChildren: boolean;
|
||||||
|
dataSourceId: string | null;
|
||||||
|
modelType: 'DataSource' | 'FeatureDataSource' | null;
|
||||||
|
effectiveNeutralize: boolean | 'mixed';
|
||||||
|
effectiveScope: string;
|
||||||
|
effectiveRagIndexEnabled: boolean | 'mixed';
|
||||||
|
supportsRag: boolean;
|
||||||
|
canBeAdded: boolean;
|
||||||
|
displayOrder?: number;
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
authority?: string;
|
||||||
|
connectionId?: string;
|
||||||
|
service?: string;
|
||||||
|
sourceType?: string;
|
||||||
|
path?: string;
|
||||||
|
featureInstanceId?: string;
|
||||||
|
featureCode?: string;
|
||||||
|
mandateId?: string;
|
||||||
|
tableName?: string;
|
||||||
|
fieldName?: string;
|
||||||
|
objectKey?: string;
|
||||||
|
displayPath?: string;
|
||||||
|
/** fdsTable-only: persisted list of column names to neutralize (PII mask). */
|
||||||
|
neutralizeFields?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kinds that represent the *root* of a data source (one DataSource record per
|
||||||
|
* node). Settings are only meaningful here; folders/files/services/tables
|
||||||
|
* inherit settings from the root and don't get their own gear icon. */
|
||||||
|
const _DATA_SOURCE_ROOT_KINDS = new Set<UdbBackendKind>([
|
||||||
|
'connection',
|
||||||
|
'featureNode',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Icon resolution (kept inline; UDB-domain mapping, not Tree-generic)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const _AUTHORITY_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
msft: <FaMicrosoft style={{ color: '#00a4ef', fontSize: 12 }} />,
|
||||||
|
google: <FaGoogle style={{ color: '#4285f4', fontSize: 12 }} />,
|
||||||
|
clickup: <FaTasks style={{ color: '#7b68ee', fontSize: 12 }} />,
|
||||||
|
infomaniak: <FaCloud style={{ color: '#0098db', fontSize: 12 }} />,
|
||||||
|
'local:ftp': <FaLink style={{ color: '#795548', fontSize: 12 }} />,
|
||||||
|
'local:jira': <SiJira style={{ color: '#0052CC', fontSize: 12 }} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const _SERVICE_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
sharepoint: <FaFolder style={{ color: '#0078d4', fontSize: 11 }} />,
|
||||||
|
onedrive: <FaCloudUploadAlt style={{ color: '#0078d4', fontSize: 11 }} />,
|
||||||
|
outlook: <FaEnvelope style={{ color: '#0078d4', fontSize: 11 }} />,
|
||||||
|
teams: <FaComments style={{ color: '#6264a7', fontSize: 11 }} />,
|
||||||
|
drive: <FaCloudUploadAlt style={{ color: '#4285f4', fontSize: 11 }} />,
|
||||||
|
gmail: <FaEnvelope style={{ color: '#ea4335', fontSize: 11 }} />,
|
||||||
|
files: <FaLink style={{ color: '#795548', fontSize: 11 }} />,
|
||||||
|
kdrive: <FaCloudUploadAlt style={{ color: '#0098db', fontSize: 11 }} />,
|
||||||
|
calendar: <FaCalendarAlt style={{ color: '#888', fontSize: 11 }} />,
|
||||||
|
contact: <FaUser style={{ color: '#888', fontSize: 11 }} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const _KIND_FALLBACK_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
synthRoot: <FaDatabase style={{ color: '#666', fontSize: 12 }} />,
|
||||||
|
connection: <FaLink style={{ color: '#888', fontSize: 12 }} />,
|
||||||
|
service: <FaFolder style={{ color: '#888', fontSize: 11 }} />,
|
||||||
|
folder: <FaFolder style={{ color: '#888', fontSize: 11 }} />,
|
||||||
|
file: <FaFile style={{ color: '#888', fontSize: 11 }} />,
|
||||||
|
mandateGroup: <FaBuilding style={{ color: '#7b1fa2', fontSize: 12 }} />,
|
||||||
|
featureNode: <FaDatabase style={{ color: '#7b1fa2', fontSize: 11 }} />,
|
||||||
|
fdsTable: <FaTable style={{ color: '#7b1fa2', fontSize: 11 }} />,
|
||||||
|
fdsRecord: <FaFile style={{ color: '#7b1fa2', fontSize: 11 }} />,
|
||||||
|
fdsField: <span style={{ color: '#9e9e9e', fontSize: 10, fontFamily: 'monospace' }}>{'\u22EE'}</span>,
|
||||||
|
};
|
||||||
|
|
||||||
|
function _renderIcon(node: UdbBackendNode): React.ReactNode {
|
||||||
|
if (node.kind === 'connection') {
|
||||||
|
return _AUTHORITY_ICONS[node.icon || ''] ?? _KIND_FALLBACK_ICONS.connection;
|
||||||
|
}
|
||||||
|
if (node.kind === 'service') {
|
||||||
|
return _SERVICE_ICONS[node.icon || ''] ?? _KIND_FALLBACK_ICONS.service;
|
||||||
|
}
|
||||||
|
return _KIND_FALLBACK_ICONS[node.kind] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Domain rule: which kinds expose flag toggles
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Synthetic / structural containers carry no DB record and have no flags.
|
||||||
|
* The provider hides scope/neutralize/ragIndexEnabled for them so the tree
|
||||||
|
* doesn't render dead buttons. */
|
||||||
|
function _isSyntheticContainer(kind: UdbBackendKind): boolean {
|
||||||
|
return kind === 'synthRoot' || kind === 'mandateGroup';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mapping: backend payload -> generic TreeNode
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _mapBackendNode(
|
||||||
|
n: UdbBackendNode,
|
||||||
|
onSettingsClick: (n: UdbBackendNode) => Promise<void> | void,
|
||||||
|
): TreeNode<UdbBackendNode> {
|
||||||
|
const isSynthetic = _isSyntheticContainer(n.kind);
|
||||||
|
const isFolderLike = n.hasChildren;
|
||||||
|
|
||||||
|
const node: TreeNode<UdbBackendNode> = {
|
||||||
|
id: n.key,
|
||||||
|
name: n.label,
|
||||||
|
type: isFolderLike ? 'folder' : 'file',
|
||||||
|
parentId: n.parentKey,
|
||||||
|
ownership: 'own',
|
||||||
|
icon: _renderIcon(n),
|
||||||
|
displayOrder: n.displayOrder,
|
||||||
|
defaultExpanded: n.defaultExpanded,
|
||||||
|
data: n,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isSynthetic) {
|
||||||
|
if (n.kind === 'fdsField') {
|
||||||
|
// Fields expose ONLY neutralize (mapped to parent table's
|
||||||
|
// neutralizeFields list). Scope and RAG are not field-level concepts.
|
||||||
|
node.neutralize = n.effectiveNeutralize;
|
||||||
|
} else {
|
||||||
|
node.scope = n.effectiveScope as ScopeValue | 'mixed';
|
||||||
|
node.neutralize = n.effectiveNeutralize;
|
||||||
|
if (n.supportsRag) {
|
||||||
|
node.ragIndexEnabled = n.effectiveRagIndexEnabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_DATA_SOURCE_ROOT_KINDS.has(n.kind)) {
|
||||||
|
node.extraActions = [{
|
||||||
|
key: 'settings',
|
||||||
|
icon: '\u2699\uFE0F',
|
||||||
|
tooltip: 'Einstellungen',
|
||||||
|
onClick: () => onSettingsClick(n),
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Provider factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface UdbSourcesProviderHandle extends TreeNodeProvider<UdbBackendNode> {
|
||||||
|
/** Test/diagnostic hook only -- exposes the latest cached backend payloads
|
||||||
|
* so consumers can inspect data flow without round-tripping through the
|
||||||
|
* network. Not part of the contract used at runtime. */
|
||||||
|
_diagnosticGetCacheSize(): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUdbSourcesProvider(
|
||||||
|
instanceId: string,
|
||||||
|
onOpenSettings: (dataSourceId: string, label: string) => void,
|
||||||
|
): UdbSourcesProviderHandle {
|
||||||
|
// Per-id cache of the most recent backend payload. Updated by every
|
||||||
|
// `loadChildren` call. Read by patch/ensureRecord paths.
|
||||||
|
const nodeCache = new Map<string, UdbBackendNode>();
|
||||||
|
|
||||||
|
async function _ensureRecord(node: UdbBackendNode): Promise<string | null> {
|
||||||
|
if (node.dataSourceId) return node.dataSourceId;
|
||||||
|
try {
|
||||||
|
if (node.kind === 'connection' || node.kind === 'service'
|
||||||
|
|| node.kind === 'folder' || node.kind === 'file') {
|
||||||
|
const sourceType = node.sourceType
|
||||||
|
|| (node.kind === 'connection' ? node.authority : '')
|
||||||
|
|| '';
|
||||||
|
const res = await api.post(`/api/workspace/${instanceId}/datasources`, {
|
||||||
|
connectionId: node.connectionId || '',
|
||||||
|
sourceType,
|
||||||
|
path: node.path || '/',
|
||||||
|
label: node.label,
|
||||||
|
displayPath: node.displayPath || node.label,
|
||||||
|
});
|
||||||
|
const newId: string | null = res.data?.id ?? null;
|
||||||
|
if (newId) {
|
||||||
|
nodeCache.set(node.key, { ...node, dataSourceId: newId, modelType: 'DataSource' });
|
||||||
|
}
|
||||||
|
return newId;
|
||||||
|
}
|
||||||
|
if (node.kind === 'featureNode' || node.kind === 'fdsTable' || node.kind === 'fdsRecord') {
|
||||||
|
const tableName = node.tableName || (node.kind === 'featureNode' ? '*' : '');
|
||||||
|
const objectKey = node.objectKey
|
||||||
|
|| (node.kind === 'featureNode' ? `data.feature.${node.featureCode}.*` : '');
|
||||||
|
const res = await api.post(`/api/workspace/${instanceId}/feature-datasources`, {
|
||||||
|
featureInstanceId: node.featureInstanceId || '',
|
||||||
|
featureCode: node.featureCode || '',
|
||||||
|
tableName,
|
||||||
|
objectKey,
|
||||||
|
label: node.label,
|
||||||
|
});
|
||||||
|
const newId: string | null = res.data?.id ?? null;
|
||||||
|
if (newId) {
|
||||||
|
nodeCache.set(node.key, { ...node, dataSourceId: newId, modelType: 'FeatureDataSource' });
|
||||||
|
}
|
||||||
|
return newId;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[UdbSourcesProvider] ensureRecord failed', err);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _onSettingsClick(node: UdbBackendNode): Promise<void> {
|
||||||
|
const dsId = await _ensureRecord(node);
|
||||||
|
if (!dsId) {
|
||||||
|
console.warn('[UdbSourcesProvider] settings click: cannot ensure record', node.key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onOpenSettings(dsId, node.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** fdsField-specific neutralize: ensure the parent fdsTable record exists,
|
||||||
|
* read its current `neutralizeFields` list, add or remove the field,
|
||||||
|
* PATCH the new list back. Backend treats the FDS-record as the single
|
||||||
|
* source of truth for per-field neutralization. */
|
||||||
|
async function _patchFieldNeutralize(fieldNodeId: string, neutralize: boolean): Promise<void> {
|
||||||
|
const fieldNode = nodeCache.get(fieldNodeId);
|
||||||
|
if (!fieldNode || fieldNode.kind !== 'fdsField') {
|
||||||
|
console.warn('[UdbSourcesProvider] field-neutralize target missing', fieldNodeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fieldName = fieldNode.fieldName;
|
||||||
|
const featureInstanceId = fieldNode.featureInstanceId;
|
||||||
|
const tableName = fieldNode.tableName;
|
||||||
|
if (!fieldName || !featureInstanceId || !tableName) {
|
||||||
|
console.warn('[UdbSourcesProvider] field-neutralize missing context', fieldNode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Resolve the parent fdsTable record. Use the node's dataSourceId if
|
||||||
|
// already known (synthesized by the backend); otherwise create the
|
||||||
|
// record via _ensureRecord on a synthetic table-shaped node.
|
||||||
|
let dsId = fieldNode.dataSourceId;
|
||||||
|
if (!dsId) {
|
||||||
|
const tableNode: UdbBackendNode = {
|
||||||
|
...fieldNode,
|
||||||
|
kind: 'fdsTable',
|
||||||
|
key: `fdstbl|${featureInstanceId}|${tableName}`,
|
||||||
|
};
|
||||||
|
dsId = await _ensureRecord(tableNode);
|
||||||
|
}
|
||||||
|
if (!dsId) return;
|
||||||
|
// The parent fdsTable node carries `neutralizeFields` in its payload;
|
||||||
|
// pull it from the cache. Falls back to the field's effective state if
|
||||||
|
// the parent isn't cached for some reason.
|
||||||
|
const tableKey = `fdstbl|${featureInstanceId}|${tableName}`;
|
||||||
|
const tableNode = nodeCache.get(tableKey);
|
||||||
|
const currentList: string[] =
|
||||||
|
tableNode && Array.isArray(tableNode.neutralizeFields)
|
||||||
|
? [...tableNode.neutralizeFields]
|
||||||
|
: [];
|
||||||
|
const set = new Set(currentList);
|
||||||
|
if (neutralize) set.add(fieldName);
|
||||||
|
else set.delete(fieldName);
|
||||||
|
const newList = Array.from(set);
|
||||||
|
try {
|
||||||
|
await api.patch(`/api/datasources/${dsId}/neutralize-fields`, { neutralizeFields: newList });
|
||||||
|
// Keep the cache in sync so subsequent toggles in the same session
|
||||||
|
// start from the right baseline.
|
||||||
|
if (tableNode) {
|
||||||
|
nodeCache.set(tableKey, { ...tableNode, neutralizeFields: newList });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[UdbSourcesProvider] patch neutralize-fields failed', { fieldNodeId, err });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _patchFlag(
|
||||||
|
ids: string[],
|
||||||
|
flag: 'scope' | 'neutralize' | 'rag-index',
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const id of ids) {
|
||||||
|
const cached = nodeCache.get(id);
|
||||||
|
if (!cached) {
|
||||||
|
console.warn('[UdbSourcesProvider] patch target not in cache', id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dsId = await _ensureRecord(cached);
|
||||||
|
if (!dsId) continue;
|
||||||
|
try {
|
||||||
|
await api.patch(`/api/datasources/${dsId}/${flag}`, body);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[UdbSourcesProvider] patch failed', { id, flag, err });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rootKey: `udb-sources-${instanceId}`,
|
||||||
|
|
||||||
|
async loadChildren(parentId, _ownership) {
|
||||||
|
const res = await api.post(`/api/workspace/${instanceId}/tree/children`, {
|
||||||
|
parents: [parentId],
|
||||||
|
});
|
||||||
|
const nodesByParent = res.data?.nodesByParent || {};
|
||||||
|
const lookupKey = parentId ?? '__root__';
|
||||||
|
const list: UdbBackendNode[] = nodesByParent[lookupKey] || [];
|
||||||
|
for (const n of list) nodeCache.set(n.key, n);
|
||||||
|
return list.map((n) => _mapBackendNode(n, _onSettingsClick));
|
||||||
|
},
|
||||||
|
|
||||||
|
canPatchScope(node) {
|
||||||
|
const data = node.data;
|
||||||
|
// Field-level scope makes no sense; it's inherited from the parent table.
|
||||||
|
return !!data && !_isSyntheticContainer(data.kind) && data.kind !== 'fdsField';
|
||||||
|
},
|
||||||
|
|
||||||
|
canPatchNeutralize(node) {
|
||||||
|
const data = node.data;
|
||||||
|
return !!data && !_isSyntheticContainer(data.kind);
|
||||||
|
},
|
||||||
|
|
||||||
|
canPatchRagIndex(node) {
|
||||||
|
const data = node.data;
|
||||||
|
// RAG is not a field-level concept either; only the table-record carries it.
|
||||||
|
return !!data && data.supportsRag === true && data.kind !== 'fdsField';
|
||||||
|
},
|
||||||
|
|
||||||
|
async patchScope(ids, scope, _cascadeChildren) {
|
||||||
|
// Backend cascades NULL on descendants automatically based on the
|
||||||
|
// existence of explicit child records; the cascadeChildren flag is the
|
||||||
|
// FilesTab convention and is irrelevant here.
|
||||||
|
await _patchFlag(ids, 'scope', { scope });
|
||||||
|
},
|
||||||
|
|
||||||
|
async patchNeutralize(ids, neutralize) {
|
||||||
|
// fdsField nodes don't have their own DB record — they are addressed
|
||||||
|
// via the parent fdsTable's `neutralizeFields` array. Split the batch
|
||||||
|
// accordingly and dispatch each kind to the right endpoint.
|
||||||
|
const fieldIds: string[] = [];
|
||||||
|
const otherIds: string[] = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
const cached = nodeCache.get(id);
|
||||||
|
if (cached?.kind === 'fdsField') fieldIds.push(id);
|
||||||
|
else otherIds.push(id);
|
||||||
|
}
|
||||||
|
if (otherIds.length > 0) await _patchFlag(otherIds, 'neutralize', { neutralize });
|
||||||
|
for (const fieldId of fieldIds) await _patchFieldNeutralize(fieldId, neutralize);
|
||||||
|
},
|
||||||
|
|
||||||
|
async patchRagIndex(ids, ragIndexEnabled) {
|
||||||
|
await _patchFlag(ids, 'rag-index', { ragIndexEnabled });
|
||||||
|
},
|
||||||
|
|
||||||
|
customizeDragData(node, dataTransfer) {
|
||||||
|
const data = node.data as UdbBackendNode | undefined;
|
||||||
|
if (!data || _isSyntheticContainer(data.kind)) return;
|
||||||
|
|
||||||
|
if (data.kind === 'connection' || data.kind === 'service'
|
||||||
|
|| data.kind === 'folder' || data.kind === 'file') {
|
||||||
|
const sourceType = data.sourceType
|
||||||
|
|| (data.kind === 'connection' ? data.authority : '') || '';
|
||||||
|
const payload = {
|
||||||
|
connectionId: data.connectionId || '',
|
||||||
|
sourceType,
|
||||||
|
path: data.path || '/',
|
||||||
|
label: data.label,
|
||||||
|
displayPath: data.displayPath || data.label,
|
||||||
|
};
|
||||||
|
dataTransfer.setData('application/datasource', JSON.stringify(payload));
|
||||||
|
} else if (data.kind === 'featureNode' || data.kind === 'fdsTable' || data.kind === 'fdsRecord') {
|
||||||
|
const tableName = data.tableName || (data.kind === 'featureNode' ? '*' : '');
|
||||||
|
const objectKey = data.objectKey
|
||||||
|
|| (data.kind === 'featureNode' ? `data.feature.${data.featureCode}.*` : '');
|
||||||
|
const payload = {
|
||||||
|
featureInstanceId: data.featureInstanceId || '',
|
||||||
|
featureCode: data.featureCode || '',
|
||||||
|
tableName,
|
||||||
|
objectKey,
|
||||||
|
label: data.label,
|
||||||
|
};
|
||||||
|
dataTransfer.setData('application/feature-source', JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshAttributes(ids: string[]) {
|
||||||
|
const res = await api.post(`/api/workspace/${instanceId}/tree/attributes`, {
|
||||||
|
keys: ids,
|
||||||
|
});
|
||||||
|
const raw: Record<string, {
|
||||||
|
effectiveNeutralize?: boolean | 'mixed';
|
||||||
|
effectiveScope?: string | 'mixed';
|
||||||
|
effectiveRagIndexEnabled?: boolean | 'mixed';
|
||||||
|
}> = res.data?.attributes ?? {};
|
||||||
|
const result = new Map<string, {
|
||||||
|
neutralize?: boolean | 'mixed';
|
||||||
|
scope?: ScopeValue | 'mixed';
|
||||||
|
ragIndexEnabled?: boolean | 'mixed';
|
||||||
|
}>();
|
||||||
|
for (const [key, attrs] of Object.entries(raw)) {
|
||||||
|
result.set(key, {
|
||||||
|
neutralize: attrs.effectiveNeutralize,
|
||||||
|
scope: attrs.effectiveScope as ScopeValue | 'mixed',
|
||||||
|
ragIndexEnabled: attrs.effectiveRagIndexEnabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
_diagnosticGetCacheSize() {
|
||||||
|
return nodeCache.size;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,384 @@
|
||||||
|
// Copyright (c) 2026 Patrick Motsch
|
||||||
|
// All rights reserved.
|
||||||
|
|
||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
import { createUdbSourcesProvider, type UdbBackendNode } from '../UdbSourcesProvider';
|
||||||
|
|
||||||
|
// Mock the api module that the provider imports.
|
||||||
|
vi.mock('../../../api', () => ({
|
||||||
|
default: {
|
||||||
|
post: vi.fn(),
|
||||||
|
patch: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import api from '../../../api';
|
||||||
|
const apiMock = api as unknown as { post: ReturnType<typeof vi.fn>; patch: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _makeBackendNode(overrides: Partial<UdbBackendNode> = {}): UdbBackendNode {
|
||||||
|
return {
|
||||||
|
key: 'conn|c1',
|
||||||
|
kind: 'connection',
|
||||||
|
parentKey: 'personalRoot',
|
||||||
|
label: 'My Microsoft',
|
||||||
|
icon: 'msft',
|
||||||
|
hasChildren: true,
|
||||||
|
dataSourceId: null,
|
||||||
|
modelType: null,
|
||||||
|
effectiveNeutralize: false,
|
||||||
|
effectiveScope: 'personal',
|
||||||
|
effectiveRagIndexEnabled: false,
|
||||||
|
supportsRag: true,
|
||||||
|
canBeAdded: true,
|
||||||
|
authority: 'msft',
|
||||||
|
connectionId: 'c1',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _makeSynthRootNode(): UdbBackendNode {
|
||||||
|
return {
|
||||||
|
key: 'personalRoot',
|
||||||
|
kind: 'synthRoot',
|
||||||
|
parentKey: null,
|
||||||
|
label: 'Persoenliche Quellen',
|
||||||
|
icon: 'person',
|
||||||
|
hasChildren: true,
|
||||||
|
dataSourceId: null,
|
||||||
|
modelType: null,
|
||||||
|
effectiveNeutralize: false,
|
||||||
|
effectiveScope: 'personal',
|
||||||
|
effectiveRagIndexEnabled: false,
|
||||||
|
supportsRag: false,
|
||||||
|
canBeAdded: false,
|
||||||
|
displayOrder: 0,
|
||||||
|
defaultExpanded: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const _instanceId = 'inst-42';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
apiMock.post.mockReset();
|
||||||
|
apiMock.patch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// loadChildren
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('UdbSourcesProvider.loadChildren', () => {
|
||||||
|
it('calls POST /api/workspace/{instanceId}/tree/children with parents=[parentId]', async () => {
|
||||||
|
apiMock.post.mockResolvedValue({ data: { nodesByParent: { __root__: [] } } });
|
||||||
|
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||||
|
|
||||||
|
await provider.loadChildren(null, 'own');
|
||||||
|
|
||||||
|
expect(apiMock.post).toHaveBeenCalledWith(
|
||||||
|
`/api/workspace/${_instanceId}/tree/children`,
|
||||||
|
{ parents: [null] },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps backend nodes to TreeNode shape with flag-bearer fields', async () => {
|
||||||
|
const conn = _makeBackendNode();
|
||||||
|
apiMock.post.mockResolvedValue({ data: { nodesByParent: { 'personalRoot': [conn] } } });
|
||||||
|
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||||
|
|
||||||
|
const result = await provider.loadChildren('personalRoot', 'own');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
const tn = result[0];
|
||||||
|
expect(tn.id).toBe('conn|c1');
|
||||||
|
expect(tn.name).toBe('My Microsoft');
|
||||||
|
expect(tn.parentId).toBe('personalRoot');
|
||||||
|
expect(tn.ownership).toBe('own');
|
||||||
|
expect(tn.scope).toBe('personal');
|
||||||
|
expect(tn.neutralize).toBe(false);
|
||||||
|
expect(tn.ragIndexEnabled).toBe(false);
|
||||||
|
expect(tn.type).toBe('folder');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides scope/neutralize/ragIndexEnabled on synthetic containers', async () => {
|
||||||
|
const root = _makeSynthRootNode();
|
||||||
|
apiMock.post.mockResolvedValue({ data: { nodesByParent: { __root__: [root] } } });
|
||||||
|
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||||
|
|
||||||
|
const result = await provider.loadChildren(null, 'own');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].scope).toBeUndefined();
|
||||||
|
expect(result[0].neutralize).toBeUndefined();
|
||||||
|
expect(result[0].ragIndexEnabled).toBeUndefined();
|
||||||
|
expect(result[0].displayOrder).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits ragIndexEnabled when supportsRag is false', async () => {
|
||||||
|
const node = _makeBackendNode({
|
||||||
|
key: 'mgrp|m1',
|
||||||
|
kind: 'mandateGroup',
|
||||||
|
parentKey: null,
|
||||||
|
supportsRag: false,
|
||||||
|
});
|
||||||
|
apiMock.post.mockResolvedValue({ data: { nodesByParent: { __root__: [node] } } });
|
||||||
|
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||||
|
|
||||||
|
const result = await provider.loadChildren(null, 'own');
|
||||||
|
|
||||||
|
expect(result[0].ragIndexEnabled).toBeUndefined();
|
||||||
|
expect(result[0].scope).toBeUndefined();
|
||||||
|
expect(result[0].neutralize).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches the settings extraAction on every data-source-root, even without a record yet', async () => {
|
||||||
|
const onSettings = vi.fn();
|
||||||
|
const withId = _makeBackendNode({ dataSourceId: 'ds-1', canBeAdded: false });
|
||||||
|
const withoutId = _makeBackendNode({ key: 'conn|c2', dataSourceId: null });
|
||||||
|
apiMock.post.mockResolvedValue({
|
||||||
|
data: { nodesByParent: { personalRoot: [withId, withoutId] } },
|
||||||
|
});
|
||||||
|
const provider = createUdbSourcesProvider(_instanceId, onSettings);
|
||||||
|
|
||||||
|
const result = await provider.loadChildren('personalRoot', 'own');
|
||||||
|
|
||||||
|
expect(result[0].extraActions).toHaveLength(1);
|
||||||
|
expect(result[0].extraActions?.[0].key).toBe('settings');
|
||||||
|
await result[0].extraActions?.[0].onClick?.();
|
||||||
|
expect(onSettings).toHaveBeenCalledWith('ds-1', 'My Microsoft');
|
||||||
|
|
||||||
|
// The conn without a record still gets a settings button (always visible
|
||||||
|
// on data-source-roots). Click triggers an _ensureRecord POST first.
|
||||||
|
expect(result[1].extraActions).toHaveLength(1);
|
||||||
|
expect(result[1].extraActions?.[0].key).toBe('settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the settings extraAction on non-root nodes (folders, files, services, ...)', async () => {
|
||||||
|
const folder = _makeBackendNode({ kind: 'folder', dataSourceId: 'ds-9' });
|
||||||
|
apiMock.post.mockResolvedValue({
|
||||||
|
data: { nodesByParent: { 'conn|c1': [folder] } },
|
||||||
|
});
|
||||||
|
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||||
|
|
||||||
|
const result = await provider.loadChildren('conn|c1', 'own');
|
||||||
|
expect(result[0].extraActions).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards defaultExpanded from backend payload to the TreeNode', async () => {
|
||||||
|
const expanded = _makeBackendNode({
|
||||||
|
key: 'personalRoot',
|
||||||
|
kind: 'synthRoot',
|
||||||
|
defaultExpanded: true,
|
||||||
|
});
|
||||||
|
apiMock.post.mockResolvedValue({
|
||||||
|
data: { nodesByParent: { __root__: [expanded] } },
|
||||||
|
});
|
||||||
|
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||||
|
|
||||||
|
const [node] = await provider.loadChildren(null, 'own');
|
||||||
|
expect(node.defaultExpanded).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('populates the internal cache so subsequent patches can resolve nodes', async () => {
|
||||||
|
apiMock.post.mockResolvedValue({
|
||||||
|
data: { nodesByParent: { personalRoot: [_makeBackendNode()] } },
|
||||||
|
});
|
||||||
|
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||||
|
|
||||||
|
expect(provider._diagnosticGetCacheSize()).toBe(0);
|
||||||
|
await provider.loadChildren('personalRoot', 'own');
|
||||||
|
expect(provider._diagnosticGetCacheSize()).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// canPatch* predicates
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('UdbSourcesProvider.canPatch*', () => {
|
||||||
|
it('canPatchScope is false for synthetic containers', async () => {
|
||||||
|
apiMock.post.mockResolvedValue({
|
||||||
|
data: { nodesByParent: { __root__: [_makeSynthRootNode()] } },
|
||||||
|
});
|
||||||
|
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||||
|
const [synthNode] = await provider.loadChildren(null, 'own');
|
||||||
|
expect(provider.canPatchScope?.(synthNode)).toBe(false);
|
||||||
|
expect(provider.canPatchNeutralize?.(synthNode)).toBe(false);
|
||||||
|
expect(provider.canPatchRagIndex?.(synthNode)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canPatchRagIndex requires supportsRag=true', async () => {
|
||||||
|
apiMock.post.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
nodesByParent: {
|
||||||
|
personalRoot: [
|
||||||
|
_makeBackendNode({ key: 'a', supportsRag: true }),
|
||||||
|
_makeBackendNode({ key: 'b', supportsRag: false }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||||
|
const [a, b] = await provider.loadChildren('personalRoot', 'own');
|
||||||
|
expect(provider.canPatchRagIndex?.(a)).toBe(true);
|
||||||
|
expect(provider.canPatchRagIndex?.(b)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// patch flow: ensureRecord + PATCH
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('UdbSourcesProvider.patchScope', () => {
|
||||||
|
it('PATCHes existing dataSourceId without creating a new record', async () => {
|
||||||
|
apiMock.post.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
nodesByParent: {
|
||||||
|
personalRoot: [_makeBackendNode({ dataSourceId: 'ds-existing', canBeAdded: false })],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
apiMock.patch.mockResolvedValue({ data: {} });
|
||||||
|
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||||
|
await provider.loadChildren('personalRoot', 'own');
|
||||||
|
|
||||||
|
await provider.patchScope?.(['conn|c1'], 'mandate', true);
|
||||||
|
|
||||||
|
expect(apiMock.patch).toHaveBeenCalledWith(
|
||||||
|
`/api/datasources/ds-existing/scope`,
|
||||||
|
{ scope: 'mandate' },
|
||||||
|
);
|
||||||
|
// Only one POST: the loadChildren call. No POST datasources.
|
||||||
|
expect(apiMock.post).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a DataSource record first when canBeAdded=true', async () => {
|
||||||
|
apiMock.post
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
nodesByParent: {
|
||||||
|
personalRoot: [_makeBackendNode({ dataSourceId: null, canBeAdded: true })],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ data: { id: 'ds-new' } });
|
||||||
|
apiMock.patch.mockResolvedValue({ data: {} });
|
||||||
|
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||||
|
await provider.loadChildren('personalRoot', 'own');
|
||||||
|
|
||||||
|
await provider.patchScope?.(['conn|c1'], 'mandate', true);
|
||||||
|
|
||||||
|
expect(apiMock.post).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
`/api/workspace/${_instanceId}/datasources`,
|
||||||
|
expect.objectContaining({
|
||||||
|
connectionId: 'c1',
|
||||||
|
sourceType: 'msft',
|
||||||
|
path: '/',
|
||||||
|
label: 'My Microsoft',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(apiMock.patch).toHaveBeenCalledWith(
|
||||||
|
`/api/datasources/ds-new/scope`,
|
||||||
|
{ scope: 'mandate' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips silently when target node is not in cache', async () => {
|
||||||
|
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||||
|
await provider.patchScope?.(['unknown'], 'personal', false);
|
||||||
|
expect(apiMock.patch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UdbSourcesProvider.patchNeutralize', () => {
|
||||||
|
it('PATCHes /neutralize with the supplied boolean', async () => {
|
||||||
|
apiMock.post.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
nodesByParent: {
|
||||||
|
personalRoot: [_makeBackendNode({ dataSourceId: 'ds-1', canBeAdded: false })],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
apiMock.patch.mockResolvedValue({ data: {} });
|
||||||
|
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||||
|
await provider.loadChildren('personalRoot', 'own');
|
||||||
|
|
||||||
|
await provider.patchNeutralize?.(['conn|c1'], true);
|
||||||
|
|
||||||
|
expect(apiMock.patch).toHaveBeenCalledWith(
|
||||||
|
`/api/datasources/ds-1/neutralize`,
|
||||||
|
{ neutralize: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UdbSourcesProvider.patchRagIndex', () => {
|
||||||
|
it('PATCHes /rag-index with the supplied boolean (note dash in URL, camelCase in body)', async () => {
|
||||||
|
apiMock.post.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
nodesByParent: {
|
||||||
|
personalRoot: [_makeBackendNode({ dataSourceId: 'ds-1', canBeAdded: false })],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
apiMock.patch.mockResolvedValue({ data: {} });
|
||||||
|
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||||
|
await provider.loadChildren('personalRoot', 'own');
|
||||||
|
|
||||||
|
await provider.patchRagIndex?.(['conn|c1'], true);
|
||||||
|
|
||||||
|
expect(apiMock.patch).toHaveBeenCalledWith(
|
||||||
|
`/api/datasources/ds-1/rag-index`,
|
||||||
|
{ ragIndexEnabled: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes to feature-datasources when the cached node is a featureNode', async () => {
|
||||||
|
const featureNode: UdbBackendNode = {
|
||||||
|
key: 'feat|m1|trustee|inst-1',
|
||||||
|
kind: 'featureNode',
|
||||||
|
parentKey: 'mgrp|m1',
|
||||||
|
label: 'Trustee',
|
||||||
|
icon: 'mdi-database',
|
||||||
|
hasChildren: true,
|
||||||
|
dataSourceId: null,
|
||||||
|
modelType: null,
|
||||||
|
effectiveNeutralize: false,
|
||||||
|
effectiveScope: 'personal',
|
||||||
|
effectiveRagIndexEnabled: false,
|
||||||
|
supportsRag: true,
|
||||||
|
canBeAdded: true,
|
||||||
|
featureInstanceId: 'inst-1',
|
||||||
|
featureCode: 'trustee',
|
||||||
|
mandateId: 'm1',
|
||||||
|
tableName: '*',
|
||||||
|
};
|
||||||
|
apiMock.post
|
||||||
|
.mockResolvedValueOnce({ data: { nodesByParent: { 'mgrp|m1': [featureNode] } } })
|
||||||
|
.mockResolvedValueOnce({ data: { id: 'fds-new' } });
|
||||||
|
apiMock.patch.mockResolvedValue({ data: {} });
|
||||||
|
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||||
|
await provider.loadChildren('mgrp|m1', 'own');
|
||||||
|
|
||||||
|
await provider.patchRagIndex?.([featureNode.key], true);
|
||||||
|
|
||||||
|
expect(apiMock.post).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
`/api/workspace/${_instanceId}/feature-datasources`,
|
||||||
|
expect.objectContaining({
|
||||||
|
featureInstanceId: 'inst-1',
|
||||||
|
featureCode: 'trustee',
|
||||||
|
tableName: '*',
|
||||||
|
objectKey: 'data.feature.trustee.*',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(apiMock.patch).toHaveBeenCalledWith(
|
||||||
|
`/api/datasources/fds-new/rag-index`,
|
||||||
|
{ ragIndexEnabled: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -11,6 +11,11 @@ export interface BackgroundJob {
|
||||||
triggeredBy?: string | null;
|
triggeredBy?: string | null;
|
||||||
status: BackgroundJobStatus;
|
status: BackgroundJobStatus;
|
||||||
progress: number;
|
progress: number;
|
||||||
|
/**
|
||||||
|
* Walker progress text, already translated by the route handler
|
||||||
|
* (`resolveJobMessage` server-side). Render 1:1 -- do NOT pass through
|
||||||
|
* `t()`; the i18n key lives in the backend, not in user code here.
|
||||||
|
*/
|
||||||
progressMessage?: string | null;
|
progressMessage?: string | null;
|
||||||
payload?: Record<string, any>;
|
payload?: Record<string, any>;
|
||||||
result?: Record<string, any> | null;
|
result?: Record<string, any> | null;
|
||||||
|
|
|
||||||
88
src/hooks/useTreeExpansion.ts
Normal file
88
src/hooks/useTreeExpansion.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
// Copyright (c) 2026 Patrick Motsch
|
||||||
|
// All rights reserved.
|
||||||
|
/**
|
||||||
|
* useTreeExpansion - fire-and-forget persistence for tree expand state.
|
||||||
|
*
|
||||||
|
* Simple contract:
|
||||||
|
* - On mount: load saved expandedIds from backend (or null if none).
|
||||||
|
* - Returns the loaded ids (once) so the tree can seed its initial state.
|
||||||
|
* - Provides a `save(ids)` function that debounce-PUTs to the backend.
|
||||||
|
* - No bidirectional state flow, no props, no re-render triggers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
const _SAVE_DEBOUNCE_MS = 600;
|
||||||
|
|
||||||
|
export interface UseTreeExpansionResult {
|
||||||
|
loaded: boolean;
|
||||||
|
initialIds: string[] | null;
|
||||||
|
save: (ids: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTreeExpansion(
|
||||||
|
instanceId: string | null | undefined,
|
||||||
|
scope: string,
|
||||||
|
): UseTreeExpansionResult {
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [initialIds, setInitialIds] = useState<string[] | null>(null);
|
||||||
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const latestRef = useRef<string[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!instanceId) {
|
||||||
|
setLoaded(true);
|
||||||
|
setInitialIds(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setLoaded(false);
|
||||||
|
api
|
||||||
|
.get(`/api/workspace/${instanceId}/ui-tree-expansion/${encodeURIComponent(scope)}`)
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const fromServer: string[] | null = res.data?.expandedNodes ?? null;
|
||||||
|
setInitialIds(fromServer);
|
||||||
|
latestRef.current = fromServer;
|
||||||
|
setLoaded(true);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
console.warn('[useTreeExpansion] load failed', err);
|
||||||
|
setInitialIds(null);
|
||||||
|
setLoaded(true);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [instanceId, scope]);
|
||||||
|
|
||||||
|
const save = useCallback(
|
||||||
|
(ids: string[]) => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
const sorted = [...ids].sort().join('|');
|
||||||
|
const prevSorted = latestRef.current ? [...latestRef.current].sort().join('|') : null;
|
||||||
|
if (sorted === prevSorted) return;
|
||||||
|
latestRef.current = ids;
|
||||||
|
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||||
|
saveTimerRef.current = setTimeout(() => {
|
||||||
|
api
|
||||||
|
.put(
|
||||||
|
`/api/workspace/${instanceId}/ui-tree-expansion/${encodeURIComponent(scope)}`,
|
||||||
|
{ expandedNodes: latestRef.current ?? [] },
|
||||||
|
)
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn('[useTreeExpansion] save failed', err);
|
||||||
|
});
|
||||||
|
}, _SAVE_DEBOUNCE_MS);
|
||||||
|
},
|
||||||
|
[instanceId, scope],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { loaded, initialIds, save };
|
||||||
|
}
|
||||||
|
|
@ -131,6 +131,16 @@
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Section title ── */
|
||||||
|
.sectionTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Connection Card ── */
|
/* ── Connection Card ── */
|
||||||
.connectionCard {
|
.connectionCard {
|
||||||
border: 1px solid var(--color-border, #e5e7eb);
|
border: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
|
@ -220,6 +230,22 @@
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sync finished, but a hard limit (maxBytes/maxItems/...) cut the walk short.
|
||||||
|
Amber, not red — the data we DID index is valid; the user just needs to
|
||||||
|
know more would have been indexed without the limit. */
|
||||||
|
.partialBanner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #fcd34d;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
.reindexBtn {
|
.reindexBtn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,15 @@
|
||||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
import { useApiRequest } from '../hooks/useApi';
|
import { useApiRequest } from '../hooks/useApi';
|
||||||
import { useUserMandates } from '../hooks/useUserMandates';
|
import type { RagInventoryDto, RagConnectionDto, RagFeatureInstanceDto } from '../api/connectionApi';
|
||||||
import type { RagInventoryDto, RagConnectionDto } from '../api/connectionApi';
|
import { FaDatabase, FaStop, FaSync, FaToggleOn, FaToggleOff, FaRedo, FaExclamationTriangle, FaCheckCircle, FaSlidersH, FaCubes } from 'react-icons/fa';
|
||||||
import { FaDatabase, FaStop, FaSync, FaToggleOn, FaToggleOff, FaRedo, FaExclamationTriangle, FaCheckCircle } from 'react-icons/fa';
|
|
||||||
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
|
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
|
||||||
|
import { DataSourceSettingsModal } from '../components/UnifiedDataBar/DataSourceSettingsModal';
|
||||||
import styles from './RagInventoryPage.module.css';
|
import styles from './RagInventoryPage.module.css';
|
||||||
|
|
||||||
export const RagInventoryPage: React.FC = () => {
|
export const RagInventoryPage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
const { fetchMandates } = useUserMandates();
|
|
||||||
|
|
||||||
const [mandates, setMandates] = useState<any[]>([]);
|
const [mandates, setMandates] = useState<any[]>([]);
|
||||||
const [mandatesLoading, setMandatesLoading] = useState(true);
|
const [mandatesLoading, setMandatesLoading] = useState(true);
|
||||||
|
|
@ -31,12 +30,29 @@ export const RagInventoryPage: React.FC = () => {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const [settingsModal, setSettingsModal] = useState<{
|
||||||
|
dataSourceId?: string;
|
||||||
|
connectionId?: string;
|
||||||
|
title: string;
|
||||||
|
initialKnowledgeIngestionEnabled?: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const _openSettingsForConnection = useCallback((conn: RagConnectionDto) => {
|
||||||
|
const activeDs = (conn.dataSources || []).find(ds => ds.ragIndexEnabled) || (conn.dataSources || [])[0];
|
||||||
|
setSettingsModal({
|
||||||
|
dataSourceId: activeDs?.id,
|
||||||
|
connectionId: conn.id,
|
||||||
|
title: `${conn.authority} · ${conn.externalEmail || conn.id}`,
|
||||||
|
initialKnowledgeIngestionEnabled: conn.knowledgeIngestionEnabled,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
setMandatesLoading(true);
|
setMandatesLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await fetchMandates();
|
const data = await request({ url: '/api/rag/inventory/my-mandates', method: 'get' });
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
const list = Array.isArray(data) ? data : [];
|
const list = Array.isArray(data) ? data : [];
|
||||||
setMandates(list);
|
setMandates(list);
|
||||||
|
|
@ -46,7 +62,7 @@ export const RagInventoryPage: React.FC = () => {
|
||||||
finally { if (!cancelled) setMandatesLoading(false); }
|
finally { if (!cancelled) setMandatesLoading(false); }
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [fetchMandates]);
|
}, [request]);
|
||||||
|
|
||||||
const _apiEndpoint = useMemo(() => {
|
const _apiEndpoint = useMemo(() => {
|
||||||
if (selectedScope === 'personal') return '/api/rag/inventory/me';
|
if (selectedScope === 'personal') return '/api/rag/inventory/me';
|
||||||
|
|
@ -59,11 +75,13 @@ export const RagInventoryPage: React.FC = () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
if (selectedScope !== 'personal' && selectedScope !== 'platform') {
|
|
||||||
params.mandateId = selectedScope;
|
|
||||||
}
|
|
||||||
if (onlyMyData) params.onlyMine = 'true';
|
if (onlyMyData) params.onlyMine = 'true';
|
||||||
const data = await request({ url: _apiEndpoint, method: 'get', params });
|
const isMandateScope = selectedScope !== 'personal' && selectedScope !== 'platform';
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (isMandateScope) {
|
||||||
|
headers['X-Mandate-Id'] = selectedScope;
|
||||||
|
}
|
||||||
|
const data = await request({ url: _apiEndpoint, method: 'get', params, additionalConfig: { headers } });
|
||||||
setInventory(data);
|
setInventory(data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.message?.includes('403')) {
|
if (err?.message?.includes('403')) {
|
||||||
|
|
@ -81,7 +99,10 @@ export const RagInventoryPage: React.FC = () => {
|
||||||
_fetchInventory();
|
_fetchInventory();
|
||||||
}, [_fetchInventory]);
|
}, [_fetchInventory]);
|
||||||
|
|
||||||
const _hasActiveJobs = !!inventory?.connections?.some(c => (c.runningJobs?.length || 0) > 0);
|
const _hasActiveJobs = !!(
|
||||||
|
inventory?.connections?.some(c => (c.runningJobs?.length || 0) > 0) ||
|
||||||
|
inventory?.featureInstances?.some(fi => (fi.runningJobs?.length || 0) > 0)
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pollRef.current) clearInterval(pollRef.current);
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
|
|
@ -109,6 +130,13 @@ export const RagInventoryPage: React.FC = () => {
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _handleReindexFeature = async (workspaceInstanceId: string) => {
|
||||||
|
try {
|
||||||
|
await request({ url: `/api/rag/inventory/reindex-feature/${workspaceInstanceId}`, method: 'post' });
|
||||||
|
_fetchInventory();
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
const _handleConsentToggle = async (connectionId: string, currentEnabled: boolean) => {
|
const _handleConsentToggle = async (connectionId: string, currentEnabled: boolean) => {
|
||||||
if (!currentEnabled || window.confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?'))) {
|
if (!currentEnabled || window.confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?'))) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -139,6 +167,21 @@ export const RagInventoryPage: React.FC = () => {
|
||||||
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
|
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/** Render the budget value next to its name. Bytes get MB units so the user
|
||||||
|
* immediately recognises the 200 MB default; everything else stays raw. */
|
||||||
|
const _formatLimit = useCallback((name: string, budget: number | undefined, bytesProcessed: number | undefined): string => {
|
||||||
|
if (budget == null) return name;
|
||||||
|
if (name === 'maxBytes') {
|
||||||
|
const mb = Math.round(budget / 1024 / 1024);
|
||||||
|
const procMb = bytesProcessed != null ? ` (${(bytesProcessed / 1024 / 1024).toFixed(0)} MB ${t('verarbeitet')})` : '';
|
||||||
|
return `${name}=${mb} MB${procMb}`;
|
||||||
|
}
|
||||||
|
if (name === 'maxFileSize') {
|
||||||
|
return `${name}=${Math.round(budget / 1024 / 1024)} MB`;
|
||||||
|
}
|
||||||
|
return `${name}=${budget}`;
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
const scopeOptions = useMemo(() => {
|
const scopeOptions = useMemo(() => {
|
||||||
const opts: { value: string; label: string }[] = [
|
const opts: { value: string; label: string }[] = [
|
||||||
{ value: 'personal', label: t('Meine Verbindungen') },
|
{ value: 'personal', label: t('Meine Verbindungen') },
|
||||||
|
|
@ -193,7 +236,9 @@ export const RagInventoryPage: React.FC = () => {
|
||||||
{inventory && (
|
{inventory && (
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<div className={styles.totals}>
|
<div className={styles.totals}>
|
||||||
<span className={styles.totalLabel}>{t('Total Chunks')}:</span>
|
<span className={styles.totalLabel}>{t('Total Dateien')}:</span>
|
||||||
|
<strong className={styles.totalValue}>{inventory.totals?.files ?? 0}</strong>
|
||||||
|
<span className={styles.totalLabel} title={t('Embedding-Fragmente (~400 Tokens), die der RAG-Retrieval trifft')}>{t('Total Chunks')}:</span>
|
||||||
<strong className={styles.totalValue}>{inventory.totals?.chunks ?? 0}</strong>
|
<strong className={styles.totalValue}>{inventory.totals?.chunks ?? 0}</strong>
|
||||||
{inventory.totals?.bytes != null && inventory.totals.bytes > 0 && (
|
{inventory.totals?.bytes != null && inventory.totals.bytes > 0 && (
|
||||||
<span className={styles.totalBytes}>{(inventory.totals.bytes / 1024 / 1024).toFixed(1)} MB</span>
|
<span className={styles.totalBytes}>{(inventory.totals.bytes / 1024 / 1024).toFixed(1)} MB</span>
|
||||||
|
|
@ -205,8 +250,13 @@ export const RagInventoryPage: React.FC = () => {
|
||||||
<div className={styles.connectionHeader}>
|
<div className={styles.connectionHeader}>
|
||||||
<span className={styles.authority}>{conn.authority}</span>
|
<span className={styles.authority}>{conn.authority}</span>
|
||||||
<span className={styles.email}>{conn.externalEmail}</span>
|
<span className={styles.email}>{conn.externalEmail}</span>
|
||||||
{conn.totalChunks > 0 && (
|
{(conn.totalFiles > 0 || conn.totalChunks > 0) && (
|
||||||
<span className={styles.connChunks}>{conn.totalChunks} chunks</span>
|
<span
|
||||||
|
className={styles.connChunks}
|
||||||
|
title={t('Embedding-Fragmente (~400 Tokens), die der RAG-Retrieval trifft')}
|
||||||
|
>
|
||||||
|
{t('{f} Dateien · {c} Chunks', { f: conn.totalFiles, c: conn.totalChunks })}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className={styles.consentToggle}
|
className={styles.consentToggle}
|
||||||
|
|
@ -261,6 +311,31 @@ export const RagInventoryPage: React.FC = () => {
|
||||||
s.skippedPolicy > 0 ? t('{n} übersprungen', { n: s.skippedPolicy }) : null,
|
s.skippedPolicy > 0 ? t('{n} übersprungen', { n: s.skippedPolicy }) : null,
|
||||||
s.failed > 0 ? t('{n} fehler', { n: s.failed }) : null,
|
s.failed > 0 ? t('{n} fehler', { n: s.failed }) : null,
|
||||||
].filter(Boolean).join(' · ');
|
].filter(Boolean).join(' · ');
|
||||||
|
|
||||||
|
const stop = s.stoppedAtLimit;
|
||||||
|
if (stop) {
|
||||||
|
const budget = s.limits?.[stop];
|
||||||
|
const limitText = _formatLimit(stop, budget, s.bytesProcessed);
|
||||||
|
return (
|
||||||
|
<div className={styles.partialBanner}>
|
||||||
|
<FaExclamationTriangle />
|
||||||
|
<span>
|
||||||
|
<strong>{t('Sync abgeschlossen, Korpus aber unvollständig')}</strong> ({_formatRelative(okAt)})
|
||||||
|
{' — '}
|
||||||
|
{t('Limit {l} erreicht', { l: limitText })}.
|
||||||
|
{stats && <> {stats}.</>}{' '}
|
||||||
|
{t('Weitere Dateien wurden NICHT indexiert.')}
|
||||||
|
</span>
|
||||||
|
<button className={styles.reindexBtn} onClick={() => _openSettingsForConnection(conn)} title={t('Limit für diese Datenquelle anpassen')}>
|
||||||
|
<FaSlidersH size={12} /> {t('Limit anpassen')}
|
||||||
|
</button>
|
||||||
|
<button className={styles.reindexBtn} onClick={() => _handleReindex(conn.id)} title={t('Erneut indexieren')}>
|
||||||
|
<FaRedo size={12} /> {t('Erneut indexieren')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.successBanner}>
|
<div className={styles.successBanner}>
|
||||||
<FaCheckCircle />
|
<FaCheckCircle />
|
||||||
|
|
@ -293,7 +368,12 @@ export const RagInventoryPage: React.FC = () => {
|
||||||
<div key={ds.id} className={`${styles.dsRow} ${ds.ragIndexEnabled ? styles.dsActive : ''}`}>
|
<div key={ds.id} className={`${styles.dsRow} ${ds.ragIndexEnabled ? styles.dsActive : ''}`}>
|
||||||
<span className={styles.dsLabel}>{ds.label || ds.path}</span>
|
<span className={styles.dsLabel}>{ds.label || ds.path}</span>
|
||||||
<span className={styles.dsType}>{ds.sourceType}</span>
|
<span className={styles.dsType}>{ds.sourceType}</span>
|
||||||
<span className={styles.dsChunks}>{ds.chunkCount} chunks</span>
|
<span
|
||||||
|
className={styles.dsChunks}
|
||||||
|
title={t('{f} indizierte Dateien · {c} Embedding-Chunks (~400 Tokens)', { f: ds.fileCount, c: ds.chunkCount })}
|
||||||
|
>
|
||||||
|
{ds.fileCount} {t('Dateien')} · {ds.chunkCount} {t('Chunks')}
|
||||||
|
</span>
|
||||||
<span className={styles.dsIndex}>{ds.ragIndexEnabled ? '\uD83E\uDDE0' : '\u2014'}</span>
|
<span className={styles.dsIndex}>{ds.ragIndexEnabled ? '\uD83E\uDDE0' : '\u2014'}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -304,11 +384,133 @@ export const RagInventoryPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{(inventory.connections || []).length === 0 && (
|
{(inventory.featureInstances || []).length > 0 && (
|
||||||
|
<>
|
||||||
|
<h2 className={styles.sectionTitle}>
|
||||||
|
<FaCubes style={{ marginRight: 8 }} />
|
||||||
|
{t('Feature-Daten')}
|
||||||
|
</h2>
|
||||||
|
{(inventory.featureInstances || []).map((fi: RagFeatureInstanceDto) => {
|
||||||
|
const runningJobs = fi.runningJobs || [];
|
||||||
|
const lastSuccess = fi.lastSuccess;
|
||||||
|
const lastError = fi.lastError;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={fi.featureInstanceId} className={styles.connectionCard}>
|
||||||
|
<div className={styles.connectionHeader}>
|
||||||
|
<span className={styles.authority}>{fi.featureCode}</span>
|
||||||
|
<span className={styles.email}>{fi.label}</span>
|
||||||
|
{(fi.fileCount > 0 || fi.chunkCount > 0) && (
|
||||||
|
<span
|
||||||
|
className={styles.connChunks}
|
||||||
|
title={t('Embedding-Fragmente (~400 Tokens), die der RAG-Retrieval trifft')}
|
||||||
|
>
|
||||||
|
{t('{f} Dateien · {c} Chunks', { f: fi.fileCount, c: fi.chunkCount })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={styles.dsIndex} title={fi.ragEnabled ? t('RAG aktiv') : t('RAG inaktiv')}>
|
||||||
|
{fi.ragEnabled ? '\uD83E\uDDE0' : '\u2014'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!fi.ragEnabled && (fi.dataSources || []).length > 0 && (
|
||||||
|
<div className={styles.consentWarning}>
|
||||||
|
{t('RAG-Indexierung ist für keine Datenquelle dieser Feature-Instanz aktiviert. Aktivierung erfolgt in der UDB (Unified Data Bar) der jeweiligen Workspace-Sitzung.')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{runningJobs.length > 0 ? (
|
||||||
|
<div className={styles.jobBanner}>
|
||||||
|
<FaSync className={styles.spinIcon} />
|
||||||
|
<span>{runningJobs[0].progressMessage || t('Feature-Daten werden synchronisiert...')}</span>
|
||||||
|
</div>
|
||||||
|
) : (() => {
|
||||||
|
const errAt = lastError?.finishedAt ?? 0;
|
||||||
|
const okAt = lastSuccess?.finishedAt ?? 0;
|
||||||
|
const errorIsNewer = !!lastError && errAt > okAt;
|
||||||
|
|
||||||
|
if (errorIsNewer) {
|
||||||
|
return (
|
||||||
|
<div className={styles.errorBanner}>
|
||||||
|
<FaExclamationTriangle />
|
||||||
|
<span>
|
||||||
|
{t('Letzter Sync fehlgeschlagen')} ({_formatRelative(errAt)}): {lastError?.errorMessage || t('unbekannter Fehler')}
|
||||||
|
</span>
|
||||||
|
<button className={styles.reindexBtn} onClick={() => _handleReindexFeature(fi.featureInstanceId)} title={t('Neu indexieren')}>
|
||||||
|
<FaRedo size={12} /> {t('Neu indexieren')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastSuccess) {
|
||||||
|
const s = lastSuccess;
|
||||||
|
const stats = [
|
||||||
|
s.indexed > 0 ? t('{n} neu indexiert', { n: s.indexed }) : null,
|
||||||
|
s.skippedDuplicate > 0 ? t('{n} unverändert', { n: s.skippedDuplicate }) : null,
|
||||||
|
s.failed > 0 ? t('{n} fehler', { n: s.failed }) : null,
|
||||||
|
].filter(Boolean).join(' · ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.successBanner}>
|
||||||
|
<FaCheckCircle />
|
||||||
|
<span>
|
||||||
|
{t('Sync erfolgreich')} {_formatRelative(okAt)}
|
||||||
|
{stats && <> — {stats}</>}
|
||||||
|
</span>
|
||||||
|
<button className={styles.reindexBtn} onClick={() => _handleReindexFeature(fi.featureInstanceId)} title={t('Erneut indexieren')}>
|
||||||
|
<FaRedo size={12} /> {t('Erneut indexieren')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fi.ragEnabled) {
|
||||||
|
return (
|
||||||
|
<div className={styles.reindexHint}>
|
||||||
|
<button className={styles.reindexBtn} onClick={() => _handleReindexFeature(fi.featureInstanceId)} title={t('Indexierung starten')}>
|
||||||
|
<FaRedo size={12} /> {t('Indexierung starten')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<div className={styles.dsList}>
|
||||||
|
{(fi.dataSources || []).map(ds => (
|
||||||
|
<div key={ds.id} className={`${styles.dsRow} ${ds.ragIndexEnabled ? styles.dsActive : ''}`}>
|
||||||
|
<span className={styles.dsLabel}>{ds.label || ds.tableName}</span>
|
||||||
|
<span className={styles.dsType}>{ds.featureCode}</span>
|
||||||
|
<span className={styles.dsIndex}>{ds.ragIndexEnabled ? '\uD83E\uDDE0' : '\u2014'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(fi.dataSources || []).length === 0 && fi.fileCount === 0 && (
|
||||||
|
<div className={styles.dsEmpty}>{t('Keine Datenquellen konfiguriert')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(inventory.connections || []).length === 0 && (inventory.featureInstances || []).length === 0 && (
|
||||||
<div className={styles.emptyState}>{t('Keine Daten für diese Sicht vorhanden.')}</div>
|
<div className={styles.emptyState}>{t('Keine Daten für diese Sicht vorhanden.')}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<DataSourceSettingsModal
|
||||||
|
open={!!settingsModal}
|
||||||
|
title={settingsModal?.title || ''}
|
||||||
|
dataSourceId={settingsModal?.dataSourceId}
|
||||||
|
connectionId={settingsModal?.connectionId}
|
||||||
|
initialKnowledgeIngestionEnabled={settingsModal?.initialKnowledgeIngestionEnabled}
|
||||||
|
showRagSection
|
||||||
|
onSaved={() => _fetchInventory()}
|
||||||
|
onClose={() => setSettingsModal(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,9 @@ export const AdminDemoConfigPage: React.FC = () => {
|
||||||
setActionInProgress(code);
|
setActionInProgress(code);
|
||||||
setLastResult(null);
|
setLastResult(null);
|
||||||
try {
|
try {
|
||||||
const response = await api.post(`/api/admin/demo-config/${code}/remove`);
|
const response = await api.post(`/api/admin/demo-config/${code}/remove`, null, {
|
||||||
|
headers: { 'X-Confirm-Destructive': 'true' },
|
||||||
|
});
|
||||||
setLastResult({ code, action: 'remove', status: 'ok', summary: response.data.summary });
|
setLastResult({ code, action: 'remove', status: 'ok', summary: response.data.summary });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setLastResult({ code, action: 'remove', status: 'error', error: err.response?.data?.detail || String(err) });
|
setLastResult({ code, action: 'remove', status: 'error', error: err.response?.data?.detail || String(err) });
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,14 @@ import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||||
import { useConnections, type Connection } from '../../hooks/useConnections';
|
import { useConnections, type Connection } from '../../hooks/useConnections';
|
||||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { FaSync, FaLink, FaRedo, FaPlus, FaSpinner, FaTimes, FaSyncAlt } from 'react-icons/fa';
|
import { FaSync, FaLink, FaRedo, FaPlus, FaSpinner, FaTimes, FaSyncAlt, FaToggleOn, FaToggleOff } from 'react-icons/fa';
|
||||||
import styles from '../admin/Admin.module.css';
|
import styles from '../admin/Admin.module.css';
|
||||||
import bannerStyles from './ConnectionsPage.module.css';
|
import bannerStyles from './ConnectionsPage.module.css';
|
||||||
import { AddConnectionWizard } from '../../components/AddConnectionWizard/AddConnectionWizard';
|
import { AddConnectionWizard } from '../../components/AddConnectionWizard/AddConnectionWizard';
|
||||||
import type { ConnectorType } from '../../components/AddConnectionWizard/AddConnectionWizard';
|
import type { ConnectorType } from '../../components/AddConnectionWizard/AddConnectionWizard';
|
||||||
import type { KnowledgePreferences } from '../../api/connectionApi';
|
import { patchKnowledgeConsent, type KnowledgePreferences } from '../../api/connectionApi';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { useApiRequest } from '../../hooks/useApi';
|
||||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||||
import { getApiBaseUrl } from '../../../config/config';
|
import { getApiBaseUrl } from '../../../config/config';
|
||||||
|
|
||||||
|
|
@ -52,7 +53,10 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
const [deletingConnections, setDeletingConnections] = useState<Set<string>>(new Set());
|
const [deletingConnections, setDeletingConnections] = useState<Set<string>>(new Set());
|
||||||
const [refreshingConnections, setRefreshingConnections] = useState<Set<string>>(new Set());
|
const [refreshingConnections, setRefreshingConnections] = useState<Set<string>>(new Set());
|
||||||
const [reconnectingConnections, setReconnectingConnections] = useState<Set<string>>(new Set());
|
const [reconnectingConnections, setReconnectingConnections] = useState<Set<string>>(new Set());
|
||||||
|
const [togglingConsent, setTogglingConsent] = useState<Set<string>>(new Set());
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
|
|
||||||
|
const { request } = useApiRequest();
|
||||||
// Banner shown while knowledge bootstrap is running in the background
|
// Banner shown while knowledge bootstrap is running in the background
|
||||||
const [syncBanner, setSyncBanner] = useState<{
|
const [syncBanner, setSyncBanner] = useState<{
|
||||||
connector: string;
|
connector: string;
|
||||||
|
|
@ -235,6 +239,28 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
window.open(url, 'msft-admin-consent', 'width=560,height=720,scrollbars=yes,resizable=yes');
|
window.open(url, 'msft-admin-consent', 'width=560,height=720,scrollbars=yes,resizable=yes');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleConsentToggle = async (connection: Connection) => {
|
||||||
|
const currentEnabled = !!(connection as any).knowledgeIngestionEnabled;
|
||||||
|
const newEnabled = !currentEnabled;
|
||||||
|
if (currentEnabled) {
|
||||||
|
const ok = window.confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?'));
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
setTogglingConsent(prev => new Set(prev).add(connection.id));
|
||||||
|
try {
|
||||||
|
await patchKnowledgeConsent(request, connection.id, newEnabled);
|
||||||
|
await refetch();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling knowledge consent:', error);
|
||||||
|
} finally {
|
||||||
|
setTogglingConsent(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(connection.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Form attributes for edit modal
|
// Form attributes for edit modal
|
||||||
const formAttributes = useMemo(() => {
|
const formAttributes = useMemo(() => {
|
||||||
const excludedFields = [
|
const excludedFields = [
|
||||||
|
|
@ -344,6 +370,22 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
}] : []),
|
}] : []),
|
||||||
]}
|
]}
|
||||||
customActions={[
|
customActions={[
|
||||||
|
{
|
||||||
|
id: 'knowledge-consent-on',
|
||||||
|
icon: <FaToggleOn />,
|
||||||
|
onClick: handleConsentToggle,
|
||||||
|
title: t('Wissensdatenbank aktiv — klicken zum Deaktivieren'),
|
||||||
|
visible: (row: Connection) => !!(row as any).knowledgeIngestionEnabled,
|
||||||
|
loading: (row: Connection) => togglingConsent.has(row.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'knowledge-consent-off',
|
||||||
|
icon: <FaToggleOff />,
|
||||||
|
onClick: handleConsentToggle,
|
||||||
|
title: t('Wissensdatenbank inaktiv — klicken zum Aktivieren'),
|
||||||
|
visible: (row: Connection) => !(row as any).knowledgeIngestionEnabled,
|
||||||
|
loading: (row: Connection) => togglingConsent.has(row.id),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'connect',
|
id: 'connect',
|
||||||
icon: <FaLink />,
|
icon: <FaLink />,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
import React, { useRef, useEffect, useCallback, useState } from 'react';
|
import React, { useRef, useEffect, useCallback, useState } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import { FaRegCopy, FaCheck } from 'react-icons/fa';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
|
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
|
||||||
import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes';
|
import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes';
|
||||||
|
|
@ -41,6 +42,14 @@ export const ChatStream: React.FC<ChatStreamProps> = ({ messages,
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const audioQueue = useAudioQueue();
|
const audioQueue = useAudioQueue();
|
||||||
const enqueuedIdsRef = useRef<Set<string>>(new Set());
|
const enqueuedIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const _handleCopy = useCallback((msgId: string, text: string) => {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
setCopiedId(msgId);
|
||||||
|
setTimeout(() => setCopiedId((prev) => (prev === msgId ? null : prev)), 1500);
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
|
@ -92,7 +101,25 @@ export const ChatStream: React.FC<ChatStreamProps> = ({ messages,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{msg.role === 'assistant' && (
|
{msg.role === 'assistant' && (
|
||||||
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>Assistant</div>
|
<div style={{ fontSize: 11, color: '#888', marginBottom: 4, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<span>Assistant</span>
|
||||||
|
{msg.message && (
|
||||||
|
<button
|
||||||
|
onClick={() => _handleCopy(msg.id, msg.message!)}
|
||||||
|
title={copiedId === msg.id ? t('Kopiert') : t('In Zwischenablage kopieren')}
|
||||||
|
style={{
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
padding: '2px 4px', borderRadius: 4, display: 'flex', alignItems: 'center',
|
||||||
|
color: copiedId === msg.id ? 'var(--success-color, #4caf50)' : '#aaa',
|
||||||
|
transition: 'color 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (copiedId !== msg.id) e.currentTarget.style.color = '#666'; }}
|
||||||
|
onMouseLeave={e => { if (copiedId !== msg.id) e.currentTarget.style.color = '#aaa'; }}
|
||||||
|
>
|
||||||
|
{copiedId === msg.id ? <FaCheck size={12} /> : <FaRegCopy size={12} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{msg.role === 'status' ? (
|
{msg.role === 'status' ? (
|
||||||
<span>{msg.message}</span>
|
<span>{msg.message}</span>
|
||||||
|
|
@ -648,6 +675,15 @@ function _CodeBlock({
|
||||||
}: React.HTMLAttributes<HTMLElement> & { inline?: boolean }) {
|
}: React.HTMLAttributes<HTMLElement> & { inline?: boolean }) {
|
||||||
const match = /language-(\w+)/.exec(className || '');
|
const match = /language-(\w+)/.exec(className || '');
|
||||||
const isInline = !match && !String(children).includes('\n');
|
const isInline = !match && !String(children).includes('\n');
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const _copyCode = useCallback(() => {
|
||||||
|
const text = String(children).replace(/\n$/, '');
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [children]);
|
||||||
|
|
||||||
if (isInline) {
|
if (isInline) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -668,15 +704,32 @@ function _CodeBlock({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative', margin: '8px 0' }}>
|
<div style={{ position: 'relative', margin: '8px 0' }}>
|
||||||
{match && (
|
<div style={{
|
||||||
<div style={{
|
position: 'absolute', top: 0, right: 0,
|
||||||
position: 'absolute', top: 0, right: 0,
|
display: 'flex', alignItems: 'center', gap: 4,
|
||||||
padding: '2px 8px', fontSize: 10, color: '#888',
|
background: '#2d2d2d', borderBottomLeftRadius: 4,
|
||||||
background: '#2d2d2d', borderBottomLeftRadius: 4,
|
padding: '2px 4px',
|
||||||
}}>
|
}}>
|
||||||
{match[1]}
|
{match && (
|
||||||
</div>
|
<span style={{ fontSize: 10, color: '#888', padding: '0 4px' }}>
|
||||||
)}
|
{match[1]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={_copyCode}
|
||||||
|
title={copied ? 'Kopiert' : 'Kopieren'}
|
||||||
|
style={{
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
padding: '2px 6px', display: 'flex', alignItems: 'center',
|
||||||
|
color: copied ? '#4caf50' : '#888',
|
||||||
|
transition: 'color 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!copied) e.currentTarget.style.color = '#ccc'; }}
|
||||||
|
onMouseLeave={e => { if (!copied) e.currentTarget.style.color = '#888'; }}
|
||||||
|
>
|
||||||
|
{copied ? <FaCheck size={11} /> : <FaRegCopy size={11} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<pre style={{
|
<pre style={{
|
||||||
background: '#1e1e1e',
|
background: '#1e1e1e',
|
||||||
color: '#d4d4d4',
|
color: '#d4d4d4',
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,19 @@ export const WorkspaceInput = forwardRef<WorkspaceInputHandle, WorkspaceInputPro
|
||||||
onPendingAttachFdsConsumed?.();
|
onPendingAttachFdsConsumed?.();
|
||||||
}, [pendingAttachFdsId, onPendingAttachFdsConsumed, _persistAttachments, attachedDataSourceIds]);
|
}, [pendingAttachFdsId, onPendingAttachFdsConsumed, _persistAttachments, attachedDataSourceIds]);
|
||||||
|
|
||||||
|
const _prevWorkflowId = useRef<string | null | undefined>(undefined);
|
||||||
|
useEffect(() => {
|
||||||
|
if (_prevWorkflowId.current === undefined) {
|
||||||
|
_prevWorkflowId.current = workflowId ?? null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wasNull = !_prevWorkflowId.current;
|
||||||
|
_prevWorkflowId.current = workflowId ?? null;
|
||||||
|
if (wasNull && workflowId && (attachedDataSourceIds.length > 0 || attachedFeatureDataSourceIds.length > 0)) {
|
||||||
|
_persistAttachments(attachedDataSourceIds, attachedFeatureDataSourceIds);
|
||||||
|
}
|
||||||
|
}, [workflowId, _persistAttachments, attachedDataSourceIds, attachedFeatureDataSourceIds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loadedNonce === undefined) return;
|
if (loadedNonce === undefined) return;
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
|
|
@ -534,7 +547,6 @@ export const WorkspaceInput = forwardRef<WorkspaceInputHandle, WorkspaceInputPro
|
||||||
const chatId = e.dataTransfer.getData('application/chat-id');
|
const chatId = e.dataTransfer.getData('application/chat-id');
|
||||||
if (chatId) {
|
if (chatId) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
|
||||||
const chatLabel = e.dataTransfer.getData('text/plain');
|
const chatLabel = e.dataTransfer.getData('text/plain');
|
||||||
const refLabel = chatLabel ? `[Chat: ${chatLabel}]` : `[Chat: ${chatId.slice(0, 8)}]`;
|
const refLabel = chatLabel ? `[Chat: ${chatLabel}]` : `[Chat: ${chatId.slice(0, 8)}]`;
|
||||||
setPrompt(prev => (prev ? `${prev} ${refLabel}` : refLabel));
|
setPrompt(prev => (prev ? `${prev} ${refLabel}` : refLabel));
|
||||||
|
|
@ -544,7 +556,6 @@ export const WorkspaceInput = forwardRef<WorkspaceInputHandle, WorkspaceInputPro
|
||||||
const featureSourceJson = e.dataTransfer.getData('application/feature-source');
|
const featureSourceJson = e.dataTransfer.getData('application/feature-source');
|
||||||
if (featureSourceJson && onFeatureSourceDrop) {
|
if (featureSourceJson && onFeatureSourceDrop) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
|
||||||
const params = JSON.parse(featureSourceJson);
|
const params = JSON.parse(featureSourceJson);
|
||||||
onFeatureSourceDrop(params);
|
onFeatureSourceDrop(params);
|
||||||
return;
|
return;
|
||||||
|
|
@ -553,7 +564,6 @@ export const WorkspaceInput = forwardRef<WorkspaceInputHandle, WorkspaceInputPro
|
||||||
const dataSourceJson = e.dataTransfer.getData('application/datasource');
|
const dataSourceJson = e.dataTransfer.getData('application/datasource');
|
||||||
if (dataSourceJson && onDataSourceDrop) {
|
if (dataSourceJson && onDataSourceDrop) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
|
||||||
const params = JSON.parse(dataSourceJson);
|
const params = JSON.parse(dataSourceJson);
|
||||||
onDataSourceDrop(params);
|
onDataSourceDrop(params);
|
||||||
return;
|
return;
|
||||||
|
|
@ -562,7 +572,6 @@ export const WorkspaceInput = forwardRef<WorkspaceInputHandle, WorkspaceInputPro
|
||||||
const handled = await _ingestDataTransfer(e.dataTransfer);
|
const handled = await _ingestDataTransfer(e.dataTransfer);
|
||||||
if (handled) {
|
if (handled) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
|
||||||
textareaRef.current?.focus();
|
textareaRef.current?.focus();
|
||||||
}
|
}
|
||||||
}, [_ingestDataTransfer, onFeatureSourceDrop, onDataSourceDrop]);
|
}, [_ingestDataTransfer, onFeatureSourceDrop, onDataSourceDrop]);
|
||||||
|
|
|
||||||
|
|
@ -209,12 +209,15 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
}, [_isCenterDropInteresting]);
|
}, [_isCenterDropInteresting]);
|
||||||
|
|
||||||
const _handleDrop = useCallback(async (e: React.DragEvent) => {
|
const _handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||||
|
const alreadyHandled = e.defaultPrevented;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
dragCounterRef.current = 0;
|
dragCounterRef.current = 0;
|
||||||
setIsDragOver(false);
|
setIsDragOver(false);
|
||||||
|
|
||||||
await _consumeDataTransferFilesOrChat(e.dataTransfer);
|
if (!alreadyHandled) {
|
||||||
|
await _consumeDataTransferFilesOrChat(e.dataTransfer);
|
||||||
|
}
|
||||||
}, [_consumeDataTransferFilesOrChat]);
|
}, [_consumeDataTransferFilesOrChat]);
|
||||||
|
|
||||||
const _handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
const _handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "./tsconfig.app.json" },
|
{ "path": "./tsconfig.app.json" },
|
||||||
{ "path": "./tsconfig.node.json" }
|
{ "path": "./tsconfig.node.json" },
|
||||||
|
{ "path": "./tsconfig.test.json" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue