Skip to content

Commit

Permalink
Improve reusability of event definition wizard. (#18731)
Browse files Browse the repository at this point in the history
* Unify event definition type.

* Remove redundant event definition type.

* Create component for event definition form buttons.

* Make event definition form controls pluggable.

* Fixing linter hint.

* Default to first step when in security mode

* Fix duplicate in event definition form (#18722)

* register event definition export globally

- EventDefinitionsBindings, EventNotificationsBindings, FieldValueProvidersBindings

* fix types

* Fix search query being erased while typing

* fix test

* fix types

* Make initial step configurable.

* Only set URL query param `step` when necessary.

* Refactor from controls.

* Update props:

* Reimplement `onChange` in `FilterForm` to fix problem with params.

* make embryonic only constrained to FilterForm

* Fixing linter hints.

---------

Co-authored-by: Simon Huang <[email protected]>
Co-authored-by: Ousmane SAMBA <[email protected]>
Co-authored-by: Ousmane Samba <[email protected]>
  • Loading branch information
4 people authored Mar 21, 2024
1 parent 475f722 commit d6ebb26
Show file tree
Hide file tree
Showing 29 changed files with 301 additions and 238 deletions.
19 changes: 18 additions & 1 deletion graylog2-web-interface/src/@types/graylog-web-plugin/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,30 @@ type DataTiering = {
}>,
}

type FieldValueProvider = {
type: string,
displayName: string,
formComponent: React.ComponentType,
summaryComponent: React.ComponentType,
defaultConfig: {
template?: string,
table_name?: string,
key_field?: string,
},
requiredFields: {
template?: string,
table_name?: string,
key_field?: string,
},
}
declare module 'graylog-web-plugin/plugin' {
interface PluginExports {
navigation?: Array<PluginNavigation>;
dataTiering?: Array<DataTiering>
defaultNavigation?: Array<PluginNavigation>;
navigationItems?: Array<PluginNavigationItems>;
globalNotifications?: Array<GlobalNotification>
globalNotifications?: Array<GlobalNotification>;
fieldValueProviders?:Array<FieldValueProvider>;
// Global context providers allow to fetch and process data once
// and provide the result for all components in your plugin.
globalContextProviders?: Array<React.ComponentType<React.PropsWithChildrean<{}>>>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ import { Select } from 'components/common';
import { Clearfix, Col, ControlLabel, FormGroup, HelpBlock, Row } from 'components/bootstrap';
import { HelpPanel } from 'components/event-definitions/common/HelpPanel';
import type User from 'logic/users/User';
import { getPathnameWithoutId } from 'util/URLUtils';
import useSendTelemetry from 'logic/telemetry/useSendTelemetry';
import useLocation from 'routing/useLocation';
import { TELEMETRY_EVENT_TYPE } from 'logic/telemetry/Constants';
import { getPathnameWithoutId } from 'util/URLUtils';

import styles from './EventConditionForm.css';

Expand Down Expand Up @@ -143,9 +143,10 @@ const EventConditionForm = ({ action, entityTypes, eventDefinition, validation,
Configure how Graylog should create Events of this kind. You can later use those Events as input on other
Conditions, making it possible to build powerful Conditions based on others.
</p>
<FormGroup controlId="event-definition-priority" validationState={validation.errors.config ? 'error' : null}>
<ControlLabel>Condition Type</ControlLabel>
<FormGroup validationState={validation.errors.config ? 'error' : null}>
<ControlLabel htmlFor="event-condition-type-select">Condition Type</ControlLabel>
<Select placeholder="Select a Condition Type"
inputId="event-condition-type-select"
options={formattedEventDefinitionTypes()}
value={eventDefinition.config.type}
onChange={handleEventDefinitionTypeChange}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,34 +15,33 @@
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
import type { SyntheticEvent } from 'react';
import React, { useEffect, useState } from 'react';
import * as React from 'react';
import PropTypes from 'prop-types';
import last from 'lodash/last';
import defaultTo from 'lodash/defaultTo';
import { PluginStore } from 'graylog-web-plugin/plugin';
import styled from 'styled-components';
import URI from 'urijs';
import QS from 'qs';

import { getPathnameWithoutId } from 'util/URLUtils';
import { Button, Col, Row } from 'components/bootstrap';
import { ModalSubmit, Wizard } from 'components/common';
import { Col, Row } from 'components/bootstrap';
import { Wizard } from 'components/common';
import type { EventNotification } from 'stores/event-notifications/EventNotificationsStore';
import type { EventDefinition } from 'components/event-definitions/event-definitions-types';
import type { EventDefinition, EventDefinitionFormControlsProps } from 'components/event-definitions/event-definitions-types';
import type User from 'logic/users/User';
import useQuery from 'routing/useQuery';
import useHistory from 'routing/useHistory';
import useSendTelemetry from 'logic/telemetry/useSendTelemetry';
import useLocation from 'routing/useLocation';
import { TELEMETRY_EVENT_TYPE } from 'logic/telemetry/Constants';
import EventDefinitionFormControls from 'components/event-definitions/event-definition-form/EventDefinitionFormControls';

import EventDetailsForm from './EventDetailsForm';
import EventConditionForm from './EventConditionForm';
import FieldsForm from './FieldsForm';
import NotificationsForm from './NotificationsForm';
import EventDefinitionSummary from './EventDefinitionSummary';

const STEP_KEYS = ['event-details', 'condition', 'fields', 'notifications', 'summary'];
const WizardContainer = styled.div`
margin-bottom: 10px;
`;
export const STEP_KEYS = ['event-details', 'condition', 'fields', 'notifications', 'summary'];
const STEP_TELEMETRY_KEYS = [
TELEMETRY_EVENT_TYPE.EVENTDEFINITION_DETAILS.STEP_CLICKED,
TELEMETRY_EVENT_TYPE.EVENTDEFINITION_CONDITION.STEP_CLICKED,
Expand All @@ -59,11 +58,8 @@ const getConditionPlugin = (type: string | undefined) => {
return PluginStore.exports('eventDefinitionTypes').find((edt) => edt.type === type) || {};
};

const WizardContainer = styled.div`
margin-bottom: 10px;
`;

type Props = {
activeStep: string,
action: 'edit' | 'create',
eventDefinition: EventDefinition,
currentUser: User,
Expand All @@ -77,48 +73,40 @@ type Props = {
notifications: Array<EventNotification>,
defaults: { default_backlog_size: number },
onChange: (key: string, value: unknown) => void,
onChangeStep: (step: string) => void,
onCancel: () => void,
onSubmit: () => void
canEdit: boolean,
formControls?: React.ComponentType<EventDefinitionFormControlsProps>
}

const EventDefinitionForm = ({
action,
eventDefinition,
activeStep,
canEdit,
currentUser,
validation,
defaults,
entityTypes,
eventDefinition,
formControls: FormControls,
notifications,
defaults,
onChange,
onCancel,
onChange,
onChangeStep,
onSubmit,
canEdit,
validation,
}: Props) => {
const { step } = useQuery();
const [activeStep, setActiveStep] = useState(step as string || STEP_KEYS[0]);
const history = useHistory();
const { pathname } = useLocation();
const sendTelemetry = useSendTelemetry();

useEffect(() => {
const currentUrl = new URI(window.location.href);
const queryParameters = QS.parse(currentUrl.query());

if (queryParameters.step !== activeStep) {
const newUrl = currentUrl.removeSearch('step').addQuery('step', activeStep);
history.replace(newUrl.resource());
}
}, [activeStep, history]);
const activeStepIndex = STEP_KEYS.indexOf(activeStep);

const handleSubmit = (event: SyntheticEvent) => {
if (event) {
event.preventDefault();
}

if (activeStep === last(STEP_KEYS)) {
onSubmit();
}
onSubmit();
};

const defaultStepProps = {
Expand Down Expand Up @@ -170,67 +158,39 @@ const EventDefinitionForm = ({
},
];

const handleStepChange = (nextStep) => {
const handleStepChange = (nextStep: string) => {
sendTelemetry(STEP_TELEMETRY_KEYS[STEP_KEYS.indexOf(nextStep)], {
app_pathname: getPathnameWithoutId(pathname),
app_section: (action === 'create') ? 'new-event-definition' : 'edit-event-definition',
app_action_value: 'event-definition-step',
current_step: steps[STEP_KEYS.indexOf(activeStep)].title,
});

setActiveStep(nextStep);
onChangeStep(nextStep);
};

const renderButtons = () => {
if (activeStep === last(STEP_KEYS)) {
return (
<ModalSubmit onCancel={onCancel}
onSubmit={handleSubmit}
submitButtonText={`${eventDefinition.id ? 'Update' : 'Create'} event definition`} />
);
}

const activeStepIndex = STEP_KEYS.indexOf(activeStep);

const handlePreviousClick = () => {
sendTelemetry(TELEMETRY_EVENT_TYPE.EVENTDEFINITION_PREVIOUS_CLICKED, {
app_pathname: getPathnameWithoutId(pathname),
app_section: (action === 'create') ? 'new-event-definition' : 'edit-event-definition',
app_action_value: 'previous-button',
current_step: steps[activeStepIndex].title,
});

const previousStep = activeStepIndex > 0 ? STEP_KEYS[activeStepIndex - 1] : undefined;
setActiveStep(previousStep);
};
const openPrevPage = () => {
sendTelemetry(TELEMETRY_EVENT_TYPE.EVENTDEFINITION_PREVIOUS_CLICKED, {
app_pathname: getPathnameWithoutId(pathname),
app_section: (action === 'create') ? 'new-event-definition' : 'edit-event-definition',
app_action_value: 'previous-button',
current_step: steps[activeStepIndex].title,
});

const handleNextClick = () => {
sendTelemetry(TELEMETRY_EVENT_TYPE.EVENTDEFINITION_NEXT_CLICKED, {
app_pathname: getPathnameWithoutId(pathname),
app_section: (action === 'create') ? 'new-event-definition' : 'edit-event-definition',
app_action_value: 'next-button',
current_step: steps[activeStepIndex].title,
});
const previousStep = activeStepIndex > 0 ? STEP_KEYS[activeStepIndex - 1] : undefined;
onChangeStep(previousStep);
};

const nextStep = STEP_KEYS[activeStepIndex + 1];
setActiveStep(nextStep);
};
const openNextPage = () => {
sendTelemetry(TELEMETRY_EVENT_TYPE.EVENTDEFINITION_NEXT_CLICKED, {
app_pathname: getPathnameWithoutId(pathname),
app_section: (action === 'create') ? 'new-event-definition' : 'edit-event-definition',
app_action_value: 'next-button',
current_step: steps[activeStepIndex].title,
});

return (
<div>
<Button bsStyle="info"
onClick={handlePreviousClick}
disabled={activeStepIndex === 0}>
Previous
</Button>
<div className="pull-right">
<Button bsStyle="info"
onClick={handleNextClick}>
Next
</Button>
</div>
</div>
);
const nextStep = STEP_KEYS[activeStepIndex + 1];
onChangeStep(nextStep);
};

return (
Expand All @@ -245,7 +205,13 @@ const EventDefinitionForm = ({
containerClassName=""
hidePreviousNextButtons />
</WizardContainer>
{renderButtons()}
<FormControls activeStepIndex={activeStepIndex}
action={action}
onOpenPrevPage={openPrevPage}
onOpenNextPage={openNextPage}
steps={steps}
onSubmit={handleSubmit}
onCancel={onCancel} />
</Col>
</Row>
);
Expand All @@ -266,6 +232,7 @@ EventDefinitionForm.propTypes = {

EventDefinitionForm.defaultProps = {
action: 'edit',
formControls: EventDefinitionFormControls,
};

export default EventDefinitionForm;
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,12 @@ jest.mock('logic/telemetry/useSendTelemetry');
jest.mock('hooks/useScopePermissions');
jest.mock('hooks/useCurrentUser');

jest.mock('components/perspectives/hooks/useActivePerspective', () => () => ({
id: 'security',
title: 'Security',
welcomeRoute: '/security',
}));

describe('EventDefinitionFormContainer', () => {
beforeEach(() => {
asMock(useLocation).mockImplementation(() => ({ pathname: '/event-definitions', search: '', hash: '', state: null, key: 'mock-key' }));
Expand Down Expand Up @@ -226,18 +232,18 @@ describe('EventDefinitionFormContainer', () => {

it('should render Filters & Aggregation form enabled', async () => {
render(<EventDefinitionFormContainer action="edit" eventDefinition={mockAggregationEventDefinition} />);

const tab = await screen.findByRole('button', { name: /filter & aggregation/i });
const tab = await screen.findByRole('button', { name: /condition/i });
userEvent.click(tab);
const conditionTypeSelect = await screen.findByLabelText('Condition Type');

expect(screen.getByRole('textbox', { name: /search query/i })).toBeEnabled();
expect(conditionTypeSelect).toBeInTheDocument();
});

it('Filters & Aggregation should not be accessible for immutable entities', async () => {
asMock(useScopePermissions).mockImplementation(() => exampleEntityScopeImmutable);
render(<EventDefinitionFormContainer action="edit" eventDefinition={mockAggregationEventDefinition} />);

const tab = await screen.findByRole('button', { name: /filter & aggregation/i });
const tab = await screen.findByRole('button', { name: /condition/i });
userEvent.click(tab);

expect(screen.getByText(/cannot be edited/i)).toBeVisible();
Expand Down
Loading

0 comments on commit d6ebb26

Please sign in to comment.