From 417b4dbe9b3472fd64212ef8233b865585e5ade3 Mon Sep 17 00:00:00 2001 From: lgalis Date: Mon, 12 Aug 2024 15:42:56 -0400 Subject: [PATCH] Add event source mappings in Rulebook Activation form (#2907) --- frontend/eda/interfaces/EdaSource.ts | 9 ++ frontend/eda/interfaces/generated/eda-api.ts | 6 + .../RulebookActivationForm.tsx | 88 +++++++----- .../components/SourceEventMapFields.tsx | 108 ++++++++++++++ .../SourceEventStreamMapping.cy.tsx | 78 +++++++++++ .../components/SourceEventStreamMapping.tsx | 132 ++++++++++++++++++ 6 files changed, 388 insertions(+), 33 deletions(-) create mode 100644 frontend/eda/interfaces/EdaSource.ts create mode 100644 frontend/eda/rulebook-activations/components/SourceEventMapFields.tsx create mode 100644 frontend/eda/rulebook-activations/components/SourceEventStreamMapping.cy.tsx create mode 100644 frontend/eda/rulebook-activations/components/SourceEventStreamMapping.tsx diff --git a/frontend/eda/interfaces/EdaSource.ts b/frontend/eda/interfaces/EdaSource.ts new file mode 100644 index 0000000000..c8708cad8b --- /dev/null +++ b/frontend/eda/interfaces/EdaSource.ts @@ -0,0 +1,9 @@ +import { Source } from './generated/eda-api'; +export type EdaSource = Source; + +export type EdaSourceEventMapping = { + source_name: string; + webhook_id: string; + webhook_name: string; + rulebook_hash: string; +}; diff --git a/frontend/eda/interfaces/generated/eda-api.ts b/frontend/eda/interfaces/generated/eda-api.ts index 5c40c13785..2b970e30dc 100644 --- a/frontend/eda/interfaces/generated/eda-api.ts +++ b/frontend/eda/interfaces/generated/eda-api.ts @@ -1552,6 +1552,12 @@ export enum ScmTypeEnum { Git = 'git', } +export interface Source { + name: string; + source_info: string; + rulebook_hash: string; +} + /** * * `starting` - starting * * `running` - running diff --git a/frontend/eda/rulebook-activations/RulebookActivationForm.tsx b/frontend/eda/rulebook-activations/RulebookActivationForm.tsx index 8d68f341ee..06df66484a 100644 --- a/frontend/eda/rulebook-activations/RulebookActivationForm.tsx +++ b/frontend/eda/rulebook-activations/RulebookActivationForm.tsx @@ -1,5 +1,5 @@ -import { useCallback } from 'react'; -import { useWatch } from 'react-hook-form'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { Link, useNavigate } from 'react-router-dom'; import { @@ -12,8 +12,10 @@ import { PageLayout, compareStrings, useGetPageUrl, + usePageDialog, usePageNavigate, } from '../../../framework'; +import { Button, Label, Tooltip } from '@patternfly/react-core'; import { PageFormAsyncSelect } from '../../../framework/PageForm/Inputs/PageFormAsyncSelect'; import { PageFormSection } from '../../../framework/PageForm/Utils/PageFormSection'; import { requestGet, swrOptions } from '../../common/crud/Data'; @@ -37,8 +39,11 @@ import { EdaProjectCell } from '../projects/components/EdaProjectCell'; import { PageFormSelectOrganization } from '../access/organizations/components/PageFormOrganizationSelect'; import useSWR from 'swr'; import { EdaOrganization } from '../interfaces/EdaOrganization'; -import { EdaWebhook } from '../interfaces/EdaWebhook'; -import { PageFormMultiSelect } from '../../../framework/PageForm/Inputs/PageFormMultiSelect'; +import { SourceEventStreamMappingModal } from './components/SourceEventStreamMapping'; +import { EdaSourceEventMapping } from '../interfaces/EdaSource'; +import { PageFormGroup } from '../../../framework/PageForm/Inputs/PageFormGroup'; +import jsyaml from 'js-yaml'; +import { LabelGroupWrapper } from '../../common/label-group-wrapper'; export function CreateRulebookActivation() { const { t } = useTranslation(); @@ -94,7 +99,6 @@ export function CreateRulebookActivation() { restart_policy: RestartPolicyEnum.OnFailure, log_level: LogLevelEnum.Error, is_enabled: true, - swap_single_source: false, }} > @@ -106,6 +110,8 @@ export function CreateRulebookActivation() { export function RulebookActivationInputs() { const { t } = useTranslation(); const getPageUrl = useGetPageUrl(); + const [sourceMappings, setSourceMappings] = useState([]); + const { register, setValue } = useFormContext(); const restartPolicyHelpBlock = ( <>

@@ -138,8 +144,8 @@ export function RulebookActivationInputs() { const { data: tokens } = useGet>( edaAPI`/users/me/awx-tokens/?page=1&page_size=200` ); - const { data: webhooks } = useGet>(edaAPI`/webhooks/?page=1&page_size=200`); + const [_, setDialog] = usePageDialog(); const RESTART_OPTIONS = [ { label: t('On failure'), value: 'on-failure' }, { label: t('Always'), value: 'always' }, @@ -156,6 +162,10 @@ export function RulebookActivationInputs() { name: 'project_id', }) as number; + const rulebook = useWatch({ + name: 'rulebook', + }) as EdaRulebook; + const query = useCallback(async () => { const response = await requestGet>( projectId !== undefined @@ -168,6 +178,14 @@ export function RulebookActivationInputs() { }); }, [projectId]); + useEffect(() => { + setValue('source_mappings', jsyaml.dump(sourceMappings)); + }, [setValue, sourceMappings]); + + useEffect(() => { + setSourceMappings(undefined); + }, [rulebook, setSourceMappings]); + return ( <> @@ -215,7 +233,37 @@ export function RulebookActivationInputs() { isRequired labelHelp={t('Rulebooks will be shown according to the project selected.')} labelHelpTitle={t('Rulebook')} + additionalControls={ + + } /> + {sourceMappings && sourceMappings.length > 0 && ( + + + {sourceMappings.map((map) => ( + <> + {map.source_name}}> + + + + ))} + + + )} + name="credential_refs" credentialKinds={['vault,cloud']} @@ -289,32 +337,6 @@ export function RulebookActivationInputs() { labelHelp={t('Optional service name.')} labelHelpTitle={t('Service name')} /> - - - name="webhooks" - label={t('Event stream(s)')} - options={ - webhooks?.results - ? webhooks.results.map((item) => ({ - label: item?.name || '', - value: `${item.id}`, - })) - : [] - } - placeholder={t('Select event stream(s)')} - footer={Create event stream} - /> - - id="swap_single_source" - name="swap_single_source" - label={t('Swap single source?')} - labelHelp={t( - 'Event streams can be used to swap out one or more sources in your rulebook, the name of the source and the event stream have to match.' - )} - labelHelpTitle={t('Swap single source')} - /> - - id="rulebook-activation" name="is_enabled" @@ -345,5 +367,5 @@ type IEdaRulebookActivationInputs = Omit void; +}) { + const { t } = useTranslation(); + const { index, onDelete } = props; + const { register, setValue } = useFormContext(); + + const { data: sources } = useGet>( + edaAPI`/rulebooks/` + `${props?.rulebook?.id}/sources/?page=1&page_size=200` + ); + const { data: events } = useGet>(edaAPI`/webhooks/?page=1&page_size=200`); + + const selectedSource = useWatch({ name: `mappings.${index}.source_name` }) as string; + let srcIndex = -1; + if (sources?.results) { + srcIndex = sources.results.findIndex((source) => source.name === selectedSource); + + if (srcIndex > -1) { + setValue(`${index}.source_info`, sources.results[srcIndex].source_info); + setValue(`mappings.${index}.rulebook_hash`, sources.results[srcIndex].rulebook_hash); + } + } + const selectedEvent = useWatch({ name: `mappings.${index}.webhook_id` }) as number; + let evIndex = -1; + if (events?.results) { + evIndex = events.results.findIndex((event) => event.id === selectedEvent); + + if (evIndex > -1) { + setValue(`mappings.${index}.webhook_name`, events.results[evIndex].name); + } + } + + return ( + } + aria-label={t('Delete map')} + onClick={() => onDelete(index)} + variant="plain" + /> + } + /> + } + > + ({ + label: item.name, + value: item.name, + })) + : [] + } + /> + ({ + label: item.name, + value: item.id, + })) + : [] + } + /> + + + + ); +} diff --git a/frontend/eda/rulebook-activations/components/SourceEventStreamMapping.cy.tsx b/frontend/eda/rulebook-activations/components/SourceEventStreamMapping.cy.tsx new file mode 100644 index 0000000000..5bfcb77ef6 --- /dev/null +++ b/frontend/eda/rulebook-activations/components/SourceEventStreamMapping.cy.tsx @@ -0,0 +1,78 @@ +import { edaAPI } from '../../common/eda-utils'; +import { SourceEventStreamMappingModal } from './SourceEventStreamMapping'; + +describe('SourceEventStreamMapping.cy.ts', () => { + beforeEach(() => { + cy.intercept( + { method: 'GET', url: edaAPI`/rulebooks/1/sources/?page=1&page_size=200` }, + { + count: 3, + next: null, + previous: null, + page_size: 10, + page: 1, + results: [ + { + name: '__SOURCE_1', + source_info: 'ansible.eda.sample1:\n sample1: 10\n', + rulebook_hash: 'hash_1', + }, + { + name: '__SOURCE_2', + source_info: 'ansible.eda.sample2:\n sample2: 10\n', + rulebook_hash: 'hash_2', + }, + { + name: '__SOURCE_3', + source_info: 'ansible.eda.sample3:\n sample3: 10\n', + rulebook_hash: 'hash_3', + }, + ], + } + ); + cy.intercept( + { method: 'GET', url: edaAPI`/webhooks/*` }, + { + fixture: 'edaWebhooks.json', + } + ); + }); + + it('Renders the correct rulebook activations columns', () => { + cy.mount( + = 0\n action:\n run_job_template:\n name: Demo Job Template\n organization: Default\n job_args:\n extra_vars:\n hello: Fred\n retries: 1\n delay: 10\n', + project_id: 2, + organization_id: 1, + created_at: '2024-08-07T22:29:42.081976Z', + modified_at: '2024-08-07T22:29:42.081983Z', + }} + mappings={undefined} + setSourceMappings={() => undefined} + /> + ); + cy.get('.pf-v5-c-modal-box__title-text').should('contain', 'Event streams'); + cy.contains( + 'Event streams represent server side webhooks which ease the routing issues related to running webhooks individually in a container or a pod. ' + + 'You can swap the sources in your rulebook with a matching event stream. Typically the sources to swap out are of the type ansible.eda.rulebook, ' + + 'but you may also be able to swap out your own webhook source plugins. The swapping process replaces just the source type and args and leaves your ' + + 'filters intact. We swap out the webhook source type with a source of type ansible.eda.pg_listener.' + ).should('be.visible'); + cy.get('[data-cy="rulebook"]').should('contain.text', 'sample_rulebook.yml'); + cy.get('[data-cy="number-of-sources"]').should('contain.text', '3'); + cy.get('[data-cy="add_event_stream"]').should('contain.text', 'Add event stream'); + cy.clickButton(/^Add event stream$/); + cy.get('[data-cy="mapping-header-0"]').should('contain.text', 'Mapping 1'); + cy.get('[data-cy="mappings-0-source-name"]').click(); + cy.get('#--source-1 > .pf-v5-c-menu__item-main > .pf-v5-c-menu__item-text').click(); + cy.get('[data-cy="0-source-info"]').should( + 'contain.text', + 'ansible.eda.sample1:\n sample1: 10\n' + ); + }); +}); diff --git a/frontend/eda/rulebook-activations/components/SourceEventStreamMapping.tsx b/frontend/eda/rulebook-activations/components/SourceEventStreamMapping.tsx new file mode 100644 index 0000000000..7ee0642e23 --- /dev/null +++ b/frontend/eda/rulebook-activations/components/SourceEventStreamMapping.tsx @@ -0,0 +1,132 @@ +import { Button, Modal, ModalBoxBody, ModalVariant } from '@patternfly/react-core'; +import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + PageDetail, + PageDetails, + PageFormSubmitHandler, + usePageDialog, +} from '../../../../framework'; +import { SourceEventMapFields } from './SourceEventMapFields'; +import { EdaRulebook } from '../../interfaces/EdaRulebook'; +import { EdaSource, EdaSourceEventMapping } from '../../interfaces/EdaSource'; +import { EdaPageForm } from '../../common/EdaPageForm'; +import { PageFormSection } from '../../../../framework/PageForm/Utils/PageFormSection'; +import { useFieldArray, useFormContext } from 'react-hook-form'; +import { PlusCircleIcon } from '@patternfly/react-icons'; +import { useGet } from '../../../common/crud/useGet'; +import { EdaResult } from '../../interfaces/EdaResult'; +import { edaAPI } from '../../common/eda-utils'; + +export interface EventStreamMappingProps { + rulebook: EdaRulebook; + mappings: EdaSourceEventMapping[] | undefined; + setSourceMappings: (sourceMappings: EdaSourceEventMapping[]) => void; +} + +export function SourceEventStreamMapping(options: EventStreamMappingProps) { + const { t } = useTranslation(); + const { control, setValue } = useFormContext(); + + const { + fields: mappings, + append: addMap, + remove: removeMap, + } = useFieldArray({ + control, + name: 'mappings', + shouldUnregister: false, + }); + + const addMapping = () => { + const map: EdaSourceEventMapping = { + source_name: '', + webhook_id: '', + webhook_name: '', + rulebook_hash: '', + }; + addMap(map); + }; + const { data: sources } = useGet>( + edaAPI`/rulebooks/` + `${options?.rulebook?.id}/sources/?page=1&page_size=200` + ); + + useEffect(() => { + setValue('mappings', options.mappings); + }, [setValue, options.mappings]); + + return ( + <> + + + {options?.rulebook?.name} + {sources?.count} + + {mappings.map((map, i) => ( + + ))} + + + + + + ); +} + +/** + */ +export function SourceEventStreamMappingModal(options: EventStreamMappingProps) { + const { t } = useTranslation(); + const [_, setDialog] = usePageDialog(); + + const onClose = () => setDialog(undefined); + + const onSubmit: PageFormSubmitHandler<{ mappings: EdaSourceEventMapping[] }> = (values) => { + options.setSourceMappings(values.mappings); + onClose(); + return Promise.resolve(); + }; + + return ( + + {t( + 'Event streams represent server side webhooks which ease the routing issues related to running webhooks individually in a container or a pod. ' + + 'You can swap the sources in your rulebook with a matching event stream. Typically the sources to swap out are of the type ansible.eda.rulebook, ' + + 'but you may also be able to swap out your own webhook source plugins. The swapping process replaces just the source type and args and leaves your ' + + 'filters intact. We swap out the webhook source type with a source of type ansible.eda.pg_listener.' + )} + + } + variant={ModalVariant.large} + isOpen + onClose={onClose} + hasNoBodyWrapper + > + + + + + + + ); +}