@finos/legend-query-builder 4.17.25 → 4.17.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,40 +14,49 @@
14
14
  * limitations under the License.
15
15
  */
16
16
 
17
- import { useEffect } from 'react';
17
+ import React, { useEffect } from 'react';
18
18
  import {
19
- PanelContent,
20
19
  clsx,
21
20
  Dialog,
22
21
  Modal,
23
- ModalHeader,
24
22
  ModalBody,
25
23
  ModalFooter,
26
24
  ModalFooterButton,
25
+ ModalHeader,
26
+ PanelContent,
27
27
  } from '@finos/legend-art';
28
28
  import { observer } from 'mobx-react-lite';
29
29
  import {
30
- ReactFlow,
31
30
  Background,
32
31
  Controls,
32
+ type Edge as ReactFlowEdge,
33
33
  MiniMap,
34
- ReactFlowProvider,
35
- Position,
36
34
  type Node as ReactFlowNode,
37
- type Edge as ReactFlowEdge,
35
+ Position,
36
+ ReactFlow,
37
+ ReactFlowProvider,
38
38
  } from 'reactflow';
39
39
  import {
40
- type LineageState,
41
40
  LINEAGE_VIEW_MODE,
41
+ type LineageState,
42
42
  } from '../../stores/lineage/LineageState.js';
43
43
 
44
44
  import {
45
45
  type Graph,
46
+ type LineageEdge,
47
+ type LineageNode,
48
+ type LineageProperty,
46
49
  type Owner,
50
+ type OwnerLink,
51
+ type PropertyLineageNode,
52
+ type PropertyLineageReport,
53
+ PropertyOwnerNode,
47
54
  type ReportLineage,
48
- type LineageNode,
49
- type LineageEdge,
50
55
  } from '@finos/legend-graph';
56
+ import {
57
+ PropertyOwnerNode as PropertyOwnerNodeComponent,
58
+ type PropertyOwnerNodeData,
59
+ } from './PropertyOwnerNode.js';
51
60
 
