From 649536c387aa04e5eeb6be613cefed1344c0a62a Mon Sep 17 00:00:00 2001 From: Kevin Qualters <56408403+kqualters-elastic@users.noreply.github.com> Date: Tue, 6 Oct 2020 00:07:27 -0400 Subject: [PATCH] [7.x] [Security Solution][Resolver] Resolver query panel load more (#79160) (#79603) --- .../resolver/data_access_layer/factory.ts | 2 +- .../one_node_with_paginated_related_events.ts | 110 ++++++++++++++++++ .../public/resolver/mocks/resolver_tree.ts | 41 +++++++ .../public/resolver/store/data/action.ts | 24 ++++ .../data/node_events_in_category_model.ts | 1 + .../public/resolver/store/data/reducer.ts | 28 ++++- .../public/resolver/store/data/selectors.ts | 73 +++++++++++- .../middleware/related_events_fetcher.ts | 87 +++++++++----- .../public/resolver/store/selectors.ts | 31 +++++ .../public/resolver/types.ts | 11 ++ .../view/panels/node_events_of_type.test.tsx | 98 ++++++++++++++++ .../view/panels/node_events_of_type.tsx | 89 +++++++++++--- 12 files changed, 545 insertions(+), 50 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_node_with_paginated_related_events.ts create mode 100644 x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.test.tsx diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts index ae07104fa0e22..66dc7b98168ea 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts @@ -50,7 +50,7 @@ export function dataAccessLayerFactory( after?: string ): Promise { return context.services.http.post('/api/endpoint/resolver/events', { - query: { afterEvent: after }, + query: { afterEvent: after, limit: 25 }, body: JSON.stringify({ filter: `process.entity_id:"${entityID}" and event.category:"${category}"`, }), diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_node_with_paginated_related_events.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_node_with_paginated_related_events.ts new file mode 100644 index 0000000000000..01477ff16868e --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_node_with_paginated_related_events.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DataAccessLayer } from '../../types'; +import { mockTreeWithOneNodeAndTwoPagesOfRelatedEvents } from '../../mocks/resolver_tree'; +import { + ResolverRelatedEvents, + ResolverTree, + ResolverEntityIndex, + SafeResolverEvent, +} from '../../../../common/endpoint/types'; +import * as eventModel from '../../../../common/endpoint/models/event'; + +interface Metadata { + /** + * The `_id` of the document being analyzed. + */ + databaseDocumentID: string; + /** + * A record of entityIDs to be used in tests assertions. + */ + entityIDs: { + /** + * The entityID of the node related to the document being analyzed. + */ + origin: 'origin'; + }; +} +export function oneNodeWithPaginatedEvents(): { + dataAccessLayer: DataAccessLayer; + metadata: Metadata; +} { + const metadata: Metadata = { + databaseDocumentID: '_id', + entityIDs: { origin: 'origin' }, + }; + const tree = mockTreeWithOneNodeAndTwoPagesOfRelatedEvents({ + originID: metadata.entityIDs.origin, + }); + + return { + metadata, + dataAccessLayer: { + /** + * Fetch related events for an entity ID + */ + async relatedEvents(entityID: string): Promise { + /** + * Respond with the mocked related events when the origin's related events are fetched. + **/ + const events = entityID === metadata.entityIDs.origin ? tree.relatedEvents.events : []; + + return { + entityID, + events, + nextEvent: null, + }; + }, + + /** + * If called with an "after" cursor, return the 2nd page, else return the first. + */ + async eventsWithEntityIDAndCategory( + entityID: string, + category: string, + after?: string + ): Promise<{ events: SafeResolverEvent[]; nextEvent: string | null }> { + let events: SafeResolverEvent[] = []; + const eventsOfCategory = tree.relatedEvents.events.filter( + (event) => event.event?.category === category + ); + if (after === undefined) { + events = eventsOfCategory.slice(0, 25); + } else { + events = eventsOfCategory.slice(25); + } + return { + events, + nextEvent: typeof after === 'undefined' ? 'firstEventPage2' : null, + }; + }, + + /** + * Any of the origin's related events by event.id + */ + async event(eventID: string): Promise { + return ( + tree.relatedEvents.events.find((event) => eventModel.eventID(event) === eventID) ?? null + ); + }, + + /** + * Fetch a ResolverTree for a entityID + */ + async resolverTree(): Promise { + return tree; + }, + + /** + * Get entities matching a document. + */ + async entities(): Promise { + return [{ entity_id: metadata.entityIDs.origin }]; + }, + }, + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/mocks/resolver_tree.ts b/x-pack/plugins/security_solution/public/resolver/mocks/resolver_tree.ts index 5b851d588543d..50cc7eaa378ea 100644 --- a/x-pack/plugins/security_solution/public/resolver/mocks/resolver_tree.ts +++ b/x-pack/plugins/security_solution/public/resolver/mocks/resolver_tree.ts @@ -8,6 +8,47 @@ import { mockEndpointEvent } from './endpoint_event'; import { ResolverTree, SafeResolverEvent } from '../../../common/endpoint/types'; import * as eventModel from '../../../common/endpoint/models/event'; +export function mockTreeWithOneNodeAndTwoPagesOfRelatedEvents({ + originID, +}: { + originID: string; +}): ResolverTree { + const originEvent: SafeResolverEvent = mockEndpointEvent({ + entityID: originID, + processName: 'c', + parentEntityID: undefined, + timestamp: 1600863932318, + }); + const events = []; + // page size is currently 25 + const eventsToGenerate = 30; + for (let i = 0; i < eventsToGenerate; i++) { + const newEvent = mockEndpointEvent({ + entityID: originID, + eventID: `test-${i}`, + eventType: 'access', + eventCategory: 'registry', + timestamp: 1600863932318, + }); + events.push(newEvent); + } + return { + entityID: originID, + children: { + childNodes: [], + nextChild: null, + }, + ancestry: { + nextAncestor: null, + ancestors: [], + }, + lifecycle: [originEvent], + relatedEvents: { events, nextEvent: null }, + relatedAlerts: { alerts: [], nextAlert: null }, + stats: { events: { total: eventsToGenerate, byCategory: {} }, totalAlerts: 0 }, + }; +} + export function mockTreeWith2AncestorsAndNoChildren({ originID, firstAncestorID, diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/action.ts b/x-pack/plugins/security_solution/public/resolver/store/data/action.ts index 40a103ac6add7..35a1e14a66625 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/action.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/action.ts @@ -34,6 +34,28 @@ interface AppRequestedResolverData { readonly payload: TreeFetcherParameters; } +interface UserRequestedAdditionalRelatedEvents { + readonly type: 'userRequestedAdditionalRelatedEvents'; +} + +interface ServerFailedToReturnNodeEventsInCategory { + readonly type: 'serverFailedToReturnNodeEventsInCategory'; + readonly payload: { + /** + * The cursor, if any, that can be used to retrieve more events. + */ + cursor: string | null; + /** + * The nodeID that `events` are related to. + */ + nodeID: string; + /** + * The category that `events` have in common. + */ + eventCategory: string; + }; +} + interface ServerFailedToReturnResolverData { readonly type: 'serverFailedToReturnResolverData'; /** @@ -101,4 +123,6 @@ export type DataAction = | ServerReturnedRelatedEventData | ServerReturnedNodeEventsInCategory | AppRequestedResolverData + | UserRequestedAdditionalRelatedEvents + | ServerFailedToReturnNodeEventsInCategory | AppAbortedResolverDataRequest; diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/node_events_in_category_model.ts b/x-pack/plugins/security_solution/public/resolver/store/data/node_events_in_category_model.ts index b834671458d6b..d10edf64dcd35 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/node_events_in_category_model.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/node_events_in_category_model.ts @@ -39,6 +39,7 @@ export function updatedWith( eventCategory: first.eventCategory, events: [...first.events, ...second.events], cursor: second.cursor, + lastCursorRequested: null, }; } else { return undefined; diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts index 7760bda19ff07..b91cf5b59ce21 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts @@ -19,7 +19,7 @@ const initialState: DataState = { relatedEvents: new Map(), resolverComponentInstanceID: undefined, }; - +/* eslint-disable complexity */ export const dataReducer: Reducer = (state = initialState, action) => { if (action.type === 'appReceivedNewExternalProperties') { const nextState: DataState = { @@ -157,6 +157,32 @@ export const dataReducer: Reducer = (state = initialS // the action is stale, ignore it return state; } + } else if (action.type === 'userRequestedAdditionalRelatedEvents') { + if (state.nodeEventsInCategory) { + const nextState: DataState = { + ...state, + nodeEventsInCategory: { + ...state.nodeEventsInCategory, + lastCursorRequested: state.nodeEventsInCategory?.cursor, + }, + }; + return nextState; + } else { + return state; + } + } else if (action.type === 'serverFailedToReturnNodeEventsInCategory') { + if (state.nodeEventsInCategory) { + const nextState: DataState = { + ...state, + nodeEventsInCategory: { + ...state.nodeEventsInCategory, + error: true, + }, + }; + return nextState; + } else { + return state; + } } else if (action.type === 'appRequestedCurrentRelatedEventData') { const nextState: DataState = { ...state, diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index 8e06b26b5c316..5eb920ca835f4 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -20,7 +20,7 @@ import { import { isGraphableProcess, isTerminatedProcess } from '../../models/process_event'; import * as indexedProcessTreeModel from '../../models/indexed_process_tree'; import * as eventModel from '../../../../common/endpoint/models/event'; - +import * as nodeEventsInCategoryModel from './node_events_in_category_model'; import { ResolverTree, ResolverNodeStats, @@ -665,3 +665,74 @@ export const panelViewAndParameters = createSelector( export const nodeEventsInCategory = (state: DataState) => { return state.nodeEventsInCategory?.events ?? []; }; + +export const lastRelatedEventResponseContainsCursor = createSelector( + (state: DataState) => state.nodeEventsInCategory, + panelViewAndParameters, + /* eslint-disable-next-line no-shadow */ + function (nodeEventsInCategory, panelViewAndParameters) { + if ( + nodeEventsInCategory !== undefined && + nodeEventsInCategoryModel.isRelevantToPanelViewAndParameters( + nodeEventsInCategory, + panelViewAndParameters + ) + ) { + return nodeEventsInCategory.cursor !== null; + } else { + return false; + } + } +); + +export const hadErrorLoadingNodeEventsInCategory = createSelector( + (state: DataState) => state.nodeEventsInCategory, + panelViewAndParameters, + /* eslint-disable-next-line no-shadow */ + function (nodeEventsInCategory, panelViewAndParameters) { + if ( + nodeEventsInCategory !== undefined && + nodeEventsInCategoryModel.isRelevantToPanelViewAndParameters( + nodeEventsInCategory, + panelViewAndParameters + ) + ) { + return nodeEventsInCategory && nodeEventsInCategory.error === true; + } else { + return false; + } + } +); + +export const isLoadingNodeEventsInCategory = createSelector( + (state: DataState) => state.nodeEventsInCategory, + panelViewAndParameters, + /* eslint-disable-next-line no-shadow */ + function (nodeEventsInCategory, panelViewAndParameters) { + const { panelView } = panelViewAndParameters; + return panelView === 'nodeEventsInCategory' && nodeEventsInCategory === undefined; + } +); + +export const isLoadingMoreNodeEventsInCategory = createSelector( + (state: DataState) => state.nodeEventsInCategory, + panelViewAndParameters, + /* eslint-disable-next-line no-shadow */ + function (nodeEventsInCategory, panelViewAndParameters) { + if ( + nodeEventsInCategory !== undefined && + nodeEventsInCategoryModel.isRelevantToPanelViewAndParameters( + nodeEventsInCategory, + panelViewAndParameters + ) + ) { + return ( + nodeEventsInCategory && + nodeEventsInCategory.lastCursorRequested !== null && + nodeEventsInCategory.cursor === nodeEventsInCategory.lastCursorRequested + ); + } else { + return false; + } + } +); diff --git a/x-pack/plugins/security_solution/public/resolver/store/middleware/related_events_fetcher.ts b/x-pack/plugins/security_solution/public/resolver/store/middleware/related_events_fetcher.ts index 0b0a469a047c3..6d054a20b856d 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/middleware/related_events_fetcher.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/middleware/related_events_fetcher.ts @@ -6,7 +6,7 @@ import { Dispatch, MiddlewareAPI } from 'redux'; import { isEqual } from 'lodash'; -import { ResolverPaginatedEvents, ResolverRelatedEvents } from '../../../../common/endpoint/types'; +import { ResolverPaginatedEvents } from '../../../../common/endpoint/types'; import { ResolverState, DataAccessLayer, PanelViewAndParameters } from '../../types'; import * as selectors from '../selectors'; @@ -25,46 +25,69 @@ export function RelatedEventsFetcher( const state = api.getState(); const newParams = selectors.panelViewAndParameters(state); + const isLoadingMoreEvents = selectors.isLoadingMoreNodeEventsInCategory(state); const oldParams = last; // Update this each time before fetching data (or even if we don't fetch data) so that subsequent actions that call this (concurrently) will have up to date info. last = newParams; + async function fetchEvents({ + nodeID, + eventCategory, + cursor, + }: { + nodeID: string; + eventCategory: string; + cursor: string | null; + }) { + let result: ResolverPaginatedEvents | null = null; + try { + if (cursor) { + result = await dataAccessLayer.eventsWithEntityIDAndCategory( + nodeID, + eventCategory, + cursor + ); + } else { + result = await dataAccessLayer.eventsWithEntityIDAndCategory(nodeID, eventCategory); + } + } catch (error) { + api.dispatch({ + type: 'serverFailedToReturnNodeEventsInCategory', + payload: { + nodeID, + eventCategory, + cursor, + }, + }); + } + + if (result) { + api.dispatch({ + type: 'serverReturnedNodeEventsInCategory', + payload: { + events: result.events, + eventCategory, + cursor: result.nextEvent, + nodeID, + }, + }); + } + } + // If the panel view params have changed and the current panel view is either `nodeEventsInCategory` or `eventDetail`, then fetch the related events for that nodeID. if (!isEqual(newParams, oldParams)) { if (newParams.panelView === 'nodeEventsInCategory') { const nodeID = newParams.panelParameters.nodeID; - - const result: - | ResolverPaginatedEvents - | undefined = await dataAccessLayer.eventsWithEntityIDAndCategory( + fetchEvents({ nodeID, - newParams.panelParameters.eventCategory - ); - - if (result) { - api.dispatch({ - type: 'serverReturnedNodeEventsInCategory', - payload: { - events: result.events, - eventCategory: newParams.panelParameters.eventCategory, - cursor: result.nextEvent, - nodeID, - }, - }); - } - } else if (newParams.panelView === 'eventDetail') { - const nodeID = newParams.panelParameters.nodeID; - - const result: ResolverRelatedEvents | undefined = await dataAccessLayer.relatedEvents( - nodeID - ); - - if (result) { - api.dispatch({ - type: 'serverReturnedRelatedEventData', - payload: result, - }); - } + eventCategory: newParams.panelParameters.eventCategory, + cursor: null, + }); + } + } else if (isLoadingMoreEvents) { + const nodeEventsInCategory = state.data.nodeEventsInCategory; + if (nodeEventsInCategory !== undefined) { + fetchEvents(nodeEventsInCategory); } } }; diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts index 8809b4b15a3fb..e805c16ed9c28 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts @@ -364,6 +364,37 @@ export const nodeEventsInCategory = composeSelectors( dataSelectors.nodeEventsInCategory ); +/** + * Flag used to show a Load More Data button in the nodeEventsOfType panel view. + */ +export const lastRelatedEventResponseContainsCursor = composeSelectors( + dataStateSelector, + dataSelectors.lastRelatedEventResponseContainsCursor +); + +/** + * Flag to show an error message when loading more related events. + */ +export const hadErrorLoadingNodeEventsInCategory = composeSelectors( + dataStateSelector, + dataSelectors.hadErrorLoadingNodeEventsInCategory +); +/** + * Flag used to show a loading view for the initial loading of related events. + */ +export const isLoadingNodeEventsInCategory = composeSelectors( + dataStateSelector, + dataSelectors.isLoadingNodeEventsInCategory +); + +/** + * Flag used to show a loading state for any additional related events. + */ +export const isLoadingMoreNodeEventsInCategory = composeSelectors( + dataStateSelector, + dataSelectors.isLoadingMoreNodeEventsInCategory +); + /** * Calls the `secondSelector` with the result of the `selector`. Use this when re-exporting a * concern-specific selector. `selector` should return the concern-specific state. diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index 9f440d7094987..5007b7cffa5c6 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -227,6 +227,17 @@ export interface NodeEventsInCategoryState { * The cursor, if any, that can be used to retrieve more events. */ cursor: null | string; + + /** + * The cursor, if any, that was last used to fetch additional related events. + */ + + lastCursorRequested?: null | string; + + /** + * Flag for showing an error message when fetching additional related events. + */ + error?: boolean; } /** diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.test.tsx new file mode 100644 index 0000000000000..5f6b4e81e740e --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.test.tsx @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { createMemoryHistory, History as HistoryPackageHistoryInterface } from 'history'; + +import { oneNodeWithPaginatedEvents } from '../../data_access_layer/mocks/one_node_with_paginated_related_events'; +import { Simulator } from '../../test_utilities/simulator'; +// Extend jest with a custom matcher +import '../../test_utilities/extend_jest'; +import { urlSearch } from '../../test_utilities/url_search'; + +// the resolver component instance ID, used by the react code to distinguish piece of global state from those used by other resolver instances +const resolverComponentInstanceID = 'resolverComponentInstanceID'; + +describe(`Resolver: when analyzing a tree with only the origin and paginated related events, and when the component instance ID is ${resolverComponentInstanceID}`, () => { + /** + * Get (or lazily create and get) the simulator. + */ + let simulator: () => Simulator; + /** lazily populated by `simulator`. */ + let simulatorInstance: Simulator | undefined; + let memoryHistory: HistoryPackageHistoryInterface; + + beforeEach(() => { + // create a mock data access layer + const { metadata: dataAccessLayerMetadata, dataAccessLayer } = oneNodeWithPaginatedEvents(); + + memoryHistory = createMemoryHistory(); + + // create a resolver simulator, using the data access layer and an arbitrary component instance ID + simulator = () => { + if (simulatorInstance) { + return simulatorInstance; + } else { + simulatorInstance = new Simulator({ + databaseDocumentID: dataAccessLayerMetadata.databaseDocumentID, + dataAccessLayer, + resolverComponentInstanceID, + history: memoryHistory, + indices: [], + }); + return simulatorInstance; + } + }; + }); + + afterEach(() => { + simulatorInstance = undefined; + }); + + describe(`when the URL query string is showing a resolver with nodeID origin, panel view nodeEventsInCategory, and eventCategory registry`, () => { + beforeEach(() => { + memoryHistory.push({ + search: urlSearch(resolverComponentInstanceID, { + panelParameters: { nodeID: 'origin', eventCategory: 'registry' }, + panelView: 'nodeEventsInCategory', + }), + }); + }); + it('should show the load more data button', async () => { + await expect( + simulator().map(() => ({ + loadMoreButton: simulator().testSubject('resolver:nodeEventsInCategory:loadMore').length, + visibleEvents: simulator().testSubject( + 'resolver:panel:node-events-in-category:event-link' + ).length, + })) + ).toYieldEqualTo({ + loadMoreButton: 1, + visibleEvents: 25, + }); + }); + describe('when the user clicks the load more button', () => { + beforeEach(async () => { + const loadMore = await simulator().resolve('resolver:nodeEventsInCategory:loadMore'); + if (loadMore) { + loadMore.simulate('click', { button: 0 }); + } + }); + it('should hide the load more button and show all 30 events', async () => { + await expect( + simulator().map(() => ({ + loadMoreButton: simulator().testSubject('resolver:nodeEventsInCategory:loadMore') + .length, + visibleEvents: simulator().testSubject( + 'resolver:panel:node-events-in-category:event-link' + ).length, + })) + ).toYieldEqualTo({ + loadMoreButton: 0, + visibleEvents: 30, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx index 54652105c8688..17e91902d0c96 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx @@ -4,11 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -/* eslint-disable react/display-name */ - -import React, { memo, Fragment } from 'react'; +import React, { memo, useCallback, Fragment } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiSpacer, EuiText, EuiButtonEmpty, EuiHorizontalRule } from '@elastic/eui'; +import { + EuiSpacer, + EuiText, + EuiButtonEmpty, + EuiHorizontalRule, + EuiFlexItem, + EuiButton, + EuiCallOut, +} from '@elastic/eui'; import { useSelector } from 'react-redux'; import { FormattedMessage } from '@kbn/i18n/react'; import { StyledPanel } from '../styles'; @@ -21,6 +27,7 @@ import { ResolverState } from '../../types'; import { PanelLoading } from './panel_loading'; import { DescriptiveName } from './descriptive_name'; import { useLinkProps } from '../use_link_props'; +import { useResolverDispatch } from '../use_resolver_dispatch'; import { useFormattedDate } from './use_formatted_date'; /** @@ -44,29 +51,56 @@ export const NodeEventsInCategory = memo(function ({ ); const events = useSelector((state: ResolverState) => selectors.nodeEventsInCategory(state)); + const isLoading = useSelector(selectors.isLoadingNodeEventsInCategory); + const hasError = useSelector(selectors.hadErrorLoadingNodeEventsInCategory); return ( <> - {eventCount === undefined || processEvent === null ? ( + {isLoading || processEvent === null ? ( ) : ( - - - + {hasError ? ( + +

+ +

+
+ ) : ( + <> + + + + + )}
)} ); }); +NodeEventsInCategory.displayName = 'NodeEventsInCategory'; + /** * Rendered for each event in the list. */ @@ -136,6 +170,14 @@ const NodeEventList = memo(function NodeEventList({ events: SafeResolverEvent[]; nodeID: string; }) { + const dispatch = useResolverDispatch(); + const handleLoadMore = useCallback(() => { + dispatch({ + type: 'userRequestedAdditionalRelatedEvents', + }); + }, [dispatch]); + const isLoading = useSelector(selectors.isLoadingMoreNodeEventsInCategory); + const hasMore = useSelector(selectors.lastRelatedEventResponseContainsCursor); return ( <> {events.map((event, index) => ( @@ -144,6 +186,23 @@ const NodeEventList = memo(function NodeEventList({ {index === events.length - 1 ? null : } ))} + {hasMore && ( + + + + + + )} ); }); @@ -166,7 +225,7 @@ const NodeEventsInCategoryBreadcrumbs = memo(function ({ /** * The events to list. */ - eventCount: number; + eventCount: number | undefined; nodeID: string; /** * The count of events in the category that this list is showing.