From 7aafb45eab8d2bb1797d183e369e418d81c4a854 Mon Sep 17 00:00:00 2001 From: Ousmane Samba Date: Thu, 19 Mar 2026 10:07:12 +0100 Subject: [PATCH 1/2] fix Stream details output creation --- .../components/outputs/OutputsComponent.tsx | 7 +- .../routing-destination/AddOutputButton.tsx | 6 +- .../DestinationOutputs.test.tsx | 107 ++++++++++++++++++ .../DestinationOutputs.tsx | 12 +- .../streams/useAvailableOutputTypes.test.ts | 64 +++++++++++ .../streams/useAvailableOutputTypes.ts | 10 ++ 6 files changed, 199 insertions(+), 7 deletions(-) create mode 100644 graylog2-web-interface/src/components/streams/StreamDetails/routing-destination/DestinationOutputs.test.tsx create mode 100644 graylog2-web-interface/src/components/streams/useAvailableOutputTypes.test.ts diff --git a/graylog2-web-interface/src/components/outputs/OutputsComponent.tsx b/graylog2-web-interface/src/components/outputs/OutputsComponent.tsx index 98f056f30b7a..1327558bbc89 100644 --- a/graylog2-web-interface/src/components/outputs/OutputsComponent.tsx +++ b/graylog2-web-interface/src/components/outputs/OutputsComponent.tsx @@ -27,6 +27,7 @@ import { TELEMETRY_EVENT_TYPE } from 'logic/telemetry/Constants'; import useSendTelemetry from 'logic/telemetry/useSendTelemetry'; import { isPermitted } from 'util/PermissionsMixin'; import useAvailableOutputTypes from 'components/streams/useAvailableOutputTypes'; +import { getOutputTypeDefinition } from 'components/streams/useAvailableOutputTypes'; import useOutputs from 'hooks/useOutputs'; import useStreamOutputs from 'hooks/useStreamOutputs'; import useOutputMutations from 'hooks/useOutputMutations'; @@ -72,8 +73,10 @@ const OutputsComponent = ({ streamId = undefined, permissions }: Props) => { const getTypeDefinition = useCallback( (typeName: string, callback: (def: any) => void) => { - if (types?.[typeName]) { - callback(types[typeName]); + const typeDefinition = getOutputTypeDefinition(types, typeName); + + if (typeDefinition) { + callback(typeDefinition); } }, [types], diff --git a/graylog2-web-interface/src/components/streams/StreamDetails/routing-destination/AddOutputButton.tsx b/graylog2-web-interface/src/components/streams/StreamDetails/routing-destination/AddOutputButton.tsx index 1318a0d51e9c..2a96ec7628f9 100644 --- a/graylog2-web-interface/src/components/streams/StreamDetails/routing-destination/AddOutputButton.tsx +++ b/graylog2-web-interface/src/components/streams/StreamDetails/routing-destination/AddOutputButton.tsx @@ -30,13 +30,17 @@ import { TELEMETRY_EVENT_TYPE } from 'logic/telemetry/Constants'; import useStreamOutputMutation from 'hooks/useStreamOutputMutations'; import type { AvailableOutputRequestedConfiguration, + AvailableOutputSummary, AvailableOutputTypes, } from 'components/streams/useAvailableOutputTypes'; import { Icon } from 'components/common'; type Props = { stream: Stream; - getTypeDefinition: (type: string) => AvailableOutputRequestedConfiguration; + getTypeDefinition: ( + type: string, + callback?: (available: AvailableOutputSummary) => void, + ) => AvailableOutputRequestedConfiguration | undefined; availableOutputTypes: AvailableOutputTypes; assignableOutputs: Array; }; diff --git a/graylog2-web-interface/src/components/streams/StreamDetails/routing-destination/DestinationOutputs.test.tsx b/graylog2-web-interface/src/components/streams/StreamDetails/routing-destination/DestinationOutputs.test.tsx new file mode 100644 index 000000000000..7591ccdf79b7 --- /dev/null +++ b/graylog2-web-interface/src/components/streams/StreamDetails/routing-destination/DestinationOutputs.test.tsx @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import * as React from 'react'; +import { render } from 'wrappedTestingLibrary'; +import type { ConfigurationField } from 'components/configurationforms'; + +import { asMock } from 'helpers/mocking'; +import useOutputs from 'hooks/useOutputs'; +import useStreamOutputs from 'hooks/useStreamOutputs'; +import useAvailableOutputTypes from 'components/streams/useAvailableOutputTypes'; +import type { AvailableOutputTypes } from 'components/streams/useAvailableOutputTypes'; +import AddOutputButton from 'components/streams/StreamDetails/routing-destination/AddOutputButton'; +import OutputsList from 'components/streams/StreamDetails/routing-destination/OutputsList'; + +import DestinationOutputs from './DestinationOutputs'; + +jest.mock('hooks/useOutputs'); +jest.mock('hooks/useStreamOutputs'); +jest.mock('components/streams/useAvailableOutputTypes', () => { + const actual = jest.requireActual('components/streams/useAvailableOutputTypes'); + + return { + __esModule: true, + ...actual, + default: jest.fn(), + }; +}); +jest.mock('components/streams/StreamDetails/routing-destination/AddOutputButton', () => jest.fn(() =>
add output button
)); +jest.mock('components/streams/StreamDetails/routing-destination/OutputsList', () => jest.fn(() =>
outputs list
)); + +describe('DestinationOutputs', () => { + const streamOutput = { id: 'output-id', title: 'Existing output', type: 'enterprise-output', configuration: {} }; + const hostField: ConfigurationField = { + type: 'text', + human_name: 'Host', + additional_info: {}, + attributes: [], + default_value: '', + description: 'Host to connect to', + is_encrypted: false, + is_optional: false, + position: 0, + }; + const availableOutputTypes: AvailableOutputTypes = { + 'enterprise-output': { + type: 'enterprise-output', + name: 'Enterprise output', + human_name: 'Enterprise output', + link_to_docs: '', + requested_configuration: { + host: hostField, + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + asMock(useStreamOutputs).mockReturnValue({ + data: { outputs: [streamOutput], total: 1 }, + refetch: jest.fn(), + isInitialLoading: false, + isError: false, + }); + asMock(useOutputs).mockReturnValue({ + data: { outputs: [streamOutput], total: 1 }, + refetch: jest.fn(), + isInitialLoading: false, + }); + asMock(useAvailableOutputTypes).mockReturnValue({ + data: availableOutputTypes, + refetch: jest.fn(), + isInitialLoading: false, + }); + }); + + it('uses flat available output types map to resolve requested configuration for create and edit paths', () => { + render(); + + const addOutputButtonProps = asMock(AddOutputButton).mock.calls[0][0]; + const callback = jest.fn(); + const requestedConfiguration = addOutputButtonProps.getTypeDefinition('enterprise-output', callback); + + expect(addOutputButtonProps.availableOutputTypes).toEqual(availableOutputTypes); + expect(callback).toHaveBeenCalledWith(availableOutputTypes['enterprise-output']); + expect(requestedConfiguration).toEqual(availableOutputTypes['enterprise-output'].requested_configuration); + + const outputsListProps = asMock(OutputsList).mock.calls[0][0]; + expect(outputsListProps.getTypeDefinition('enterprise-output')).toEqual( + availableOutputTypes['enterprise-output'].requested_configuration, + ); + }); +}); diff --git a/graylog2-web-interface/src/components/streams/StreamDetails/routing-destination/DestinationOutputs.tsx b/graylog2-web-interface/src/components/streams/StreamDetails/routing-destination/DestinationOutputs.tsx index fe4e5161044a..8198ea489ed6 100644 --- a/graylog2-web-interface/src/components/streams/StreamDetails/routing-destination/DestinationOutputs.tsx +++ b/graylog2-web-interface/src/components/streams/StreamDetails/routing-destination/DestinationOutputs.tsx @@ -22,6 +22,10 @@ import type { Stream } from 'stores/streams/StreamsStore'; import useStreamOutputs from 'hooks/useStreamOutputs'; import type { AvailableOutputSummary } from 'components/streams/useAvailableOutputTypes'; import useAvailableOutputTypes from 'components/streams/useAvailableOutputTypes'; +import { + getOutputTypeDefinition, + getRequestedOutputConfiguration, +} from 'components/streams/useAvailableOutputTypes'; import SectionCountLabel from 'components/streams/StreamDetails/SectionCountLabel'; import AddOutputButton from 'components/streams/StreamDetails/routing-destination/AddOutputButton'; import OutputsList from 'components/streams/StreamDetails/routing-destination/OutputsList'; @@ -37,13 +41,13 @@ const DestinationOutputs = ({ stream }: Props) => { const { data: availableOutputTypes, isInitialLoading: isLoadingOutputTypes } = useAvailableOutputTypes(); const getTypeDefinition = (type: string, callback?: (available: AvailableOutputSummary) => void) => { - const definitition = availableOutputTypes.types[type]; + const definition = getOutputTypeDefinition(availableOutputTypes, type); - if (callback && definitition) { - callback(definitition); + if (callback && definition) { + callback(definition); } - return definitition?.requested_configuration; + return getRequestedOutputConfiguration(availableOutputTypes, type); }; if (isInitialLoading || isLoadingOutput || isLoadingOutputTypes) { diff --git a/graylog2-web-interface/src/components/streams/useAvailableOutputTypes.test.ts b/graylog2-web-interface/src/components/streams/useAvailableOutputTypes.test.ts new file mode 100644 index 000000000000..ead1e04fa6d2 --- /dev/null +++ b/graylog2-web-interface/src/components/streams/useAvailableOutputTypes.test.ts @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ + +import { + getOutputTypeDefinition, + getRequestedOutputConfiguration, + type AvailableOutputTypes, +} from 'components/streams/useAvailableOutputTypes'; +import type { ConfigurationField } from 'components/configurationforms'; + +describe('useAvailableOutputTypes helpers', () => { + const hostField: ConfigurationField = { + type: 'text', + human_name: 'Host', + additional_info: {}, + attributes: [], + default_value: '', + description: 'Host to connect to', + is_encrypted: false, + is_optional: false, + position: 0, + }; + + const outputTypes: AvailableOutputTypes = { + 'enterprise-output': { + type: 'enterprise-output', + name: 'Enterprise output', + human_name: 'Enterprise output', + link_to_docs: '', + requested_configuration: { + host: hostField, + }, + }, + }; + + it('returns output definition for a known output type', () => { + expect(getOutputTypeDefinition(outputTypes, 'enterprise-output')).toEqual(outputTypes['enterprise-output']); + }); + + it('returns requested configuration from the flat output types map', () => { + expect(getRequestedOutputConfiguration(outputTypes, 'enterprise-output')).toEqual( + outputTypes['enterprise-output'].requested_configuration, + ); + }); + + it('returns undefined for unknown type or missing map', () => { + expect(getOutputTypeDefinition(outputTypes, 'missing-output')).toBeUndefined(); + expect(getRequestedOutputConfiguration(undefined, 'enterprise-output')).toBeUndefined(); + }); +}); diff --git a/graylog2-web-interface/src/components/streams/useAvailableOutputTypes.ts b/graylog2-web-interface/src/components/streams/useAvailableOutputTypes.ts index 49edf12ac2ea..8be8509b6c3f 100644 --- a/graylog2-web-interface/src/components/streams/useAvailableOutputTypes.ts +++ b/graylog2-web-interface/src/components/streams/useAvailableOutputTypes.ts @@ -40,6 +40,16 @@ export type AvailableOutputTypes = { [_key: string]: AvailableOutputSummary; }; +export const getOutputTypeDefinition = ( + outputTypes: AvailableOutputTypes | undefined, + outputType: string, +): AvailableOutputSummary | undefined => outputTypes?.[outputType]; + +export const getRequestedOutputConfiguration = ( + outputTypes: AvailableOutputTypes | undefined, + outputType: string, +): AvailableOutputRequestedConfiguration | undefined => getOutputTypeDefinition(outputTypes, outputType)?.requested_configuration; + export const fetchOutputsTypes = () => { const url = qualifyUrl(ApiRoutes.OutputsApiController.availableTypes().url); From 2cfcf729ef331358d10a25970a5929c8b4440106 Mon Sep 17 00:00:00 2001 From: Ousmane Samba Date: Thu, 19 Mar 2026 10:07:12 +0100 Subject: [PATCH 2/2] add changlog --- changelog/unreleased/pr-25552.toml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog/unreleased/pr-25552.toml diff --git a/changelog/unreleased/pr-25552.toml b/changelog/unreleased/pr-25552.toml new file mode 100644 index 000000000000..85446808a70c --- /dev/null +++ b/changelog/unreleased/pr-25552.toml @@ -0,0 +1,5 @@ +type = "fixed" +message = "Fix creating output from Data Routing page for Stream results in empty output configuration." + +issues = ["23793"] +pulls = ["25552"]