Skip to content

Commit 6736fd0

Browse files
committed
Integrate entity relationships in flyout popover
1 parent 108bda4 commit 6736fd0

File tree

7 files changed

+278
-162
lines changed

7 files changed

+278
-162
lines changed

x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/filters/filter_store.ts

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,26 @@ export interface FilterToggleEvent {
2525
action: 'show' | 'hide';
2626
}
2727

28+
/**
29+
* Event emitted when an entity relationship toggle action is requested.
30+
* Components emit these events to expand/collapse entity relationships in the graph.
31+
* FilterStore instances subscribe and handle events for their scopeId.
32+
*/
33+
export interface EntityRelationshipEvent {
34+
scopeId: string;
35+
entityId: string;
36+
action: 'show' | 'hide';
37+
}
38+
2839
// Global event bus for filter toggle actions
2940
const filterToggleEvents$ = new Subject<FilterToggleEvent>();
3041

42+
// Global event bus for entity relationship toggle actions
43+
const entityRelationshipEvents$ = new Subject<EntityRelationshipEvent>();
44+
3145
// Store emitted events for testing purposes
3246
const emittedFilterEvents: FilterToggleEvent[] = [];
47+
const emittedEntityRelationshipEvents: EntityRelationshipEvent[] = [];
3348

3449
/**
3550
* Emit a filter toggle event. Any FilterStore listening for this scopeId
@@ -61,6 +76,40 @@ export const emitFilterToggle = (
6176
filterToggleEvents$.next(event);
6277
};
6378

79+
/**
80+
* Emit an entity relationship toggle event. Any FilterStore listening for this scopeId
81+
* will receive the event and update its expanded entity IDs state.
82+
*
83+
* @param scopeId - Unique identifier for the graph instance
84+
* @param entityId - The entity ID to expand/collapse
85+
* @param action - 'show' to expand, 'hide' to collapse
86+
*/
87+
export const emitEntityRelationshipToggle = (
88+
scopeId: string,
89+
entityId: string,
90+
action: 'show' | 'hide'
91+
): void => {
92+
const event: EntityRelationshipEvent = { scopeId, entityId, action };
93+
emittedEntityRelationshipEvents.push(event);
94+
entityRelationshipEvents$.next(event);
95+
};
96+
97+
/**
98+
* Check if an entity's relationships are expanded for the given scope.
99+
* Returns false gracefully if no store exists.
100+
*
101+
* @param scopeId - Unique identifier for the graph instance
102+
* @param entityId - The entity ID to check
103+
* @returns true if the entity's relationships are expanded
104+
*/
105+
export const isEntityRelationshipExpandedForScope = (
106+
scopeId: string,
107+
entityId: string
108+
): boolean => {
109+
const store = stores.get(scopeId);
110+
return store?.isEntityRelationshipExpanded(entityId) ?? false;
111+
};
112+
64113
/**
65114
* Check if a filter is active for the given scope, field, and value.
66115
* Returns false gracefully if no store exists (no warning logged).
@@ -95,6 +144,20 @@ export const __clearEmittedFilterEvents = (): void => {
95144
emittedFilterEvents.length = 0;
96145
};
97146

147+
/**
148+
* Get all emitted entity relationship events. Primarily for testing.
149+
*/
150+
export const __getEmittedEntityRelationshipEvents = (): EntityRelationshipEvent[] => {
151+
return [...emittedEntityRelationshipEvents];
152+
};
153+
154+
/**
155+
* Clear all emitted entity relationship events. Primarily for testing.
156+
*/
157+
export const __clearEmittedEntityRelationshipEvents = (): void => {
158+
emittedEntityRelationshipEvents.length = 0;
159+
};
160+
98161
// =============================================================================
99162
// FilterStore Class
100163
// =============================================================================
@@ -114,17 +177,26 @@ export class FilterStore {
114177
readonly scopeId: string;
115178
private dataViewId?: string;
116179
private readonly filters$ = new BehaviorSubject<Filter[]>([]);
117-
private readonly eventSubscription: Subscription;
180+
private readonly expandedEntityIds$ = new BehaviorSubject<Set<string>>(new Set());
181+
private readonly filterEventSubscription: Subscription;
182+
private readonly entityRelationshipEventSubscription: Subscription;
118183

119184
constructor(scopeId: string) {
120185
this.scopeId = scopeId;
121186

122187
// Subscribe to filter toggle events for this scopeId
123-
this.eventSubscription = filterToggleEvents$
188+
this.filterEventSubscription = filterToggleEvents$
124189
.pipe(rxFilter((event) => event.scopeId === this.scopeId))
125190
.subscribe((event) => {
126191
this.toggleFilter(event.field, event.value, event.action);
127192
});
193+
194+
// Subscribe to entity relationship toggle events for this scopeId
195+
this.entityRelationshipEventSubscription = entityRelationshipEvents$
196+
.pipe(rxFilter((event) => event.scopeId === this.scopeId))
197+
.subscribe((event) => {
198+
this.toggleEntityRelationship(event.entityId, event.action);
199+
});
128200
}
129201

130202
/**
@@ -183,20 +255,65 @@ export class FilterStore {
183255
return containsFilter(this.filters$.value, field, value);
184256
}
185257

258+
// ===========================================================================
259+
// Entity Relationship State
260+
// ===========================================================================
261+
262+
/**
263+
* Toggle an entity's relationship expansion state.
264+
* @param entityId - The entity ID to expand/collapse
265+
* @param action - 'show' to expand, 'hide' to collapse
266+
*/
267+
toggleEntityRelationship(entityId: string, action: 'show' | 'hide'): void {
268+
const next = new Set(this.expandedEntityIds$.value);
269+
if (action === 'show') {
270+
next.add(entityId);
271+
} else {
272+
next.delete(entityId);
273+
}
274+
this.expandedEntityIds$.next(next);
275+
}
276+
277+
/**
278+
* Check if an entity's relationships are currently expanded.
279+
*/
280+
isEntityRelationshipExpanded(entityId: string): boolean {
281+
return this.expandedEntityIds$.value.has(entityId);
282+
}
283+
284+
/**
285+
* Get the current set of expanded entity IDs.
286+
*/
287+
getExpandedEntityIds(): Set<string> {
288+
return this.expandedEntityIds$.value;
289+
}
290+
291+
/**
292+
* Subscribe to expanded entity IDs changes.
293+
* @param callback - Function called when expanded entity IDs change
294+
* @returns Subscription that should be unsubscribed on cleanup
295+
*/
296+
subscribeToExpandedEntityIds(callback: (expandedEntityIds: Set<string>) => void): Subscription {
297+
return this.expandedEntityIds$.subscribe(callback);
298+
}
299+
186300
/**
187301
* Reset the filter store to empty state.
188302
*/
189303
reset(): void {
190304
this.filters$.next([]);
305+
this.expandedEntityIds$.next(new Set());
191306
}
192307

193308
/**
194-
* Clean up the store by completing the BehaviorSubject and unsubscribing from events.
309+
* Clean up the store by completing the BehaviorSubjects and unsubscribing from events.
195310
* Called when the graph instance unmounts.
196311
*/
197312
destroy(): void {
198-
this.eventSubscription.unsubscribe();
313+
this.filterEventSubscription.unsubscribe();
314+
this.entityRelationshipEventSubscription.unsubscribe();
199315
this.filters$.complete();
316+
this.expandedEntityIds$.complete();
200317
}
201318
}
202319

