Skip to content

Commit 1504c3d

Browse files
MAR-2236: Add toggle to hide/show hypernodes
1 parent 753ecb1 commit 1504c3d

9 files changed

Lines changed: 106 additions & 16 deletions

File tree

packages/app-builder/src/components/Graph/GraphImpl.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ function personRefFromNodeId(nodes: GraphRfNode[], key: string): GraphObjectRef
5656
export function GraphImpl({ data, dataModel }: GraphImplProps) {
5757
const { t } = useTranslation(graphI18n);
5858
const theme = useTheme();
59-
const { showEdgeLabels, setShowEdgeLabels } = useGraphViewSettings();
59+
const { showEdgeLabels, setShowEdgeLabels, hideHypernodes } = useGraphViewSettings();
6060
const selectedObject = useSelectedObject();
6161
const setSelectedObject = useSetSelectedObject();
6262
const { hiddenNodeIds } = useGraphStructure();
@@ -75,7 +75,7 @@ export function GraphImpl({ data, dataModel }: GraphImplProps) {
7575
// this component and never see the node arrays.
7676
const graphStats = useMemo(() => {
7777
const countWith = (hidden: Set<string>) =>
78-
applyVisibilityFilters(flatGraph.nodes, flatGraph.edges, hidden, flatGraph.startKey).nodes.length;
78+
applyVisibilityFilters(flatGraph.nodes, flatGraph.edges, hidden, flatGraph.startKey, hideHypernodes).nodes.length;
7979

8080
const unhiddenCount = hiddenNodeIds.size === 0 ? visibleGraph.nodes.length : countWith(new Set());
8181
const hiddenCount = unhiddenCount - visibleGraph.nodes.length;
@@ -84,7 +84,7 @@ export function GraphImpl({ data, dataModel }: GraphImplProps) {
8484
const withChecked = countWith(new Set([...hiddenNodeIds, ...checkedNodeIds]));
8585
const removed = visibleGraph.nodes.length - withChecked;
8686
return { hiddenCount, hidePreviewOrphans: Math.max(0, removed - checkedNodeIds.size) };
87-
}, [flatGraph, hiddenNodeIds, checkedNodeIds, visibleGraph]);
87+
}, [flatGraph, hiddenNodeIds, checkedNodeIds, visibleGraph, hideHypernodes]);
8888

8989
useEffect(() => {
9090
setGraphStats(graphStats);
@@ -115,6 +115,21 @@ export function GraphImpl({ data, dataModel }: GraphImplProps) {
115115
setSelectedObject({ ...selectedObject, persons });
116116
}, [connectedPersonsForNode, nodes, selectedObject, setSelectedObject]);
117117

118+
useEffect(() => {
119+
if (!hideHypernodes || selectedObject?.nodeType !== 'hypernode') return;
120+
121+
const startNode = flatGraph.nodes.find(
122+
(n): n is Extract<GraphRfNode, { type: 'person' }> => n.id === flatGraph.startKey && n.type === 'person',
123+
);
124+
if (!startNode) return;
125+
126+
setSelectedObject({
127+
nodeType: 'person',
128+
...personRefFromRfNode(startNode),
129+
persons: connectedPersonsForNode(startNode.id),
130+
});
131+
}, [hideHypernodes, selectedObject, flatGraph, connectedPersonsForNode, setSelectedObject]);
132+
118133
const onNodeClick = useCallback<NodeMouseHandler<GraphRfNode>>(
119134
(_event, node) => {
120135
if (node.type === 'person') {

packages/app-builder/src/components/Graph/GraphSettingsPanel.tsx

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@ import { LAYOUT_NAMES } from 'ego-graph';
1313
import { type ReactNode, useMemo, useRef, useState } from 'react';
1414
import { useTranslation } from 'react-i18next';
1515
import { match, P } from 'ts-pattern';
16-
import { Button, cn, MenuCommand, Switch, Tag, ThresholdRange } from 'ui-design-system';
16+
import { Button, Checkbox, cn, MenuCommand, Switch, Tag, ThresholdRange } from 'ui-design-system';
1717
import { Icon, type IconName } from 'ui-icons';
1818
import { useGraphAnnotationsActions } from './contexts/GraphAnnotationsContext';
1919
import { useSelectedObject, useSetSelectedObject } from './contexts/GraphFocusContext';
20-
import { useGraphInteractionActions, useSelectionMode } from './contexts/GraphInteractionContext';
20+
import { useGraphInteractionActions, useIsNodeChecked, useSelectionMode } from './contexts/GraphInteractionContext';
2121
import { useGraphSession } from './contexts/GraphSessionContext';
2222
import { useGraphStats } from './contexts/GraphStatsContext';
2323
import { useGraphStructureActions } from './contexts/GraphStructureContext';
@@ -229,24 +229,36 @@ function PersonRow({ person, showTags }: { person: GraphObjectRef; showTags: boo
229229
const setSelectedObject = useSetSelectedObject();
230230
const { hoverNode } = useGraphInteractionActions();
231231
const selectionMode = useSelectionMode();
232+
const nk = nodeKey(person.objectType, person.objectId);
233+
const isNodeChecked = useIsNodeChecked(nk);
234+
const { toggleCheckedNode } = useGraphInteractionActions();
235+
232236
const title = resolveTitle(person.label, person.objectId);
233237
const id = nodeKey(person.objectType, person.objectId);
234238

239+
function handleClick() {
240+
if (selectionMode) return;
241+
setSelectedObject({ ...person, nodeType: 'person', persons: [person] });
242+
}
243+
235244
return (
236245
<li
237246
className="-mx-xs rounded-sm px-xs py-2xs transition-colors border border-transparent hover:bg-purple-background-light hover:border hover:border-purple-border cursor-pointer"
238247
onMouseEnter={() => {
239248
if (!selectionMode) hoverNode(id);
240249
}}
241250
onMouseLeave={() => hoverNode(null)}
242-
onClick={() => setSelectedObject({ ...person, nodeType: 'person', persons: [person] })}
251+
onClick={handleClick}
243252
>
244253
<div className="flex flex-col gap-xs">
245-
<div className="flex flex-wrap items-center gap-sm">
254+
<label className="flex flex-wrap items-center gap-sm" htmlFor={nk}>
255+
{selectionMode && (
256+
<Checkbox size="small" checked={isNodeChecked} onCheckedChange={() => toggleCheckedNode(nk)} id={nk} />
257+
)}
246258
<Icon icon={subEntityIcon(person)} className="size-4 shrink-0 text-purple-primary" />
247259
<span className="text-sm">{title}</span>
248260
<ObjectRiskBadge {...person} />
249-
</div>
261+
</label>
250262
{showTags ? <ObjectTags {...person} /> : null}
251263
</div>
252264
</li>
@@ -314,7 +326,8 @@ export function GraphSettingsPanel() {
314326
refreshGraph,
315327
isGeneratingGraph,
316328
} = useGraphSession();
317-
const { showRiskScore, setShowRiskScore, showTags, setShowTags } = useGraphViewSettings();
329+
const { showRiskScore, setShowRiskScore, showTags, setShowTags, hideHypernodes, setHideHypernodes } =
330+
useGraphViewSettings();
318331
const selectedObject = useSelectedObject();
319332
const { restoreHiddenNodes } = useGraphStructureActions();
320333
const { setNodeTagIds } = useGraphAnnotationsActions();
@@ -484,6 +497,12 @@ export function GraphSettingsPanel() {
484497
</label>
485498
<Switch id="show-tags" checked={showTags} onCheckedChange={setShowTags} />
486499
</div>
500+
<div className="flex items-center justify-between gap-sm">
501+
<label htmlFor="hide-hyper-connected-nodes" className="text-grey-primary cursor-pointer text-sm">
502+
{t('graph:panel.hide_hyper_connected_nodes')}
503+
</label>
504+
<Switch id="hide-hyper-connected-nodes" checked={hideHypernodes} onCheckedChange={setHideHypernodes} />
505+
</div>
487506
<ClusterThresholdControl />
488507
</div>
489508
</aside>

packages/app-builder/src/components/Graph/contexts/GraphSessionContext.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export function GraphSessionProvider({
8585
);
8686
const [showRiskScore, setShowRiskScore] = useState(true);
8787
const [showTags, setShowTags] = useState(false);
88+
const [hideHypernodes, setHideHypernodes] = useState(false);
8889
const [showEdgeLabels, setShowEdgeLabels] = useState(false);
8990
const [clusterThreshold, setClusterThreshold] = useState<ClusterThreshold>(DEFAULT_CLUSTER_THRESHOLD);
9091
const [layoutMode, setLayoutMode] = useState<GraphLayoutMode>('polarPetal');
@@ -161,14 +162,16 @@ export function GraphSessionProvider({
161162
onShowRiskScoreChange: setShowRiskScore,
162163
showTags,
163164
onShowTagsChange: setShowTags,
165+
hideHypernodes,
166+
onHideHypernodesChange: setHideHypernodes,
164167
showEdgeLabels,
165168
onShowEdgeLabelsChange: setShowEdgeLabels,
166169
clusterThreshold,
167170
onClusterThresholdChange: setClusterThreshold,
168171
layoutMode,
169172
onLayoutModeChange: setLayoutMode,
170173
}),
171-
[showRiskScore, showTags, showEdgeLabels, clusterThreshold, layoutMode],
174+
[showRiskScore, showTags, hideHypernodes, showEdgeLabels, clusterThreshold, layoutMode],
172175
);
173176

174177
const value = useMemo(

packages/app-builder/src/components/Graph/contexts/GraphViewSettingsContext.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export type ControlledGraphSettings = {
1919
onShowRiskScoreChange: (value: boolean) => void;
2020
showTags: boolean;
2121
onShowTagsChange: (value: boolean) => void;
22+
hideHypernodes: boolean;
23+
onHideHypernodesChange: (value: boolean) => void;
2224
showEdgeLabels: boolean;
2325
onShowEdgeLabelsChange: (value: boolean) => void;
2426
clusterThreshold: ClusterThreshold;
@@ -40,6 +42,8 @@ export type GraphViewSettings = {
4042
maxRiskLevel: MaxRiskLevel | undefined;
4143
showTags: boolean;
4244
setShowTags: (value: boolean) => void;
45+
hideHypernodes: boolean;
46+
setHideHypernodes: (value: boolean) => void;
4347
showEdgeLabels: boolean;
4448
setShowEdgeLabels: (value: boolean) => void;
4549
layoutMode: GraphLayoutMode;
@@ -59,6 +63,8 @@ export function GraphViewSettingsProvider({
5963
onShowRiskScoreChange,
6064
showTags: controlledShowTags,
6165
onShowTagsChange,
66+
hideHypernodes: controlledHideHypernodes,
67+
onHideHypernodesChange,
6268
showEdgeLabels: controlledShowEdgeLabels,
6369
onShowEdgeLabelsChange,
6470
clusterThreshold: controlledClusterThreshold,
@@ -72,6 +78,11 @@ export function GraphViewSettingsProvider({
7278
const rawMaxRiskLevel = scoringSettings?.maxRiskLevel;
7379
const maxRiskLevel = rawMaxRiskLevel != null && isMaxRiskLevelInRange(rawMaxRiskLevel) ? rawMaxRiskLevel : undefined;
7480
const [showTags, setShowTags] = useControllableState(false, controlledShowTags, onShowTagsChange);
81+
const [hideHypernodes, setHideHypernodes] = useControllableState(
82+
false,
83+
controlledHideHypernodes,
84+
onHideHypernodesChange,
85+
);
7586
const [showEdgeLabels, setShowEdgeLabels] = useControllableState(
7687
false,
7788
controlledShowEdgeLabels,
@@ -96,6 +107,8 @@ export function GraphViewSettingsProvider({
96107
maxRiskLevel,
97108
showTags,
98109
setShowTags,
110+
hideHypernodes,
111+
setHideHypernodes,
99112
showEdgeLabels,
100113
setShowEdgeLabels,
101114
layoutMode,
@@ -110,6 +123,8 @@ export function GraphViewSettingsProvider({
110123
maxRiskLevel,
111124
showTags,
112125
setShowTags,
126+
hideHypernodes,
127+
setHideHypernodes,
113128
showEdgeLabels,
114129
setShowEdgeLabels,
115130
layoutMode,

packages/app-builder/src/components/Graph/lib/use-laid-out-graph.spec.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ function edge(source: string, target: string): GraphRfEdge {
2323
return { id: `${source}->${target}`, source, target, type: 'link', data: { kind: 'link' } };
2424
}
2525

26+
function hypernode(id: string): GraphRfNode {
27+
return {
28+
id,
29+
position: { x: 0, y: 0 },
30+
type: 'hypernode',
31+
data: { count: 100, objectType: 'accounts', objectId: id },
32+
};
33+
}
34+
2635
describe('applyVisibilityFilters', () => {
2736
it('keeps the start node even when it is in the hidden set', () => {
2837
const visible = applyVisibilityFilters(
@@ -46,4 +55,28 @@ describe('applyVisibilityFilters', () => {
4655
expect(visible.nodes.map((node) => node.id)).toEqual(['start']);
4756
expect(visible.edges).toEqual([]);
4857
});
58+
59+
it('drops hypernodes and their edges when hideHypernodes is set', () => {
60+
const visible = applyVisibilityFilters(
61+
[person('start', true), hypernode('h1'), person('a')],
62+
[edge('start', 'h1'), edge('start', 'a')],
63+
new Set(),
64+
'start',
65+
true,
66+
);
67+
68+
expect(visible.nodes.map((node) => node.id)).toEqual(['start', 'a']);
69+
expect(visible.edges.map((item) => item.id)).toEqual(['start->a']);
70+
});
71+
72+
it('keeps hypernodes when hideHypernodes is unset', () => {
73+
const visible = applyVisibilityFilters(
74+
[person('start', true), hypernode('h1')],
75+
[edge('start', 'h1')],
76+
new Set(),
77+
'start',
78+
);
79+
80+
expect(visible.nodes.map((node) => node.id)).toEqual(['start', 'h1']);
81+
});
4982
});

packages/app-builder/src/components/Graph/lib/use-laid-out-graph.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@ function resolveStartKey(nodes: GraphRfNode[], fallback: string): string {
3030
return start?.id ?? fallback;
3131
}
3232

33-
function isNodeVisible(node: GraphRfNode, hiddenNodeIds: Set<string>): boolean {
33+
function isNodeVisible(node: GraphRfNode, hiddenNodeIds: Set<string>, hideHypernodes: boolean) {
3434
if (node.type === 'person' && node.data.isStart) return true;
35+
if (hideHypernodes && node.type === 'hypernode') return false;
3536
return !hiddenNodeIds.has(node.id);
3637
}
3738

@@ -40,8 +41,9 @@ export function applyVisibilityFilters(
4041
edges: GraphRfEdge[],
4142
hiddenNodeIds: Set<string>,
4243
startKey: string,
43-
): { nodes: GraphRfNode[]; edges: GraphRfEdge[] } {
44-
const typeVisibleNodes = nodes.filter((node) => isNodeVisible(node, hiddenNodeIds));
44+
hideHypernodes = false,
45+
) {
46+
const typeVisibleNodes = nodes.filter((node) => isNodeVisible(node, hiddenNodeIds, hideHypernodes));
4547
const typeVisibleIds = new Set(typeVisibleNodes.map((node) => node.id));
4648
const typeVisibleEdges = edges.filter((edge) => typeVisibleIds.has(edge.source) && typeVisibleIds.has(edge.target));
4749

@@ -60,15 +62,15 @@ export function applyVisibilityFilters(
6062

6163
export function useLaidOutGraph({ data, dataModel }: { data: GraphData; dataModel: DataModel }) {
6264
const { hiddenNodeIds, expandedRootIds } = useGraphStructure();
63-
const { clusterThreshold, layoutMode } = useGraphViewSettings();
65+
const { clusterThreshold, layoutMode, hideHypernodes } = useGraphViewSettings();
6466

6567
const typeHelpers = useMemo(() => createGraphTypeHelpers(dataModel), [dataModel]);
6668

6769
const flatGraph = useMemo(() => toFlatFlowElements(data, typeHelpers), [data, typeHelpers]);
6870

6971
const visibleGraph = useMemo(
70-
() => applyVisibilityFilters(flatGraph.nodes, flatGraph.edges, hiddenNodeIds, flatGraph.startKey),
71-
[flatGraph, hiddenNodeIds],
72+
() => applyVisibilityFilters(flatGraph.nodes, flatGraph.edges, hiddenNodeIds, flatGraph.startKey, hideHypernodes),
73+
[flatGraph, hiddenNodeIds, hideHypernodes],
7274
);
7375

7476
const filteredLayout = useMemo(() => {

packages/app-builder/src/locales/ar/graph.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"node.too_many": "هذا الجزء من الرسم البياني متصل بشكل مفرط وتم تجاهله ≈ {{count}} عقد تم تجاهلها",
3434
"panel.connected_nodes": "العقد المتصلة",
3535
"panel.grouped_branch": "فرع مجمّع",
36+
"panel.hide_hyper_connected_nodes": "إخفاء العقد فائقة الاتصال",
3637
"panel.hypernode": "عقدة فائقة",
3738
"panel.items_one": "عنصر {{count}}",
3839
"panel.items_other": "{{count}} عناصر",

packages/app-builder/src/locales/en/graph.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"node.too_many": "This part of the graph is hyperconnected and has been discarded ≈ {{count}} nodes ignored",
2626
"panel.connected_nodes": "Connected nodes",
2727
"panel.grouped_branch": "Grouped branch",
28+
"panel.hide_hyper_connected_nodes": "Hide hyper connected nodes",
2829
"panel.hypernode": "Hypernode",
2930
"panel.items_one": "{{count}} item",
3031
"panel.items_other": "{{count}} items",

packages/app-builder/src/locales/fr/graph.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"node.too_many": "Cette partie du graphe est hyperconnectée et a été ignorée ≈ {{count}} nœuds ignorés",
2626
"panel.connected_nodes": "Nœuds connectés",
2727
"panel.grouped_branch": "Branche regroupée",
28+
"panel.hide_hyper_connected_nodes": "Masquer les nœuds hyperconnectés",
2829
"panel.hypernode": "Hypernœud",
2930
"panel.items_one": "{{count}} élément",
3031
"panel.items_other": "{{count}} éléments",

0 commit comments

Comments
 (0)