diff --git a/changelog/unreleased/issue-17746.toml b/changelog/unreleased/issue-17746.toml new file mode 100644 index 000000000000..637bbabc90ef --- /dev/null +++ b/changelog/unreleased/issue-17746.toml @@ -0,0 +1,12 @@ +type = "a" +message = "Add index set field type profiles overview and edit page" + +pulls = ["17775"] +issues=["17746"] + +details.user = """ +Before this change, it was possible to create and manage custom field type mappings +of existing index sets. For every new index set that is created, +these steps have to be repeated though. This change gives an option to bundle up +custom field types into profiles. +""" diff --git a/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/CreateProfile.test.tsx b/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/CreateProfile.test.tsx new file mode 100644 index 000000000000..4aea0ac4fefe --- /dev/null +++ b/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/CreateProfile.test.tsx @@ -0,0 +1,124 @@ +/* + * 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, act, waitFor } from 'wrappedTestingLibrary'; +import selectEvent from 'react-select-event'; + +import asMock from 'helpers/mocking/AsMock'; +import { loadViewsPlugin, unloadViewsPlugin } from 'views/test/testViewsPlugin'; +import useFieldTypesForMappings from 'views/logic/fieldactions/ChangeFieldType/hooks/useFieldTypesForMappings'; +import useFieldTypes from 'views/logic/fieldtypes/useFieldTypes'; +import CreateProfile from 'components/indices/IndexSetFieldTypeProfiles/CreateProfile'; +import useProfileMutations from 'components/indices/IndexSetFieldTypeProfiles/hooks/useProfileMutations'; +import { simpleFields } from 'fixtures/fields'; + +const renderCreateNewProfile = () => render( + , +); + +jest.mock('components/indices/IndexSetFieldTypeProfiles/hooks/useProfileMutations', () => jest.fn()); +jest.mock('views/logic/fieldactions/ChangeFieldType/hooks/useFieldTypesForMappings', () => jest.fn()); + +jest.mock('views/logic/fieldtypes/useFieldTypes', () => jest.fn()); + +const selectItem = async (select: HTMLElement, option: string | RegExp) => { + selectEvent.openMenu(select); + + return selectEvent.select(select, option); +}; + +describe('IndexSetFieldTypesList', () => { + const createMock = jest.fn(() => Promise.resolve()); + const editMock = jest.fn(() => Promise.resolve()); + + beforeAll(loadViewsPlugin); + + afterAll(unloadViewsPlugin); + + beforeEach(() => { + asMock(useFieldTypesForMappings).mockReturnValue({ + data: { + fieldTypes: { + string: 'String type', + int: 'Number(int)', + bool: 'Boolean', + ip: 'IP', + date: 'Date', + }, + }, + isLoading: false, + }); + + asMock(useProfileMutations).mockReturnValue(({ + editProfile: editMock, + isEditLoading: false, + createProfile: createMock, + isCreateLoading: false, + isLoading: false, + })); + + asMock(useFieldTypes).mockImplementation(() => ( + { data: simpleFields().toArray(), refetch: jest.fn() } + )); + }); + + it('Run createProfile with form data', async () => { + renderCreateNewProfile(); + + const name = await screen.findByRole('textbox', { + name: /name/i, + hidden: true, + }); + const description = await screen.findByRole('textbox', { + name: /description/i, + hidden: true, + }); + const addMappingButton = await screen.findByRole('button', { name: /add mapping/i }); + + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(async () => { + fireEvent.click(addMappingButton); + }); + + const fieldFirst = await screen.findByLabelText(/select customFieldMappings.0.field/i); + const typeFirst = await screen.findByLabelText(/select customFieldMappings.0.type/i); + const fieldSecond = await screen.findByLabelText(/select customFieldMappings.1.field/i); + const typeSecond = await screen.findByLabelText(/select customFieldMappings.1.type/i); + const submitButton = await screen.findByTitle(/create profile/i); + + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(async () => { + fireEvent.change(name, { target: { value: 'Profile new' } }); + fireEvent.change(description, { target: { value: 'Profile description' } }); + await selectItem(fieldFirst, 'date'); + await selectItem(typeFirst, 'String type'); + await selectItem(fieldSecond, 'http_method'); + await selectItem(typeSecond, 'String type'); + await waitFor(() => expect(submitButton.hasAttribute('disabled')).toBe(false)); + fireEvent.click(submitButton); + }); + + expect(createMock).toHaveBeenCalledWith({ + name: 'Profile new', + description: 'Profile description', + customFieldMappings: [ + { field: 'date', type: 'string' }, + { field: 'http_method', type: 'string' }, + ], + }); + }); +}); diff --git a/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/CreateProfile.tsx b/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/CreateProfile.tsx new file mode 100644 index 000000000000..3a2a2f8d9e76 --- /dev/null +++ b/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/CreateProfile.tsx @@ -0,0 +1,61 @@ +/* + * 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 React, { useMemo, useCallback, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import useSendTelemetry from 'logic/telemetry/useSendTelemetry'; +import { TELEMETRY_EVENT_TYPE } from 'logic/telemetry/Constants'; +import { getPathnameWithoutId } from 'util/URLUtils'; +import useLocation from 'routing/useLocation'; +import ProfileForm from 'components/indices/IndexSetFieldTypeProfiles/ProfileForm'; +import type { IndexSetFieldTypeProfile } from 'components/indices/IndexSetFieldTypeProfiles/types'; +import useProfileMutations from 'components/indices/IndexSetFieldTypeProfiles/hooks/useProfileMutations'; +import Routes from 'routing/Routes'; + +const CreateProfile = () => { + const sendTelemetry = useSendTelemetry(); + const { pathname } = useLocation(); + const navigate = useNavigate(); + const { createProfile } = useProfileMutations(); + const telemetryPathName = useMemo(() => getPathnameWithoutId(pathname), [pathname]); + + const onSubmit = useCallback((profile: IndexSetFieldTypeProfile) => { + createProfile(profile).then(() => { + sendTelemetry(TELEMETRY_EVENT_TYPE.INDEX_SET_FIELD_TYPE_PROFILE.CREATED, { + app_pathname: telemetryPathName, + app_action_value: { mappingsQuantity: profile?.customFieldMappings?.length }, + }); + + navigate(Routes.SYSTEM.INDICES.FIELD_TYPE_PROFILES.OVERVIEW); + }); + }, [createProfile, navigate, sendTelemetry, telemetryPathName]); + + useEffect(() => { + sendTelemetry(TELEMETRY_EVENT_TYPE.INDEX_SET_FIELD_TYPE_PROFILE.NEW_OPENED, { app_pathname: telemetryPathName, app_action_value: 'create-new-index-set-field-type-profile-opened' }); + }, [sendTelemetry, telemetryPathName]); + + const onCancel = useCallback(() => { + sendTelemetry(TELEMETRY_EVENT_TYPE.INDEX_SET_FIELD_TYPE_PROFILE.NEW_CANCELED, { app_pathname: telemetryPathName, app_action_value: 'create-new-index-set-field-type-profile-canceled' }); + navigate(Routes.SYSTEM.INDICES.FIELD_TYPE_PROFILES.OVERVIEW); + }, [navigate, sendTelemetry, telemetryPathName]); + + return ( + + ); +}; + +export default CreateProfile; diff --git a/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/CreateProfileButton.tsx b/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/CreateProfileButton.tsx new file mode 100644 index 000000000000..d87dc4012f3a --- /dev/null +++ b/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/CreateProfileButton.tsx @@ -0,0 +1,30 @@ +/* + * 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 { Button } from 'components/bootstrap'; +import Routes from 'routing/Routes'; +import { LinkContainer } from 'components/common/router'; + +const CreateProfileButton = () => ( + + + +); + +export default CreateProfileButton; diff --git a/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/CustomFieldTypesList.tsx b/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/CustomFieldTypesList.tsx new file mode 100644 index 000000000000..d8dd05353130 --- /dev/null +++ b/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/CustomFieldTypesList.tsx @@ -0,0 +1,44 @@ +/* + * 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 React from 'react'; +import { styled } from 'styled-components'; + +import type { CustomFieldMapping } from 'components/indices/IndexSetFieldTypeProfiles/types'; +import type { FieldTypes } from 'views/logic/fieldactions/ChangeFieldType/types'; + +const Item = styled.div` + display: flex; + gap: 5px; + flex-wrap: wrap; +`; + +const List = styled.div` + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1em; +`; +const CustomFieldTypesList = ({ list, fieldTypes }: { list: Array, fieldTypes: FieldTypes }) => ( + + {list.map(({ field, type }) => ( + + {field}:{fieldTypes[type]} + + ))} + +); + +export default CustomFieldTypesList; diff --git a/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/EditProfile.test.tsx b/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/EditProfile.test.tsx new file mode 100644 index 000000000000..2add6b87ad3b --- /dev/null +++ b/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/EditProfile.test.tsx @@ -0,0 +1,143 @@ +/* + * 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, act, waitFor } from 'wrappedTestingLibrary'; +import selectEvent from 'react-select-event'; + +import asMock from 'helpers/mocking/AsMock'; +import { loadViewsPlugin, unloadViewsPlugin } from 'views/test/testViewsPlugin'; +import useFieldTypesForMappings from 'views/logic/fieldactions/ChangeFieldType/hooks/useFieldTypesForMappings'; +import useFieldTypes from 'views/logic/fieldtypes/useFieldTypes'; +import EditProfile from 'components/indices/IndexSetFieldTypeProfiles/EditProfile'; +import useProfileMutations from 'components/indices/IndexSetFieldTypeProfiles/hooks/useProfileMutations'; +import { simpleFields } from 'fixtures/fields'; +import { profile1 } from 'fixtures/indexSetFieldTypeProfiles'; + +const renderEditProfile = () => render( + , +); + +jest.mock('components/indices/IndexSetFieldTypeProfiles/hooks/useProfileMutations', () => jest.fn()); +jest.mock('views/logic/fieldactions/ChangeFieldType/hooks/useFieldTypesForMappings', () => jest.fn()); + +jest.mock('views/logic/fieldtypes/useFieldTypes', () => jest.fn()); + +const selectItem = async (select: HTMLElement, option: string | RegExp) => { + selectEvent.openMenu(select); + + return selectEvent.select(select, option); +}; + +describe('IndexSetFieldTypesList', () => { + const createMock = jest.fn(() => Promise.resolve()); + const editMock = jest.fn(() => Promise.resolve()); + + beforeAll(loadViewsPlugin); + + afterAll(unloadViewsPlugin); + + beforeEach(() => { + asMock(useFieldTypesForMappings).mockReturnValue({ + data: { + fieldTypes: { + string: 'String type', + int: 'Number(int)', + bool: 'Boolean', + ip: 'IP', + }, + }, + isLoading: false, + }); + + asMock(useProfileMutations).mockReturnValue(({ + editProfile: editMock, + isEditLoading: false, + createProfile: createMock, + isCreateLoading: false, + isLoading: false, + })); + + asMock(useFieldTypes).mockImplementation(() => ( + { data: simpleFields().toArray(), refetch: jest.fn() } + )); + }); + + it('Run editProfile with changed form data', async () => { + renderEditProfile(); + + const name = await screen.findByRole('textbox', { + name: /name/i, + hidden: true, + }); + + const fieldFirst = await screen.findByLabelText(/select customFieldMappings.0.field/i); + const typeFirst = await screen.findByLabelText(/select customFieldMappings.0.type/i); + const submitButton = await screen.findByTitle(/update profile/i); + + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(async () => { + fireEvent.change(name, { target: { value: 'Profile 1 new name' } }); + await selectItem(fieldFirst, 'date'); + await selectItem(typeFirst, 'String type'); + fireEvent.click(submitButton); + }); + + expect(editMock).toHaveBeenCalledWith({ + name: 'Profile 1 new name', + description: 'Description 1', + id: '111', + customFieldMappings: [ + { field: 'date', type: 'string' }, + { field: 'user_ip', type: 'ip' }, + ], + }); + }); + + it('Run editProfile with added form data', async () => { + renderEditProfile(); + + const addMappingButton = await screen.findByRole('button', { name: /add mapping/i }); + + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(async () => { + fireEvent.click(addMappingButton); + }); + + const fieldThird = await screen.findByLabelText(/select customFieldMappings.2.field/i); + const typeThird = await screen.findByLabelText(/select customFieldMappings.2.type/i); + const submitButton = await screen.findByTitle(/update profile/i); + + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(async () => { + await selectItem(fieldThird, 'date'); + await selectItem(typeThird, 'String type'); + await waitFor(() => expect(submitButton.hasAttribute('disabled')).toBe(false)); + fireEvent.click(submitButton); + }); + + expect(editMock).toHaveBeenCalledWith({ + name: 'Profile 1', + description: 'Description 1', + id: '111', + customFieldMappings: [ + { field: 'http_method', type: 'string' }, + { field: 'user_ip', type: 'ip' }, + { field: 'date', type: 'string' }, + ], + }); + }); +}); diff --git a/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/EditProfile.tsx b/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/EditProfile.tsx new file mode 100644 index 000000000000..511dd730d013 --- /dev/null +++ b/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/EditProfile.tsx @@ -0,0 +1,67 @@ +/* + * 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 React, { useMemo, useCallback, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import useSendTelemetry from 'logic/telemetry/useSendTelemetry'; +import { TELEMETRY_EVENT_TYPE } from 'logic/telemetry/Constants'; +import { getPathnameWithoutId } from 'util/URLUtils'; +import useLocation from 'routing/useLocation'; +import ProfileForm from 'components/indices/IndexSetFieldTypeProfiles/ProfileForm'; +import type { IndexSetFieldTypeProfile } from 'components/indices/IndexSetFieldTypeProfiles/types'; +import useProfileMutations from 'components/indices/IndexSetFieldTypeProfiles/hooks/useProfileMutations'; +import Routes from 'routing/Routes'; + +type Props = { + profile: IndexSetFieldTypeProfile, +} + +const EditProfile = ({ + profile, +}: Props) => { + const sendTelemetry = useSendTelemetry(); + const navigate = useNavigate(); + const { pathname } = useLocation(); + const { editProfile } = useProfileMutations(); + const telemetryPathName = useMemo(() => getPathnameWithoutId(pathname), [pathname]); + + const onSubmit = useCallback((newProfile: IndexSetFieldTypeProfile) => { + editProfile(newProfile).then(() => { + sendTelemetry(TELEMETRY_EVENT_TYPE.INDEX_SET_FIELD_TYPE_PROFILE.EDIT, { + app_pathname: telemetryPathName, + app_action_value: { mappingsQuantity: newProfile?.customFieldMappings?.length }, + }); + + navigate(Routes.SYSTEM.INDICES.FIELD_TYPE_PROFILES.OVERVIEW); + }); + }, [editProfile, navigate, sendTelemetry, telemetryPathName]); + + useEffect(() => { + sendTelemetry(TELEMETRY_EVENT_TYPE.INDEX_SET_FIELD_TYPE_PROFILE.EDIT_OPENED, { app_pathname: telemetryPathName, app_action_value: 'create-new-index-set-field-type-profile-opened' }); + }, [sendTelemetry, telemetryPathName]); + + const onCancel = useCallback(() => { + sendTelemetry(TELEMETRY_EVENT_TYPE.INDEX_SET_FIELD_TYPE_PROFILE.EDIT_CANCELED, { app_pathname: telemetryPathName, app_action_value: 'create-new-index-set-field-type-profile-canceled' }); + navigate(Routes.SYSTEM.INDICES.FIELD_TYPE_PROFILES.OVERVIEW); + }, [navigate, sendTelemetry, telemetryPathName]); + + return ( + + ); +}; + +export default EditProfile; diff --git a/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/ExpandedCustomFieldTypes.tsx b/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/ExpandedCustomFieldTypes.tsx new file mode 100644 index 000000000000..376399bfa553 --- /dev/null +++ b/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/ExpandedCustomFieldTypes.tsx @@ -0,0 +1,36 @@ +/* + * 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 React from 'react'; + +import type { IndexSetFieldTypeProfile } from 'components/indices/IndexSetFieldTypeProfiles/types'; +import CustomFieldTypesList from 'components/indices/IndexSetFieldTypeProfiles/CustomFieldTypesList'; +import useFieldTypesForMappings from 'views/logic/fieldactions/ChangeFieldType/hooks/useFieldTypesForMappings'; + +type Props = { + customFieldMappings: IndexSetFieldTypeProfile['customFieldMappings'], +} + +const ExpandedCustomFieldTypes = ({ customFieldMappings }: Props) => { + const { data: { fieldTypes } } = useFieldTypesForMappings(); + + return ( + + ); +}; + +export default ExpandedCustomFieldTypes; diff --git a/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/ExpandedSectionsRenderer.tsx b/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/ExpandedSectionsRenderer.tsx new file mode 100644 index 000000000000..ac6302789fd9 --- /dev/null +++ b/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/ExpandedSectionsRenderer.tsx @@ -0,0 +1,31 @@ +/* + * 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 React from 'react'; + +import type { IndexSetFieldTypeProfile } from 'components/indices/IndexSetFieldTypeProfiles/types'; +import ExpandedCustomFieldTypes from 'components/indices/IndexSetFieldTypeProfiles/ExpandedCustomFieldTypes'; + +const useExpandedSectionsRenderer = () => ({ + customFieldMapping: { + title: 'Custom Field Mappings', + content: ({ customFieldMappings }: IndexSetFieldTypeProfile) => ( + + ), + }, +}); + +export default useExpandedSectionsRenderer; diff --git a/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/ProfileActions.tsx b/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/ProfileActions.tsx new file mode 100644 index 000000000000..b2373e95d10b --- /dev/null +++ b/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/ProfileActions.tsx @@ -0,0 +1,33 @@ +/* + * 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 React from 'react'; + +import { Button, ButtonToolbar } from 'components/bootstrap'; +import Routes from 'routing/Routes'; +import { LinkContainer } from 'components/common/router'; + +const ProfileActions = ({ profileId }: { profileId: string }) => ( + + + + + +); + +export default ProfileActions; diff --git a/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/ProfileForm.test.tsx b/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/ProfileForm.test.tsx new file mode 100644 index 000000000000..6d900619024f --- /dev/null +++ b/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/ProfileForm.test.tsx @@ -0,0 +1,143 @@ +/* + * 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, act } from 'wrappedTestingLibrary'; +import selectEvent from 'react-select-event'; + +import asMock from 'helpers/mocking/AsMock'; +import { loadViewsPlugin, unloadViewsPlugin } from 'views/test/testViewsPlugin'; +import useFieldTypesForMappings from 'views/logic/fieldactions/ChangeFieldType/hooks/useFieldTypesForMappings'; +import useFieldTypes from 'views/logic/fieldtypes/useFieldTypes'; +import ProfileForm from 'components/indices/IndexSetFieldTypeProfiles/ProfileForm'; +import { simpleFields } from 'fixtures/fields'; +import { profile1 } from 'fixtures/indexSetFieldTypeProfiles'; + +const mockSubmit = jest.fn(); +const mockCancel = jest.fn(); +const renderProfileForm = ({ initialValues }) => render( + , +); +jest.mock('views/logic/fieldactions/ChangeFieldType/hooks/useFieldTypesForMappings', () => jest.fn()); + +jest.mock('views/logic/fieldtypes/useFieldTypes', () => jest.fn()); + +const selectItem = async (select: HTMLElement, option: string | RegExp) => { + selectEvent.openMenu(select); + + return selectEvent.select(select, option); +}; + +describe('IndexSetFieldTypesList', () => { + beforeAll(loadViewsPlugin); + + afterAll(unloadViewsPlugin); + + beforeEach(() => { + asMock(useFieldTypesForMappings).mockReturnValue({ + data: { + fieldTypes: { + string: 'String type', + int: 'Number(int)', + bool: 'Boolean', + ip: 'IP', + date: 'Date', + }, + }, + isLoading: false, + }); + + asMock(useFieldTypes).mockImplementation(() => ( + { data: simpleFields().toArray(), refetch: jest.fn() } + )); + }); + + it('Do not run onSubmit when has empty name', async () => { + renderProfileForm({ + initialValues: { + ...profile1, + name: '', + }, + }); + + const submitButton = await screen.findByLabelText('Submit'); + fireEvent.click(submitButton); + + expect(mockSubmit).not.toHaveBeenCalled(); + }); + + it('Do not run onSubmit when has empty customFieldMapping', async () => { + renderProfileForm({ + initialValues: { + ...profile1, + customFieldMappings: [ + { field: 'http_method', type: 'string' }, + ], + }, + }); + + const addMappingButton = await screen.findByRole('button', { name: /add mapping/i }); + + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(async () => { + fireEvent.click(addMappingButton); + }); + + const typeSecond = await screen.findByLabelText(/select customFieldMappings.1.type/i); + const submitButton = await screen.findByLabelText('Submit'); + + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(async () => { + await selectItem(typeSecond, 'String type'); + + fireEvent.click(submitButton); + }); + + expect(mockSubmit).not.toHaveBeenCalled(); + }); + + it('Do not run onSubmit when has same fields in customFieldMapping', async () => { + renderProfileForm({ + initialValues: { + ...profile1, + customFieldMappings: [ + { field: 'http_method', type: 'string' }, + ], + }, + }); + + const addMappingButton = await screen.findByRole('button', { name: /add mapping/i }); + + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(async () => { + fireEvent.click(addMappingButton); + }); + + const fieldSecond = await screen.findByLabelText(/select customFieldMappings.1.field/i); + const typeSecond = await screen.findByLabelText(/select customFieldMappings.1.type/i); + const submitButton = await screen.findByLabelText('Submit'); + + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(async () => { + await selectItem(typeSecond, 'String type'); + await selectItem(fieldSecond, 'http_method'); + + fireEvent.click(submitButton); + }); + + expect(mockSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/ProfileForm.tsx b/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/ProfileForm.tsx new file mode 100644 index 000000000000..f285f9b84da9 --- /dev/null +++ b/graylog2-web-interface/src/components/indices/IndexSetFieldTypeProfiles/ProfileForm.tsx @@ -0,0 +1,218 @@ +/* + * 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 { styled } from 'styled-components'; +import React, { useMemo } from 'react'; +import { Formik, Form, FieldArray, Field } from 'formik'; +import countBy from 'lodash/countBy'; + +import type { IndexSetFieldTypeProfile } from 'components/indices/IndexSetFieldTypeProfiles/types'; +import { FormikInput, IconButton, Select, FormSubmit, Spinner, InputOptionalInfo } from 'components/common'; +import { Button, Col, HelpBlock, Input } from 'components/bootstrap'; +import useFieldTypes from 'views/logic/fieldtypes/useFieldTypes'; +import useFieldTypesForMapping from 'views/logic/fieldactions/ChangeFieldType/hooks/useFieldTypesForMappings'; +import { defaultCompare } from 'logic/DefaultCompare'; + +const SelectContainer = styled.div` + flex-basis: 100%; +`; + +const SelectGroup = styled.div` + flex-grow: 1; + display: flex; + gap: 5px; +`; +const List = styled.div` + display: flex; + flex-direction: column; +`; +const StyledLabel = styled.h5` + font-weight: bold; + margin-bottom: 5px; +`; + +const Item = styled.div` + display: flex; + gap: 5px; +`; + +const StyledFormSubmit = styled(FormSubmit)` + margin-top: 30px; +`; +type Props = { + initialValues?: IndexSetFieldTypeProfile, + submitButtonText: string, + submitLoadingText: string, + onCancel: () => void, + onSubmit: (profile: IndexSetFieldTypeProfile) => void +} + +const getFieldError = (field: string, occurrences: number) => { + if (!field) return 'Filed is required'; + if (occurrences > 1) return 'This field occurs several times'; + + return undefined; +}; + +const validate = (formValues: IndexSetFieldTypeProfile) => { + const errors: { name?: string, customFieldMappings?: Array<{ field?: string, type?: string }>} = {}; + + if (!formValues.name) { + errors.name = 'Profile name is required'; + } + + const fieldsOccurrences = countBy(formValues.customFieldMappings, 'field'); + + const customFieldMappings: Array<{ field: string, type: string }> = formValues + .customFieldMappings + .map(({ field, type }) => { + if (field && type && (fieldsOccurrences[field] === 1)) return undefined; + + return ({ + field: getFieldError(field, fieldsOccurrences[field]), + type: !type ? 'Type is required' : undefined, + }); + }); + + if (customFieldMappings.filter((item) => item).length > 0) { + errors.customFieldMappings = customFieldMappings; + } + + return errors; +}; + +type ProfileFormSelectProps = { + onChange: (param: { target: { value: string, name: string } }) => void, + options: Array<{ value: string, label: string, disabled?: boolean }>, + error: string, + name: string, + value: string | undefined | null, + placeholder: string, + allowCreate: boolean, +} + +const ProfileFormSelect = ({ onChange, options, error, name, value, placeholder, allowCreate }: ProfileFormSelectProps) => ( + + +