x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/filters/use_graph_filters.ts

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,26 +7,40 @@
77

88
import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react';
99
import type { Filter } from '@kbn/es-query';
10-
import { getOrCreateFilterStore, destroyFilterStore } from './filter_store';
10+
import {
11+
getOrCreateFilterStore,
12+
destroyFilterStore,
13+
emitEntityRelationshipToggle,
14+
} from './filter_store';
15+
import type { NodeProps } from '../types';
1116

1217
/**
1318
* Hook that manages graph filter state for a specific scope.
1419
*
1520
* This hook:
1621
* 1. Creates or retrieves a FilterStore for the given scopeId
1722
* 2. Subscribes to filter changes from the store using useSyncExternalStore
18-
* 3. Automatically re-renders when filters change (from action buttons or SearchBar)
19-
* 4. Provides setSearchFilters for SearchBar's onFiltersUpdated callback
20-
* 5. Cleans up and destroys the store on unmount
23+
* 3. Subscribes to expanded entity IDs from the store using useSyncExternalStore
24+
* 4. Automatically re-renders when filters or entity relationships change
25+
* 5. Provides setSearchFilters for SearchBar's onFiltersUpdated callback
26+
* 6. Provides onToggleEntityRelationships for entity relationship toggle
27+
* 7. Computes entityIdsForApi from expandedEntityIds for graph data fetching
28+
* 8. Cleans up and destroys the store on unmount
2129
*
2230
* @param scopeId - Unique identifier for the graph instance
2331
* @param dataViewId - The data view ID used when constructing new filters
24-
* @returns Object containing searchFilters and setSearchFilters
32+
* @returns Object containing searchFilters, setSearchFilters, expandedEntityIds, onToggleEntityRelationships, entityIdsForApi
2533
*/
2634
export const useGraphFilters = (
2735
scopeId: string,
2836
dataViewId: string
29-
): { searchFilters: Filter[]; setSearchFilters: (filters: Filter[]) => void } => {
37+
): {
38+
searchFilters: Filter[];
39+
setSearchFilters: (filters: Filter[]) => void;
40+
expandedEntityIds: Set<string>;
41+
onToggleEntityRelationships: (node: NodeProps, action: 'show' | 'hide') => void;
42+
entityIdsForApi: Array<{ id: string; isOrigin: boolean }> | undefined;
43+
} => {
3044
// Get or create the FilterStore for this scopeId
3145
const store = useMemo(() => getOrCreateFilterStore(scopeId), [scopeId]);
3246

@@ -42,7 +56,7 @@ export const useGraphFilters = (
4256
};
4357
}, [scopeId]);
4458

