From 9c7e08131322f77ed4d8567c87f35ff0aebce441 Mon Sep 17 00:00:00 2001 From: Ryan Carroll Date: Fri, 14 Aug 2026 11:09:50 -0500 Subject: [PATCH 1/4] Reain selected sharing info across navigation --- changelog/unreleased/issue-14480.toml | 4 ++ .../EventDefinitionFormContainer.test.tsx | 42 +++++++++++++++++++ .../event-definition-form/ShareForm.tsx | 3 +- .../EntityCreateShareFormGroup.test.tsx | 35 +++++++++++++++- .../EntityCreateShareFormGroup.tsx | 17 ++++++-- 5 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 changelog/unreleased/issue-14480.toml diff --git a/changelog/unreleased/issue-14480.toml b/changelog/unreleased/issue-14480.toml new file mode 100644 index 000000000000..fc8363e939f9 --- /dev/null +++ b/changelog/unreleased/issue-14480.toml @@ -0,0 +1,4 @@ +type = "fixed" +message = "Keep the share settings of an event definition populated when navigating between the wizard steps." + +issues = ["Graylog2/graylog-plugin-enterprise#14480"] diff --git a/graylog2-web-interface/src/components/event-definitions/event-definition-form/EventDefinitionFormContainer.test.tsx b/graylog2-web-interface/src/components/event-definitions/event-definition-form/EventDefinitionFormContainer.test.tsx index 390a1f0ee297..b70bf50bdd59 100644 --- a/graylog2-web-interface/src/components/event-definitions/event-definition-form/EventDefinitionFormContainer.test.tsx +++ b/graylog2-web-interface/src/components/event-definitions/event-definition-form/EventDefinitionFormContainer.test.tsx @@ -16,10 +16,14 @@ */ import * as React from 'react'; +import * as Immutable from 'immutable'; import { render, screen } from 'wrappedTestingLibrary'; import userEvent from '@testing-library/user-event'; import { defaultUser as mockDefaultUser } from 'defaultMockValues'; +import selectEvent from 'helpers/selectEvent'; +import EntityShareDomain from 'domainActions/permissions/EntityShareDomain'; +import { createEntityShareState, everyone, viewer } from 'fixtures/entityShareState'; import useLocation from 'routing/useLocation'; import useSendTelemetry from 'logic/telemetry/useSendTelemetry'; import { asMock } from 'helpers/mocking'; @@ -198,6 +202,15 @@ jest.mock('logic/telemetry/withTelemetry', () => ({ ), })); +jest.mock('domainActions/permissions/EntityShareDomain', () => ({ + __esModule: true, + default: { prepare: jest.fn(() => Promise.resolve()), update: jest.fn(() => Promise.resolve()) }, +})); + +// Rendered as soon as the form becomes dirty. It relies on `useBlocker`, which needs a data +// router that the test wrapper does not set up, so it is stubbed out here. +jest.mock('components/common/ConfirmLeaveDialog', () => () => null); + jest.mock('components/event-definitions/hooks/useEventDefinitionConfigFromLocalStorage'); jest.mock('routing/useLocation'); jest.mock('logic/telemetry/useSendTelemetry'); @@ -358,4 +371,33 @@ describe('EventDefinitionFormContainer', () => { expect(screen.getByRole('button', { name: /update event definition/i })).toBeVisible(); }); + + it('keeps the selected collaborators when navigating away from and back to the Share step', async () => { + // Mirror the server: the prepared state reflects exactly the capabilities that were sent. + asMock(EntityShareDomain.prepare).mockImplementation((_type, _title, _grn, payload?: any) => + Promise.resolve( + createEntityShareState + .toBuilder() + .selectedGranteeCapabilities(payload?.selected_grantee_capabilities ?? Immutable.Map()) + .build(), + ), + ); + + render(); + + await userEvent.click(await screen.findByRole('button', { name: /^share$/i })); + + await selectEvent.chooseOption('Search for users and teams', everyone.title); + await selectEvent.chooseOption('Select a capability', viewer.title); + await userEvent.click(await screen.findByRole('button', { name: /add collaborator/i })); + + expect(await screen.findByText(everyone.title)).toBeVisible(); + + await userEvent.click(await screen.findByRole('button', { name: /^summary$/i })); + await userEvent.click(await screen.findByRole('button', { name: /^share$/i })); + + // Re-entering the step must re-prepare with the selection made before, otherwise the + // freshly prepared (empty) state overwrites it and the form appears cleared. + expect(await screen.findByText(everyone.title)).toBeVisible(); + }); }); diff --git a/graylog2-web-interface/src/components/event-definitions/event-definition-form/ShareForm.tsx b/graylog2-web-interface/src/components/event-definitions/event-definition-form/ShareForm.tsx index ba9d5a4bfb64..af008ecadba9 100644 --- a/graylog2-web-interface/src/components/event-definitions/event-definition-form/ShareForm.tsx +++ b/graylog2-web-interface/src/components/event-definitions/event-definition-form/ShareForm.tsx @@ -26,7 +26,7 @@ import type { EventDefinition } from '../event-definitions-types'; type Props = { onChange: (name: string, value: EntitySharePayload) => void; - eventDefinition: EventDefinition; + eventDefinition: EventDefinition & { share_request?: EntitySharePayload }; }; const ShareForm = ({ onChange, eventDefinition }: Props) => { @@ -49,6 +49,7 @@ const ShareForm = ({ onChange, eventDefinition }: Props) => { entityType="event_definition" entityTitle="" dependenciesGRN={[...streamDependenciesGRN, ...notificationDependenciesGRN]} + defaultSharePayload={eventDefinition?.share_request} /> diff --git a/graylog2-web-interface/src/components/permissions/EntityCreateShareFormGroup.test.tsx b/graylog2-web-interface/src/components/permissions/EntityCreateShareFormGroup.test.tsx index f0341ea2bb1b..f652fa9a6337 100644 --- a/graylog2-web-interface/src/components/permissions/EntityCreateShareFormGroup.test.tsx +++ b/graylog2-web-interface/src/components/permissions/EntityCreateShareFormGroup.test.tsx @@ -90,7 +90,40 @@ describe('EntityCreateShareFormGroup', () => { render(); await waitFor(() => { - expect(EntityShareDomain.prepare).toHaveBeenCalledWith(mockEntity.entityType, '', mockEntity.entityId, {}); + expect(EntityShareDomain.prepare).toHaveBeenCalledWith(mockEntity.entityType, '', mockEntity.entityId, { + prepare_request: null, + }); + }); + }); + + it('restores a previously made selection from the default share payload', async () => { + const selected_grantee_capabilities = createEntityShareState.selectedGranteeCapabilities.merge({ + [everyone.id]: viewer.id, + }); + + render(); + + await waitFor(() => { + expect(EntityShareDomain.prepare).toHaveBeenCalledWith(mockEntity.entityType, '', mockEntity.entityId, { + selected_grantee_capabilities, + prepare_request: null, + }); + }); + }); + + it('re-runs the dependency check when restoring a selection', async () => { + const selected_grantee_capabilities = createEntityShareState.selectedGranteeCapabilities.merge({ + [everyone.id]: viewer.id, + }); + const dependenciesGRN = ['grn::::stream:stream-id']; + + render(); + + await waitFor(() => { + expect(EntityShareDomain.prepare).toHaveBeenCalledWith(mockEntity.entityType, '', mockEntity.entityId, { + selected_grantee_capabilities, + prepare_request: dependenciesGRN, + }); }); }); diff --git a/graylog2-web-interface/src/components/permissions/EntityCreateShareFormGroup.tsx b/graylog2-web-interface/src/components/permissions/EntityCreateShareFormGroup.tsx index 80f3eaf69916..f1d1365a1cf4 100644 --- a/graylog2-web-interface/src/components/permissions/EntityCreateShareFormGroup.tsx +++ b/graylog2-web-interface/src/components/permissions/EntityCreateShareFormGroup.tsx @@ -86,13 +86,24 @@ const EntityCreateShareFormGroup = ({ ); const PluggableEntityShareFormGroup = usePluggableEntityShareFormGroup(); + // Callers usually build `dependenciesGRN` inline, so its identity changes on every render. + // Serializing it keeps it usable as an effect dependency without re-triggering the effect + // on every render. + const dependenciesKey = JSON.stringify(dependenciesGRN ?? []); + useEffect(() => { const { selected_collections: _, ...rest } = defaultSharePayload ?? {}; - - EntityShareDomain.prepare(entityType, entityTitle, entityGRN, rest).then((state) => { + const dependencies: Array = JSON.parse(dependenciesKey); + // When a previously made selection is restored (e.g. the user navigates back to a wizard + // step), the dependency check has to run again as well. Otherwise the missing dependency + // warnings shown before would silently disappear. Mirrors what `handleSelection` sends. + const prepare_request = + (rest.selected_grantee_capabilities?.size ?? 0) > 0 && dependencies.length > 0 ? dependencies : null; + + EntityShareDomain.prepare(entityType, entityTitle, entityGRN, { ...rest, prepare_request }).then((state) => { setEntityShareState(entityGRN, state); }); - }, [entityType, entityTitle, entityGRN, defaultSharePayload, setEntityShareState]); + }, [entityType, entityTitle, entityGRN, defaultSharePayload, dependenciesKey, setEntityShareState]); const resetSelection = () => { setDisableSubmit(false); From 3ffac3af9513c603c5c01015ddee78ea07c5b809 Mon Sep 17 00:00:00 2001 From: Ryan Carroll Date: Fri, 14 Aug 2026 11:13:07 -0500 Subject: [PATCH 2/4] update cl --- changelog/unreleased/issue-14480.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/changelog/unreleased/issue-14480.toml b/changelog/unreleased/issue-14480.toml index fc8363e939f9..d675f701450d 100644 --- a/changelog/unreleased/issue-14480.toml +++ b/changelog/unreleased/issue-14480.toml @@ -1,4 +1,5 @@ type = "fixed" -message = "Keep the share settings of an event definition populated when navigating between the wizard steps." +message = "Keep the share settings of an event definition populated when navigating between wizard steps." issues = ["Graylog2/graylog-plugin-enterprise#14480"] +pulls = ["26990"] From 890600dd093691f4ea3e79130bb387af3c15cb96 Mon Sep 17 00:00:00 2001 From: Ryan Carroll Date: Fri, 14 Aug 2026 11:17:54 -0500 Subject: [PATCH 3/4] cleanup --- .../EventDefinitionFormContainer.test.tsx | 7 ++----- .../permissions/EntityCreateShareFormGroup.tsx | 9 +++------ 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/graylog2-web-interface/src/components/event-definitions/event-definition-form/EventDefinitionFormContainer.test.tsx b/graylog2-web-interface/src/components/event-definitions/event-definition-form/EventDefinitionFormContainer.test.tsx index b70bf50bdd59..7161d4432772 100644 --- a/graylog2-web-interface/src/components/event-definitions/event-definition-form/EventDefinitionFormContainer.test.tsx +++ b/graylog2-web-interface/src/components/event-definitions/event-definition-form/EventDefinitionFormContainer.test.tsx @@ -207,8 +207,7 @@ jest.mock('domainActions/permissions/EntityShareDomain', () => ({ default: { prepare: jest.fn(() => Promise.resolve()), update: jest.fn(() => Promise.resolve()) }, })); -// Rendered as soon as the form becomes dirty. It relies on `useBlocker`, which needs a data -// router that the test wrapper does not set up, so it is stubbed out here. +// Rendered once the form is dirty and relies on `useBlocker`, which needs a data router. jest.mock('components/common/ConfirmLeaveDialog', () => () => null); jest.mock('components/event-definitions/hooks/useEventDefinitionConfigFromLocalStorage'); @@ -373,7 +372,7 @@ describe('EventDefinitionFormContainer', () => { }); it('keeps the selected collaborators when navigating away from and back to the Share step', async () => { - // Mirror the server: the prepared state reflects exactly the capabilities that were sent. + // Mirrors the server: the prepared state reflects exactly the capabilities that were sent. asMock(EntityShareDomain.prepare).mockImplementation((_type, _title, _grn, payload?: any) => Promise.resolve( createEntityShareState @@ -396,8 +395,6 @@ describe('EventDefinitionFormContainer', () => { await userEvent.click(await screen.findByRole('button', { name: /^summary$/i })); await userEvent.click(await screen.findByRole('button', { name: /^share$/i })); - // Re-entering the step must re-prepare with the selection made before, otherwise the - // freshly prepared (empty) state overwrites it and the form appears cleared. expect(await screen.findByText(everyone.title)).toBeVisible(); }); }); diff --git a/graylog2-web-interface/src/components/permissions/EntityCreateShareFormGroup.tsx b/graylog2-web-interface/src/components/permissions/EntityCreateShareFormGroup.tsx index f1d1365a1cf4..673ec999f3b9 100644 --- a/graylog2-web-interface/src/components/permissions/EntityCreateShareFormGroup.tsx +++ b/graylog2-web-interface/src/components/permissions/EntityCreateShareFormGroup.tsx @@ -86,17 +86,14 @@ const EntityCreateShareFormGroup = ({ ); const PluggableEntityShareFormGroup = usePluggableEntityShareFormGroup(); - // Callers usually build `dependenciesGRN` inline, so its identity changes on every render. - // Serializing it keeps it usable as an effect dependency without re-triggering the effect - // on every render. + // Serialized because callers build `dependenciesGRN` inline, so its identity changes every render. const dependenciesKey = JSON.stringify(dependenciesGRN ?? []); useEffect(() => { const { selected_collections: _, ...rest } = defaultSharePayload ?? {}; const dependencies: Array = JSON.parse(dependenciesKey); - // When a previously made selection is restored (e.g. the user navigates back to a wizard - // step), the dependency check has to run again as well. Otherwise the missing dependency - // warnings shown before would silently disappear. Mirrors what `handleSelection` sends. + // Restoring a selection has to re-run the dependency check, or the missing dependency + // warnings shown before would silently disappear. const prepare_request = (rest.selected_grantee_capabilities?.size ?? 0) > 0 && dependencies.length > 0 ? dependencies : null; From 6cad850c97f67cb96d39157ca8065c6486900a5c Mon Sep 17 00:00:00 2001 From: Ryan Carroll Date: Fri, 14 Aug 2026 11:34:34 -0500 Subject: [PATCH 4/4] fix linter issues --- .../components/permissions/EntityCreateShareFormGroup.test.tsx | 3 ++- .../src/components/permissions/EntityCreateShareFormGroup.tsx | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/graylog2-web-interface/src/components/permissions/EntityCreateShareFormGroup.test.tsx b/graylog2-web-interface/src/components/permissions/EntityCreateShareFormGroup.test.tsx index f652fa9a6337..6f3a9950e1f2 100644 --- a/graylog2-web-interface/src/components/permissions/EntityCreateShareFormGroup.test.tsx +++ b/graylog2-web-interface/src/components/permissions/EntityCreateShareFormGroup.test.tsx @@ -15,6 +15,7 @@ * . */ import * as React from 'react'; +import * as mockImmutable from 'immutable'; import { render, waitFor, screen } from 'wrappedTestingLibrary'; import userEvent from '@testing-library/user-event'; @@ -34,7 +35,7 @@ jest.mock('domainActions/permissions/EntityShareDomain', () => ({ update: jest.fn(() => Promise.resolve()), loadUserSharesPaginated: jest.fn(() => Promise.resolve({ - list: require('immutable').List(), + list: mockImmutable.List(), pagination: { page: 1, perPage: 10, query: '', total: 0, count: 0 }, }), ), diff --git a/graylog2-web-interface/src/components/permissions/EntityCreateShareFormGroup.tsx b/graylog2-web-interface/src/components/permissions/EntityCreateShareFormGroup.tsx index 673ec999f3b9..762b668658e4 100644 --- a/graylog2-web-interface/src/components/permissions/EntityCreateShareFormGroup.tsx +++ b/graylog2-web-interface/src/components/permissions/EntityCreateShareFormGroup.tsx @@ -210,6 +210,8 @@ const EntityCreateShareFormGroup = ({ availableGrantees={entityShareState.availableGrantees} /> {PluggableEntityShareFormGroup && ( + /* Resolved from the plugin store at render time and cannot be hoisted. */ + /* eslint-disable-next-line react-hooks/static-components */