;
- isTableAdded: (featureInstanceId: string, tableName: string) => boolean;
- addingKey: string | null;
- onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void;
- featureDataSources: UdbFeatureDataSource[];
- onCycleScope: (fds: UdbFeatureDataSource, currentEffective?: string) => void;
- onToggleNeutralize: (fds: UdbFeatureDataSource, currentEffective?: boolean) => void;
- onToggleNeutralizeField: (fds: UdbFeatureDataSource, fieldName: string) => void;
- featureTree: MandateGroupNode[];
-}
-
-/* ─── MandateGroupView (mandate + feature instances) ─────────────────── */
-
-interface _MandateGroupViewProps extends _FeatureActionContext {
- group: MandateGroupNode;
- onToggleGroup: (mandateId: string) => void;
- onToggleFeature: (node: FeatureConnectionNode) => void;
-}
-
-const _MandateGroupView: React.FC<_MandateGroupViewProps> = (props) => {
- const { group, onToggleGroup, onToggleFeature, ...ctx } = props;
- const [hovered, setHovered] = useState(false);
- const chevron = group.expanded ? '\u25BE' : '\u25B8';
-
- return (
-
-
onToggleGroup(group.mandateId)}
- onMouseEnter={() => setHovered(true)}
- onMouseLeave={() => setHovered(false)}
- style={{
- display: 'flex', alignItems: 'center', gap: 4,
- paddingLeft: 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
- cursor: 'pointer', borderRadius: 3,
- background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent',
- transition: 'background 0.1s', userSelect: 'none',
- }}
- >
-
- {chevron}
-
-
- {group.mandateLabel}
-
-
-
- {group.expanded && (
-
- {group.featureConnections.map(fNode => (
- <_FeatureNodeView
- key={fNode.featureInstanceId}
- node={fNode}
- onToggle={onToggleFeature}
- {...ctx}
- />
- ))}
-
- )}
-
- );
-};
-
-/* ─── FeatureNodeView (feature instance + recursive items) ───────────── */
-
-interface _FeatureNodeViewProps extends _FeatureActionContext {
- node: FeatureConnectionNode;
- onToggle: (node: FeatureConnectionNode) => void;
-}
-
-const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = (props) => {
- const { node, onToggle, ...ctx } = props;
- const { t } = useLanguage();
- const [hovered, setHovered] = useState(false);
- const chevron = node.expanded ? '\u25BE' : '\u25B8';
-
- const wildcardFds = ctx.featureDataSources.find(
- f => f.featureInstanceId === node.featureInstanceId && f.tableName === '*' && !f.recordFilter,
- );
-
- const topItems = useMemo(
- () => _buildTopFeatureTree(node.tables || []),
- [node.tables],
- );
-
- return (
-
-
onToggle(node)}
- onMouseEnter={() => setHovered(true)}
- onMouseLeave={() => setHovered(false)}
- draggable
- onDragStart={(e) => {
- e.stopPropagation();
- const payload = JSON.stringify({
- featureInstanceId: node.featureInstanceId,
- featureCode: node.featureCode,
- objectKey: `data.feature.${node.featureCode}.*`,
- label: node.label,
- });
- e.dataTransfer.setData('application/feature-source', payload);
- e.dataTransfer.setData('text/plain', node.label);
- e.dataTransfer.effectAllowed = 'copy';
- }}
- style={{
- display: 'flex', alignItems: 'center', gap: 4,
- // Compensate the 3px borderLeft on active wildcard rows with -3px
- // paddingLeft so the row content stays at the same x-position.
- paddingLeft: wildcardFds ? 1 : 4,
- paddingRight: 4, paddingTop: 3, paddingBottom: 3,
- cursor: 'pointer', borderRadius: 3,
- background: wildcardFds
- ? (hovered ? '#ede7f6' : '#7b1fa208')
- : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'),
- borderLeft: wildcardFds ? '3px solid #7b1fa2' : undefined,
- transition: 'background 0.1s', userSelect: 'none',
- }}
- >
-
- {node.loading ? _Spinner() : chevron}
-
-
- {getPageIcon(`feature.${node.featureCode}`) || '\uD83D\uDDC3\uFE0F'}
-
-
- {node.label}
-
-
- {node.tableCount} {t('Tabellen')}
-
-
-
-
-
-
-
-
- {node.expanded && topItems.length > 0 && (
-
- {topItems.map((item, idx) => (
- <_FeatureItemView
- key={_itemKey(item, idx)}
- featureNode={node}
- item={item}
- pathSegments={[]}
- depth={1}
- inheritedScope={wildcardFds?.scope ?? undefined}
- inheritedNeutralize={wildcardFds?.neutralize ?? undefined}
- {...ctx}
- />
- ))}
-
- )}
-
- {node.expanded && (node.tables?.length ?? 0) === 0 && !node.loading && (
-
- {t('(keine Tabellen)')}
-
- )}
-
- );
-};
-
-function _itemKey(item: FeatureItem, idx: number): string {
- if (item.kind === 'group') return `g:${item.objectKey}-${idx}`;
- if (item.kind === 'parentGroup') return `p:${item.table.tableName}-${idx}`;
- return `t:${item.table.tableName}-${idx}`;
-}
-
-/* ─── FeatureItemView (recursive — handles group / parentGroup / table) ── */
-
-interface _FeatureItemViewProps extends _FeatureActionContext {
- featureNode: FeatureConnectionNode;
- item: FeatureItem;
- pathSegments: string[];
- depth: number;
- inheritedScope?: string;
- inheritedNeutralize?: boolean;
-}
-
-const _FeatureItemView: React.FC<_FeatureItemViewProps> = (props) => {
- const { featureNode, item, pathSegments, depth, inheritedScope, inheritedNeutralize, ...ctx } = props;
-
- if (item.kind === 'group') {
- return (
- <_GroupFolderView
- featureNode={featureNode}
- objectKey={item.objectKey}
- label={item.label}
- items={item.items}
- pathSegments={pathSegments}
- depth={depth}
- inheritedScope={inheritedScope}
- inheritedNeutralize={inheritedNeutralize}
- {...ctx}
- />
- );
- }
- if (item.kind === 'parentGroup') {
- return (
- <_ParentGroupView
- featureNode={featureNode}
- table={item.table}
- pathSegments={pathSegments}
- depth={depth}
- inheritedScope={inheritedScope}
- inheritedNeutralize={inheritedNeutralize}
- {...ctx}
- />
- );
- }
- return (
- <_FeatureTableRow
- featureNode={featureNode}
- table={item.table}
- depth={depth}
- onAddFeatureTable={ctx.onAddFeatureTable}
- onSendToChat={ctx.onSendToChat}
- featureDataSources={ctx.featureDataSources}
- onCycleScope={ctx.onCycleScope}
- onToggleNeutralize={ctx.onToggleNeutralize}
- onToggleNeutralizeField={ctx.onToggleNeutralizeField}
- featureTree={ctx.featureTree}
- inheritedScope={inheritedScope}
- inheritedNeutralize={inheritedNeutralize}
- />
- );
-};
-
-/* ─── GroupFolderView (categorical folder) ───────────────────────────── */
-
-interface _GroupFolderViewProps extends _FeatureActionContext {
- featureNode: FeatureConnectionNode;
- objectKey: string;
- label: string;
- items: FeatureItem[];
- pathSegments: string[];
- depth: number;
- inheritedScope?: string;
- inheritedNeutralize?: boolean;
-}
-
-const _GroupFolderView: React.FC<_GroupFolderViewProps> = (props) => {
- const { featureNode, objectKey, label, items, pathSegments, depth, inheritedScope, inheritedNeutralize, ...ctx } = props;
- const { t } = useLanguage();
- const [hovered, setHovered] = useState(false);
-
- const segments = [...pathSegments, `g:${objectKey}`];
- const pathKey = _pathKey(featureNode.featureInstanceId, segments);
- const expanded = ctx.featureExpandedPaths.has(pathKey);
- const chevron = expanded ? '\u25BE' : '\u25B8';
-
- // Container-wildcard objectKey: matches every record/table inside this group.
- // Pattern lives in the backend workspaceContext-resolver -- the trailing `.*`
- // is treated as a glob-prefix so a single FDS row drives chat/scope/neutralize
- // for every child without having to add each one individually.
- const containerObjectKey = `data.feature.${featureNode.featureCode}.group:${objectKey}.*`;
- const wildcardFds = ctx.featureDataSources.find(
- f => f.featureInstanceId === featureNode.featureInstanceId && f.objectKey === containerObjectKey,
- );
- const _chatPayload = {
- featureInstanceId: featureNode.featureInstanceId,
- featureCode: featureNode.featureCode,
- objectKey: containerObjectKey,
- label,
- };
-
- return (
-
-
ctx.onToggleFeaturePath(pathKey)}
- onMouseEnter={() => setHovered(true)}
- onMouseLeave={() => setHovered(false)}
- draggable
- onDragStart={(e) => {
- e.stopPropagation();
- e.dataTransfer.setData('application/feature-source', JSON.stringify(_chatPayload));
- e.dataTransfer.setData('text/plain', label);
- e.dataTransfer.effectAllowed = 'copy';
- }}
- style={{
- display: 'flex', alignItems: 'center', gap: 4,
- // Compensate the 3px border on active wildcard rows so the row
- // content stays at the same x-position whether or not it's active.
- paddingLeft: (depth * 16 + 4) - (wildcardFds ? 3 : 0),
- paddingRight: 4, paddingTop: 3, paddingBottom: 3,
- cursor: 'pointer', borderRadius: 3,
- background: wildcardFds
- ? (hovered ? '#ede7f6' : '#7b1fa208')
- : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'),
- borderLeft: wildcardFds ? '3px solid #7b1fa2' : undefined,
- transition: 'background 0.1s', userSelect: 'none',
- }}
- >
-
- {chevron}
-
- {'\uD83D\uDCC1'}
-
- {label}
-
-
-
-
-
-
-
- {expanded && items.length > 0 && (
-
- {items.map((sub, idx) => (
- <_FeatureItemView
- key={_itemKey(sub, idx)}
- featureNode={featureNode}
- item={sub}
- pathSegments={segments}
- depth={depth + 1}
- inheritedScope={inheritedScope}
- inheritedNeutralize={inheritedNeutralize}
- {...ctx}
- />
- ))}
-
- )}
-
- );
-};
-
-/* ─── ParentGroupView (parent table → list of records) ───────────────── */
-
-interface _ParentGroupViewProps extends _FeatureActionContext {
- featureNode: FeatureConnectionNode;
- table: FeatureTableNode;
- pathSegments: string[];
- depth: number;
- inheritedScope?: string;
- inheritedNeutralize?: boolean;
-}
-
-const _ParentGroupView: React.FC<_ParentGroupViewProps> = (props) => {
- const { featureNode, table, pathSegments, depth, inheritedScope, inheritedNeutralize, ...ctx } = props;
- const { t } = useLanguage();
- const [hovered, setHovered] = useState(false);
-
- const segments = [...pathSegments, `p:${table.tableName}`];
- const pathKey = _pathKey(featureNode.featureInstanceId, segments);
- const expanded = ctx.featureExpandedPaths.has(pathKey);
- const loading = ctx.featureLoadingPath === pathKey;
- const records = ctx.featureRecordsByPath[pathKey];
- const chevron = expanded ? '\u25BE' : '\u25B8';
-
- const childTables = (featureNode.tables || []).filter(c => c.parentTable === table.tableName);
-
- const _onToggle = () => {
- const willExpand = !expanded;
- ctx.onToggleFeaturePath(pathKey);
- if (willExpand && !records) {
- ctx.onLoadRecordsAtPath(featureNode, table, pathSegments);
- }
- };
-
- // Container-wildcard objectKey for the parent group: matches every record in
- // ``table`` so a single FDS row drives chat/scope/neutralize for the whole list.
- const containerObjectKey = `data.feature.${featureNode.featureCode}.${table.tableName}.*`;
- const wildcardFds = ctx.featureDataSources.find(
- f => f.featureInstanceId === featureNode.featureInstanceId
- && f.tableName === table.tableName
- && !f.recordFilter
- && f.objectKey === containerObjectKey,
- );
- const _chatPayload = {
- featureInstanceId: featureNode.featureInstanceId,
- featureCode: featureNode.featureCode,
- tableName: table.tableName,
- objectKey: containerObjectKey,
- label: table.label || table.tableName,
- };
-
- return (
-
-
setHovered(true)}
- onMouseLeave={() => setHovered(false)}
- draggable
- onDragStart={(e) => {
- e.stopPropagation();
- e.dataTransfer.setData('application/feature-source', JSON.stringify(_chatPayload));
- e.dataTransfer.setData('text/plain', _chatPayload.label);
- e.dataTransfer.effectAllowed = 'copy';
- }}
- style={{
- display: 'flex', alignItems: 'center', gap: 4,
- paddingLeft: (depth * 16 + 4) - (wildcardFds ? 3 : 0),
- paddingRight: 4, paddingTop: 3, paddingBottom: 3,
- cursor: 'pointer', borderRadius: 3,
- background: wildcardFds
- ? (hovered ? '#ede7f6' : '#7b1fa208')
- : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'),
- borderLeft: wildcardFds ? '3px solid #7b1fa2' : undefined,
- transition: 'background 0.1s', userSelect: 'none',
- }}
- >
-
- {loading ? _Spinner() : chevron}
-
- {'\uD83D\uDCC2'}
-
- {table.label || table.tableName}
-
- {childTables.length > 0 && (
-
- +{childTables.length} {t('Tabellen')}
-
- )}
-
-
-
-
-
-
- {expanded && records && records.length > 0 && (
-
- {records.map(record => (
- <_RecordRowView
- key={record.id}
- featureNode={featureNode}
- parentTable={table}
- record={record}
- pathSegments={segments}
- depth={depth + 1}
- inheritedScope={inheritedScope}
- inheritedNeutralize={inheritedNeutralize}
- {...ctx}
- />
- ))}
-
- )}
-
- {expanded && records && records.length === 0 && !loading && (
-
- {t('(keine Einträge)')}
-
- )}
-
- );
-};
-
-/* ─── RecordRowView (single record + recursive children when expanded) ── */
-
-interface _RecordRowViewProps extends _FeatureActionContext {
- featureNode: FeatureConnectionNode;
- parentTable: FeatureTableNode;
- record: ParentRecordNode;
- pathSegments: string[];
- depth: number;
- inheritedScope?: string;
- inheritedNeutralize?: boolean;
-}
-
-const _RecordRowView: React.FC<_RecordRowViewProps> = (props) => {
- const { featureNode, parentTable, record, pathSegments, depth, inheritedScope, inheritedNeutralize, ...ctx } = props;
- const { t } = useLanguage();
- const [hovered, setHovered] = useState(false);
-
- const segments = [...pathSegments, `r:${parentTable.tableName}:${record.id}`];
- const pathKey = _pathKey(featureNode.featureInstanceId, segments);
- const expanded = ctx.featureExpandedPaths.has(pathKey);
- const chevron = expanded ? '\u25BE' : '\u25B8';
-
- const fds = ctx.featureDataSources.find(
- f => f.featureInstanceId === featureNode.featureInstanceId
- && f.tableName === parentTable.tableName
- && f.recordFilter?.id === record.id,
- );
-
- // Effective values: own explicit > inherited from parent FDS in tree.
- // null on own fds means "inherit" (cascade-reset by backend).
- const effectiveScope: string = (fds?.scope ?? inheritedScope ?? 'personal') as string;
- const effectiveNeutralize: boolean = (fds?.neutralize ?? inheritedNeutralize ?? false) as boolean;
-
- const childItems = useMemo(
- () => _childrenForRecord(featureNode.tables || [], parentTable.tableName),
- [featureNode.tables, parentTable.tableName],
- );
-
- const isAdded = ctx.isRecordAdded(featureNode.featureInstanceId, parentTable.tableName, record.id);
- const addingKey = `${_pathKey(featureNode.featureInstanceId, pathSegments)}|r:${parentTable.tableName}:${record.id}`;
- const isAdding = ctx.addingRecordPath === addingKey;
-
- const _chatPayload = {
- featureInstanceId: featureNode.featureInstanceId,
- featureCode: featureNode.featureCode,
- tableName: parentTable.tableName,
- objectKey: parentTable.objectKey,
- label: `${parentTable.label || parentTable.tableName}: ${record.displayLabel}`,
- };
-
- return (
-
-
ctx.onToggleFeaturePath(pathKey)}
- onMouseEnter={() => setHovered(true)}
- onMouseLeave={() => setHovered(false)}
- draggable
- onDragStart={(e) => {
- e.stopPropagation();
- const payload = JSON.stringify({
- featureInstanceId: featureNode.featureInstanceId,
- featureCode: featureNode.featureCode,
- objectKey: parentTable.objectKey,
- label: `${parentTable.label || parentTable.tableName}: ${record.displayLabel}`,
- });
- e.dataTransfer.setData('application/feature-source', payload);
- e.dataTransfer.setData('text/plain', record.displayLabel);
- e.dataTransfer.effectAllowed = 'copy';
- }}
- style={{
- display: 'flex', alignItems: 'center', gap: 4,
- paddingLeft: depth * 16 + 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
- cursor: 'pointer', borderRadius: 3,
- background: fds
- ? (hovered ? '#ede7f6' : '#7b1fa208')
- : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'),
- transition: 'background 0.1s', userSelect: 'none',
- }}
- title={Object.entries(record.fields).map(([k, v]) => `${k}: ${v}`).join(', ')}
- >
-
- {chevron}
-
- {'\uD83D\uDCCB'}
-
- {record.displayLabel}
-
-
- {/* Add record + direct children as data sources (only when not already added). */}
- {!fds && !isAdded && (
-
- )}
-
-
- {fds ? (
-
- ) : (
-
- {_SCOPE_ICONS[inheritedScope || 'personal']}
-
- )}
- {fds ? (
-
- ) : (
-
- {'\uD83D\uDD12'}
-
- )}
-
-
- {expanded && childItems.length > 0 && (
-
- {childItems.map((sub, idx) => (
- <_FeatureItemView
- key={_itemKey(sub, idx)}
- featureNode={featureNode}
- item={sub}
- pathSegments={segments}
- depth={depth + 1}
- inheritedScope={effectiveScope}
- inheritedNeutralize={effectiveNeutralize}
- {...ctx}
- />
- ))}
-
- )}
-
- );
-};
-
-/* ─── FeatureTableRow (leaf table) ───────────────────────────────────── */
-
-interface _FeatureTableRowProps {
- featureNode: FeatureConnectionNode;
- table: FeatureTableNode;
- depth: number;
- onAddFeatureTable: (
- node: FeatureConnectionNode,
- table: FeatureTableNode,
- extra?: { recordFilter?: Record; labelOverride?: string },
- ) => Promise;
- onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void;
- featureDataSources: UdbFeatureDataSource[];
- onCycleScope: (fds: UdbFeatureDataSource, currentEffective?: string) => void;
- onToggleNeutralize: (fds: UdbFeatureDataSource, currentEffective?: boolean) => void;
- onToggleNeutralizeField: (fds: UdbFeatureDataSource, fieldName: string) => void;
- featureTree: MandateGroupNode[];
- inheritedScope?: string;
- inheritedNeutralize?: boolean;
-}
-
-const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
- featureNode, table, depth, onAddFeatureTable, onSendToChat,
- featureDataSources, onCycleScope, onToggleNeutralize, onToggleNeutralizeField,
- featureTree, inheritedScope, inheritedNeutralize,
-}) => {
- const { t } = useLanguage();
- const [hovered, setHovered] = useState(false);
- const [fieldsExpanded, setFieldsExpanded] = useState(false);
- const tableLabel = table.label || table.tableName;
-
- const fds = featureDataSources.find(
- f => f.featureInstanceId === featureNode.featureInstanceId
- && f.tableName === table.tableName
- && !f.recordFilter,
- );
-
- const effectiveScope = fds?.scope ?? inheritedScope;
- const effectiveNeutralize = fds?.neutralize ?? inheritedNeutralize ?? false;
- const _chatPayload = {
- featureInstanceId: featureNode.featureInstanceId,
- featureCode: featureNode.featureCode,
- tableName: table.tableName,
- objectKey: table.objectKey,
- label: tableLabel,
- };
-
- const resolvedFields = featureTree
- ? _findTableFields(featureTree, featureNode.featureInstanceId, table.tableName)
- : table.fields;
- const neutralizedCount = fds?.neutralizeFields?.length ?? 0;
-
- return (
-
-
setHovered(true)}
- onMouseLeave={() => setHovered(false)}
- draggable
- onDragStart={(e) => {
- e.stopPropagation();
- e.dataTransfer.setData('application/feature-source', JSON.stringify(_chatPayload));
- e.dataTransfer.setData('text/plain', tableLabel);
- e.dataTransfer.effectAllowed = 'copy';
- }}
- style={{
- display: 'flex', alignItems: 'center', gap: 4,
- paddingLeft: depth * 16 + 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
- borderRadius: 3,
- background: fds
- ? (hovered ? '#ede7f6' : '#7b1fa208')
- : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'),
- transition: 'background 0.1s', userSelect: 'none',
- }}
- title={`${table.tableName}: ${table.fields.join(', ')}`}
- >
- { e.stopPropagation(); if (resolvedFields.length > 0) setFieldsExpanded(prev => !prev); }}
- style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0, cursor: resolvedFields.length > 0 ? 'pointer' : 'default' }}
- >
- {resolvedFields.length > 0 ? (fieldsExpanded ? '\u25BE' : '\u25B8') : '\u00A0\u00A0'}
-
- {'\uD83D\uDCC1'}
-
- {tableLabel}
- {neutralizedCount > 0 && (
- ({neutralizedCount} {t('Felder')})
- )}
-
-
-
-
-
-
-
-
- {fieldsExpanded && resolvedFields.length > 0 && (
-
- {resolvedFields.map(field => {
- const isNeutralized = (fds?.neutralizeFields || []).includes(field);
- return (
- <_FeatureFieldRow
- key={field}
- featureNode={featureNode}
- table={table}
- fieldName={field}
- depth={depth + 1}
- isNeutralized={isNeutralized || effectiveNeutralize}
- fds={fds}
- onToggleNeutralizeField={onToggleNeutralizeField}
- onSendToChat={onSendToChat}
- inheritedScope={fds?.scope ?? inheritedScope}
- />
- );
- })}
-
- )}
-
- );
-};
-
-/* ─── FeatureFieldRow (single field under a table) ────────────────────── */
-
-interface _FeatureFieldRowProps {
- featureNode: FeatureConnectionNode;
- table: FeatureTableNode;
- fieldName: string;
- depth: number;
- isNeutralized: boolean;
- fds?: UdbFeatureDataSource;
- onToggleNeutralizeField?: (fds: UdbFeatureDataSource, fieldName: string) => void;
- onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void;
- inheritedScope?: string;
-}
-
-const _FeatureFieldRow: React.FC<_FeatureFieldRowProps> = ({
- featureNode, table, fieldName, depth, isNeutralized, fds, onToggleNeutralizeField, onSendToChat, inheritedScope,
-}) => {
- const { t } = useLanguage();
- const [hovered, setHovered] = useState(false);
- const _chatPayload = {
- featureInstanceId: featureNode.featureInstanceId,
- featureCode: featureNode.featureCode,
- tableName: table.tableName,
- objectKey: table.objectKey,
- label: `${table.label || table.tableName}.${fieldName}`,
- fieldName,
- };
-
- return (
- setHovered(true)}
- onMouseLeave={() => setHovered(false)}
- draggable
- onDragStart={(e) => {
- e.stopPropagation();
- e.dataTransfer.setData('application/feature-source', JSON.stringify(_chatPayload));
- e.dataTransfer.setData('text/plain', `${table.tableName}.${fieldName}`);
- e.dataTransfer.effectAllowed = 'copy';
- }}
- style={{
- display: 'flex', alignItems: 'center', gap: 4,
- paddingLeft: depth * 16 + 8, paddingRight: 4, paddingTop: 2, paddingBottom: 2,
- borderRadius: 3,
- background: isNeutralized
- ? (hovered ? '#f3e5f5' : '#f3e5f508')
- : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'),
- transition: 'background 0.1s', userSelect: 'none',
- fontSize: 11,
- }}
- >
- {'\u2514'}
-
- {fieldName}
-
-
-
-
- {_SCOPE_ICONS[inheritedScope || 'personal']}
-
- {fds && onToggleNeutralizeField ? (
-
- ) : (
-
- {'\uD83D\uDD12'}
-
- )}
-
- );
-};
-
export default SourcesTab;
diff --git a/src/components/UnifiedDataBar/UdbSourcesProvider.tsx b/src/components/UnifiedDataBar/UdbSourcesProvider.tsx
new file mode 100644
index 0000000..4d2abfd
--- /dev/null
+++ b/src/components/UnifiedDataBar/UdbSourcesProvider.tsx
@@ -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([
+ 'connection',
+ 'featureNode',
+]);
+
+// ---------------------------------------------------------------------------
+// Icon resolution (kept inline; UDB-domain mapping, not Tree-generic)
+// ---------------------------------------------------------------------------
+
+const _AUTHORITY_ICONS: Record = {
+ msft: ,
+ google: ,
+ clickup: ,
+ infomaniak: ,
+ 'local:ftp': ,
+ 'local:jira': ,
+};
+
+const _SERVICE_ICONS: Record = {
+ sharepoint: ,
+ onedrive: ,
+ outlook: ,
+ teams: ,
+ drive: ,
+ gmail: ,
+ files: ,
+ kdrive: ,
+ calendar: ,
+ contact: ,
+};
+
+const _KIND_FALLBACK_ICONS: Record = {
+ synthRoot: ,
+ connection: ,
+ service: ,
+ folder: ,
+ file: ,
+ mandateGroup: ,
+ featureNode: ,
+ fdsTable: ,
+ fdsRecord: ,
+ fdsField: {'\u22EE'},
+};
+
+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,
+): TreeNode {
+ const isSynthetic = _isSyntheticContainer(n.kind);
+ const isFolderLike = n.hasChildren;
+
+ const node: TreeNode = {
+ 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 {
+ /** 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();
+
+ async function _ensureRecord(node: UdbBackendNode): Promise {
+ 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 {
+ 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 {
+ 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,
+ ): Promise {
+ 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 = res.data?.attributes ?? {};
+ const result = new Map();
+ 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;
+ },
+ };
+}
diff --git a/src/components/UnifiedDataBar/__tests__/UdbSourcesProvider.test.ts b/src/components/UnifiedDataBar/__tests__/UdbSourcesProvider.test.ts
new file mode 100644
index 0000000..8fb974e
--- /dev/null
+++ b/src/components/UnifiedDataBar/__tests__/UdbSourcesProvider.test.ts
@@ -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; patch: ReturnType };
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+
+function _makeBackendNode(overrides: Partial = {}): 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 },
+ );
+ });
+});
diff --git a/src/hooks/useTreeExpansion.ts b/src/hooks/useTreeExpansion.ts
new file mode 100644
index 0000000..9a647c1
--- /dev/null
+++ b/src/hooks/useTreeExpansion.ts
@@ -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(null);
+ const saveTimerRef = useRef | null>(null);
+ const latestRef = useRef(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 };
+}
diff --git a/src/pages/RagInventoryPage.module.css b/src/pages/RagInventoryPage.module.css
index 8a72cd0..9fbd759 100644
--- a/src/pages/RagInventoryPage.module.css
+++ b/src/pages/RagInventoryPage.module.css
@@ -131,6 +131,16 @@
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 ── */
.connectionCard {
border: 1px solid var(--color-border, #e5e7eb);
diff --git a/src/pages/RagInventoryPage.tsx b/src/pages/RagInventoryPage.tsx
index fbe06d1..d8f943a 100644
--- a/src/pages/RagInventoryPage.tsx
+++ b/src/pages/RagInventoryPage.tsx
@@ -10,9 +10,8 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useLanguage } from '../providers/language/LanguageContext';
import { useApiRequest } from '../hooks/useApi';
-import { useUserMandates } from '../hooks/useUserMandates';
-import type { RagInventoryDto, RagConnectionDto } from '../api/connectionApi';
-import { FaDatabase, FaStop, FaSync, FaToggleOn, FaToggleOff, FaRedo, FaExclamationTriangle, FaCheckCircle, FaSlidersH } from 'react-icons/fa';
+import type { RagInventoryDto, RagConnectionDto, RagFeatureInstanceDto } from '../api/connectionApi';
+import { FaDatabase, FaStop, FaSync, FaToggleOn, FaToggleOff, FaRedo, FaExclamationTriangle, FaCheckCircle, FaSlidersH, FaCubes } from 'react-icons/fa';
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
import { DataSourceSettingsModal } from '../components/UnifiedDataBar/DataSourceSettingsModal';
import styles from './RagInventoryPage.module.css';
@@ -20,7 +19,6 @@ import styles from './RagInventoryPage.module.css';
export const RagInventoryPage: React.FC = () => {
const { t } = useLanguage();
const { request } = useApiRequest();
- const { fetchMandates } = useUserMandates();
const [mandates, setMandates] = useState([]);
const [mandatesLoading, setMandatesLoading] = useState(true);
@@ -54,7 +52,7 @@ export const RagInventoryPage: React.FC = () => {
(async () => {
setMandatesLoading(true);
try {
- const data = await fetchMandates();
+ const data = await request({ url: '/api/rag/inventory/my-mandates', method: 'get' });
if (!cancelled) {
const list = Array.isArray(data) ? data : [];
setMandates(list);
@@ -64,7 +62,7 @@ export const RagInventoryPage: React.FC = () => {
finally { if (!cancelled) setMandatesLoading(false); }
})();
return () => { cancelled = true; };
- }, [fetchMandates]);
+ }, [request]);
const _apiEndpoint = useMemo(() => {
if (selectedScope === 'personal') return '/api/rag/inventory/me';
@@ -77,11 +75,13 @@ export const RagInventoryPage: React.FC = () => {
setError(null);
try {
const params: Record = {};
- if (selectedScope !== 'personal' && selectedScope !== 'platform') {
- params.mandateId = selectedScope;
- }
if (onlyMyData) params.onlyMine = 'true';
- const data = await request({ url: _apiEndpoint, method: 'get', params });
+ const isMandateScope = selectedScope !== 'personal' && selectedScope !== 'platform';
+ const headers: Record = {};
+ if (isMandateScope) {
+ headers['X-Mandate-Id'] = selectedScope;
+ }
+ const data = await request({ url: _apiEndpoint, method: 'get', params, additionalConfig: { headers } });
setInventory(data);
} catch (err: any) {
if (err?.message?.includes('403')) {
@@ -99,7 +99,10 @@ export const RagInventoryPage: React.FC = () => {
_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(() => {
if (pollRef.current) clearInterval(pollRef.current);
@@ -127,6 +130,13 @@ export const RagInventoryPage: React.FC = () => {
} 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) => {
if (!currentEnabled || window.confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?'))) {
try {
@@ -374,7 +384,118 @@ export const RagInventoryPage: React.FC = () => {