diff --git a/changelog/unreleased/issue-14480.toml b/changelog/unreleased/issue-14480.toml new file mode 100644 index 000000000000..d675f701450d --- /dev/null +++ b/changelog/unreleased/issue-14480.toml @@ -0,0 +1,5 @@ +type = "fixed" +message = "Keep the share settings of an event definition populated when navigating between wizard steps." + +issues = ["Graylog2/graylog-plugin-enterprise#14480"] +pulls = ["26990"] 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..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 @@ -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,14 @@ jest.mock('logic/telemetry/withTelemetry', () => ({ ), })); +jest.mock('domainActions/permissions/EntityShareDomain', () => ({ + __esModule: true, + default: { prepare: jest.fn(() => Promise.resolve()), update: jest.fn(() => Promise.resolve()) }, +})); + +// 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'); jest.mock('routing/useLocation'); jest.mock('logic/telemetry/useSendTelemetry'); @@ -358,4 +370,31 @@ 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 () => { + // 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 + .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 })); + + 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..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 }, }), ), @@ -90,7 +91,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..762b668658e4 100644 --- a/graylog2-web-interface/src/components/permissions/EntityCreateShareFormGroup.tsx +++ b/graylog2-web-interface/src/components/permissions/EntityCreateShareFormGroup.tsx @@ -86,13 +86,21 @@ const EntityCreateShareFormGroup = ({ ); const PluggableEntityShareFormGroup = usePluggableEntityShareFormGroup(); + // 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); + // 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; - EntityShareDomain.prepare(entityType, entityTitle, entityGRN, rest).then((state) => { + 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); @@ -202,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 */