diff --git a/graylog2-web-interface/src/components/inputs/InputSetupWizard/InputSetupWizard.SetupRouting.test.tsx b/graylog2-web-interface/src/components/inputs/InputSetupWizard/InputSetupWizard.SetupRouting.test.tsx new file mode 100644 index 000000000000..e95d7dc0f732 --- /dev/null +++ b/graylog2-web-interface/src/components/inputs/InputSetupWizard/InputSetupWizard.SetupRouting.test.tsx @@ -0,0 +1,209 @@ +/* + * 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, screen, fireEvent } from 'wrappedTestingLibrary'; +import selectEvent from 'react-select-event'; + +import { Button } from 'components/bootstrap'; +import { asMock } from 'helpers/mocking'; +import usePipelinesConnectedStream from 'hooks/usePipelinesConnectedStream'; +import { useInputSetupWizard, InputSetupWizardProvider, INPUT_WIZARD_STEPS } from 'components/inputs/InputSetupWizard'; +import type { WizardData } from 'components/inputs/InputSetupWizard'; +import useStreams from 'components/streams/hooks/useStreams'; + +import InputSetupWizard from './InputSetupWizard'; + +const OpenWizardTestButton = ({ wizardData } : { wizardData: WizardData}) => { + const { openWizard, setActiveStep } = useInputSetupWizard(); + + const open = () => { + setActiveStep(INPUT_WIZARD_STEPS.SETUP_ROUTING); + openWizard(wizardData); + }; + + return (); +}; + +const renderWizard = (wizardData: WizardData = {}) => ( + render( + + + + , + ) +); + +jest.mock('components/streams/hooks/useStreams'); +jest.mock('hooks/usePipelinesConnectedStream'); + +const useStreamsResult = (list = []) => ({ + data: { list: list, pagination: { total: 1 }, attributes: [] }, + isInitialLoading: false, + isFetching: false, + error: undefined, + refetch: () => {}, +}); + +const pipelinesConnectedMock = (response = []) => ({ + data: response, + refetch: jest.fn(), + isInitialLoading: false, + error: undefined, + isError: false, +}); + +beforeEach(() => { + asMock(useStreams).mockReturnValue(useStreamsResult()); + asMock(usePipelinesConnectedStream).mockReturnValue(pipelinesConnectedMock()); +}); + +describe('InputSetupWizard Setup Routing', () => { + it('should render the Setup Routing step', async () => { + renderWizard(); + + const openButton = await screen.findByRole('button', { name: /Open Wizard!/ }); + + fireEvent.click(openButton); + + const wizard = await screen.findByText('Setup Routing'); + + expect(wizard).toBeInTheDocument(); + }); + + it('should only show editable existing streams', async () => { + asMock(useStreams).mockReturnValue(useStreamsResult( + [ + { id: 'alohoid', title: 'Aloho', is_editable: true }, + { id: 'moraid', title: 'Mora', is_editable: false }, + ], + )); + + renderWizard(); + + const openButton = await screen.findByRole('button', { name: /Open Wizard!/ }); + + fireEvent.click(openButton); + + const streamSelect = await screen.findByLabelText(/All messages \(Default\)/i); + + await selectEvent.openMenu(streamSelect); + + const alohoOption = await screen.findByText(/Aloho/i); + const moraOption = screen.queryByText(/Mora/i); + + expect(alohoOption).toBeInTheDocument(); + expect(moraOption).not.toBeInTheDocument(); + }); + + it('should not show existing default stream in select', async () => { + asMock(useStreams).mockReturnValue(useStreamsResult( + [ + { id: 'alohoid', title: 'Aloho', is_editable: true, is_default: true }, + { id: 'moraid', title: 'Mora', is_editable: true }, + ], + )); + + renderWizard(); + + const openButton = await screen.findByRole('button', { name: /Open Wizard!/ }); + + fireEvent.click(openButton); + + const streamSelect = await screen.findByLabelText(/All messages \(Default\)/i); + + await selectEvent.openMenu(streamSelect); + + const moraOption = await screen.findByText(/Mora/i); + const alohoOption = screen.queryByText(/Aloho/i); + + expect(moraOption).toBeInTheDocument(); + expect(alohoOption).not.toBeInTheDocument(); + }); + + it('should allow the user to select a stream', async () => { + asMock(useStreams).mockReturnValue(useStreamsResult( + [ + { id: 'alohoid', title: 'Aloho', is_editable: true }, + { id: 'moraid', title: 'Mora', is_editable: true }, + ], + )); + + renderWizard(); + + const openButton = await screen.findByRole('button', { name: /Open Wizard!/ }); + + fireEvent.click(openButton); + + const streamSelect = await screen.findByLabelText(/All messages \(Default\)/i); + + await selectEvent.openMenu(streamSelect); + + await selectEvent.select(streamSelect, 'Aloho'); + }); + + it('should show a warning if the selected stream has connected pipelines', async () => { + asMock(useStreams).mockReturnValue(useStreamsResult( + [ + { id: 'alohoid', title: 'Aloho', is_editable: true }, + { id: 'moraid', title: 'Mora', is_editable: true }, + ], + )); + + asMock(usePipelinesConnectedStream).mockReturnValue(pipelinesConnectedMock([ + { id: 'pipeline1', title: 'Pipeline1' }, + { id: 'pipeline2', title: 'Pipeline2' }, + ])); + + renderWizard(); + + const openButton = await screen.findByRole('button', { name: /Open Wizard!/ }); + + fireEvent.click(openButton); + + const streamSelect = await screen.findByLabelText(/All messages \(Default\)/i); + + await selectEvent.openMenu(streamSelect); + + await selectEvent.select(streamSelect, 'Aloho'); + + const warning = await screen.findByText(/The selected stream has existing pipelines/i); + const warningPipeline1 = await screen.findByText(/Pipeline1/i); + const warningPipeline2 = await screen.findByText(/Pipeline2/i); + + expect(warning).toBeInTheDocument(); + expect(warningPipeline1).toBeInTheDocument(); + expect(warningPipeline2).toBeInTheDocument(); + }); + + it('should allow the user to create a new stream', async () => { + renderWizard(); + + const openButton = await screen.findByRole('button', { name: /Open Wizard!/i }); + + fireEvent.click(openButton); + + const createStreamButton = await screen.findByRole('button', { + name: /Create Stream/i, + hidden: true, + }); + fireEvent.click(createStreamButton); + + const createStreamHeadline = await screen.findByRole('heading', { name: /Create new stream/i, hidden: true }); + + expect(createStreamHeadline).toBeInTheDocument(); + }); +}); diff --git a/graylog2-web-interface/src/components/inputs/InputSetupWizard/InputSetupWizard.test.tsx b/graylog2-web-interface/src/components/inputs/InputSetupWizard/InputSetupWizard.test.tsx new file mode 100644 index 000000000000..d145198e2c4d --- /dev/null +++ b/graylog2-web-interface/src/components/inputs/InputSetupWizard/InputSetupWizard.test.tsx @@ -0,0 +1,103 @@ +/* + * 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, screen, fireEvent } from 'wrappedTestingLibrary'; + +import { Button } from 'components/bootstrap'; +import { asMock } from 'helpers/mocking'; +import useStreams from 'components/streams/hooks/useStreams'; +import usePipelinesConnectedStream from 'hooks/usePipelinesConnectedStream'; +import { useInputSetupWizard, InputSetupWizardProvider } from 'components/inputs/InputSetupWizard'; +import type { WizardData } from 'components/inputs/InputSetupWizard'; + +import InputSetupWizard from './InputSetupWizard'; + +const OpenWizardTestButton = ({ wizardData } : { wizardData: WizardData}) => { + const { openWizard } = useInputSetupWizard(); + + return (); +}; + +const CloseWizardTestButton = () => { + const { closeWizard } = useInputSetupWizard(); + + return (); +}; + +const renderWizard = (wizardData: WizardData = {}) => ( + render( + + + + + , + ) +); + +jest.mock('components/streams/hooks/useStreams'); +jest.mock('hooks/usePipelinesConnectedStream'); + +const useStreamsResult = (list = []) => ({ + data: { list: list, pagination: { total: 1 }, attributes: [] }, + isInitialLoading: false, + isFetching: false, + error: undefined, + refetch: () => {}, +}); + +const pipelinesConnectedMock = (response = []) => ({ + data: response, + refetch: jest.fn(), + isInitialLoading: false, + error: undefined, + isError: false, +}); + +beforeEach(() => { + asMock(useStreams).mockReturnValue(useStreamsResult()); + asMock(usePipelinesConnectedStream).mockReturnValue(pipelinesConnectedMock()); +}); + +describe('InputSetupWizard', () => { + it('should render the wizard and shows routing step as first step', async () => { + renderWizard(); + + const openButton = await screen.findByRole('button', { name: /Open Wizard!/ }); + + fireEvent.click(openButton); + + const wizard = await screen.findByText('Setup Routing'); + + expect(wizard).toBeInTheDocument(); + }); + + it('should close the wizard', async () => { + renderWizard(); + const openButton = await screen.findByRole('button', { name: /Open Wizard!/ }); + const closeButton = await screen.findByRole('button', { name: /Close Wizard!/ }); + + fireEvent.click(openButton); + + const wizard = await screen.findByText('Setup Routing'); + + expect(wizard).toBeInTheDocument(); + + fireEvent.click(closeButton); + + expect(wizard).not.toBeInTheDocument(); + }); +}); diff --git a/graylog2-web-interface/src/components/inputs/InputSetupWizard/InputSetupWizard.tsx b/graylog2-web-interface/src/components/inputs/InputSetupWizard/InputSetupWizard.tsx index db397342fa07..089105e1c3f8 100644 --- a/graylog2-web-interface/src/components/inputs/InputSetupWizard/InputSetupWizard.tsx +++ b/graylog2-web-interface/src/components/inputs/InputSetupWizard/InputSetupWizard.tsx @@ -15,66 +15,69 @@ * . */ import * as React from 'react'; -import { useEffect, useCallback, useMemo, useState } from 'react'; +import { useEffect, useCallback, useMemo } from 'react'; import { PluginStore } from 'graylog-web-plugin/plugin'; import { Modal } from 'components/bootstrap'; import { Wizard } from 'components/common'; import { INPUT_WIZARD_STEPS } from 'components/inputs/InputSetupWizard/types'; import useInputSetupWizard from 'components/inputs/InputSetupWizard/hooks/useInputSetupWizard'; +import { getStepData } from 'components/inputs/InputSetupWizard/helpers/stepHelper'; -import { TestInputStep } from './steps'; +import { InputDiagnosisStep, SetupRoutingStep, StartInputStep } from './steps'; const InputSetupWizard = () => { - const { activeStep, setActiveStep, show, closeWizard, wizardData } = useInputSetupWizard(); - const [orderedSteps, setOrderedSteps] = useState([]); - const { category, subcategory } = wizardData; + const { activeStep, setActiveStep, show, orderedSteps, setOrderedSteps, stepsData, closeWizard } = useInputSetupWizard(); const enterpriseSteps = PluginStore.exports('inputSetupWizard').find((plugin) => (!!plugin.steps))?.steps; const steps = useMemo(() => { const defaultSteps = { - [INPUT_WIZARD_STEPS.TEST_INPUT]: { - key: INPUT_WIZARD_STEPS.TEST_INPUT, + [INPUT_WIZARD_STEPS.SETUP_ROUTING]: { + key: INPUT_WIZARD_STEPS.SETUP_ROUTING, title: ( <> - Test Input + Setup Routing ), component: ( - + ), + disabled: false, + }, + [INPUT_WIZARD_STEPS.START_INPUT]: { + key: INPUT_WIZARD_STEPS.START_INPUT, + title: ( + <> + Start Input + + ), + component: ( + + ), + disabled: !getStepData(stepsData, INPUT_WIZARD_STEPS.START_INPUT, 'enabled'), + }, + [INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS]: { + key: INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS, + title: ( + <> + Input Diagnosis + + ), + component: ( + + ), + disabled: !getStepData(stepsData, INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS, 'enabled'), }, }; if (enterpriseSteps) return { ...defaultSteps, ...enterpriseSteps }; return defaultSteps; - }, [enterpriseSteps]); + }, [enterpriseSteps, stepsData]); const determineFirstStep = useCallback(() => { - if (!category || !subcategory) { - if (steps[INPUT_WIZARD_STEPS.SELECT_CATEGORY]) { - setActiveStep(INPUT_WIZARD_STEPS.SELECT_CATEGORY); - setOrderedSteps([INPUT_WIZARD_STEPS.SELECT_CATEGORY]); - - return; - } - - setActiveStep(INPUT_WIZARD_STEPS.TEST_INPUT); - setOrderedSteps([INPUT_WIZARD_STEPS.TEST_INPUT]); - - return; - } - - if (steps[INPUT_WIZARD_STEPS.ACTIVATE_ILLUMINATE]) { - setActiveStep(INPUT_WIZARD_STEPS.ACTIVATE_ILLUMINATE); - setOrderedSteps([INPUT_WIZARD_STEPS.ACTIVATE_ILLUMINATE]); - - return; - } - - setActiveStep(INPUT_WIZARD_STEPS.TEST_INPUT); - setOrderedSteps([INPUT_WIZARD_STEPS.TEST_INPUT]); - }, [setActiveStep, category, subcategory, steps]); + setActiveStep(INPUT_WIZARD_STEPS.SETUP_ROUTING); + setOrderedSteps([INPUT_WIZARD_STEPS.SETUP_ROUTING, INPUT_WIZARD_STEPS.START_INPUT, INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS]); + }, [setActiveStep, setOrderedSteps]); useEffect(() => { if (!activeStep) { @@ -84,7 +87,7 @@ const InputSetupWizard = () => { if (activeStep && orderedSteps.length < 1) { setOrderedSteps([activeStep]); } - }, [activeStep, determineFirstStep, orderedSteps]); + }, [activeStep, determineFirstStep, orderedSteps, setOrderedSteps]); if (!show || orderedSteps.length < 1) return null; diff --git a/graylog2-web-interface/src/components/inputs/InputSetupWizard/contexts/InputSetupWizardContext.tsx b/graylog2-web-interface/src/components/inputs/InputSetupWizard/contexts/InputSetupWizardContext.tsx index 67bf0c634048..cc9bcdce7318 100644 --- a/graylog2-web-interface/src/components/inputs/InputSetupWizard/contexts/InputSetupWizardContext.tsx +++ b/graylog2-web-interface/src/components/inputs/InputSetupWizard/contexts/InputSetupWizardContext.tsx @@ -14,19 +14,24 @@ * along with this program. If not, see * . */ + import * as React from 'react'; import { singleton } from 'logic/singleton'; -import type { InputSetupWizardStep, WizardData } from 'components/inputs/InputSetupWizard/types'; +import type { InputSetupWizardStep, WizardData, StepsData } from 'components/inputs/InputSetupWizard/types'; type InputSetupWizardContextType = { activeStep: InputSetupWizardStep | undefined, setActiveStep: (InputSetupWizardStep) => void, - getStepData: (stepName: InputSetupWizardStep) => object | undefined; - setStepData: (stepName: InputSetupWizardStep, data: object) => void, + stepsData: StepsData, + setStepsData: (stepsData: StepsData) => void, wizardData: WizardData, - setWizardDataAttribute: (key: keyof WizardData, value: WizardData[typeof key]) => void, + updateWizardData: (key: keyof WizardData, value: WizardData[typeof key]) => void, + orderedSteps: Array, + setOrderedSteps: (steps: Array) => void, show: boolean, + goToPreviousStep: () => void, + goToNextStep: (step?: InputSetupWizardStep) => void, openWizard: (data?: WizardData) => void, closeWizard: () => void, }; diff --git a/graylog2-web-interface/src/components/inputs/InputSetupWizard/contexts/InputSetupWizardProvider.test.tsx b/graylog2-web-interface/src/components/inputs/InputSetupWizard/contexts/InputSetupWizardProvider.test.tsx new file mode 100644 index 000000000000..d17dccacb455 --- /dev/null +++ b/graylog2-web-interface/src/components/inputs/InputSetupWizard/contexts/InputSetupWizardProvider.test.tsx @@ -0,0 +1,55 @@ +/* + * 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 InputSetupWizardContext from './InputSetupWizardContext'; +import InputSetupWizardProvider from './InputSetupWizardProvider'; + +const contextData = { + activeStep: undefined, + stepsData: {}, + wizardData: {}, + orderedSteps: [], + show: false, +}; + +const renderSUT = () => { + const consume = jest.fn(); + + render( + + + {consume} + + , + ); + + return consume; +}; + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render InputSetupWizardProvider', () => { + const consume = renderSUT(); + + expect(consume).toHaveBeenCalledWith(expect.objectContaining(contextData)); + }); +}); diff --git a/graylog2-web-interface/src/components/inputs/InputSetupWizard/contexts/InputSetupWizardProvider.tsx b/graylog2-web-interface/src/components/inputs/InputSetupWizard/contexts/InputSetupWizardProvider.tsx index ae7d2c9510e2..4a3ce87ad986 100644 --- a/graylog2-web-interface/src/components/inputs/InputSetupWizard/contexts/InputSetupWizardProvider.tsx +++ b/graylog2-web-interface/src/components/inputs/InputSetupWizard/contexts/InputSetupWizardProvider.tsx @@ -14,11 +14,13 @@ * along with this program. If not, see * . */ + import * as React from 'react'; import { useCallback, useMemo, useState } from 'react'; import InputSetupWizardContext from 'components/inputs/InputSetupWizard/contexts/InputSetupWizardContext'; import type { InputSetupWizardStep, StepsData, WizardData } from 'components/inputs/InputSetupWizard/types'; +import { addStepAfter, getNextStep, checkHasPreviousStep, checkIsNextStepDisabled } from 'components/inputs/InputSetupWizard/helpers/stepHelper'; const DEFAULT_ACTIVE_STEP = undefined; const DEFAULT_WIZARD_DATA = {}; @@ -27,19 +29,11 @@ const DEFAULT_STEPS_DATA = {}; const InputSetupWizardProvider = ({ children = null }: React.PropsWithChildren<{}>) => { const [activeStep, setActiveStep] = useState(DEFAULT_ACTIVE_STEP); const [wizardData, setWizardData] = useState(DEFAULT_WIZARD_DATA); + const [orderedSteps, setOrderedSteps] = useState>([]); const [stepsData, setStepsData] = useState(DEFAULT_STEPS_DATA); const [show, setShow] = useState(false); - const setStepData = useCallback( - (stepName: InputSetupWizardStep, data: object) => { - setStepsData({ ...stepsData, [stepName]: data }); - }, - [stepsData], - ); - - const getStepData = useCallback((stepName: InputSetupWizardStep) => (stepsData[stepName]), [stepsData]); - - const setWizardDataAttribute = useCallback( + const updateWizardData = useCallback( (key: keyof WizardData, value: WizardData[typeof key]) => { setWizardData({ ...wizardData, [key]: value }); }, @@ -62,24 +56,55 @@ const InputSetupWizardProvider = ({ children = null }: React.PropsWithChildren<{ setShow(true); }, [wizardData]); + const goToNextStep = useCallback((step?: InputSetupWizardStep) => { + const nextStep = step ?? getNextStep(orderedSteps, activeStep); + + if (step) { + const newOrderedSteps = addStepAfter(orderedSteps, step, activeStep); + setOrderedSteps(newOrderedSteps); + } + + if (!nextStep) return; + + if (checkIsNextStepDisabled(orderedSteps, activeStep, stepsData, nextStep)) return; + + const nextStepIndex = orderedSteps.indexOf(nextStep); + + setActiveStep(orderedSteps[nextStepIndex]); + }, [activeStep, orderedSteps, stepsData]); + + const goToPreviousStep = useCallback(() => { + if (!checkHasPreviousStep(orderedSteps, activeStep)) return; + + const previousStepIndex = orderedSteps.indexOf(activeStep) - 1; + + setActiveStep(orderedSteps[previousStepIndex]); + }, [activeStep, orderedSteps]); + const value = useMemo(() => ({ setActiveStep, activeStep, - getStepData, - setStepData, + stepsData, + setStepsData, wizardData, - setWizardDataAttribute, + updateWizardData, show, + orderedSteps, + setOrderedSteps, + goToPreviousStep, + goToNextStep, openWizard, closeWizard, }), [ - setActiveStep, activeStep, - getStepData, - setStepData, + stepsData, + setStepsData, wizardData, - setWizardDataAttribute, + updateWizardData, show, + orderedSteps, + goToPreviousStep, + goToNextStep, openWizard, closeWizard, ]); diff --git a/graylog2-web-interface/src/components/inputs/InputSetupWizard/helpers/stepHelper.test.ts b/graylog2-web-interface/src/components/inputs/InputSetupWizard/helpers/stepHelper.test.ts new file mode 100644 index 000000000000..e63822c2077c --- /dev/null +++ b/graylog2-web-interface/src/components/inputs/InputSetupWizard/helpers/stepHelper.test.ts @@ -0,0 +1,350 @@ +/* + * 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 { INPUT_WIZARD_STEPS } from 'components/inputs/InputSetupWizard/types'; +import type { StepData, StepsData } from 'components/inputs/InputSetupWizard/types'; + +import { + getStepData, + getNextStep, + checkHasNextStep, + checkHasPreviousStep, + checkIsNextStepDisabled, + addStepAfter, + updateStepData, + enableNextStep, +} from './stepHelper'; + +const stepsData = { + [INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS]: { + foo: 'foo1', + bar: 'bar1', + }, + [INPUT_WIZARD_STEPS.SELECT_CATEGORY]: { + aloho: 'aloho1', + mora: 'mora1', + }, +}; + +const orderedSteps = [INPUT_WIZARD_STEPS.SELECT_CATEGORY, INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS]; + +describe('stepHelper', () => { + describe('getStepData', () => { + it('returns data for specific step', () => { + expect(getStepData(stepsData as StepsData, INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS)).toEqual( + stepsData[INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS], + ); + }); + + it('returns undefined if no step data exists', () => { + expect(getStepData(stepsData as StepsData, INPUT_WIZARD_STEPS.SETUP_ROUTING)).toEqual( + undefined, + ); + }); + }); + + describe('getNextStep', () => { + it('returns the next step', () => { + expect(getNextStep(orderedSteps, INPUT_WIZARD_STEPS.SELECT_CATEGORY)).toEqual( + INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS, + ); + }); + + it('returns undefined if there is no next step', () => { + expect(getNextStep(orderedSteps, INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS)).toEqual( + undefined, + ); + }); + + it('returns undefined if active step is not in ordered steps', () => { + expect(getNextStep(orderedSteps, INPUT_WIZARD_STEPS.SETUP_ROUTING)).toEqual( + undefined, + ); + }); + }); + + describe('checkHasNextStep', () => { + it('returns true when there is a next step', () => { + expect(checkHasNextStep(orderedSteps, INPUT_WIZARD_STEPS.SELECT_CATEGORY)).toBe(true); + }); + + it('returns false when there is no next step', () => { + expect(checkHasNextStep(orderedSteps, INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS)).toBe(false); + }); + + it('returns false when the active step is not part of orderedSteps', () => { + expect(checkHasNextStep(orderedSteps, INPUT_WIZARD_STEPS.SETUP_ROUTING)).toBe(false); + }); + }); + + describe('checkHasPreviousStep', () => { + it('returns true when there is a previous step', () => { + expect(checkHasPreviousStep(orderedSteps, INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS)).toBe(true); + }); + + it('returns false when there is no previous step', () => { + expect(checkHasPreviousStep(orderedSteps, INPUT_WIZARD_STEPS.SELECT_CATEGORY)).toBe(false); + }); + + it('returns false when the active step is not part of orderedSteps', () => { + expect(checkHasPreviousStep(orderedSteps, INPUT_WIZARD_STEPS.SETUP_ROUTING)).toBe(false); + }); + }); + + describe('checkIsNextStepDisabled', () => { + it('returns true when the next step is disabled', () => { + const testStepsData = { + [INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS]: { + enabled: false, + }, + [INPUT_WIZARD_STEPS.SELECT_CATEGORY]: { + enabled: true, + }, + }; + + expect(checkIsNextStepDisabled(orderedSteps, INPUT_WIZARD_STEPS.SELECT_CATEGORY, testStepsData as StepsData)).toBe(true); + }); + + it('returns false when the next step is not disabled', () => { + const testStepsData = { + [INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS]: { + enabled: true, + }, + [INPUT_WIZARD_STEPS.SELECT_CATEGORY]: { + enabled: true, + }, + }; + + expect(checkIsNextStepDisabled(orderedSteps, INPUT_WIZARD_STEPS.SELECT_CATEGORY, testStepsData as StepsData)).toBe(false); + }); + + it('returns true when there is no data for the next step', () => { + const testStepsData = { + [INPUT_WIZARD_STEPS.SELECT_CATEGORY]: { + enabled: true, + }, + }; + + expect(checkIsNextStepDisabled(orderedSteps, INPUT_WIZARD_STEPS.SELECT_CATEGORY, testStepsData as StepsData)).toBe(true); + }); + + it('returns true there is no next step', () => { + const testStepsData = { + [INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS]: { + enabled: true, + }, + [INPUT_WIZARD_STEPS.SELECT_CATEGORY]: { + enabled: true, + }, + }; + + expect(checkIsNextStepDisabled(orderedSteps, INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS, testStepsData as StepsData)).toBe(true); + }); + }); + + describe('enableNextStep', () => { + it('returns updated steps data with next step enabled', () => { + const testStepsData = { + [INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS]: { + enabled: false, + }, + [INPUT_WIZARD_STEPS.SELECT_CATEGORY]: { + enabled: false, + }, + }; + + expect(enableNextStep(orderedSteps, INPUT_WIZARD_STEPS.SELECT_CATEGORY, testStepsData as StepsData)).toEqual({ + [INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS]: { + enabled: true, + }, + [INPUT_WIZARD_STEPS.SELECT_CATEGORY]: { + enabled: false, + }, + }); + }); + + it('returns updated steps data with next step enabled when there is no data for the step yet', () => { + const testStepsData = { + [INPUT_WIZARD_STEPS.SELECT_CATEGORY]: { + enabled: false, + }, + }; + + expect(enableNextStep(orderedSteps, INPUT_WIZARD_STEPS.SELECT_CATEGORY, testStepsData as StepsData)).toEqual({ + [INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS]: { + enabled: true, + }, + [INPUT_WIZARD_STEPS.SELECT_CATEGORY]: { + enabled: false, + }, + }); + }); + + it('returns the original steps data when there is no next step', () => { + const testStepsData = { + [INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS]: { + enabled: false, + }, + [INPUT_WIZARD_STEPS.SELECT_CATEGORY]: { + enabled: false, + }, + }; + + expect(enableNextStep(orderedSteps, INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS, testStepsData as StepsData)).toEqual(testStepsData); + }); + + it('returns the original steps data when the active step is not in orderedSteps', () => { + const testStepsData = { + [INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS]: { + enabled: false, + }, + [INPUT_WIZARD_STEPS.SELECT_CATEGORY]: { + enabled: false, + }, + }; + + expect(enableNextStep(orderedSteps, INPUT_WIZARD_STEPS.SETUP_ROUTING, testStepsData as StepsData)).toEqual(testStepsData); + }); + }); + + describe('updateStepData', () => { + it('returns updated steps data with new attribute', () => { + const testStepsData = { + [INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS]: { + enabled: false, + }, + [INPUT_WIZARD_STEPS.SELECT_CATEGORY]: { + enabled: false, + }, + }; + + expect(updateStepData(testStepsData as StepsData, INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS, { foo: 'bar' } as StepData)).toEqual({ + [INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS]: { + enabled: false, + foo: 'bar', + }, + [INPUT_WIZARD_STEPS.SELECT_CATEGORY]: { + enabled: false, + }, + }); + }); + + it('returns updated steps data with updated existing attribute', () => { + const testStepsData = { + [INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS]: { + enabled: false, + foo: 'foo', + }, + [INPUT_WIZARD_STEPS.SELECT_CATEGORY]: { + enabled: true, + foo: 'foo', + }, + }; + + expect(updateStepData(testStepsData as StepsData, INPUT_WIZARD_STEPS.SELECT_CATEGORY, { foo: 'bar' } as StepData)).toEqual({ + [INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS]: { + enabled: false, + foo: 'foo', + }, + [INPUT_WIZARD_STEPS.SELECT_CATEGORY]: { + enabled: true, + foo: 'bar', + }, + }); + }); + + it('returns the original steps data when no data was given', () => { + const testStepsData = { + [INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS]: { + enabled: false, + foo: 'foo', + }, + [INPUT_WIZARD_STEPS.SELECT_CATEGORY]: { + enabled: true, + foo: 'foo', + }, + }; + + expect(updateStepData(testStepsData as StepsData, INPUT_WIZARD_STEPS.SELECT_CATEGORY, {} as StepData)).toEqual(testStepsData); + }); + + it('returns updated steps data when no step data existed', () => { + const testStepsData = { + [INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS]: { + enabled: false, + foo: 'foo', + }, + }; + + expect(updateStepData(testStepsData as StepsData, INPUT_WIZARD_STEPS.SELECT_CATEGORY, { foo: 'bar' } as StepData)).toEqual({ + [INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS]: { + enabled: false, + foo: 'foo', + }, + [INPUT_WIZARD_STEPS.SELECT_CATEGORY]: { + foo: 'bar', + }, + }); + }); + + it('returns new steps data when no steps data existed', () => { + expect(updateStepData(undefined, INPUT_WIZARD_STEPS.SELECT_CATEGORY, { foo: 'bar' } as StepData)).toEqual({ + [INPUT_WIZARD_STEPS.SELECT_CATEGORY]: { + foo: 'bar', + }, + }); + }); + + it('returns empty object when no step name is given', () => { + expect(updateStepData(undefined, undefined, { foo: 'bar' } as StepData)).toEqual({}); + }); + }); + + describe('addStepAfter', () => { + it('returns ordered steps with added step in the middle', () => { + const testOrderedSteps = [INPUT_WIZARD_STEPS.SELECT_CATEGORY, INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS]; + + expect(addStepAfter(testOrderedSteps, INPUT_WIZARD_STEPS.SETUP_ROUTING, INPUT_WIZARD_STEPS.SELECT_CATEGORY)).toEqual( + [INPUT_WIZARD_STEPS.SELECT_CATEGORY, INPUT_WIZARD_STEPS.SETUP_ROUTING, INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS], + ); + }); + + it('returns ordered steps with added step at the end', () => { + const testOrderedSteps = [INPUT_WIZARD_STEPS.SELECT_CATEGORY, INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS]; + + expect(addStepAfter(testOrderedSteps, INPUT_WIZARD_STEPS.SETUP_ROUTING, INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS)).toEqual( + [INPUT_WIZARD_STEPS.SELECT_CATEGORY, INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS, INPUT_WIZARD_STEPS.SETUP_ROUTING], + ); + }); + + it('returns ordered steps with added step at the end when no step to set after given', () => { + const testOrderedSteps = [INPUT_WIZARD_STEPS.SELECT_CATEGORY, INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS]; + + expect(addStepAfter(testOrderedSteps, INPUT_WIZARD_STEPS.SETUP_ROUTING)).toEqual( + [INPUT_WIZARD_STEPS.SELECT_CATEGORY, INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS, INPUT_WIZARD_STEPS.SETUP_ROUTING], + ); + }); + + it('returns original ordered steps when step to set after is not in the array', () => { + const testOrderedSteps = [INPUT_WIZARD_STEPS.SELECT_CATEGORY, INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS]; + + expect(addStepAfter(testOrderedSteps, INPUT_WIZARD_STEPS.SETUP_ROUTING, INPUT_WIZARD_STEPS.START_INPUT)).toEqual( + testOrderedSteps, + ); + }); + }); +}); diff --git a/graylog2-web-interface/src/components/inputs/InputSetupWizard/helpers/stepHelper.ts b/graylog2-web-interface/src/components/inputs/InputSetupWizard/helpers/stepHelper.ts new file mode 100644 index 000000000000..180bbe30fea1 --- /dev/null +++ b/graylog2-web-interface/src/components/inputs/InputSetupWizard/helpers/stepHelper.ts @@ -0,0 +1,93 @@ +/* + * 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 type { InputSetupWizardStep, StepsData, StepData } from 'components/inputs/InputSetupWizard/types'; + +export const getStepData = (stepsData: StepsData, stepName: InputSetupWizardStep, key?: string) => { + if (key) return stepsData[stepName] ? stepsData[stepName][key] : undefined; + + return stepsData[stepName]; +}; + +export const getNextStep = (orderedSteps: Array, activeStep: InputSetupWizardStep) : InputSetupWizardStep | undefined => { + const activeStepIndex = orderedSteps.indexOf(activeStep); + + if (activeStepIndex < 0) return undefined; + + return orderedSteps[activeStepIndex + 1]; +}; + +export const checkIsNextStepDisabled = (orderedSteps: Array, activeStep: InputSetupWizardStep, stepsData: StepsData, step?: InputSetupWizardStep) => { + const nextStep = step ?? getNextStep(orderedSteps, activeStep); + + return !stepsData[nextStep]?.enabled; +}; + +export const checkHasNextStep = (orderedSteps: Array, activeStep: InputSetupWizardStep) => { + const nextStep = getNextStep(orderedSteps, activeStep); + + return !!nextStep; +}; + +export const checkHasPreviousStep = (orderedSteps: Array, activeStep: InputSetupWizardStep) => { + if (orderedSteps.length === 0 || !activeStep) return false; + + const activeStepIndex = orderedSteps.indexOf(activeStep); + + if (activeStepIndex === -1) return false; + + if (activeStepIndex === 0) return false; + + return true; +}; + +export const addStepAfter = (orderedSteps: Array, step: InputSetupWizardStep, setAfterStep?: InputSetupWizardStep) : Array => { + if (!setAfterStep) return [...orderedSteps, step]; + + const setAfterStepIndex = orderedSteps.indexOf(setAfterStep); + + if (setAfterStepIndex === -1) return orderedSteps; + + const newOrderedSteps = [ + ...orderedSteps.slice(0, setAfterStepIndex + 1), + step, + ...orderedSteps.slice(setAfterStepIndex + 1), + ]; + + return newOrderedSteps; +}; + +export const updateStepData = (stepsData: StepsData, stepName: InputSetupWizardStep, data: StepData = {}) : StepsData => { + if (!stepName) return {}; + + if (!stepsData) return { [stepName]: data }; + + return { ...stepsData, [stepName]: { ...stepsData[stepName], ...data } }; +}; + +export const enableNextStep = ( + orderedSteps: Array, + activeStep: InputSetupWizardStep, + stepsData: StepsData, + step?: InputSetupWizardStep, +) : StepsData => { + const nextStep = step ?? getNextStep(orderedSteps, activeStep); + + if (!nextStep) return stepsData; + + return updateStepData(stepsData, nextStep, { enabled: true }); +}; diff --git a/graylog2-web-interface/src/components/inputs/InputSetupWizard/steps/InputDiagnosisStep.tsx b/graylog2-web-interface/src/components/inputs/InputSetupWizard/steps/InputDiagnosisStep.tsx new file mode 100644 index 000000000000..08980241c651 --- /dev/null +++ b/graylog2-web-interface/src/components/inputs/InputSetupWizard/steps/InputDiagnosisStep.tsx @@ -0,0 +1,25 @@ +/* + * 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'; + +const InputDiagnosisStep = () => ( +
+ Input Diagnosis +
+); + +export default InputDiagnosisStep; diff --git a/graylog2-web-interface/src/components/inputs/InputSetupWizard/steps/SetupRoutingStep.tsx b/graylog2-web-interface/src/components/inputs/InputSetupWizard/steps/SetupRoutingStep.tsx new file mode 100644 index 000000000000..574a68facfbe --- /dev/null +++ b/graylog2-web-interface/src/components/inputs/InputSetupWizard/steps/SetupRoutingStep.tsx @@ -0,0 +1,181 @@ +/* + * 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 { useEffect, useMemo, useState } from 'react'; +import styled, { css } from 'styled-components'; + +import { Alert, Button, Row, Col } from 'components/bootstrap'; +import { Select } from 'components/common'; +import useInputSetupWizard from 'components/inputs/InputSetupWizard/hooks/useInputSetupWizard'; +import { defaultCompare } from 'logic/DefaultCompare'; +import type { StepData } from 'components/inputs/InputSetupWizard/types'; +import { INPUT_WIZARD_STEPS } from 'components/inputs/InputSetupWizard/types'; +import CreateStream from 'components/inputs/InputSetupWizard/steps/components/CreateStream'; +import { checkHasPreviousStep, checkHasNextStep, checkIsNextStepDisabled, enableNextStep, updateStepData } from 'components/inputs/InputSetupWizard/helpers/stepHelper'; +import useStreams from 'components/streams/hooks/useStreams'; +import usePipelinesConnectedStream from 'hooks/usePipelinesConnectedStream'; + +const StepCol = styled(Col)(({ theme }) => css` + padding-left: ${theme.spacings.lg}; + padding-right: ${theme.spacings.lg}; + padding-top: ${theme.spacings.sm}; +`); + +const DescriptionCol = styled(Col)(({ theme }) => css` + margin-bottom: ${theme.spacings.md}; +`); + +const StyledHeading = styled.h3(({ theme }) => css` + margin-bottom: ${theme.spacings.md}; +`); + +const ExistingStreamCol = styled(Col)(({ theme }) => css` + padding-top: ${theme.spacings.sm}; + padding-bottom: ${theme.spacings.md}; +`); + +const CreateStreamCol = styled(Col)(({ theme }) => css` + border-left: 1px solid ${theme.colors.cards.border}; + padding-top: ${theme.spacings.sm}; + padding-bottom: ${theme.spacings.md}; +`); + +const ButtonCol = styled(Col)(({ theme }) => css` + display: flex; + justify-content: flex-end; + gap: ${theme.spacings.xs}; + margin-top: ${theme.spacings.lg}; +`); + +const ConntectedPipelinesList = styled.ul` + list-style-type: disc; + padding-left: 20px; +`; + +interface RoutingStepData extends StepData { + streamId: string +} + +const SetupRoutingStep = () => { + const { goToPreviousStep, goToNextStep, orderedSteps, activeStep, stepsData, setStepsData } = useInputSetupWizard(); + const [selectedStreamId, setSelectedStreamId] = useState(undefined); + const [showCreateStream, setShowCreateStream] = useState(false); + const hasPreviousStep = checkHasPreviousStep(orderedSteps, activeStep); + const hasNextStep = checkHasNextStep(orderedSteps, activeStep); + const isNextStepDisabled = checkIsNextStepDisabled(orderedSteps, activeStep, stepsData); + const currentStepName = INPUT_WIZARD_STEPS.SETUP_ROUTING; + const { data: streamsData, isInitialLoading: isLoadingStreams } = useStreams({ query: '', page: 1, pageSize: 0, sort: { direction: 'asc', attributeId: 'title' } }); + const streams = streamsData?.list; + const { data: streamPipelinesData } = usePipelinesConnectedStream(selectedStreamId, !!selectedStreamId); + + useEffect(() => { + if (orderedSteps && activeStep && stepsData) { + const withNextStepEnabled = enableNextStep(orderedSteps, activeStep, stepsData); + setStepsData(withNextStepEnabled); + } // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const options = useMemo(() => { + if (!streams) return []; + + return streams + .filter(({ is_default, is_editable }) => !is_default && is_editable) + .sort(({ title: key1 }, { title: key2 }) => defaultCompare(key1, key2)) + .map(({ title, id }) => ({ label: title, value: id })); + }, [streams]); + + const handleStreamSelect = (streamId: string) => { + setSelectedStreamId(streamId); + }; + + const onNextStep = () => { + setStepsData( + updateStepData(stepsData, currentStepName, { streamId: selectedStreamId } as RoutingStepData), + ); + + goToNextStep(); + }; + + const handleBackClick = () => { + if (showCreateStream) { + setShowCreateStream(false); + + return; + } + + goToPreviousStep(); + }; + + const streamHasConnectedPipelines = streamPipelinesData && streamPipelinesData?.length > 0; + + return ( + + + + +

+ Choose a Destination Stream to Route Messages from this Input to. Messages that are not + routed to any streams will be sent to the "All Messages" Stream. +

+
+
+ {selectedStreamId && streamHasConnectedPipelines && ( + + + + The selected stream has existing pipelines connected to it: + + {streamPipelinesData.map((pipeline) =>
  • {pipeline.title}
  • )} +
    +
    + +
    + )} + {showCreateStream ? () : ( + + + Choose an existing Stream + {!isLoadingStreams && ( +