Skip to content

Commit 564f76b

Browse files
mmelkoclaude
authored andcommitted
feat(datamapper): support xsl:variable as source elements (#2846)
- Add Variables panel with rename, delete and scope hints - Implement SourceVariableNodeData as draggable source node - Add scope-aware DnD validation (XSLT following-sibling rule) - Create VALUE/CONTAINER mappings and render Var:// links - Handle variable shadowing in nested scopes - Add tests for DnD validation, engagement, links and scoping Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1b3e2ad commit 564f76b

28 files changed

Lines changed: 1311 additions & 105 deletions

packages/ui/src/components/Document/Parameters.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,9 @@ const ParameterPanel: FunctionComponent<ParameterPanelProps> = ({
187187
key="rename"
188188
itemName={`parameter-${parameterName}`}
189189
label="parameter"
190-
onRenameClick={() => onStartRename(parameterName)}
190+
onRenameClick={() => {
191+
onStartRename(parameterName);
192+
}}
191193
/>,
192194
<DeleteParameterButton key="delete" parameterName={parameterName} parameterReferenceId={documentReferenceId} />,
193195
],

packages/ui/src/components/Document/TargetDocumentNode.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { handleNodeKeyDown } from './document-node.utils';
2626
import { NodeContainer } from './NodeContainer';
2727
import { BaseNode } from './Nodes/BaseNode';
2828
import { NodeTitle } from './NodeTitle/NodeTitle';
29-
import { VariableInputPlaceholder } from './VariableInputPlaceholder';
29+
import { VariableInputPlaceholder } from './Variables/VariableInputPlaceholder';
3030

3131
type DocumentNodeProps = {
3232
treeNode: DocumentTreeNode;
@@ -81,6 +81,7 @@ export const TargetDocumentNode: FunctionComponent<DocumentNodeProps> = memo(
8181
const handleVariableConfirm = useCallback(
8282
(name: string) => {
8383
if (isRenamingThisVariable && nodeData instanceof VariableNodeData) {
84+
MappingService.renameVariableReferences(nodeData.mapping, name);
8485
MappingService.updateVariable(nodeData.mapping, name, nodeData.mapping.expression);
8586
} else {
8687
const parent = MappingActionService.getOrCreateParentMapping(nodeData);

packages/ui/src/components/Document/VariableInputPlaceholder.scss renamed to packages/ui/src/components/Document/Variables/VariableInputPlaceholder.scss

File renamed without changes.

packages/ui/src/components/Document/VariableInputPlaceholder.test.tsx renamed to packages/ui/src/components/Document/Variables/VariableInputPlaceholder.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { act, fireEvent, render, screen } from '@testing-library/react';
22
import type { Mock } from 'vitest';
33

4-
import { DocumentDefinitionType, DocumentType } from '../../models/datamapper/document';
5-
import { MappingTree } from '../../models/datamapper/mapping';
6-
import { MappingService } from '../../services/mapping/mapping.service';
4+
import { DocumentDefinitionType, DocumentType } from '../../../models/datamapper/document';
5+
import { MappingTree } from '../../../models/datamapper/mapping';
6+
import { MappingService } from '../../../services/mapping/mapping.service';
77
import { VariableInputPlaceholder } from './VariableInputPlaceholder';
88

99
describe('VariableInputPlaceholder', () => {

packages/ui/src/components/Document/VariableInputPlaceholder.tsx renamed to packages/ui/src/components/Document/Variables/VariableInputPlaceholder.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import './VariableInputPlaceholder.scss';
22

33
import { FunctionComponent, useCallback } from 'react';
44

5-
import { MappingParentType } from '../../models/datamapper/mapping';
6-
import { NameValidation, NameValidationStatus } from '../../models/datamapper/visualization';
7-
import { VisualizationService } from '../../services/visualization/visualization.service';
8-
import { NameInputPlaceholder } from './NameInputPlaceholder';
5+
import { MappingParentType } from '../../../models/datamapper/mapping';
6+
import { NameValidation, NameValidationStatus } from '../../../models/datamapper/visualization';
7+
import { VisualizationService } from '../../../services/visualization/visualization.service';
8+
import { NameInputPlaceholder } from '../NameInputPlaceholder';
99

1010
type VariableInputPlaceholderProps = {
1111
initialName?: string;
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import './Variables.scss';
2+
3+
import { Label } from '@patternfly/react-core';
4+
import { TrashIcon } from '@patternfly/react-icons';
5+
import { FunctionComponent, useCallback } from 'react';
6+
7+
import { useDataMapper } from '../../../hooks/useDataMapper';
8+
import { FieldItem, MappingTree, VariableItem } from '../../../models/datamapper/mapping';
9+
import { SourceVariableNodeData, VARIABLES_DOCUMENT_ID } from '../../../models/datamapper/visualization';
10+
import { MappingService } from '../../../services/mapping/mapping.service';
11+
import { ConfirmActionButton } from '../actions/ConfirmActionButton';
12+
import { RenameButton } from '../actions/RenameButton';
13+
import { NodeContainer } from '../NodeContainer';
14+
import { BaseNode } from '../Nodes/BaseNode';
15+
import { VariableInputPlaceholder } from './VariableInputPlaceholder';
16+
17+
type VariableRowProps = {
18+
variable: VariableItem;
19+
isRenaming: boolean;
20+
isReadOnly: boolean;
21+
onStartRename: (id: string) => void;
22+
onStopRename: () => void;
23+
onDelete: (variable: VariableItem) => void;
24+
};
25+
26+
export const VariableRow: FunctionComponent<VariableRowProps> = ({
27+
variable,
28+
isRenaming,
29+
isReadOnly,
30+
onStartRename,
31+
onStopRename,
32+
onDelete,
33+
}) => {
34+
const { refreshMappingTree } = useDataMapper();
35+
const nodeData = new SourceVariableNodeData(variable);
36+
37+
const handleRenameConfirm = useCallback(
38+
(newName: string) => {
39+
MappingService.renameVariableReferences(variable, newName);
40+
MappingService.updateVariable(variable, newName, variable.expression);
41+
refreshMappingTree();
42+
onStopRename();
43+
},
44+
[variable, refreshMappingTree, onStopRename],
45+
);
46+
47+
if (isRenaming) {
48+
return (
49+
<VariableInputPlaceholder
50+
initialName={variable.name}
51+
parent={variable.parent}
52+
onConfirm={handleRenameConfirm}
53+
onCancel={onStopRename}
54+
/>
55+
);
56+
}
57+
58+
const scopeLabel = (() => {
59+
const p = variable.parent;
60+
if (p instanceof MappingTree) return undefined;
61+
if (p instanceof FieldItem) return p.field.displayName;
62+
return p.name;
63+
})();
64+
65+
const variableTitle = (
66+
<span className="node__spacer variable-row-title">
67+
<Label>$</Label> {variable.name}
68+
{scopeLabel && (
69+
<span className="variable-row-scope-hint" title={`Defined inside "${scopeLabel}"`}>
70+
&nbsp;({scopeLabel})
71+
</span>
72+
)}
73+
</span>
74+
);
75+
76+
const actions = !isReadOnly && (
77+
<span className="variable-row-actions">
78+
<RenameButton
79+
itemName={`variable-${variable.name}`}
80+
label="variable"
81+
onRenameClick={() => {
82+
onStartRename(variable.id);
83+
}}
84+
/>
85+
<ConfirmActionButton
86+
icon={<TrashIcon />}
87+
title={`Delete variable $${variable.name}`}
88+
triggerTestId={`delete-variable-${variable.name}-button`}
89+
modalTestId={`delete-variable-${variable.name}-modal`}
90+
confirmTestId={`delete-variable-${variable.name}-modal-confirm-btn`}
91+
cancelTestId={`delete-variable-${variable.name}-modal-cancel-btn`}
92+
modalTitle="Delete variable"
93+
description={`Delete variable "$${variable.name}"? Mappings referencing it will also be removed.`}
94+
onConfirm={() => {
95+
onDelete(variable);
96+
}}
97+
/>
98+
</span>
99+
);
100+
101+
return (
102+
<div className="node__container" data-testid={`variable-row-${variable.name}`}>
103+
<NodeContainer nodeData={nodeData}>
104+
<div className="node__header">
105+
<BaseNode
106+
nodeData={nodeData}
107+
title={variableTitle}
108+
rank={0}
109+
nodePath={nodeData.path.toString()}
110+
documentId={VARIABLES_DOCUMENT_ID}
111+
>
112+
{actions}
113+
</BaseNode>
114+
</div>
115+
</NodeContainer>
116+
</div>
117+
);
118+
};
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
.variable-row-title {
2+
white-space: nowrap;
3+
overflow: hidden;
4+
text-overflow: ellipsis;
5+
min-width: 0;
6+
}
7+
8+
.variable-row-scope-hint {
9+
font-size: var(--pf-t--global--font--size--sm);
10+
font-style: italic;
11+
color: var(--pf-t--global--text--color--subtle);
12+
white-space: nowrap;
13+
}
14+
15+
.variable-row-actions {
16+
flex: 1;
17+
display: flex;
18+
justify-content: flex-end;
19+
align-items: center;
20+
padding-right: 0.5rem;
21+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { FunctionComponent } from 'react';
2+
3+
type VariablesHeaderProps = {
4+
isReadOnly: boolean;
5+
};
6+
7+
export const VariablesHeader: FunctionComponent<VariablesHeaderProps> = () => (
8+
<div className="parameters-header" data-testid="source-variables-header">
9+
<span className="parameters-header__title panel-header-text">Variables</span>
10+
{/* TODO(#3340): Enable "Add Variable" button when global variable support is implemented.
11+
Context menu "Add Variable" on target nodes is also disabled (mapping-action.service.ts). */}
12+
</div>
13+
);
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react';
2+
3+
import { useConnectionPortSync } from '../../../hooks/useConnectionPortSync.hook';
4+
import { useDataMapper } from '../../../hooks/useDataMapper';
5+
import { VariableItem } from '../../../models/datamapper/mapping';
6+
import { VARIABLES_DOCUMENT_ID } from '../../../models/datamapper/visualization';
7+
import { MappingService } from '../../../services/mapping/mapping.service';
8+
import { ExpansionPanel } from '../../ExpansionPanels/ExpansionPanel';
9+
import { PANEL_COLLAPSED_HEIGHT, PANEL_MIN_HEIGHT } from '../../ExpansionPanels/panel-dimensions';
10+
import { VariableRow } from './VariableRow';
11+
import { VariablesHeader } from './VariablesHeader';
12+
13+
type VariablesSectionProps = {
14+
isReadOnly: boolean;
15+
onLayoutChange?: () => void;
16+
};
17+
18+
export const VariablesSection: FunctionComponent<VariablesSectionProps> = ({ isReadOnly, onLayoutChange }) => {
19+
const { variables, refreshMappingTree } = useDataMapper();
20+
const { syncConnectionPorts } = useConnectionPortSync(VARIABLES_DOCUMENT_ID);
21+
22+
const [renamingVariableId, setRenamingVariableId] = useState<string | null>(null);
23+
24+
useEffect(() => {
25+
syncConnectionPorts();
26+
}, [variables.length, syncConnectionPorts]);
27+
28+
const handleStartRename = useCallback((id: string) => {
29+
setRenamingVariableId(id);
30+
}, []);
31+
const handleStopRename = useCallback(() => {
32+
setRenamingVariableId(null);
33+
}, []);
34+
35+
const handleDelete = useCallback(
36+
(variable: VariableItem) => {
37+
MappingService.removeVariableReferences(variable);
38+
MappingService.removeVariable(variable);
39+
refreshMappingTree();
40+
},
41+
[refreshMappingTree],
42+
);
43+
44+
const hasContent = variables.length > 0;
45+
46+
const edgeMarkers = useMemo(
47+
() => (
48+
<>
49+
<span
50+
className="expansion-panel__edge-marker expansion-panel__edge-marker--top expansion-panel__edge-marker--source"
51+
data-connection-port="true"
52+
data-document-id={VARIABLES_DOCUMENT_ID}
53+
data-node-path={`${VARIABLES_DOCUMENT_ID}:EDGE:top`}
54+
/>
55+
<span
56+
className="expansion-panel__edge-marker expansion-panel__edge-marker--bottom expansion-panel__edge-marker--source"
57+
data-connection-port="true"
58+
data-document-id={VARIABLES_DOCUMENT_ID}
59+
data-node-path={`${VARIABLES_DOCUMENT_ID}:EDGE:bottom`}
60+
/>
61+
</>
62+
),
63+
[],
64+
);
65+
66+
return (
67+
<ExpansionPanel
68+
id="variables"
69+
summary={<VariablesHeader isReadOnly={isReadOnly} />}
70+
defaultExpanded={hasContent}
71+
defaultHeight={hasContent ? PANEL_COLLAPSED_HEIGHT + variables.length * 32 : PANEL_COLLAPSED_HEIGHT}
72+
minHeight={PANEL_MIN_HEIGHT}
73+
onLayoutChange={() => {
74+
syncConnectionPorts();
75+
onLayoutChange?.();
76+
}}
77+
>
78+
{hasContent && (
79+
<>
80+
{edgeMarkers}
81+
{variables.map((variable) => (
82+
<VariableRow
83+
key={variable.id}
84+
variable={variable}
85+
isRenaming={renamingVariableId === variable.id}
86+
isReadOnly={isReadOnly}
87+
onStartRename={handleStartRename}
88+
onStopRename={handleStopRename}
89+
onDelete={handleDelete}
90+
/>
91+
))}
92+
</>
93+
)}
94+
</ExpansionPanel>
95+
);
96+
};
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export { VariableInputPlaceholder } from './VariableInputPlaceholder';
2+
export { VariableRow } from './VariableRow';
3+
export { VariablesHeader } from './VariablesHeader';
4+
export { VariablesSection } from './VariablesSection';

0 commit comments

Comments
 (0)