45-
// Subscribe function for useSyncExternalStore
59+
// Subscribe function for useSyncExternalStore (filters)
4660
const subscribe = useCallback(
4761
(onStoreChange: () => void) => {
4862
const subscription = store.subscribe(onStoreChange);
@@ -51,12 +65,30 @@ export const useGraphFilters = (
5165
[store]
5266
);
5367

54-
// Snapshot function for useSyncExternalStore
68+
// Snapshot function for useSyncExternalStore (filters)
5569
const getSnapshot = useCallback(() => store.getFilters(), [store]);
5670

5771
// Use React 18's useSyncExternalStore for optimal concurrent rendering support
5872
const searchFilters = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
5973

74+
// Subscribe function for useSyncExternalStore (expanded entity IDs)
75+
const subscribeToExpandedEntityIds = useCallback(
76+
(onStoreChange: () => void) => {
77+
const subscription = store.subscribeToExpandedEntityIds(onStoreChange);
78+
return () => subscription.unsubscribe();
79+
},
80+
[store]
81+
);
82+
83+
// Snapshot function for useSyncExternalStore (expanded entity IDs)
84+
const getExpandedEntityIdsSnapshot = useCallback(() => store.getExpandedEntityIds(), [store]);
85+
86+
const expandedEntityIds = useSyncExternalStore(
87+
subscribeToExpandedEntityIds,
88+
getExpandedEntityIdsSnapshot,
89+
getExpandedEntityIdsSnapshot
90+
);
91+
6092
// Callback for SearchBar's onFiltersUpdated - sets filters directly in store
6193
const setSearchFilters = useCallback(
6294
(filters: Filter[]) => {
@@ -65,5 +97,29 @@ export const useGraphFilters = (
6597
[store]
6698
);
6799

68-
return { searchFilters, setSearchFilters };
100+
// Toggle handler for entity relationships - emits event to event bus
101+
const onToggleEntityRelationships = useCallback(
102+
(node: NodeProps, action: 'show' | 'hide') => {
103+
emitEntityRelationshipToggle(scopeId, node.id, action);
104+
},
105+
[scopeId]
106+
);
107+
108+
// Convert expandedEntityIds Set to API format
109+
const entityIdsForApi = useMemo(() => {
110+
if (expandedEntityIds.size === 0) return undefined;
111+
112+
return Array.from(expandedEntityIds).map((id) => ({
113+
id,
114+
isOrigin: false, // User-expanded entities are not the graph origin
115+
}));
116+
}, [expandedEntityIds]);
117+
118+
return {
119+
searchFilters,
120+
setSearchFilters,
121+
expandedEntityIds,
122+
onToggleEntityRelationships,
123+
entityIdsForApi,
124+
};
69125
};

x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/components/grouped_item/parts/entity_actions_button.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@ import {
1616
} from '../../../test_ids';
1717
import type { EntityItem } from '../types';
1818
import { getEntityExpandItems } from '../../../../popovers/node_expand/get_entity_expand_items';
19-
import { emitFilterToggle, isFilterActiveForScope } from '../../../../filters/filter_store';
19+
import {
20+
emitFilterToggle,
21+
isFilterActiveForScope,
22+
emitEntityRelationshipToggle,
23+
isEntityRelationshipExpandedForScope,
24+
} from '../../../../filters/filter_store';
2025
import { GenericEntityPanelKey, GENERIC_ENTITY_PREVIEW_BANNER } from '../../../constants';
2126

2227
const actionsButtonAriaLabel = i18n.translate(
@@ -69,11 +74,15 @@ export const EntityActionsButton = ({ item, scopeId }: EntityActionsButtonProps)
6974
isFilterActive: (field, value) => isFilterActiveForScope(scopeId, field, value),
7075
toggleFilter: (field, value, action) => emitFilterToggle(scopeId, field, value, action),
7176
shouldRender: {
77+
showEntityRelationships: true,
7278
showActionsByEntity: true,
7379
showActionsOnEntity: true,
7480
showRelatedEvents: true,
7581
showEntityDetails: !!item.availableInEntityStore,
7682
},
83+
isEntityRelationshipsExpanded: isEntityRelationshipExpandedForScope(scopeId, item.id),
84+
toggleEntityRelationships: (action) => emitEntityRelationshipToggle(scopeId, item.id, action),
85+
showEntityRelationshipsDisabled: !item.availableInEntityStore,
7786
});
7887

7988
return (

0 commit comments

Comments
 (0)