52
61
  function autoLayoutNodesAndEdges<T extends { id: string }>(
53
62
  nodes: T[],
@@ -177,7 +186,7 @@ const convertGraphToFlow = (graph?: Graph) => {
177
186
  }
178
187
  const nodeList = graph.nodes.map((node: LineageNode) => ({
179
188
  id: node.data.id,
180
- label: node.data.text || node.data.id,
189
+ label: node.data.text,
181
190
  }));
182
191
  const edgeList = graph.edges.map((edge: LineageEdge) => ({
183
192
  source: edge.data.source.data.id,
@@ -404,6 +413,514 @@ const convertReportLineageToFlow = (reportLineage?: ReportLineage) => {
404
413
  };
405
414
  };
406
415
 
416
+ const convertPropertyLineageToFlow = (
417
+ propertyLineage?: PropertyLineageReport,
418
+ selectedSourcePropertiesMap?: Map<string, Set<string>>,
419
+ ) => {
420
+ if (!propertyLineage?.propertyOwner.length) {
421
+ return {
422
+ nodes: [
423
+ {
424
+ id: 'no-property-lineage',
425
+ data: { label: 'No Property Lineage Generated' },
426
+ position: { x: 350, y: 300 },
427
+ type: 'default',
428
+ style: {
429
+ backgroundColor: '#f5f5f5',
430
+ border: '1px solid #ccc',
431
+ borderRadius: '5px',
432
+ padding: '10px',
433
+ width: 200,
434
+ },
435
+ },
436
+ ],
437
+ edges: [],
438
+ bounds: { width: 800, height: 600 },
439
+ };
440
+ }
441
+
442
+ const nodeList = propertyLineage.propertyOwner.map(
443
+ (node: PropertyLineageNode) => ({
444
+ id: node.id,
445
+ label: node.name,
446
+ isPropertyOwner: node instanceof PropertyOwnerNode,
447
+ node: node,
448
+ }),
449
+ );
450
+
451
+ const edgeList = propertyLineage.ownerLink.map((link: OwnerLink) => ({
452
+ source: link.source,
453
+ target: link.target,
454
+ }));
455
+
456
+ const nodeIds = new Set(nodeList.map((n) => n.id));
457
+ const validEdgeList = edgeList.filter((edge) => {
458
+ return !(!nodeIds.has(edge.source) || !nodeIds.has(edge.target));
459
+ });
460
+
461
+ const nodeDimensions = new Map<string, { width: number; height: number }>();
462
+
463
+ nodeList.forEach((nodeItem) => {
464
+ const isPropertyOwner = nodeItem.isPropertyOwner;
465
+ const highlightedProperties = selectedSourcePropertiesMap?.get(nodeItem.id);
466
+ const hasHighlightedProperties = (highlightedProperties?.size ?? 0) > 0;
467
+ let nodeWidth = 220;
468
+ let nodeHeight = isPropertyOwner ? 80 : 60;
469
+
470
+ if (hasHighlightedProperties && isPropertyOwner) {
471
+ const propertyCount = Math.min(highlightedProperties?.size ?? 0, 20);
472
+
473
+ // Header (50px) + properties (32px each including margins) + container padding (20px)
474
+ const propertiesHeight = propertyCount * 32; // 28px min-height + 4px margin
475
+
476
+ nodeHeight = 50 + propertiesHeight + 20;
477
+ nodeHeight = Math.max(nodeHeight, 160);
478
+ nodeHeight = Math.min(nodeHeight, 800);
479
+
480
+ nodeWidth = 340;
481
+ }
482
+
483
+ nodeDimensions.set(nodeItem.id, { width: nodeWidth, height: nodeHeight });
484
+ });
485
+
486
+ const maxHeight = Math.max(
487
+ ...Array.from(nodeDimensions.values()).map((d) => d.height),
488
+ );
489
+ const dynamicYSpacing = Math.max(220, maxHeight + 100);
490
+ const dynamicXSpacing = 380;
491
+
492
+ const positions = autoLayoutNodesAndEdges(
493
+ nodeList,
494
+ validEdgeList,
495
+ dynamicXSpacing,
496
+ dynamicYSpacing,
497
+ );
498
+ const bounds = getLayoutBounds(positions);
499
+
500
+ const nodes = nodeList.map((nodeItem) => {
501
+ const isPropertyOwner = nodeItem.isPropertyOwner;
502
+ const highlightedProperties = selectedSourcePropertiesMap?.get(nodeItem.id);
503
+
504
+ let allProperties: Array<{
505
+ name: string;
506
+ dataType?: string | undefined;
507
+ propertyType: string | undefined;
508
+ }> = [];
509
+ if (isPropertyOwner && nodeItem.node instanceof PropertyOwnerNode) {
510
+ const properties = nodeItem.node.properties;
511
+ allProperties = properties.map((prop) => ({
512
+ name: prop.name,
513
+ dataType: prop.dataType,
514
+ propertyType: prop.propertyType,
515
+ }));
516
+ }
517
+
518
+ const dimensions = nodeDimensions.get(nodeItem.id) ?? {
519
+ width: 220,
520
+ height: 80,
521
+ };
522
+
523
+ const nodeData: PropertyOwnerNodeData = {
524
+ label: nodeItem.label,
525
+ isPropertyOwner: isPropertyOwner,
526
+ highlightedProperties: highlightedProperties,
527
+ allProperties: allProperties,
528
+ };
529
+
530
+ return {
531
+ id: nodeItem.id,
532
+ data: nodeData,
533
+ position: positions[nodeItem.id] ?? { x: 0, y: 0 },
534
+ type: isPropertyOwner ? 'propertyOwner' : 'default',
535
+ sourcePosition: Position.Right,
536
+ targetPosition: Position.Left,
537
+ style: {
538
+ width: dimensions.width,
539
+ height: dimensions.height,
540
+ background: 'transparent',
541
+ border: 'none',
542
+ padding: 0,
543
+ margin: 0,
544
+ },
545
+ width: dimensions.width,
546
+ height: dimensions.height,
547
+ };
548
+ });
549
+
550
+ const edges = validEdgeList.map((edge, idx) => ({
551
+ id: `${edge.source}-${edge.target}-${idx}`,
552
+ source: edge.source,
553
+ target: edge.target,
554
+ type: 'smoothstep' as const,
555
+ style: { strokeWidth: 2, stroke: '#1976d2' },
556
+ }));
557
+
558
+ const expandedBounds = {
559
+ width: Math.max(bounds.width, 1200),
560
+ height: Math.max(bounds.height, 800),
561
+ };
562
+
563
+ return { nodes, edges, bounds: expandedBounds };
564
+ };
565
+
566
+ const collectSourceOwnerProperties = (
567
+ property: LineageProperty,
568
+ map: Map<string, Set<string>>,
569
+ visited: Set<string> = new Set(),
570
+ depth: number = 0,
571
+ ): void => {
572
+ if (depth > 50) {
573
+ return;
574
+ }
575
+
576
+ if (
577
+ !Array.isArray(property.sourceProperties) ||
578
+ property.sourceProperties.length === 0
579
+ ) {
580
+ return;
581
+ }
582
+
583
+ const propertyKey = `${property.ownerID}-${property.name}`;
584
+
585
+ if (visited.has(propertyKey)) {
586
+ return;
587
+ }
588
+
589
+ visited.add(propertyKey);
590
+
591
+ try {
592
+ property.sourceProperties.forEach((sourceProp) => {
593
+ if (!map.has(sourceProp.ownerID)) {
594
+ map.set(sourceProp.ownerID, new Set());
595
+ }
596
+ const sourceSet = map.get(sourceProp.ownerID);
597
+ if (sourceSet) {
598
+ sourceSet.add(sourceProp.name);
599
+ }
600
+ collectSourceOwnerProperties(sourceProp, map, visited, depth + 1);
601
+ });
602
+ } catch {
603
+ return;
604
+ } finally {
605
+ visited.delete(propertyKey);
606
+ }
607
+ };
608
+
609
+ const findRelevantEdges = (
610
+ highlightedNodeIds: Set<string>,
611
+ allEdges: ReactFlowEdge[],
612
+ ): Set<string> => {
613
+ const relevantEdgeIds = new Set<string>();
614
+
615
+ allEdges.forEach((edge) => {
616
+ if (
617
+ highlightedNodeIds.has(edge.source) &&
618
+ highlightedNodeIds.has(edge.target)
619
+ ) {
620
+ relevantEdgeIds.add(edge.id);
621
+ }
622
+ });
623
+
624
+ return relevantEdgeIds;
625
+ };
626
+
627
+ const PROPERTY_LINEAGE_NODE_TYPES = {
628
+ propertyOwner: PropertyOwnerNodeComponent,
629
+ };
630
+
631
+ const PropertyOwnerPanel = observer(
632
+ (props: { lineageState: LineageState; selectedNodeId?: string }) => {
633
+ const { lineageState, selectedNodeId } = props;
634
+ const propertyLineage = lineageState.lineageData?.propertyLineage;
635
+
636
+ if (!selectedNodeId || !propertyLineage) {
637
+ return (
638
+ <div className="property-lineage__panel">
639
+ <div className="property-lineage__panel-header">
640
+ <h3>Properties</h3>
641
+ </div>
642
+ <div className="property-lineage__panel-content">
643
+ <p>Select a node with properties to view details</p>
644
+ </div>
645
+ </div>
646
+ );
647
+ }
648
+
649
+ const selectedNode = propertyLineage.propertyOwner.find(
650
+ (node) => node.id === selectedNodeId,
651
+ );
652
+ const isPropertyOwner = selectedNode instanceof PropertyOwnerNode;
653
+
654
+ if (!isPropertyOwner) {
655
+ return (
656
+ <div className="property-lineage__panel">
657
+ <div className="property-lineage__panel-header">
658
+ <h3>Properties</h3>
659
+ </div>
660
+ <div className="property-lineage__panel-content">
661
+ <p>Selected node has no properties</p>
662
+ </div>
663
+ </div>
664
+ );
665
+ }
666
+
667
+ const propertyOwnerNode = selectedNode;
668
+
669
+ const handlePropertyClick = (property: LineageProperty) => {
670
+ const propertyKey = `${property.ownerID}-${property.name}`;
671
+ const currentSelection = lineageState.selectedProperty;
672
+
673
+ lineageState.setSelectedProperty(
674
+ currentSelection === propertyKey ? undefined : propertyKey,
675
+ );
676
+ if (currentSelection === propertyKey) {
677
+ lineageState.setSelectedSourcePropertiesMap(undefined);
678
+ } else {
679
+ const map = new Map<string, Set<string>>();
680
+ collectSourceOwnerProperties(property, map, new Set(), 0);
681
+ lineageState.setSelectedSourcePropertiesMap(map);
682
+ }
683
+ };
684
+
685
+ const properties = propertyOwnerNode.properties;
686
+
687
+ let highlightedSourceProps: Set<string> | undefined = undefined;
688
+ if (lineageState.selectedSourcePropertiesMap?.has(propertyOwnerNode.id)) {
689
+ highlightedSourceProps = lineageState.selectedSourcePropertiesMap.get(
690
+ propertyOwnerNode.id,
691
+ );
692
+ }
693
+
694
+ return (
695
+ <div className="property-lineage__panel">
696
+ <div className="property-lineage__panel-header">
697
+ <h3>{propertyOwnerNode.name}</h3>
698
+ <span className="property-lineage__panel-subtitle">
699
+ {properties.length} properties
700
+ </span>
701
+ </div>
702
+ <div className="property-lineage__panel-content">
703
+ {properties.length === 0 ? (
704
+ <p>No properties found</p>
705
+ ) : (
706
+ <div className="property-lineage__properties-list">
707
+ {properties.map((property) => {
708
+ const propertyKey = `${property.ownerID}-${property.name}`;
709
+ const isSelected =
710
+ lineageState.selectedProperty === propertyKey;
711
+
712
+ const isSourceHighlighted = highlightedSourceProps?.has(
713
+ property.name,
714
+ );
715
+
716
+ return (
717
+ <div
718
+ key={propertyKey}
719
+ className={clsx('property-lineage__property-item', {
720
+ 'property-lineage__property-item--selected': isSelected,
721
+ 'property-lineage__property-item--source-highlighted':
722
+ isSourceHighlighted,
723
+ })}
724
+ onClick={() => handlePropertyClick(property)}
725
+ style={{ cursor: 'pointer' }}
726
+ >
727
+ <div className="property-lineage__property-name">
728
+ {property.name}
729
+ </div>
730
+ <div className="property-lineage__property-details">
731
+ <span className="property-lineage__property-type">
732
+ {property.dataType ?? 'Unknown'}
733
+ </span>
734
+ <span className="property-lineage__property-scope">
735
+ {property.propertyType}
736
+ </span>
737
+ </div>
738
+ {property.scope && (
739
+ <div className="property-lineage__property-scope-detail">
740
+ {property.scope}
741
+ </div>
742
+ )}
743
+ {Array.isArray(property.sourceProperties) &&
744
+ property.sourceProperties.length > 0 && (
745
+ <div className="property-lineage__property-sources">
746
+ {property.sourceProperties.length} source properties
747
+ </div>
748
+ )}
749
+ </div>
750
+ );
751
+ })}
752
+ </div>
753
+ )}
754
+ </div>
755
+ </div>
756
+ );
757
+ },
758
+ );
759
+
760
+ const PropertyLineageGraphViewer = observer(
761
+ (props: {
762
+ lineageState: LineageState;
763
+ nodes: ReactFlowNode[];
764
+ edges: ReactFlowEdge[];
765
+ }) => {
766
+ const { lineageState, nodes, edges } = props;
767
+
768
+ const getHighlightMaps = () => {
769
+ const highlightedNodeIds = new Set<string>();
770
+
771
+ const sourcePropertiesMap =
772
+ lineageState.selectedSourcePropertiesMap ??
773
+ new Map<string, Set<string>>();
774
+
775
+ if (
776
+ !lineageState.selectedProperty ||
777
+ !lineageState.selectedPropertyOwnerNode
778
+ ) {
779
+ return { highlightedNodeIds, sourcePropertiesMap };
780
+ }
781
+
782
+ const propertyLineage = lineageState.lineageData?.propertyLineage;
783
+ if (!propertyLineage) {
784
+ return { highlightedNodeIds, sourcePropertiesMap };
785
+ }
786
+
787
+ const selectedNode = propertyLineage.propertyOwner.find(
788
+ (node) => node.id === lineageState.selectedPropertyOwnerNode,
789
+ );
790
+
791
+ if (!(selectedNode instanceof PropertyOwnerNode)) {
792
+ return { highlightedNodeIds, sourcePropertiesMap };
793
+ }
794
+
795
+ const [ownerID, propertyName] = lineageState.selectedProperty.split('-');
796
+ const selectedProperty = selectedNode.properties.find(
797
+ (prop) => prop.ownerID === ownerID && prop.name === propertyName,
798
+ );
799
+
800
+ if (selectedProperty) {
801
+ highlightedNodeIds.add(selectedProperty.ownerID);
802
+
803
+ for (const ownerId of sourcePropertiesMap.keys()) {
804
+ highlightedNodeIds.add(ownerId);
805
+ }
806
+ }
807
+
808
+ return { highlightedNodeIds, sourcePropertiesMap };
809
+ };
810
+
811
+ const { highlightedNodeIds, sourcePropertiesMap } = getHighlightMaps();
812
+
813
+ const onNodeClick = (
814
+ _event: React.MouseEvent<Element, MouseEvent>,
815
+ node: ReactFlowNode,
816
+ ) => {
817
+ if (node.data.isPropertyOwner) {
818
+ lineageState.setSelectedPropertyOwnerNode(
819
+ lineageState.selectedPropertyOwnerNode === node.id
820
+ ? undefined
821
+ : node.id,
822
+ );
823
+ lineageState.setSelectedProperty(undefined);
824
+ lineageState.setSelectedSourcePropertiesMap(undefined);
825
+ }
826
+ };
827
+
828
+ const enhancedNodes = nodes.map((node) => {
829
+ const isSelected = lineageState.selectedPropertyOwnerNode === node.id;
830
+ // Remove unnecessary type annotation
831
+ const isHighlighted = highlightedNodeIds.has(node.id);
832
+
833
+ // Add type for updatedData to avoid unsafe assignment
834
+ const updatedData: PropertyOwnerNodeData = {
835
+ ...(node.data as PropertyOwnerNodeData),
836
+ isSelected,
837
+ isHighlighted,
838
+ };
839
+ return {
840
+ ...node,
841
+ data: updatedData,
842
+ style: {
843
+ ...node.style,
844
+ cursor: node.data.isPropertyOwner ? 'pointer' : 'default',
845
+ background:
846
+ node.type === 'propertyOwner'
847
+ ? 'transparent'
848
+ : node.style?.backgroundColor,
849
+ border:
850
+ node.type === 'propertyOwner'
851
+ ? 'none'
852
+ : isSelected
853
+ ? '3px solid #ff6b35'
854
+ : isHighlighted
855
+ ? '3px solid #4caf50'
856
+ : node.style?.border,
857
+ zIndex: isSelected ? 1000 : isHighlighted ? 100 : 1,
858
+ },
859
+ };
860
+ });
861
+
862
+ const highlightedEdgeIds = findRelevantEdges(highlightedNodeIds, edges);
863
+
864
+ const enhancedEdges = edges.map((edge) => {
865
+ const isHighlighted = highlightedEdgeIds.has(edge.id);
866
+
867
+ return {
868
+ ...edge,
869
+ style: {
870
+ ...edge.style,
871
+ strokeWidth: isHighlighted ? 4 : 2,
872
+ stroke: isHighlighted ? '#4caf50' : '#1976d2',
873
+ opacity: isHighlighted ? 1 : 0.6,
874
+ },
875
+ };
876
+ });
877
+
878
+ // DO NOT set selectedSourcePropertiesMap here as it causes infinite re-renders
879
+ // The map is already set in handlePropertyClick
880
+ return (
881
+ <div style={{ height: '100%', width: '100%', display: 'flex' }}>
882
+ <div style={{ flex: 1, height: '100%' }}>
883
+ <ReactFlowProvider>
884
+ <ReactFlow
885
+ nodes={enhancedNodes}
886
+ edges={enhancedEdges}
887
+ nodeTypes={PROPERTY_LINEAGE_NODE_TYPES}
888
+ defaultEdgeOptions={{ type: 'default' }}
889
+ defaultViewport={{ x: 0, y: 0, zoom: 0.6 }}
890
+ fitView={true}
891
+ fitViewOptions={{
892
+ padding: 0.2,
893
+ minZoom: 0.3,
894
+ maxZoom: 1.5,
895
+ }}
896
+ nodesDraggable={true}
897
+ onNodeClick={onNodeClick}
898
+ nodeExtent={[
899
+ [-1000, -1000],
900
+ [3000, 3000],
901
+ ]}
902
+ key={`${lineageState.selectedProperty}-${sourcePropertiesMap.size}`}
903
+ >
904
+ <Background />
905
+ <MiniMap />
906
+ <Controls />
907
+ </ReactFlow>
908
+ </ReactFlowProvider>
909
+ </div>
910
+
911
+ {lineageState.selectedPropertyOwnerNode && (
912
+ <div style={{ width: '350px', borderLeft: '1px solid #ccc' }}>
913
+ <PropertyOwnerPanel
914
+ lineageState={lineageState}
915
+ selectedNodeId={lineageState.selectedPropertyOwnerNode}
916
+ />
917
+ </div>
918
+ )}
919
+ </div>
920
+ );
921
+ },
922
+ );
923
+
407
924
  // Graph Viewer Component
408
925
  const LineageGraphViewer = observer(
409
926
  (props: { nodes: ReactFlowNode[]; edges: ReactFlowEdge[] }) => {
@@ -435,12 +952,14 @@ const TAB_ORDER = [
435
952
  LINEAGE_VIEW_MODE.DATABASE_LINEAGE,
436
953
  LINEAGE_VIEW_MODE.CLASS_LINEAGE,
437
954
  LINEAGE_VIEW_MODE.REPORT_LINEAGE,
955
+ LINEAGE_VIEW_MODE.PROPERTY_LINEAGE,
438
956
  ];
439
957
 
440
958
  const TAB_LABELS: Record<LINEAGE_VIEW_MODE, string> = {
441
959
  [LINEAGE_VIEW_MODE.CLASS_LINEAGE]: 'Class Lineage',
442
960
  [LINEAGE_VIEW_MODE.DATABASE_LINEAGE]: 'Database Lineage',
443
961
  [LINEAGE_VIEW_MODE.REPORT_LINEAGE]: 'Report Lineage',
962
+ [LINEAGE_VIEW_MODE.PROPERTY_LINEAGE]: 'Property Lineage',
444
963
  };
445
964
 
446
965
  const LineageTabSelector = observer((props: { lineageState: LineageState }) => {
@@ -471,7 +990,6 @@ const LineageViewerContent = observer(
471
990
  const selectedTab = lineageState.selectedTab;
472
991
  const lineageData = lineageState.lineageData;
473
992
 
474
- // Prepare all three graphs
475
993
  const classLineageFlow = convertGraphToFlow(lineageData?.classLineage);
476
994
  const databaseLineageFlow = convertGraphToFlow(
477
995
  lineageData?.databaseLineage,
@@ -479,6 +997,10 @@ const LineageViewerContent = observer(
479
997
  const reportLineageFlow = convertReportLineageToFlow(
480
998
  lineageData?.reportLineage,
481
999
  );
1000
+ const propertyLineageFlow = convertPropertyLineageToFlow(
1001
+ lineageData?.propertyLineage,
1002
+ lineageState.selectedSourcePropertiesMap,
1003
+ );
482
1004
 
483
1005
  return (
484
1006
  <div
@@ -506,6 +1028,13 @@ const LineageViewerContent = observer(
506
1028
  edges={reportLineageFlow.edges}
507
1029
  />
508
1030
  )}
1031
+ {selectedTab === LINEAGE_VIEW_MODE.PROPERTY_LINEAGE && (
1032
+ <PropertyLineageGraphViewer
1033
+ lineageState={lineageState}
1034
+ nodes={propertyLineageFlow.nodes}
1035
+ edges={propertyLineageFlow.edges}
1036
+ />
1037
+ )}
509
1038
  </PanelContent>
510
1039
  </div>
511
1040
  </div>
@@ -520,6 +1049,7 @@ export const LineageViewer = observer(
520
1049
  const closePlanViewer = (): void => {
521
1050
  lineageState.setLineageData(undefined);
522
1051
  lineageState.setSelectedTab(LINEAGE_VIEW_MODE.DATABASE_LINEAGE);
1052
+ lineageState.clearPropertySelections();
523
1053
  };
524
1054
 
525
1055
  useEffect(() => {