From 90532485f91759d056daada6703224fa400a747c Mon Sep 17 00:00:00 2001 From: Abdul Wahab Zahid Date: Thu, 6 Jan 2022 11:06:07 +0100 Subject: [PATCH 01/25] [Uptime][Monitor Management] Make synthetics service monitors config sync task interval configurable (#122327) (#121233) * Making synthetics service monitors config sync task interval configurable and reschedule the task on config update. #121233 --- x-pack/plugins/uptime/common/config.ts | 1 + .../synthetics_service/synthetics_service.ts | 39 ++++++++++++------- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/uptime/common/config.ts b/x-pack/plugins/uptime/common/config.ts index 8b70869645649..38ba7b7b3fd48 100644 --- a/x-pack/plugins/uptime/common/config.ts +++ b/x-pack/plugins/uptime/common/config.ts @@ -37,6 +37,7 @@ export const config: PluginConfigDescriptor = { password: schema.string(), manifestUrl: schema.string(), hosts: schema.maybe(schema.arrayOf(schema.string())), + syncInterval: schema.maybe(schema.string()), }) ), }) diff --git a/x-pack/plugins/uptime/server/lib/synthetics_service/synthetics_service.ts b/x-pack/plugins/uptime/server/lib/synthetics_service/synthetics_service.ts index d90dc1de9a114..d9aa0c664defa 100644 --- a/x-pack/plugins/uptime/server/lib/synthetics_service/synthetics_service.ts +++ b/x-pack/plugins/uptime/server/lib/synthetics_service/synthetics_service.ts @@ -17,6 +17,7 @@ import { ConcreteTaskInstance, TaskManagerSetupContract, TaskManagerStartContract, + TaskInstance, } from '../../../../task_manager/server'; import { UptimeServerSetup } from '../adapters'; import { installSyntheticsIndexTemplates } from '../../rest_api/synthetics_service/install_index_templates'; @@ -37,6 +38,7 @@ import { const SYNTHETICS_SERVICE_SYNC_MONITORS_TASK_TYPE = 'UPTIME:SyntheticsService:Sync-Saved-Monitor-Objects'; const SYNTHETICS_SERVICE_SYNC_MONITORS_TASK_ID = 'UPTIME:SyntheticsService:sync-task'; +const SYNTHETICS_SERVICE_SYNC_INTERVAL_DEFAULT = '5m'; export class SyntheticsService { private logger: Logger; @@ -125,27 +127,38 @@ export class SyntheticsService { }); } - public scheduleSyncTask(taskManager: TaskManagerStartContract) { - taskManager - .ensureScheduled({ + public async scheduleSyncTask( + taskManager: TaskManagerStartContract + ): Promise { + const interval = + this.config.unsafe.service.syncInterval ?? SYNTHETICS_SERVICE_SYNC_INTERVAL_DEFAULT; + + try { + await taskManager.removeIfExists(SYNTHETICS_SERVICE_SYNC_MONITORS_TASK_ID); + const taskInstance = await taskManager.ensureScheduled({ id: SYNTHETICS_SERVICE_SYNC_MONITORS_TASK_ID, taskType: SYNTHETICS_SERVICE_SYNC_MONITORS_TASK_TYPE, schedule: { - interval: '1m', + interval, }, params: {}, state: {}, scope: ['uptime'], - }) - .then((_result) => { - this.logger?.info(`Task ${SYNTHETICS_SERVICE_SYNC_MONITORS_TASK_ID} scheduled. `); - }) - .catch((e) => { - this.logger?.error( - `Error running task: ${SYNTHETICS_SERVICE_SYNC_MONITORS_TASK_ID}, `, - e?.message() ?? e - ); }); + + this.logger?.info( + `Task ${SYNTHETICS_SERVICE_SYNC_MONITORS_TASK_ID} scheduled with interval ${taskInstance.schedule?.interval}.` + ); + + return taskInstance; + } catch (e) { + this.logger?.error( + `Error running task: ${SYNTHETICS_SERVICE_SYNC_MONITORS_TASK_ID}, `, + e?.message() ?? e + ); + + return null; + } } async getOutput(request?: KibanaRequest) { From eab0485fa390a5d950c40bed7c0e19fe0d3a698c Mon Sep 17 00:00:00 2001 From: Josh Dover <1813008+joshdover@users.noreply.github.com> Date: Thu, 6 Jan 2022 13:10:12 +0100 Subject: [PATCH 02/25] Add requireAllSpaces and disable options to FeatureKibanaPrivileges (#118001) Co-authored-by: Larry Gregory Co-authored-by: criamico Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Xavier Mouligneau Co-authored-by: Joe Portner --- .../common/feature_kibana_privileges.ts | 12 + .../plugins/features/server/feature_schema.ts | 2 + .../roles/__fixtures__/kibana_features.ts | 14 +- .../feature_table/feature_table.test.tsx | 1 + .../kibana/feature_table/feature_table.tsx | 33 +- .../privilege_form_calculator.ts | 52 ++- .../privilege_summary_table.tsx | 20 +- .../privilege_space_form.test.tsx | 303 +++++++++++++++++- .../privilege_space_form.tsx | 84 +++-- .../roles/model/kibana_privileges.ts | 2 +- .../roles/model/primary_feature_privilege.ts | 8 + .../roles/elasticsearch_role.test.ts | 251 +++++++++++++++ .../authorization/roles/elasticsearch_role.ts | 43 ++- .../privilege_deprecations.test.ts | 10 +- .../deprecations/privilege_deprecations.ts | 25 +- x-pack/plugins/security/server/plugin.ts | 10 +- .../routes/authorization/roles/get.test.ts | 2 + .../server/routes/authorization/roles/get.ts | 11 +- .../authorization/roles/get_all.test.ts | 2 + .../routes/authorization/roles/get_all.ts | 9 +- .../routes/authorization/roles/model/index.ts | 6 +- .../roles/model/put_payload.test.ts | 122 ++++++- .../authorization/roles/model/put_payload.ts | 50 ++- .../routes/authorization/roles/put.test.ts | 134 +++++--- .../server/routes/authorization/roles/put.ts | 38 ++- 25 files changed, 1117 insertions(+), 127 deletions(-) create mode 100644 x-pack/plugins/security/server/authorization/roles/elasticsearch_role.test.ts diff --git a/x-pack/plugins/features/common/feature_kibana_privileges.ts b/x-pack/plugins/features/common/feature_kibana_privileges.ts index cb403ce673f11..52cc792152ebd 100644 --- a/x-pack/plugins/features/common/feature_kibana_privileges.ts +++ b/x-pack/plugins/features/common/feature_kibana_privileges.ts @@ -14,6 +14,18 @@ export interface FeatureKibanaPrivileges { */ excludeFromBasePrivileges?: boolean; + /** + * Whether or not this privilege should only be granted to `All Spaces *`. Should be used for features that do not + * support Spaces. Defaults to `false`. + */ + requireAllSpaces?: boolean; + + /** + * Whether or not this privilege should be hidden in the roles UI and disallowed on the API. Defaults to `false`. + * @deprecated + */ + disabled?: boolean; + /** * If this feature includes management sections, you can specify them here to control visibility of those * pages based on user privileges. diff --git a/x-pack/plugins/features/server/feature_schema.ts b/x-pack/plugins/features/server/feature_schema.ts index 2694620c62d3f..cf6c44af07470 100644 --- a/x-pack/plugins/features/server/feature_schema.ts +++ b/x-pack/plugins/features/server/feature_schema.ts @@ -75,6 +75,8 @@ const appCategorySchema = schema.object({ const kibanaPrivilegeSchema = schema.object({ excludeFromBasePrivileges: schema.maybe(schema.boolean()), + requireAllSpaces: schema.maybe(schema.boolean()), + disabled: schema.maybe(schema.boolean()), management: schema.maybe(managementSchema), catalogue: schema.maybe(catalogueSchema), api: schema.maybe(schema.arrayOf(schema.string())), diff --git a/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts b/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts index 7bb0ba254c830..5132757e84613 100644 --- a/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts +++ b/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts @@ -17,9 +17,19 @@ export const createFeature = ( excludeFromBaseRead?: boolean; privileges?: KibanaFeatureConfig['privileges']; category?: KibanaFeatureConfig['category']; + requireAllSpacesOnAllPrivilege?: boolean; + disabledReadPrivilege?: boolean; } ) => { - const { excludeFromBaseAll, excludeFromBaseRead, privileges, category, ...rest } = config; + const { + excludeFromBaseAll, + excludeFromBaseRead, + privileges, + category, + requireAllSpacesOnAllPrivilege: requireAllSpaces = false, + disabledReadPrivilege: disabled = false, + ...rest + } = config; return new KibanaFeature({ app: [], category: category ?? { id: 'foo', label: 'foo' }, @@ -35,6 +45,7 @@ export const createFeature = ( read: ['read-type'], }, ui: ['read-ui', 'all-ui', `read-${config.id}`, `all-${config.id}`], + requireAllSpaces, }, read: { excludeFromBasePrivileges: excludeFromBaseRead, @@ -43,6 +54,7 @@ export const createFeature = ( read: ['read-type'], }, ui: ['read-ui', `read-${config.id}`], + disabled, }, }, ...rest, diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.test.tsx index a7fab418f42cc..0cc4c4281b38f 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.test.tsx @@ -49,6 +49,7 @@ const setup = (config: TestConfig) => { onChangeAll={onChangeAll} canCustomizeSubFeaturePrivileges={config.canCustomizeSubFeaturePrivileges} privilegeIndex={config.privilegeIndex} + allSpacesSelected={true} /> ); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.tsx index 2abc07b4e3b21..e14c28db3e740 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.tsx @@ -7,7 +7,7 @@ import './feature_table.scss'; -import type { EuiAccordionProps } from '@elastic/eui'; +import type { EuiAccordionProps, EuiButtonGroupOptionProps } from '@elastic/eui'; import { EuiAccordion, EuiButtonGroup, @@ -44,6 +44,7 @@ interface Props { onChange: (featureId: string, privileges: string[]) => void; onChangeAll: (privileges: string[]) => void; canCustomizeSubFeaturePrivileges: boolean; + allSpacesSelected: boolean; disabled?: boolean; } @@ -84,7 +85,8 @@ export class FeatureTable extends Component { (feature) => this.props.privilegeCalculator.getEffectivePrimaryFeaturePrivilege( feature.id, - this.props.privilegeIndex + this.props.privilegeIndex, + this.props.allSpacesSelected ) != null ).length; @@ -269,28 +271,33 @@ export class FeatureTable extends Component { const selectedPrivilegeId = this.props.privilegeCalculator.getDisplayedPrimaryFeaturePrivilegeId( feature.id, - this.props.privilegeIndex + this.props.privilegeIndex, + this.props.allSpacesSelected ); - - const options = primaryFeaturePrivileges.map((privilege) => { - return { - id: `${feature.id}_${privilege.id}`, - label: privilege.name, - isDisabled: this.props.disabled, - }; - }); + const options: EuiButtonGroupOptionProps[] = primaryFeaturePrivileges + .filter((privilege) => !privilege.disabled) // Don't show buttons for privileges that are disabled + .map((privilege) => { + const disabledDueToSpaceSelection = + privilege.requireAllSpaces && !this.props.allSpacesSelected; + return { + id: `${feature.id}_${privilege.id}`, + label: privilege.name, + isDisabled: this.props.disabled || disabledDueToSpaceSelection, + }; + }); options.push({ id: `${feature.id}_${NO_PRIVILEGE_VALUE}`, label: 'None', - isDisabled: this.props.disabled, + isDisabled: this.props.disabled ?? false, }); let warningIcon = ; if ( this.props.privilegeCalculator.hasCustomizedSubFeaturePrivileges( feature.id, - this.props.privilegeIndex + this.props.privilegeIndex, + this.props.allSpacesSelected ) ) { warningIcon = ( diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.ts index 897ac36664f08..796cfea92b43e 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.ts +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.ts @@ -26,7 +26,6 @@ export class PrivilegeFormCalculator { */ public getBasePrivilege(privilegeIndex: number) { const entry = this.role.kibana[privilegeIndex]; - const basePrivileges = this.kibanaPrivileges.getBasePrivileges(entry); return basePrivileges.find((bp) => entry.base.includes(bp.id)); } @@ -49,8 +48,13 @@ export class PrivilegeFormCalculator { * @param featureId the feature id to get the Primary Feature KibanaPrivilege for. * @param privilegeIndex the index of the kibana privileges role component */ - public getDisplayedPrimaryFeaturePrivilegeId(featureId: string, privilegeIndex: number) { - return this.getDisplayedPrimaryFeaturePrivilege(featureId, privilegeIndex)?.id; + public getDisplayedPrimaryFeaturePrivilegeId( + featureId: string, + privilegeIndex: number, + allSpacesSelected?: boolean + ) { + return this.getDisplayedPrimaryFeaturePrivilege(featureId, privilegeIndex, allSpacesSelected) + ?.id; } /** @@ -59,10 +63,18 @@ export class PrivilegeFormCalculator { * @param featureId the feature id * @param privilegeIndex the index of the kibana privileges role component */ - public hasCustomizedSubFeaturePrivileges(featureId: string, privilegeIndex: number) { + public hasCustomizedSubFeaturePrivileges( + featureId: string, + privilegeIndex: number, + allSpacesSelected?: boolean + ) { const feature = this.kibanaPrivileges.getSecuredFeature(featureId); - const displayedPrimary = this.getDisplayedPrimaryFeaturePrivilege(featureId, privilegeIndex); + const displayedPrimary = this.getDisplayedPrimaryFeaturePrivilege( + featureId, + privilegeIndex, + allSpacesSelected + ); const formPrivileges = this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges([ this.role.kibana[privilegeIndex], @@ -81,19 +93,27 @@ export class PrivilegeFormCalculator { * * @param featureId the feature id * @param privilegeIndex the index of the kibana privileges role component + * @param allSpacesSelected indicates if the privilege form is configured to grant access to all spaces. */ - public getEffectivePrimaryFeaturePrivilege(featureId: string, privilegeIndex: number) { + public getEffectivePrimaryFeaturePrivilege( + featureId: string, + privilegeIndex: number, + allSpacesSelected?: boolean + ) { const feature = this.kibanaPrivileges.getSecuredFeature(featureId); const basePrivilege = this.getBasePrivilege(privilegeIndex); const selectedFeaturePrivileges = this.getSelectedFeaturePrivileges(featureId, privilegeIndex); - return feature + const effectivePrivilege = feature .getPrimaryFeaturePrivileges({ includeMinimalFeaturePrivileges: true }) .find((fp) => { return selectedFeaturePrivileges.includes(fp.id) || basePrivilege?.grantsPrivilege(fp); }); + const correctSpacesSelected = effectivePrivilege?.requireAllSpaces ? allSpacesSelected : true; + const availablePrivileges = correctSpacesSelected && !effectivePrivilege?.disabled; + if (availablePrivileges) return effectivePrivilege; } /** @@ -264,25 +284,29 @@ export class PrivilegeFormCalculator { * @param featureId the feature id to get the Primary Feature KibanaPrivilege for. * @param privilegeIndex the index of the kibana privileges role component */ - private getDisplayedPrimaryFeaturePrivilege(featureId: string, privilegeIndex: number) { + private getDisplayedPrimaryFeaturePrivilege( + featureId: string, + privilegeIndex: number, + allSpacesSelected?: boolean + ) { const feature = this.kibanaPrivileges.getSecuredFeature(featureId); const basePrivilege = this.getBasePrivilege(privilegeIndex); const selectedFeaturePrivileges = this.getSelectedFeaturePrivileges(featureId, privilegeIndex); - return feature.getPrimaryFeaturePrivileges().find((fp) => { + const displayedPrivilege = feature.getPrimaryFeaturePrivileges().find((fp) => { const correspondingMinimalPrivilegeId = fp.getMinimalPrivilegeId(); - const correspendingMinimalPrivilege = feature + const correspondingMinimalPrivilege = feature .getMinimalFeaturePrivileges() .find((mp) => mp.id === correspondingMinimalPrivilegeId)!; // There is only one case where the minimal privileges aren't available: // 1. Sub-feature privileges cannot be customized. When this is the case, the minimal privileges aren't registered with ES, // so they end up represented in the UI as an empty privilege. Empty privileges cannot be granted other privileges, so if we - // encounter a minimal privilege that isn't granted by it's correspending primary, then we know we've encountered this scenario. - const hasMinimalPrivileges = fp.grantsPrivilege(correspendingMinimalPrivilege); + // encounter a minimal privilege that isn't granted by it's corresponding primary, then we know we've encountered this scenario. + const hasMinimalPrivileges = fp.grantsPrivilege(correspondingMinimalPrivilege); return ( selectedFeaturePrivileges.includes(fp.id) || (hasMinimalPrivileges && @@ -290,6 +314,10 @@ export class PrivilegeFormCalculator { basePrivilege?.grantsPrivilege(fp) ); }); + + const correctSpacesSelected = displayedPrivilege?.requireAllSpaces ? allSpacesSelected : true; + const availablePrivileges = correctSpacesSelected && !displayedPrivilege?.disabled; + if (availablePrivileges) return displayedPrivilege; } private getSelectedFeaturePrivileges(featureId: string, privilegeIndex: number) { diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.tsx index b5eb274f5bbf4..8e5954f50a943 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.tsx @@ -22,8 +22,9 @@ import React, { Fragment, useMemo, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import type { Space, SpacesApiUi } from '../../../../../../../../spaces/public'; +import { ALL_SPACES_ID } from '../../../../../../../common/constants'; import type { Role, RoleKibanaPrivilege } from '../../../../../../../common/model'; -import type { KibanaPrivileges, SecuredFeature } from '../../../../model'; +import type { KibanaPrivileges, PrimaryFeaturePrivilege, SecuredFeature } from '../../../../model'; import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; import { FeatureTableCell } from '../feature_table_cell'; import type { EffectiveFeaturePrivileges } from './privilege_summary_calculator'; @@ -43,6 +44,17 @@ function getColumnKey(entry: RoleKibanaPrivilege) { return `privilege_entry_${entry.spaces.join('|')}`; } +function showPrivilege(allSpacesSelected: boolean, primaryFeature?: PrimaryFeaturePrivilege) { + if ( + primaryFeature?.name == null || + primaryFeature?.disabled || + (primaryFeature.requireAllSpaces && !allSpacesSelected) + ) { + return 'None'; + } + return primaryFeature?.name; +} + export const PrivilegeSummaryTable = (props: PrivilegeSummaryTableProps) => { const [expandedFeatures, setExpandedFeatures] = useState([]); @@ -145,7 +157,11 @@ export const PrivilegeSummaryTable = (props: PrivilegeSummaryTableProps) => { hasCustomizedSubFeaturePrivileges ? 'additionalPrivilegesGranted' : '' }`} > - {primary?.name ?? 'None'} {iconTip} + {showPrivilege( + props.spaces.some((space) => space.id === ALL_SPACES_ID), + primary + )}{' '} + {iconTip} ); }, diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.test.tsx index dd1e4f265266a..7976d76ff8d86 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.test.tsx @@ -12,7 +12,7 @@ import { findTestSubject, mountWithIntl } from '@kbn/test/jest'; import type { Space } from '../../../../../../../../spaces/public'; import type { Role } from '../../../../../../../common/model'; -import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; +import { createFeature, kibanaFeatures } from '../../../../__fixtures__/kibana_features'; import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; import { FeatureTable } from '../feature_table'; import { getDisplayedFeaturePrivileges } from '../feature_table/__fixtures__'; @@ -300,7 +300,7 @@ describe('PrivilegeSpaceForm', () => { expect(findTestSubject(wrapper, 'globalPrivilegeWarning')).toHaveLength(0); }); - it('allows all feature privileges to be changed via "change all"', () => { + it('allows all feature privileges to be changed via "change read"', () => { const role = createRole([ { base: [], @@ -391,4 +391,303 @@ describe('PrivilegeSpaceForm', () => { expect(wrapper.find(FeatureTable).props().canCustomizeSubFeaturePrivileges).toBe(canCustomize); }); + + describe('Feature with a disabled `read` privilege', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all', 'with_sub_features_cool_toggle_2', 'cool_read'], + }, + spaces: ['foo'], + }, + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['bar'], + }, + ]); + const extendedKibanaFeatures = [ + ...kibanaFeatures, + createFeature({ + id: 'no_sub_features_disabled_read', + name: 'Feature 1: No Sub Features and read disabled', + disabledReadPrivilege: true, + }), + ]; + const kibanaPrivileges = createKibanaPrivileges(extendedKibanaFeatures); + const onChange = jest.fn(); + beforeEach(() => { + onChange.mockReset(); + }); + it('still allow other features privileges to be changed via "change read"', () => { + const wrapper = mountWithIntl( + + ); + + findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click'); + findTestSubject(wrapper, 'changeAllPrivileges-read').simulate('click'); + findTestSubject(wrapper, 'createSpacePrivilegeButton').simulate('click'); + + expect(Object.keys(onChange.mock.calls[0][0].kibana[0].feature)).not.toContain( + 'no_sub_features_disabled_read' + ); + expect(onChange).toHaveBeenCalledWith( + createRole([ + { + base: [], + feature: { + excluded_from_base: ['read'], + with_excluded_sub_features: ['read'], + no_sub_features: ['read'], + with_sub_features: ['read'], + }, + spaces: ['foo'], + }, + // this set remains unchanged from the original + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['bar'], + }, + ]) + ); + }); + + it('still allow all privileges to be changed via "change all"', () => { + const wrapper = mountWithIntl( + + ); + + findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click'); + findTestSubject(wrapper, 'changeAllPrivileges-all').simulate('click'); + findTestSubject(wrapper, 'createSpacePrivilegeButton').simulate('click'); + + expect(onChange).toHaveBeenCalledWith( + createRole([ + { + base: [], + feature: { + excluded_from_base: ['all'], + with_excluded_sub_features: ['all'], + no_sub_features: ['all'], + no_sub_features_disabled_read: ['all'], + with_sub_features: ['all'], + }, + spaces: ['foo'], + }, + // this set remains unchanged from the original + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['bar'], + }, + ]) + ); + }); + }); + + describe('Feature with requireAllSpaces on all privileges', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all', 'with_sub_features_cool_toggle_2', 'cool_read'], + }, + spaces: ['foo'], + }, + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['bar'], + }, + ]); + const extendedKibanaFeatures = [ + ...kibanaFeatures, + createFeature({ + id: 'no_sub_features_require_all_space', + name: 'Feature 1: No Sub Features and all privilege require all space', + requireAllSpacesOnAllPrivilege: true, + }), + ]; + const kibanaPrivileges = createKibanaPrivileges(extendedKibanaFeatures); + + const onChange = jest.fn(); + + beforeEach(() => { + onChange.mockReset(); + }); + + it('still allow all features privileges to be changed via "change read" in foo space', () => { + const wrapper = mountWithIntl( + + ); + + findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click'); + findTestSubject(wrapper, 'changeAllPrivileges-read').simulate('click'); + findTestSubject(wrapper, 'createSpacePrivilegeButton').simulate('click'); + + expect(onChange).toHaveBeenCalledWith( + createRole([ + { + base: [], + feature: { + excluded_from_base: ['read'], + with_excluded_sub_features: ['read'], + no_sub_features: ['read'], + no_sub_features_require_all_space: ['read'], + with_sub_features: ['read'], + }, + spaces: ['foo'], + }, + // this set remains unchanged from the original + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['bar'], + }, + ]) + ); + }); + + it('still allow other features privileges to be changed via "change all" in foo space', () => { + const wrapper = mountWithIntl( + + ); + + findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click'); + findTestSubject(wrapper, 'changeAllPrivileges-all').simulate('click'); + findTestSubject(wrapper, 'createSpacePrivilegeButton').simulate('click'); + + expect(Object.keys(onChange.mock.calls[0][0].kibana[0].feature)).not.toContain( + 'no_sub_features_require_all_space' + ); + expect(onChange).toHaveBeenCalledWith( + createRole([ + { + base: [], + feature: { + excluded_from_base: ['all'], + with_excluded_sub_features: ['all'], + no_sub_features: ['all'], + with_sub_features: ['all'], + }, + spaces: ['foo'], + }, + // this set remains unchanged from the original + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['bar'], + }, + ]) + ); + }); + + it('still allow all features privileges to be changed via "change all" in all space', () => { + const roleAllSpace = createRole([ + { + base: [], + feature: { + with_sub_features: ['all', 'with_sub_features_cool_toggle_2', 'cool_read'], + }, + spaces: ['*'], + }, + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['bar'], + }, + ]); + const wrapper = mountWithIntl( + + ); + + findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click'); + findTestSubject(wrapper, 'changeAllPrivileges-all').simulate('click'); + findTestSubject(wrapper, 'createSpacePrivilegeButton').simulate('click'); + + expect(onChange).toHaveBeenCalledWith( + createRole([ + { + base: [], + feature: { + excluded_from_base: ['all'], + with_excluded_sub_features: ['all'], + no_sub_features: ['all'], + no_sub_features_require_all_space: ['all'], + with_sub_features: ['all'], + }, + spaces: ['*'], + }, + // this set remains unchanged from the original + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['bar'], + }, + ]) + ); + }); + + test.todo( + 'should unset the feature privilege and all sub-feature privileges when "* All spaces" is removed' + ); + }); }); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx index c92b981c67c7c..54c5c8770a981 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx @@ -30,7 +30,8 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import type { Space } from '../../../../../../../../spaces/public'; -import type { Role } from '../../../../../../../common/model'; +import { ALL_SPACES_ID } from '../../../../../../../common/constants'; +import type { FeaturesPrivileges, Role } from '../../../../../../../common/model'; import { copyRole } from '../../../../../../../common/model'; import type { KibanaPrivileges } from '../../../../model'; import { CUSTOM_PRIVILEGE_VALUE } from '../constants'; @@ -261,6 +262,7 @@ export class PrivilegeSpaceForm extends Component { privilegeIndex={this.state.privilegeIndex} canCustomizeSubFeaturePrivileges={this.props.canCustomizeSubFeaturePrivileges} disabled={this.state.selectedBasePrivilege.length > 0 || !hasSelectedSpaces} + allSpacesSelected={this.state.selectedSpaceIds.includes(ALL_SPACES_ID)} /> {this.requiresGlobalPrivilegeWarning() && ( @@ -427,6 +429,7 @@ export class PrivilegeSpaceForm extends Component { const form = role.kibana[this.state.privilegeIndex]; form.spaces = [...selectedSpaceIds]; + form.feature = this.resetRoleFeature(form.feature, selectedSpaceIds); // Remove any feature privilege(s) that cannot currently be selected this.setState({ selectedSpaceIds, @@ -459,6 +462,28 @@ export class PrivilegeSpaceForm extends Component { }); }; + private resetRoleFeature = (roleFeature: FeaturesPrivileges, selectedSpaceIds: string[]) => { + const securedFeatures = this.props.kibanaPrivileges.getSecuredFeatures(); + return Object.entries(roleFeature).reduce((features, [featureId, privileges]) => { + if (!Array.isArray(privileges)) { + return features; + } + const securedFeature = securedFeatures.find((sf) => sf.id === featureId); + const primaryFeaturePrivilege = securedFeature + ?.getPrimaryFeaturePrivileges({ includeMinimalFeaturePrivileges: true }) + .find((pfp) => privileges.includes(pfp.id)) ?? { disabled: false, requireAllSpaces: false }; + const newFeaturePrivileges = + primaryFeaturePrivilege?.disabled || + (primaryFeaturePrivilege?.requireAllSpaces && !selectedSpaceIds.includes(ALL_SPACES_ID)) + ? [] // The primary feature privilege cannot be selected; remove that and any selected sub-feature privileges, too + : privileges; + return { + ...features, + ...(newFeaturePrivileges.length && { [featureId]: newFeaturePrivileges }), + }; + }, {}); + }; + private getDisplayedBasePrivilege = () => { const basePrivilege = this.state.privilegeCalculator.getBasePrivilege( this.state.privilegeIndex @@ -472,34 +497,53 @@ export class PrivilegeSpaceForm extends Component { }; private onFeaturePrivilegesChange = (featureId: string, privileges: string[]) => { - const role = copyRole(this.state.role); - const form = role.kibana[this.state.privilegeIndex]; - - if (privileges.length === 0) { - delete form.feature[featureId]; - } else { - form.feature[featureId] = [...privileges]; - } - - this.setState({ - role, - privilegeCalculator: new PrivilegeFormCalculator(this.props.kibanaPrivileges, role), - }); + this.setRole(privileges, featureId); }; private onChangeAllFeaturePrivileges = (privileges: string[]) => { + this.setRole(privileges); + }; + + private setRole(privileges: string[], featureId?: string) { const role = copyRole(this.state.role); const entry = role.kibana[this.state.privilegeIndex]; if (privileges.length === 0) { - entry.feature = {}; + if (featureId) { + delete entry.feature[featureId]; + } else { + entry.feature = {}; + } } else { - this.props.kibanaPrivileges.getSecuredFeatures().forEach((feature) => { + let securedFeaturesToSet = this.props.kibanaPrivileges.getSecuredFeatures(); + if (featureId) { + securedFeaturesToSet = [securedFeaturesToSet.find((sf) => sf.id === featureId)!]; + } + securedFeaturesToSet.forEach((feature) => { const nextFeaturePrivilege = feature - .getPrimaryFeaturePrivileges() - .find((pfp) => privileges.includes(pfp.id)); + .getPrimaryFeaturePrivileges({ includeMinimalFeaturePrivileges: true }) + .find((pfp) => { + if ( + pfp?.disabled || + (pfp?.requireAllSpaces && !this.state.selectedSpaceIds.includes(ALL_SPACES_ID)) + ) { + return false; + } + return Array.isArray(privileges) && privileges.includes(pfp.id); + }); + let newPrivileges: string[] = []; if (nextFeaturePrivilege) { - entry.feature[feature.id] = [nextFeaturePrivilege.id]; + newPrivileges = [nextFeaturePrivilege.id]; + feature.getSubFeaturePrivileges().forEach((psf) => { + if (Array.isArray(privileges) && privileges.includes(psf.id)) { + newPrivileges.push(psf.id); + } + }); + } + if (newPrivileges.length === 0) { + delete entry.feature[feature.id]; + } else { + entry.feature[feature.id] = newPrivileges; } }); } @@ -507,7 +551,7 @@ export class PrivilegeSpaceForm extends Component { role, privilegeCalculator: new PrivilegeFormCalculator(this.props.kibanaPrivileges, role), }); - }; + } private canSave = () => { if (this.state.selectedSpaceIds.length === 0) { diff --git a/x-pack/plugins/security/public/management/roles/model/kibana_privileges.ts b/x-pack/plugins/security/public/management/roles/model/kibana_privileges.ts index c2dd66172f751..67e000348f016 100644 --- a/x-pack/plugins/security/public/management/roles/model/kibana_privileges.ts +++ b/x-pack/plugins/security/public/management/roles/model/kibana_privileges.ts @@ -58,7 +58,7 @@ export class KibanaPrivileges { public createCollectionFromRoleKibanaPrivileges(roleKibanaPrivileges: RoleKibanaPrivilege[]) { const filterAssigned = (assignedPrivileges: string[]) => (privilege: KibanaPrivilege) => - assignedPrivileges.includes(privilege.id); + Array.isArray(assignedPrivileges) && assignedPrivileges.includes(privilege.id); const privileges: KibanaPrivilege[] = roleKibanaPrivileges .map((entry) => { diff --git a/x-pack/plugins/security/public/management/roles/model/primary_feature_privilege.ts b/x-pack/plugins/security/public/management/roles/model/primary_feature_privilege.ts index 323575862de52..cda4d203305b4 100644 --- a/x-pack/plugins/security/public/management/roles/model/primary_feature_privilege.ts +++ b/x-pack/plugins/security/public/management/roles/model/primary_feature_privilege.ts @@ -27,4 +27,12 @@ export class PrimaryFeaturePrivilege extends KibanaPrivilege { } return `minimal_${this.id}`; } + + public get requireAllSpaces() { + return this.config.requireAllSpaces ?? false; + } + + public get disabled() { + return this.config.disabled ?? false; + } } diff --git a/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.test.ts b/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.test.ts new file mode 100644 index 0000000000000..ba7f453aded75 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.test.ts @@ -0,0 +1,251 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { omit, pick } from 'lodash'; + +import { KibanaFeature } from '../../../../features/server'; +import { transformElasticsearchRoleToRole } from './elasticsearch_role'; +import type { ElasticsearchRole } from './elasticsearch_role'; + +const roles = [ + { + name: 'global-base-all', + cluster: [], + indices: [], + applications: [ + { + application: 'kibana-.kibana', + privileges: ['all'], + resources: ['*'], + }, + ], + run_as: [], + metadata: {}, + transient_metadata: { + enabled: true, + }, + }, + { + name: 'global-base-read', + cluster: [], + indices: [], + applications: [ + { + application: 'kibana-.kibana', + privileges: ['read'], + resources: ['*'], + }, + ], + run_as: [], + metadata: {}, + transient_metadata: { + enabled: true, + }, + }, + { + name: 'global-foo-all', + cluster: [], + indices: [], + applications: [ + { + application: 'kibana-.kibana', + privileges: ['feature_foo.all'], + resources: ['*'], + }, + ], + run_as: [], + metadata: {}, + transient_metadata: { + enabled: true, + }, + }, + { + name: 'global-foo-read', + cluster: [], + indices: [], + applications: [ + { + application: 'kibana-.kibana', + privileges: ['feature_foo.read'], + resources: ['*'], + }, + ], + run_as: [], + metadata: {}, + transient_metadata: { + enabled: true, + }, + }, + { + name: 'default-base-all', + cluster: [], + indices: [], + applications: [ + { + application: 'kibana-.kibana', + privileges: ['space_all'], + resources: ['space:default'], + }, + ], + run_as: [], + metadata: {}, + transient_metadata: { + enabled: true, + }, + }, + { + name: 'default-base-read', + cluster: [], + indices: [], + applications: [ + { + application: 'kibana-.kibana', + privileges: ['space_read'], + resources: ['space:default'], + }, + ], + run_as: [], + metadata: {}, + transient_metadata: { + enabled: true, + }, + }, + { + name: 'default-foo-all', + cluster: [], + indices: [], + applications: [ + { + application: 'kibana-.kibana', + privileges: ['feature_foo.all'], + resources: ['space:default'], + }, + ], + run_as: [], + metadata: {}, + transient_metadata: { + enabled: true, + }, + }, + { + name: 'default-foo-read', + cluster: [], + indices: [], + applications: [ + { + application: 'kibana-.kibana', + privileges: ['feature_foo.read'], + resources: ['space:default'], + }, + ], + run_as: [], + metadata: {}, + transient_metadata: { + enabled: true, + }, + }, +]; + +function testRoles( + testName: string, + features: KibanaFeature[], + elasticsearchRoles: ElasticsearchRole[], + expected: any +) { + const transformedRoles = elasticsearchRoles.map((role) => { + const transformedRole = transformElasticsearchRoleToRole( + features, + omit(role, 'name'), + role.name, + 'kibana-.kibana' + ); + return pick(transformedRole, ['name', '_transform_error']); + }); + + it(`${testName}`, () => { + expect(transformedRoles).toEqual(expected); + }); +} + +describe('#transformElasticsearchRoleToRole', () => { + const featuresWithRequireAllSpaces: KibanaFeature[] = [ + new KibanaFeature({ + id: 'foo', + name: 'KibanaFeatureWithAllSpaces', + app: ['kibana-.kibana'], + category: { id: 'foo', label: 'foo' }, + privileges: { + all: { + requireAllSpaces: true, + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + }, + }), + ]; + const featuresWithReadDisabled: KibanaFeature[] = [ + new KibanaFeature({ + id: 'foo', + name: 'Foo KibanaFeatureWithReadDisabled', + app: ['kibana-.kibana'], + category: { id: 'foo', label: 'foo' }, + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + read: { + disabled: true, + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + }, + }), + ]; + + testRoles('#When features has requireAllSpaces=true', featuresWithRequireAllSpaces, roles, [ + { name: 'global-base-all', _transform_error: [] }, + { name: 'global-base-read', _transform_error: [] }, + { name: 'global-foo-all', _transform_error: [] }, + { name: 'global-foo-read', _transform_error: [] }, + { name: 'default-base-all', _transform_error: [] }, + { name: 'default-base-read', _transform_error: [] }, + { name: 'default-foo-all', _transform_error: ['kibana'] }, + { name: 'default-foo-read', _transform_error: [] }, + ]); + + testRoles( + '#When features has requireAllSpaces=false and read disabled', + featuresWithReadDisabled, + roles, + [ + { name: 'global-base-all', _transform_error: [] }, + { name: 'global-base-read', _transform_error: [] }, + { name: 'global-foo-all', _transform_error: [] }, + { name: 'global-foo-read', _transform_error: ['kibana'] }, + { name: 'default-base-all', _transform_error: [] }, + { name: 'default-base-read', _transform_error: [] }, + { name: 'default-foo-all', _transform_error: [] }, + { name: 'default-foo-read', _transform_error: ['kibana'] }, + ] + ); +}); diff --git a/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.ts b/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.ts index c0dab16f97af8..ec1f6e026b143 100644 --- a/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.ts +++ b/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { KibanaFeature } from '../../../../features/common'; import { GLOBAL_RESOURCE, RESERVED_PRIVILEGES_APPLICATION_WILDCARD, @@ -25,15 +26,16 @@ export type ElasticsearchRole = Pick, name: string, application: string ): Role { const kibanaTransformResult = transformRoleApplicationsToKibanaPrivileges( + features, elasticsearchRole.applications, application ); - return { name, metadata: elasticsearchRole.metadata, @@ -53,6 +55,7 @@ export function transformElasticsearchRoleToRole( } function transformRoleApplicationsToKibanaPrivileges( + features: KibanaFeature[], roleApplications: ElasticsearchRole['applications'], application: string ) { @@ -184,6 +187,44 @@ function transformRoleApplicationsToKibanaPrivileges( }; } + // if a feature privilege requires all spaces, but is assigned to other spaces, we won't transform these + if ( + roleKibanaApplications.some( + (entry) => + !entry.resources.includes(GLOBAL_RESOURCE) && + features.some((f) => + Object.entries(f.privileges ?? {}).some( + ([privName, featurePrivilege]) => + featurePrivilege.requireAllSpaces && + entry.privileges.includes( + PrivilegeSerializer.serializeFeaturePrivilege(f.id, privName) + ) + ) + ) + ) + ) { + return { + success: false, + }; + } + + // if a feature privilege has been disabled we won't transform these + if ( + roleKibanaApplications.some((entry) => + features.some((f) => + Object.entries(f.privileges ?? {}).some( + ([privName, featurePrivilege]) => + featurePrivilege.disabled && + entry.privileges.includes(PrivilegeSerializer.serializeFeaturePrivilege(f.id, privName)) + ) + ) + ) + ) { + return { + success: false, + }; + } + return { success: true, value: roleKibanaApplications.map(({ resources, privileges }) => { diff --git a/x-pack/plugins/security/server/deprecations/privilege_deprecations.test.ts b/x-pack/plugins/security/server/deprecations/privilege_deprecations.test.ts index e889eb17d5af9..eb229bfe2dc22 100644 --- a/x-pack/plugins/security/server/deprecations/privilege_deprecations.test.ts +++ b/x-pack/plugins/security/server/deprecations/privilege_deprecations.test.ts @@ -17,15 +17,17 @@ const application = `kibana-${kibanaIndexName}`; describe('#getPrivilegeDeprecationsService', () => { describe('#getKibanaRolesByFeatureId', () => { const mockAsCurrentUser = elasticsearchServiceMock.createScopedClusterClient(); + const mockGetFeatures = jest.fn().mockResolvedValue([]); const mockLicense = licenseMock.create(); const mockLogger = loggingSystemMock.createLogger(); const authz = { applicationName: application }; - const { getKibanaRolesByFeatureId } = getPrivilegeDeprecationsService( + const { getKibanaRolesByFeatureId } = getPrivilegeDeprecationsService({ authz, - mockLicense, - mockLogger - ); + getFeatures: mockGetFeatures, + license: mockLicense, + logger: mockLogger, + }); it('happy path to find siem roles with feature_siem privileges', async () => { mockAsCurrentUser.asCurrentUser.security.getRole.mockResolvedValue( diff --git a/x-pack/plugins/security/server/deprecations/privilege_deprecations.ts b/x-pack/plugins/security/server/deprecations/privilege_deprecations.ts index df212d5c7bde3..08413ccc74cf9 100644 --- a/x-pack/plugins/security/server/deprecations/privilege_deprecations.ts +++ b/x-pack/plugins/security/server/deprecations/privilege_deprecations.ts @@ -8,6 +8,7 @@ import { i18n } from '@kbn/i18n'; import type { Logger } from 'src/core/server'; +import type { KibanaFeature } from '../../../features/common'; import type { SecurityLicense } from '../../common/licensing'; import type { PrivilegeDeprecationsRolesByFeatureIdRequest, @@ -17,11 +18,17 @@ import { transformElasticsearchRoleToRole } from '../authorization'; import type { AuthorizationServiceSetupInternal, ElasticsearchRole } from '../authorization'; import { getDetailedErrorMessage, getErrorStatusCode } from '../errors'; -export const getPrivilegeDeprecationsService = ( - authz: Pick, - license: SecurityLicense, - logger: Logger -) => { +export const getPrivilegeDeprecationsService = ({ + authz, + getFeatures, + license, + logger, +}: { + authz: Pick; + getFeatures(): Promise; + license: SecurityLicense; + logger: Logger; +}) => { const getKibanaRolesByFeatureId = async ({ context, featureId, @@ -34,11 +41,13 @@ export const getPrivilegeDeprecationsService = ( } let kibanaRoles; try { - const { body: elasticsearchRoles } = await context.esClient.asCurrentUser.security.getRole< - Record - >(); + const [features, { body: elasticsearchRoles }] = await Promise.all([ + getFeatures(), + context.esClient.asCurrentUser.security.getRole>(), + ]); kibanaRoles = Object.entries(elasticsearchRoles).map(([roleName, elasticsearchRole]) => transformElasticsearchRoleToRole( + features, // @ts-expect-error `SecurityIndicesPrivileges.names` expected to be `string[]` elasticsearchRole, roleName, diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index e8f7aa2aacfdd..1fc3932bb551b 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -324,11 +324,13 @@ export class SecurityPlugin mode: this.authorizationSetup.mode, }, license, - privilegeDeprecationsService: getPrivilegeDeprecationsService( - this.authorizationSetup, + privilegeDeprecationsService: getPrivilegeDeprecationsService({ + authz: this.authorizationSetup, + getFeatures: () => + startServicesPromise.then((services) => services.features.getKibanaFeatures()), license, - this.logger.get('deprecations') - ), + logger: this.logger.get('deprecations'), + }), }); } diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts index d2385adc99162..a511d3aa52e21 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts @@ -32,6 +32,8 @@ describe('GET role', () => { test(description, async () => { const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); mockRouteDefinitionParams.authz.applicationName = application; + mockRouteDefinitionParams.getFeatures = jest.fn().mockResolvedValue([]); + const mockContext = { core: coreMock.createRequestHandlerContext(), licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } } as any, diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get.ts b/x-pack/plugins/security/server/routes/authorization/roles/get.ts index 6e010b69a3711..4c54854d3279b 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get.ts @@ -12,7 +12,7 @@ import { wrapIntoCustomErrorResponse } from '../../../errors'; import { createLicensedRouteHandler } from '../../licensed_route_handler'; import { transformElasticsearchRoleToRole } from './model'; -export function defineGetRolesRoutes({ router, authz }: RouteDefinitionParams) { +export function defineGetRolesRoutes({ router, authz, getFeatures }: RouteDefinitionParams) { router.get( { path: '/api/security/role/{name}', @@ -22,15 +22,18 @@ export function defineGetRolesRoutes({ router, authz }: RouteDefinitionParams) { }, createLicensedRouteHandler(async (context, request, response) => { try { - const { body: elasticsearchRoles } = + const [features, { body: elasticsearchRoles }] = await Promise.all([ + getFeatures(), await context.core.elasticsearch.client.asCurrentUser.security.getRole({ name: request.params.name, - }); - + }), + ]); const elasticsearchRole = elasticsearchRoles[request.params.name]; + if (elasticsearchRole) { return response.ok({ body: transformElasticsearchRoleToRole( + features, // @ts-expect-error `SecurityIndicesPrivileges.names` expected to be `string[]` elasticsearchRole, request.params.name, diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts index 09262d7cbbadd..8ecd5b7bd0913 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts @@ -32,6 +32,8 @@ describe('GET all roles', () => { test(description, async () => { const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); mockRouteDefinitionParams.authz.applicationName = application; + mockRouteDefinitionParams.getFeatures = jest.fn().mockResolvedValue([]); + const mockContext = { core: coreMock.createRequestHandlerContext(), licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } } as any, diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts b/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts index ba5133b780d5e..89b39ddae0118 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts @@ -11,21 +11,24 @@ import { createLicensedRouteHandler } from '../../licensed_route_handler'; import type { ElasticsearchRole } from './model'; import { transformElasticsearchRoleToRole } from './model'; -export function defineGetAllRolesRoutes({ router, authz }: RouteDefinitionParams) { +export function defineGetAllRolesRoutes({ router, authz, getFeatures }: RouteDefinitionParams) { router.get( { path: '/api/security/role', validate: false }, createLicensedRouteHandler(async (context, request, response) => { try { - const { body: elasticsearchRoles } = + const [features, { body: elasticsearchRoles }] = await Promise.all([ + getFeatures(), await context.core.elasticsearch.client.asCurrentUser.security.getRole< Record - >(); + >(), + ]); // Transform elasticsearch roles into Kibana roles and return in a list sorted by the role name. return response.ok({ body: Object.entries(elasticsearchRoles) .map(([roleName, elasticsearchRole]) => transformElasticsearchRoleToRole( + features, // @ts-expect-error @elastic/elasticsearch SecurityIndicesPrivileges.names expected to be string[] elasticsearchRole, roleName, diff --git a/x-pack/plugins/security/server/routes/authorization/roles/model/index.ts b/x-pack/plugins/security/server/routes/authorization/roles/model/index.ts index cb5bb8a91152c..ef27f20f09a55 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/model/index.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/model/index.ts @@ -7,4 +7,8 @@ export type { ElasticsearchRole } from '../../../../authorization'; export { transformElasticsearchRoleToRole } from '../../../../authorization'; -export { getPutPayloadSchema, transformPutPayloadToElasticsearchRole } from './put_payload'; +export { + getPutPayloadSchema, + transformPutPayloadToElasticsearchRole, + validateKibanaPrivileges, +} from './put_payload'; diff --git a/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.test.ts index aa49788b7fd55..377729dc5b095 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.test.ts @@ -5,7 +5,9 @@ * 2.0. */ -import { getPutPayloadSchema } from './put_payload'; +import { KibanaFeature } from '../../../../../../features/common'; +import { ALL_SPACES_ID } from '../../../../../common/constants'; +import { getPutPayloadSchema, validateKibanaPrivileges } from './put_payload'; const basePrivilegeNamesMap = { global: ['all', 'read'], @@ -345,3 +347,121 @@ describe('Put payload schema', () => { `); }); }); + +describe('validateKibanaPrivileges', () => { + const fooFeature = new KibanaFeature({ + id: 'foo', + name: 'Foo', + privileges: { + all: { + requireAllSpaces: true, + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + read: { + disabled: true, + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + }, + app: [], + category: { id: 'foo', label: 'foo' }, + }); + + test('allows valid privileges', () => { + expect( + validateKibanaPrivileges( + [fooFeature], + [ + { + spaces: [ALL_SPACES_ID], + base: [], + feature: { + foo: ['all'], + }, + }, + ] + ).validationErrors + ).toEqual([]); + }); + + test('does not reject unknown features', () => { + expect( + validateKibanaPrivileges( + [fooFeature], + [ + { + spaces: [ALL_SPACES_ID], + base: [], + feature: { + foo: ['all'], + bar: ['all'], + }, + }, + ] + ).validationErrors + ).toEqual([]); + }); + + test('returns errors if requireAllSpaces: true and not all spaces specified', () => { + expect( + validateKibanaPrivileges( + [fooFeature], + [ + { + spaces: ['foo-space'], + base: [], + feature: { + foo: ['all'], + }, + }, + ] + ).validationErrors + ).toEqual([ + `Feature privilege [foo.all] requires all spaces to be selected but received [foo-space]`, + ]); + }); + + test('returns errors if disabled: true and privilege is specified', () => { + expect( + validateKibanaPrivileges( + [fooFeature], + [ + { + spaces: [ALL_SPACES_ID], + base: [], + feature: { + foo: ['read'], + }, + }, + ] + ).validationErrors + ).toEqual([`Feature [foo] does not support privilege [read].`]); + }); + + test('returns multiple errors when necessary', () => { + expect( + validateKibanaPrivileges( + [fooFeature], + [ + { + spaces: ['foo-space'], + base: [], + feature: { + foo: ['all', 'read'], + }, + }, + ] + ).validationErrors + ).toEqual([ + `Feature privilege [foo.all] requires all spaces to be selected but received [foo-space]`, + `Feature [foo] does not support privilege [read].`, + ]); + }); +}); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.ts b/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.ts index 7fb2baf0fd410..015891b7d7ffa 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.ts @@ -11,7 +11,8 @@ import type { TypeOf } from '@kbn/config-schema'; import { schema } from '@kbn/config-schema'; import type { ElasticsearchRole } from '.'; -import { GLOBAL_RESOURCE } from '../../../../../common/constants'; +import type { KibanaFeature } from '../../../../../../features/common'; +import { ALL_SPACES_ID, GLOBAL_RESOURCE } from '../../../../../common/constants'; import { PrivilegeSerializer } from '../../../../authorization/privilege_serializer'; import { ResourceSerializer } from '../../../../authorization/resource_serializer'; @@ -302,3 +303,50 @@ const transformPrivilegesToElasticsearchPrivileges = ( }; }); }; + +export const validateKibanaPrivileges = ( + kibanaFeatures: KibanaFeature[], + kibanaPrivileges: PutPayloadSchemaType['kibana'] +) => { + const validationErrors = (kibanaPrivileges ?? []).flatMap((priv) => { + const forAllSpaces = priv.spaces.includes(ALL_SPACES_ID); + + return Object.entries(priv.feature ?? {}).flatMap(([featureId, feature]) => { + const errors: string[] = []; + const kibanaFeature = kibanaFeatures.find((f) => f.id === featureId); + if (!kibanaFeature) return errors; + + if (feature.includes('all')) { + if (kibanaFeature.privileges?.all.disabled) { + errors.push(`Feature [${featureId}] does not support privilege [all].`); + } + + if (kibanaFeature.privileges?.all.requireAllSpaces && !forAllSpaces) { + errors.push( + `Feature privilege [${featureId}.all] requires all spaces to be selected but received [${priv.spaces.join( + ',' + )}]` + ); + } + } + + if (feature.includes('read')) { + if (kibanaFeature.privileges?.read.disabled) { + errors.push(`Feature [${featureId}] does not support privilege [read].`); + } + + if (kibanaFeature.privileges?.read.requireAllSpaces && !forAllSpaces) { + errors.push( + `Feature privilege [${featureId}.read] requires all spaces to be selected but received [${priv.spaces.join( + ',' + )}]` + ); + } + } + + return errors; + }); + }); + + return { validationErrors }; +}; diff --git a/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts index ae4647ca4f515..72d78ba5aaca4 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts @@ -56,11 +56,19 @@ interface TestOptions { apiArguments?: { get: unknown[]; put: unknown[] }; recordSubFeaturePrivilegeUsage?: boolean; }; + features?: KibanaFeature[]; } const putRoleTest = ( description: string, - { name, payload, licenseCheckResult = { state: 'valid' }, apiResponses, asserts }: TestOptions + { + name, + payload, + licenseCheckResult = { state: 'valid' }, + apiResponses, + asserts, + features, + }: TestOptions ) => { test(description, async () => { const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); @@ -88,43 +96,45 @@ const putRoleTest = ( securityFeatureUsageServiceMock.createStartContract() ); - mockRouteDefinitionParams.getFeatures.mockResolvedValue([ - new KibanaFeature({ - id: 'feature_1', - name: 'feature 1', - app: [], - category: { id: 'foo', label: 'foo' }, - privileges: { - all: { - ui: [], - savedObject: { all: [], read: [] }, - }, - read: { - ui: [], - savedObject: { all: [], read: [] }, - }, - }, - subFeatures: [ - { - name: 'sub feature 1', - privilegeGroups: [ - { - groupType: 'independent', - privileges: [ - { - id: 'sub_feature_privilege_1', - name: 'first sub-feature privilege', - includeIn: 'none', - ui: [], - savedObject: { all: [], read: [] }, - }, - ], - }, - ], + mockRouteDefinitionParams.getFeatures.mockResolvedValue( + features ?? [ + new KibanaFeature({ + id: 'feature_1', + name: 'feature 1', + app: [], + category: { id: 'foo', label: 'foo' }, + privileges: { + all: { + ui: [], + savedObject: { all: [], read: [] }, + }, + read: { + ui: [], + savedObject: { all: [], read: [] }, + }, }, - ], - }), - ]); + subFeatures: [ + { + name: 'sub feature 1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub_feature_privilege_1', + name: 'first sub-feature privilege', + includeIn: 'none', + ui: [], + savedObject: { all: [], read: [] }, + }, + ], + }, + ], + }, + ], + }), + ] + ); definePutRolesRoutes(mockRouteDefinitionParams); const [[{ validate }, handler]] = mockRouteDefinitionParams.router.put.mock.calls; @@ -207,6 +217,56 @@ describe('PUT role', () => { licenseCheckResult: { state: 'invalid', message: 'test forbidden message' }, asserts: { statusCode: 403, result: { message: 'test forbidden message' } }, }); + + describe('feature validation', () => { + const fooFeature = new KibanaFeature({ + id: 'bar', + name: 'bar', + privileges: { + all: { + requireAllSpaces: true, + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + read: { + disabled: true, + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + }, + app: [], + category: { id: 'bar', label: 'bar' }, + }); + + putRoleTest('returns validation errors', { + name: 'bar-role', + payload: { + kibana: [ + { + spaces: ['bar-space'], + base: [], + feature: { + bar: ['all', 'read'], + }, + }, + ], + }, + features: [fooFeature], + asserts: { + statusCode: 400, + result: { + message: + 'Role cannot be updated due to validation errors: ["Feature privilege [bar.all] requires all spaces to be selected but received [bar-space]","Feature [bar] does not support privilege [read]."]', + }, + }, + }); + }); }); describe('success', () => { diff --git a/x-pack/plugins/security/server/routes/authorization/roles/put.ts b/x-pack/plugins/security/server/routes/authorization/roles/put.ts index af69db1f6bd43..c1b8cc56c32a4 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/put.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/put.ts @@ -12,7 +12,11 @@ import type { KibanaFeature } from '../../../../../features/common'; import { wrapIntoCustomErrorResponse } from '../../../errors'; import type { RouteDefinitionParams } from '../../index'; import { createLicensedRouteHandler } from '../../licensed_route_handler'; -import { getPutPayloadSchema, transformPutPayloadToElasticsearchRole } from './model'; +import { + getPutPayloadSchema, + transformPutPayloadToElasticsearchRole, + validateKibanaPrivileges, +} from './model'; const roleGrantsSubFeaturePrivileges = ( features: KibanaFeature[], @@ -62,11 +66,24 @@ export function definePutRolesRoutes({ const { name } = request.params; try { - const { body: rawRoles } = - await context.core.elasticsearch.client.asCurrentUser.security.getRole( + const [features, { body: rawRoles }] = await Promise.all([ + getFeatures(), + context.core.elasticsearch.client.asCurrentUser.security.getRole( { name: request.params.name }, { ignore: [404] } - ); + ), + ]); + + const { validationErrors } = validateKibanaPrivileges(features, request.body.kibana); + if (validationErrors.length) { + return response.badRequest({ + body: { + message: `Role cannot be updated due to validation errors: ${JSON.stringify( + validationErrors + )}`, + }, + }); + } const body = transformPutPayloadToElasticsearchRole( request.body, @@ -74,14 +91,11 @@ export function definePutRolesRoutes({ rawRoles[name] ? rawRoles[name].applications : [] ); - const [features] = await Promise.all([ - getFeatures(), - context.core.elasticsearch.client.asCurrentUser.security.putRole({ - name: request.params.name, - // @ts-expect-error RoleIndexPrivilege is not compatible. grant is required in IndicesPrivileges.field_security - body, - }), - ]); + await context.core.elasticsearch.client.asCurrentUser.security.putRole({ + name: request.params.name, + // @ts-expect-error RoleIndexPrivilege is not compatible. grant is required in IndicesPrivileges.field_security + body, + }); if (roleGrantsSubFeaturePrivileges(features, request.body)) { getFeatureUsageService().recordSubFeaturePrivilegeUsage(); From 00d1ad30f40e7006c9ac09da4fb3a7111d2f19bd Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 6 Jan 2022 13:22:17 +0100 Subject: [PATCH 03/25] [SearchBar] Improve rendering performance (#119189) --- package.json | 3 +- .../public/query/timefilter/time_history.ts | 4 + .../timefilter/timefilter_service.mock.ts | 2 + .../data/public/ui/filter_bar/filter_bar.tsx | 4 +- .../query_string_input/language_switcher.tsx | 4 +- .../query_bar_top_row.test.tsx | 3 +- .../query_string_input/query_bar_top_row.tsx | 528 ++++++++++-------- .../query_string_input/query_string_input.tsx | 141 +++-- .../lib/use_query_string_manager.ts | 12 +- .../data/public/ui/search_bar/search_bar.tsx | 49 +- .../suggestions_component.test.tsx.snap | 129 ----- .../typeahead/suggestion_component.test.tsx | 3 +- .../ui/typeahead/suggestion_component.tsx | 34 +- .../typeahead/suggestions_component.test.tsx | 24 +- .../ui/typeahead/suggestions_component.tsx | 238 ++++++-- src/plugins/data/public/ui/typeahead/types.ts | 2 + src/plugins/data/public/ui/utils/index.ts | 9 + src/plugins/data/public/ui/utils/on_raf.ts | 22 + .../data/public/utils/shallow_equal.ts | 36 ++ yarn.lock | 10 +- 20 files changed, 722 insertions(+), 535 deletions(-) delete mode 100644 src/plugins/data/public/ui/typeahead/__snapshots__/suggestions_component.test.tsx.snap create mode 100644 src/plugins/data/public/ui/utils/index.ts create mode 100644 src/plugins/data/public/ui/utils/on_raf.ts create mode 100644 src/plugins/data/public/utils/shallow_equal.ts diff --git a/package.json b/package.json index ed0bbf49339e8..f31baf75e07af 100644 --- a/package.json +++ b/package.json @@ -287,7 +287,7 @@ "markdown-it": "^10.0.0", "md5": "^2.1.0", "mdast-util-to-hast": "10.0.1", - "memoize-one": "^5.0.0", + "memoize-one": "^6.0.0", "mime": "^2.4.4", "mime-types": "^2.1.27", "mini-css-extract-plugin": "1.1.0", @@ -611,7 +611,6 @@ "@types/lz-string": "^1.3.34", "@types/markdown-it": "^0.0.7", "@types/md5": "^2.2.0", - "@types/memoize-one": "^4.1.0", "@types/mime": "^2.0.1", "@types/mime-types": "^2.1.0", "@types/minimatch": "^2.0.29", diff --git a/src/plugins/data/public/query/timefilter/time_history.ts b/src/plugins/data/public/query/timefilter/time_history.ts index ff08c89c98b49..f83e5e685b581 100644 --- a/src/plugins/data/public/query/timefilter/time_history.ts +++ b/src/plugins/data/public/query/timefilter/time_history.ts @@ -42,6 +42,10 @@ export class TimeHistory { get() { return this.history.get(); } + + get$() { + return this.history.get$(); + } } export type TimeHistoryContract = PublicMethodsOf; diff --git a/src/plugins/data/public/query/timefilter/timefilter_service.mock.ts b/src/plugins/data/public/query/timefilter/timefilter_service.mock.ts index 2b6a65e6c9bd7..53d9aeeec9afe 100644 --- a/src/plugins/data/public/query/timefilter/timefilter_service.mock.ts +++ b/src/plugins/data/public/query/timefilter/timefilter_service.mock.ts @@ -9,6 +9,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import { TimefilterService, TimeHistoryContract, TimefilterContract } from '.'; import { Observable } from 'rxjs'; +import { TimeRange } from '../../../common'; export type TimefilterServiceClientContract = PublicMethodsOf; @@ -43,6 +44,7 @@ const createSetupContractMock = () => { const historyMock: jest.Mocked = { add: jest.fn(), get: jest.fn(), + get$: jest.fn(() => new Observable()), }; const setupContract = { diff --git a/src/plugins/data/public/ui/filter_bar/filter_bar.tsx b/src/plugins/data/public/ui/filter_bar/filter_bar.tsx index acfafa6dd6bd8..9bc64eb1f6919 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_bar.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_bar.tsx @@ -40,7 +40,7 @@ interface Props { timeRangeForSuggestionsOverride?: boolean; } -function FilterBarUI(props: Props) { +const FilterBarUI = React.memo(function FilterBarUI(props: Props) { const groupRef = useRef(null); const [isAddFilterPopoverOpen, setIsAddFilterPopoverOpen] = useState(false); const kibana = useKibana(); @@ -226,6 +226,6 @@ function FilterBarUI(props: Props) { ); -} +}); export const FilterBar = injectI18n(FilterBarUI); diff --git a/src/plugins/data/public/ui/query_string_input/language_switcher.tsx b/src/plugins/data/public/ui/query_string_input/language_switcher.tsx index 6a52220086194..3d66edd6a022c 100644 --- a/src/plugins/data/public/ui/query_string_input/language_switcher.tsx +++ b/src/plugins/data/public/ui/query_string_input/language_switcher.tsx @@ -32,7 +32,7 @@ export interface QueryLanguageSwitcherProps { nonKqlModeHelpText?: string; } -export function QueryLanguageSwitcher({ +export const QueryLanguageSwitcher = React.memo(function QueryLanguageSwitcher({ language, anchorPosition, onSelectLanguage, @@ -148,4 +148,4 @@ export function QueryLanguageSwitcher({ ); -} +}); diff --git a/src/plugins/data/public/ui/query_string_input/query_bar_top_row.test.tsx b/src/plugins/data/public/ui/query_string_input/query_bar_top_row.test.tsx index 0fce65f2a47b1..56dd901055fbc 100644 --- a/src/plugins/data/public/ui/query_string_input/query_bar_top_row.test.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_bar_top_row.test.tsx @@ -11,9 +11,9 @@ import { mockPersistedLogFactory } from './query_string_input.test.mocks'; import React from 'react'; import { mount } from 'enzyme'; import { render } from '@testing-library/react'; +import { EMPTY } from 'rxjs'; import QueryBarTopRow from './query_bar_top_row'; - import { coreMock } from '../../../../../core/public/mocks'; import { dataPluginMock } from '../../mocks'; import { KibanaContextProvider } from 'src/plugins/kibana_react/public'; @@ -26,6 +26,7 @@ const mockTimeHistory = { get: () => { return []; }, + get$: () => EMPTY, }; startMock.uiSettings.get.mockImplementation((key: string) => { diff --git a/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx b/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx index 76d4b9dd8e801..bb5e61bdb1946 100644 --- a/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx @@ -8,7 +8,11 @@ import dateMath from '@elastic/datemath'; import classNames from 'classnames'; -import React, { useState } from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import deepEqual from 'fast-deep-equal'; +import useObservable from 'react-use/lib/useObservable'; +import { EMPTY } from 'rxjs'; +import { map } from 'rxjs/operators'; import { EuiFlexGroup, @@ -17,9 +21,10 @@ import { EuiFieldText, prettyDuration, EuiIconProps, + EuiSuperUpdateButton, + OnRefreshProps, } from '@elastic/eui'; -// @ts-ignore -import { EuiSuperUpdateButton, OnRefreshProps } from '@elastic/eui'; + import { IDataPluginServices, IIndexPattern, TimeRange, TimeHistoryContract, Query } from '../..'; import { useKibana, withKibana } from '../../../../kibana_react/public'; import QueryStringInputUI from './query_string_input'; @@ -27,6 +32,14 @@ import { UI_SETTINGS } from '../../../common'; import { getQueryLog } from '../../query'; import type { PersistedLog } from '../../query'; import { NoDataPopover } from './no_data_popover'; +import { shallowEqual } from '../../utils/shallow_equal'; + +const SuperDatePicker = React.memo( + EuiSuperDatePicker as any +) as unknown as typeof EuiSuperDatePicker; +const SuperUpdateButton = React.memo( + EuiSuperUpdateButton as any +) as unknown as typeof EuiSuperUpdateButton; const QueryStringInput = withKibana(QueryStringInputUI); @@ -63,265 +76,328 @@ export interface QueryBarTopRowProps { timeRangeForSuggestionsOverride?: boolean; } -// Needed for React.lazy -// eslint-disable-next-line import/no-default-export -export default function QueryBarTopRow(props: QueryBarTopRowProps) { - const [isDateRangeInvalid, setIsDateRangeInvalid] = useState(false); - const [isQueryInputFocused, setIsQueryInputFocused] = useState(false); - - const kibana = useKibana(); - const { uiSettings, storage, appName } = kibana.services; - - const queryLanguage = props.query && props.query.language; - const persistedLog: PersistedLog | undefined = React.useMemo( - () => - queryLanguage && uiSettings && storage && appName - ? getQueryLog(uiSettings!, storage, appName, queryLanguage) - : undefined, - [appName, queryLanguage, uiSettings, storage] - ); - - function onClickSubmitButton(event: React.MouseEvent) { - if (persistedLog && props.query) { - persistedLog.add(props.query.query); +const SharingMetaFields = React.memo(function SharingMetaFields({ + from, + to, + dateFormat, +}: { + from: string; + to: string; + dateFormat: string; +}) { + function toAbsoluteString(value: string, roundUp = false) { + const valueAsMoment = dateMath.parse(value, { roundUp }); + if (!valueAsMoment) { + return value; } - event.preventDefault(); - onSubmit({ query: props.query, dateRange: getDateRange() }); + return valueAsMoment.toISOString(); } - function getDateRange() { - const defaultTimeSetting = uiSettings!.get(UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS); - return { - from: props.dateRangeFrom || defaultTimeSetting.from, - to: props.dateRangeTo || defaultTimeSetting.to, - }; - } + const dateRangePretty = prettyDuration( + toAbsoluteString(from), + toAbsoluteString(to), + [], + dateFormat + ); - function onQueryChange(query: Query) { - props.onChange({ - query, - dateRange: getDateRange(), - }); - } + return ( +
+ ); +}); - function onChangeQueryInputFocus(isFocused: boolean) { - setIsQueryInputFocused(isFocused); - } +export const QueryBarTopRow = React.memo( + function QueryBarTopRow(props: QueryBarTopRowProps) { + const { showQueryInput = true, showDatePicker = true, showAutoRefreshOnly = false } = props; - function onTimeChange({ - start, - end, - isInvalid, - isQuickSelection, - }: { - start: string; - end: string; - isInvalid: boolean; - isQuickSelection: boolean; - }) { - setIsDateRangeInvalid(isInvalid); - const retVal = { - query: props.query, - dateRange: { - from: start, - to: end, - }, - }; + const [isDateRangeInvalid, setIsDateRangeInvalid] = useState(false); + const [isQueryInputFocused, setIsQueryInputFocused] = useState(false); - if (isQuickSelection) { - props.onSubmit(retVal); - } else { - props.onChange(retVal); - } - } + const kibana = useKibana(); + const { uiSettings, storage, appName } = kibana.services; - function onRefresh({ start, end }: OnRefreshProps) { - const retVal = { - dateRange: { - from: start, - to: end, - }, - }; - if (props.onRefresh) { - props.onRefresh(retVal); - } - } + const queryLanguage = props.query && props.query.language; + const queryRef = useRef(props.query); + queryRef.current = props.query; + + const persistedLog: PersistedLog | undefined = React.useMemo( + () => + queryLanguage && uiSettings && storage && appName + ? getQueryLog(uiSettings!, storage, appName, queryLanguage) + : undefined, + [appName, queryLanguage, uiSettings, storage] + ); - function onSubmit({ query, dateRange }: { query?: Query; dateRange: TimeRange }) { - if (props.timeHistory) { - props.timeHistory.add(dateRange); + function getDateRange() { + const defaultTimeSetting = uiSettings!.get(UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS); + return { + from: props.dateRangeFrom || defaultTimeSetting.from, + to: props.dateRangeTo || defaultTimeSetting.to, + }; } - props.onSubmit({ query, dateRange }); - } + const currentDateRange = getDateRange(); + const dateRangeRef = useRef<{ from: string; to: string }>(currentDateRange); + dateRangeRef.current = currentDateRange; - function onInputSubmit(query: Query) { - onSubmit({ - query, - dateRange: getDateRange(), - }); - } + const propsOnSubmit = props.onSubmit; - function toAbsoluteString(value: string, roundUp = false) { - const valueAsMoment = dateMath.parse(value, { roundUp }); - if (!valueAsMoment) { - return value; - } - return valueAsMoment.toISOString(); - } - - function renderQueryInput() { - if (!shouldRenderQueryInput()) return; + const toRecentlyUsedRanges = (ranges: TimeRange[]) => + ranges.map(({ from, to }: { from: string; to: string }) => { + return { + start: from, + end: to, + }; + }); + const timeHistory = props.timeHistory; + const timeHistory$ = useMemo( + () => timeHistory?.get$().pipe(map(toRecentlyUsedRanges)) ?? EMPTY, + [timeHistory] + ); - return ( - - - + const recentlyUsedRanges = useObservable( + timeHistory$, + toRecentlyUsedRanges(timeHistory?.get() ?? []) ); - } + const [commonlyUsedRanges] = useState(() => { + return ( + uiSettings + ?.get(UI_SETTINGS.TIMEPICKER_QUICK_RANGES) + ?.map(({ from, to, display }: { from: string; to: string; display: string }) => { + return { + start: from, + end: to, + label: display, + }; + }) ?? [] + ); + }); + + const onSubmit = useCallback( + ({ query, dateRange }: { query?: Query; dateRange: TimeRange }) => { + if (timeHistory) { + timeHistory.add(dateRange); + } - function renderSharingMetaFields() { - const { from, to } = getDateRange(); - const dateRangePretty = prettyDuration( - toAbsoluteString(from), - toAbsoluteString(to), - [], - uiSettings.get('dateFormat') + propsOnSubmit({ query, dateRange }); + }, + [timeHistory, propsOnSubmit] ); - return ( -
+ + const onClickSubmitButton = useCallback( + (event: React.MouseEvent) => { + if (persistedLog && queryRef.current) { + persistedLog.add(queryRef.current.query); + } + event.preventDefault(); + onSubmit({ + query: queryRef.current, + dateRange: dateRangeRef.current, + }); + }, + [persistedLog, onSubmit] ); - } - function shouldRenderDatePicker(): boolean { - return Boolean(props.showDatePicker || props.showAutoRefreshOnly); - } + const propsOnChange = props.onChange; + const onQueryChange = useCallback( + (query: Query) => { + return propsOnChange({ + query, + dateRange: dateRangeRef.current, + }); + }, + [propsOnChange] + ); - function shouldRenderQueryInput(): boolean { - return Boolean(props.showQueryInput && props.indexPatterns && props.query && storage); - } + const onChangeQueryInputFocus = useCallback((isFocused: boolean) => { + setIsQueryInputFocused(isFocused); + }, []); + + const onTimeChange = useCallback( + ({ + start, + end, + isInvalid, + isQuickSelection, + }: { + start: string; + end: string; + isInvalid: boolean; + isQuickSelection: boolean; + }) => { + setIsDateRangeInvalid(isInvalid); + const retVal = { + query: queryRef.current, + dateRange: { + from: start, + to: end, + }, + }; - function renderUpdateButton() { - const button = props.customSubmitButton ? ( - React.cloneElement(props.customSubmitButton, { onClick: onClickSubmitButton }) - ) : ( - + if (isQuickSelection) { + onSubmit(retVal); + } else { + propsOnChange(retVal); + } + }, + [propsOnChange, onSubmit] ); - if (!shouldRenderDatePicker()) { - return button; - } + const propsOnRefresh = props.onRefresh; + const onRefresh = useCallback( + ({ start, end }: OnRefreshProps) => { + const retVal = { + dateRange: { + from: start, + to: end, + }, + }; + if (propsOnRefresh) { + propsOnRefresh(retVal); + } + }, + [propsOnRefresh] + ); - return ( - - - {renderDatePicker()} - {button} - - + const onInputSubmit = useCallback( + (query: Query) => { + onSubmit({ + query, + dateRange: dateRangeRef.current, + }); + }, + [onSubmit] ); - } - function renderDatePicker() { - if (!shouldRenderDatePicker()) { - return null; + function shouldRenderQueryInput(): boolean { + return Boolean(showQueryInput && props.indexPatterns && props.query && storage); } - let recentlyUsedRanges; - if (props.timeHistory) { - recentlyUsedRanges = props.timeHistory - .get() - .map(({ from, to }: { from: string; to: string }) => { - return { - start: from, - end: to, - }; - }); + function shouldRenderDatePicker(): boolean { + return Boolean(showDatePicker || showAutoRefreshOnly); } - const commonlyUsedRanges = uiSettings! - .get(UI_SETTINGS.TIMEPICKER_QUICK_RANGES) - .map(({ from, to, display }: { from: string; to: string; display: string }) => { - return { - start: from, - end: to, - label: display, - }; + function renderDatePicker() { + if (!shouldRenderDatePicker()) { + return null; + } + + const wrapperClasses = classNames('kbnQueryBar__datePickerWrapper', { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'kbnQueryBar__datePickerWrapper-isHidden': isQueryInputFocused, }); - const wrapperClasses = classNames('kbnQueryBar__datePickerWrapper', { - // eslint-disable-next-line @typescript-eslint/naming-convention - 'kbnQueryBar__datePickerWrapper-isHidden': isQueryInputFocused, + return ( + + + + ); + } + + function renderUpdateButton() { + const button = props.customSubmitButton ? ( + React.cloneElement(props.customSubmitButton, { onClick: onClickSubmitButton }) + ) : ( + + ); + + if (!shouldRenderDatePicker()) { + return button; + } + + return ( + + + {renderDatePicker()} + {button} + + + ); + } + + function renderQueryInput() { + if (!shouldRenderQueryInput()) return; + + return ( + + + + ); + } + + const classes = classNames('kbnQueryBar', { + 'kbnQueryBar--withDatePicker': showDatePicker, }); return ( - - + {renderQueryInput()} + - + {renderUpdateButton()} + ); - } - - const classes = classNames('kbnQueryBar', { - 'kbnQueryBar--withDatePicker': props.showDatePicker, - }); + }, + ({ query: prevQuery, ...prevProps }, { query: nextQuery, ...nextProps }) => { + let isQueryEqual = true; + if (prevQuery !== nextQuery) { + if (!deepEqual(prevQuery, nextQuery)) { + isQueryEqual = false; + } + } - return ( - - {renderQueryInput()} - {renderSharingMetaFields()} - {renderUpdateButton()} - - ); -} + return isQueryEqual && shallowEqual(prevProps, nextProps); + } +); -QueryBarTopRow.defaultProps = { - showQueryInput: true, - showDatePicker: true, - showAutoRefreshOnly: false, -}; +// Needed for React.lazy +// eslint-disable-next-line import/no-default-export +export default QueryBarTopRow; diff --git a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx index 2e150b2c1e1bc..a0b214d1be8c7 100644 --- a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx @@ -6,31 +6,31 @@ * Side Public License, v 1. */ -import React, { Component, RefObject, createRef } from 'react'; +import React, { PureComponent } from 'react'; import { i18n } from '@kbn/i18n'; import classNames from 'classnames'; + import { - EuiTextArea, - EuiOutsideClickDetector, - PopoverAnchorPosition, + EuiButton, EuiFlexGroup, EuiFlexItem, - EuiButton, - EuiLink, - htmlIdGenerator, - EuiPortal, EuiIcon, EuiIconProps, + EuiLink, + EuiOutsideClickDetector, + EuiPortal, + EuiTextArea, + htmlIdGenerator, + PopoverAnchorPosition, } from '@elastic/eui'; - import { FormattedMessage } from '@kbn/i18n-react'; -import { debounce, compact, isEqual, isFunction } from 'lodash'; +import { compact, debounce, isEqual, isFunction } from 'lodash'; import { Toast } from 'src/core/public'; import { METRIC_TYPE } from '@kbn/analytics'; + import { IDataPluginServices, IIndexPattern, Query } from '../..'; import { QuerySuggestion, QuerySuggestionTypes } from '../../autocomplete'; - import { KibanaReactContextValue, toMountPoint } from '../../../../kibana_react/public'; import { fetchIndexPatterns } from './fetch_index_patterns'; import { QueryLanguageSwitcher } from './language_switcher'; @@ -38,7 +38,8 @@ import { getQueryLog, matchPairs, toUser, fromUser } from '../../query'; import type { PersistedLog } from '../../query'; import type { SuggestionsListSize } from '../typeahead/suggestions_component'; import { SuggestionsComponent } from '..'; -import { KIBANA_USER_QUERY_LANGUAGE_KEY, getFieldSubtypeNested } from '../../../common'; +import { getFieldSubtypeNested, KIBANA_USER_QUERY_LANGUAGE_KEY } from '../../../common'; +import { onRaf } from '../utils'; export interface QueryStringInputProps { indexPatterns: Array; @@ -96,7 +97,11 @@ interface State { selectionStart: number | null; selectionEnd: number | null; indexPatterns: IIndexPattern[]; - queryBarRect: DOMRect | undefined; + + /** + * Part of state because passed down to child components + */ + queryBarInputDiv: HTMLDivElement | null; } const KEY_CODES = { @@ -113,7 +118,7 @@ const KEY_CODES = { // Needed for React.lazy // eslint-disable-next-line import/no-default-export -export default class QueryStringInputUI extends Component { +export default class QueryStringInputUI extends PureComponent { static defaultProps = { storageKey: KIBANA_USER_QUERY_LANGUAGE_KEY, }; @@ -126,7 +131,7 @@ export default class QueryStringInputUI extends Component { selectionStart: null, selectionEnd: null, indexPatterns: [], - queryBarRect: undefined, + queryBarInputDiv: null, }; public inputRef: HTMLTextAreaElement | null = null; @@ -140,7 +145,6 @@ export default class QueryStringInputUI extends Component { this.services.appName ); private componentIsUnmounting = false; - private queryBarInputDivRefInstance: RefObject = createRef(); /** * If any element within the container is currently focused @@ -280,7 +284,9 @@ export default class QueryStringInputUI extends Component { suggestionLimit: 50, }); - this.onChange({ query: value, language: this.props.query.language }); + if (this.props.query.query !== value) { + this.onChange({ query: value, language: this.props.query.language }); + } }; private onInputChange = (event: React.ChangeEvent) => { @@ -318,10 +324,16 @@ export default class QueryStringInputUI extends Component { const { value, selectionStart, selectionEnd } = target; const updateQuery = (query: string, newSelectionStart: number, newSelectionEnd: number) => { this.onQueryStringChange(query); - this.setState({ - selectionStart: newSelectionStart, - selectionEnd: newSelectionEnd, - }); + + if ( + this.inputRef?.selectionStart !== newSelectionStart || + this.inputRef?.selectionEnd !== newSelectionEnd + ) { + this.setState({ + selectionStart: newSelectionStart, + selectionEnd: newSelectionEnd, + }); + } }; switch (event.keyCode) { @@ -576,7 +588,7 @@ export default class QueryStringInputUI extends Component { : getQueryLog(uiSettings, storage, appName, this.props.query.language); }; - public onMouseEnterSuggestion = (index: number) => { + public onMouseEnterSuggestion = (suggestion: QuerySuggestion, index: number) => { this.setState({ index }); }; @@ -590,13 +602,9 @@ export default class QueryStringInputUI extends Component { this.initPersistedLog(); this.fetchIndexPatterns(); - this.handleListUpdate(); + this.handleAutoHeight(); window.addEventListener('resize', this.handleAutoHeight); - window.addEventListener('scroll', this.handleListUpdate, { - passive: true, // for better performance as we won't call preventDefault - capture: true, // scroll events don't bubble, they must be captured instead - }); } public componentDidUpdate(prevProps: Props) { @@ -621,11 +629,12 @@ export default class QueryStringInputUI extends Component { selectionStart: null, selectionEnd: null, }); - if (document.activeElement !== null && document.activeElement.id === this.textareaId) { - this.handleAutoHeight(); - } else { - this.handleRemoveHeight(); - } + } + + if (document.activeElement !== null && document.activeElement.id === this.textareaId) { + this.handleAutoHeight(); + } else { + this.handleRemoveHeight(); } } @@ -634,47 +643,35 @@ export default class QueryStringInputUI extends Component { if (this.updateSuggestions.cancel) this.updateSuggestions.cancel(); this.componentIsUnmounting = true; window.removeEventListener('resize', this.handleAutoHeight); - window.removeEventListener('scroll', this.handleListUpdate, { capture: true }); } - handleListUpdate = () => { - if (this.componentIsUnmounting) return; - - return this.setState({ - queryBarRect: this.queryBarInputDivRefInstance.current?.getBoundingClientRect(), - }); - }; - - handleAutoHeight = () => { + handleAutoHeight = onRaf(() => { if (this.inputRef !== null && document.activeElement === this.inputRef) { this.inputRef.classList.add('kbnQueryBar__textarea--autoHeight'); this.inputRef.style.setProperty('height', `${this.inputRef.scrollHeight}px`, 'important'); } - this.handleListUpdate(); - }; + }); - handleRemoveHeight = () => { + handleRemoveHeight = onRaf(() => { if (this.inputRef !== null) { this.inputRef.style.removeProperty('height'); this.inputRef.classList.remove('kbnQueryBar__textarea--autoHeight'); } - }; + }); - handleBlurHeight = () => { + handleBlurHeight = onRaf(() => { if (this.inputRef !== null) { this.handleRemoveHeight(); this.inputRef.scrollTop = 0; } - }; + }); handleOnFocus = () => { if (this.props.onChangeQueryInputFocus) { this.props.onChangeQueryInputFocus(true); } - requestAnimationFrame(() => { - this.handleAutoHeight(); - }); + this.handleAutoHeight(); }; public render() { @@ -700,16 +697,7 @@ export default class QueryStringInputUI extends Component { ); return ( -
{ - this.isFocusWithin = true; - }} - onBlur={(e) => { - this.isFocusWithin = false; - this.scheduleOnInputBlur(); - }} - > +
{this.props.prepend}
{ aria-expanded={this.state.isSuggestionsVisible} data-skip-axe="aria-required-children" > -
+
{ autoFocus={ this.props.onChangeQueryInputFocus ? false : !this.props.disableAutoFocus } - inputRef={(node: any) => { - if (node) { - this.inputRef = node; - } - }} + inputRef={this.assignInputRef} autoComplete="off" spellCheck={false} aria-label={i18n.translate('data.query.queryBar.searchInputAriaLabel', { @@ -810,8 +790,8 @@ export default class QueryStringInputUI extends Component { onClick={this.onClickSuggestion} onMouseEnter={this.onMouseEnterSuggestion} loadMore={this.increaseLimit} - queryBarRect={this.state.queryBarRect} size={this.props.size} + inputContainer={this.state.queryBarInputDiv} />
@@ -858,4 +838,21 @@ export default class QueryStringInputUI extends Component { return formattedNewQueryString; } } + + private assignInputRef = (node: HTMLTextAreaElement | null) => { + this.inputRef = node; + }; + + private assignQueryInputDivRef = (node: HTMLDivElement | null) => { + this.setState({ queryBarInputDiv: node }); + }; + + private onFocusWithin = () => { + this.isFocusWithin = true; + }; + + private onBlurWithin = () => { + this.isFocusWithin = false; + this.scheduleOnInputBlur(); + }; } diff --git a/src/plugins/data/public/ui/search_bar/lib/use_query_string_manager.ts b/src/plugins/data/public/ui/search_bar/lib/use_query_string_manager.ts index 713020f249ae3..20c4b683b3253 100644 --- a/src/plugins/data/public/ui/search_bar/lib/use_query_string_manager.ts +++ b/src/plugins/data/public/ui/search_bar/lib/use_query_string_manager.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { Subscription } from 'rxjs'; import { Query } from '../../..'; import type { QueryStringContract } from '../../../query/query_string'; @@ -36,5 +36,13 @@ export const useQueryStringManager = (props: UseQueryStringProps) => { }; }, [props.queryStringManager]); - return { query }; + const stableQuery = useMemo( + () => ({ + language: query.language, + query: query.query, + }), + [query.language, query.query] + ); + + return { query: stableQuery }; }; diff --git a/src/plugins/data/public/ui/search_bar/search_bar.tsx b/src/plugins/data/public/ui/search_bar/search_bar.tsx index 87b6480096551..e33977f8d9048 100644 --- a/src/plugins/data/public/ui/search_bar/search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/search_bar.tsx @@ -12,6 +12,7 @@ import classNames from 'classnames'; import React, { Component } from 'react'; import { get, isEqual } from 'lodash'; import { EuiIconProps } from '@elastic/eui'; +import memoizeOne from 'memoize-one'; import { METRIC_TYPE } from '@kbn/analytics'; import { Query, Filter } from '@kbn/es-query'; @@ -186,6 +187,10 @@ class SearchBarUI extends Component { ); }; + componentWillUnmount() { + this.renderSavedQueryManagement.clear(); + } + private shouldRenderQueryBar() { const showDatePicker = this.props.showDatePicker || this.props.showAutoRefreshOnly; const showQueryInput = @@ -343,18 +348,6 @@ class SearchBarUI extends Component { }; public render() { - const savedQueryManagement = this.state.query && this.props.onClearSavedQuery && ( - - ); - const timeRangeForSuggestionsOverride = this.props.showDatePicker ? undefined : false; let queryBar; @@ -368,7 +361,15 @@ class SearchBarUI extends Component { indexPatterns={this.props.indexPatterns} isLoading={this.props.isLoading} fillSubmitButton={this.props.fillSubmitButton || false} - prepend={this.props.showFilterBar ? savedQueryManagement : undefined} + prepend={ + this.props.showFilterBar && this.state.query + ? this.renderSavedQueryManagement( + this.props.onClearSavedQuery, + this.props.showSaveQuery, + this.props.savedQuery + ) + : undefined + } showDatePicker={this.props.showDatePicker} dateRangeFrom={this.state.dateRangeFrom} dateRangeTo={this.state.dateRangeTo} @@ -447,6 +448,28 @@ class SearchBarUI extends Component {
); } + + private renderSavedQueryManagement = memoizeOne( + ( + onClearSavedQuery: SearchBarOwnProps['onClearSavedQuery'], + showSaveQuery: SearchBarOwnProps['showSaveQuery'], + savedQuery: SearchBarOwnProps['savedQuery'] + ) => { + const savedQueryManagement = onClearSavedQuery && ( + + ); + + return savedQueryManagement; + } + ); } // Needed for React.lazy diff --git a/src/plugins/data/public/ui/typeahead/__snapshots__/suggestions_component.test.tsx.snap b/src/plugins/data/public/ui/typeahead/__snapshots__/suggestions_component.test.tsx.snap deleted file mode 100644 index 9185e6a77d102..0000000000000 --- a/src/plugins/data/public/ui/typeahead/__snapshots__/suggestions_component.test.tsx.snap +++ /dev/null @@ -1,129 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`SuggestionsComponent Passing the index should control which suggestion is selected 1`] = ` - -
-
-
- - -
-
-
-
-`; - -exports[`SuggestionsComponent Should display given suggestions if the show prop is true 1`] = ` - -
-
-
- - -
-
-
-
-`; diff --git a/src/plugins/data/public/ui/typeahead/suggestion_component.test.tsx b/src/plugins/data/public/ui/typeahead/suggestion_component.test.tsx index 6f08493a714e5..56b156c3062be 100644 --- a/src/plugins/data/public/ui/typeahead/suggestion_component.test.tsx +++ b/src/plugins/data/public/ui/typeahead/suggestion_component.test.tsx @@ -59,9 +59,10 @@ describe('SuggestionComponent', () => { }); it('Should call innerRef with a reference to the root div element', () => { - const innerRefCallback = (ref: HTMLDivElement) => { + const innerRefCallback = (index: number, ref: HTMLDivElement) => { expect(ref.className).toBe('kbnTypeahead__item'); expect(ref.id).toBe('suggestion-1'); + expect(index).toBe(0); }; mount( diff --git a/src/plugins/data/public/ui/typeahead/suggestion_component.tsx b/src/plugins/data/public/ui/typeahead/suggestion_component.tsx index d4dadefcbddee..8f555f710612b 100644 --- a/src/plugins/data/public/ui/typeahead/suggestion_component.tsx +++ b/src/plugins/data/public/ui/typeahead/suggestion_component.tsx @@ -8,9 +8,9 @@ import { EuiIcon } from '@elastic/eui'; import classNames from 'classnames'; -import React from 'react'; +import React, { useCallback } from 'react'; import { QuerySuggestion } from '../../autocomplete'; -import { SuggestionOnClick } from './types'; +import { SuggestionOnClick, SuggestionOnMouseEnter } from './types'; function getEuiIconType(type: string) { switch (type) { @@ -31,16 +31,32 @@ function getEuiIconType(type: string) { interface Props { onClick: SuggestionOnClick; - onMouseEnter: () => void; + onMouseEnter: SuggestionOnMouseEnter; selected: boolean; index: number; suggestion: QuerySuggestion; - innerRef: (node: HTMLDivElement) => void; + innerRef: (index: number, node: HTMLDivElement) => void; ariaId: string; shouldDisplayDescription: boolean; } -export function SuggestionComponent(props: Props) { +export const SuggestionComponent = React.memo(function SuggestionComponent(props: Props) { + const { index, innerRef, onClick, onMouseEnter, suggestion } = props; + const setRef = useCallback( + (node: HTMLDivElement) => { + innerRef(index, node); + }, + [index, innerRef] + ); + + const handleClick = useCallback(() => { + onClick(suggestion, index); + }, [index, onClick, suggestion]); + + const handleMouseEnter = useCallback(() => { + onMouseEnter(suggestion, index); + }, [index, onMouseEnter, suggestion]); + return ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus
props.onClick(props.suggestion, props.index)} - onMouseEnter={props.onMouseEnter} - ref={props.innerRef} + onMouseEnter={handleMouseEnter} + onClick={handleClick} + ref={setRef} id={props.ariaId} aria-selected={props.selected} data-test-subj={`autocompleteSuggestion-${ @@ -72,4 +88,4 @@ export function SuggestionComponent(props: Props) {
); -} +}); diff --git a/src/plugins/data/public/ui/typeahead/suggestions_component.test.tsx b/src/plugins/data/public/ui/typeahead/suggestions_component.test.tsx index dce8d5bdcfcd1..d34f48e2957a9 100644 --- a/src/plugins/data/public/ui/typeahead/suggestions_component.test.tsx +++ b/src/plugins/data/public/ui/typeahead/suggestions_component.test.tsx @@ -16,6 +16,8 @@ const noop = () => { return; }; +const mockContainerDiv = document.createElement('div'); + const mockSuggestions: QuerySuggestion[] = [ { description: 'This is not a helpful suggestion', @@ -43,7 +45,7 @@ describe('SuggestionsComponent', () => { show={false} suggestions={mockSuggestions} loadMore={noop} - queryBarRect={{ top: 0 } as DOMRect} + inputContainer={mockContainerDiv} /> ); @@ -59,7 +61,7 @@ describe('SuggestionsComponent', () => { show={true} suggestions={[]} loadMore={noop} - queryBarRect={{ top: 0 } as DOMRect} + inputContainer={mockContainerDiv} /> ); @@ -67,7 +69,7 @@ describe('SuggestionsComponent', () => { }); it('Should display given suggestions if the show prop is true', () => { - const component = shallow( + const component = mount( { show={true} suggestions={mockSuggestions} loadMore={noop} - queryBarRect={{ top: 0 } as DOMRect} + inputContainer={mockContainerDiv} /> ); expect(component.isEmptyRender()).toBe(false); - expect(component).toMatchSnapshot(); + expect(component.find(SuggestionComponent)).toHaveLength(2); }); it('Passing the index should control which suggestion is selected', () => { - const component = shallow( + const component = mount( { show={true} suggestions={mockSuggestions} loadMore={noop} - queryBarRect={{ top: 0 } as DOMRect} + inputContainer={mockContainerDiv} /> ); - expect(component).toMatchSnapshot(); + expect(component.find(SuggestionComponent).at(1).prop('selected')).toBe(true); }); it('Should call onClick with the selected suggestion when it is clicked', () => { @@ -109,7 +111,7 @@ describe('SuggestionsComponent', () => { show={true} suggestions={mockSuggestions} loadMore={noop} - queryBarRect={{ top: 0 } as DOMRect} + inputContainer={mockContainerDiv} /> ); @@ -128,12 +130,12 @@ describe('SuggestionsComponent', () => { show={true} suggestions={mockSuggestions} loadMore={noop} - queryBarRect={{ top: 0 } as DOMRect} + inputContainer={mockContainerDiv} /> ); component.find(SuggestionComponent).at(1).simulate('mouseenter'); expect(mockCallback).toHaveBeenCalledTimes(1); - expect(mockCallback).toHaveBeenCalledWith(1); + expect(mockCallback).toHaveBeenCalledWith(mockSuggestions[1], 1); }); }); diff --git a/src/plugins/data/public/ui/typeahead/suggestions_component.tsx b/src/plugins/data/public/ui/typeahead/suggestions_component.tsx index f7d6e2c3d6403..7a0a4378c4ddc 100644 --- a/src/plugins/data/public/ui/typeahead/suggestions_component.tsx +++ b/src/plugins/data/public/ui/typeahead/suggestions_component.tsx @@ -7,9 +7,11 @@ */ import { isEmpty } from 'lodash'; -import React, { Component } from 'react'; +import React, { PureComponent, ReactNode } from 'react'; import classNames from 'classnames'; import styled from 'styled-components'; + +import useRafState from 'react-use/lib/useRafState'; import { QuerySuggestion } from '../../autocomplete'; import { SuggestionComponent } from './suggestion_component'; import { @@ -17,86 +19,86 @@ import { SUGGESTIONS_LIST_REQUIRED_TOP_OFFSET, SUGGESTIONS_LIST_REQUIRED_WIDTH, } from './constants'; -import { SuggestionOnClick } from './types'; +import { SuggestionOnClick, SuggestionOnMouseEnter } from './types'; +import { onRaf } from '../utils'; +import { shallowEqual } from '../../utils/shallow_equal'; interface SuggestionsComponentProps { index: number | null; onClick: SuggestionOnClick; - onMouseEnter: (index: number) => void; + onMouseEnter: SuggestionOnMouseEnter; show: boolean; suggestions: QuerySuggestion[]; loadMore: () => void; - queryBarRect?: DOMRect; size?: SuggestionsListSize; + inputContainer: HTMLElement | null; } export type SuggestionsListSize = 's' | 'l'; // Needed for React.lazy // eslint-disable-next-line import/no-default-export -export default class SuggestionsComponent extends Component { +export default class SuggestionsComponent extends PureComponent { private childNodes: HTMLDivElement[] = []; private parentNode: HTMLDivElement | null = null; + constructor(props: SuggestionsComponentProps) { + super(props); + + this.assignParentNode = this.assignParentNode.bind(this); + this.assignChildNode = this.assignChildNode.bind(this); + } + + private assignParentNode(node: HTMLDivElement) { + this.parentNode = node; + } + + private assignChildNode(index: number, node: HTMLDivElement) { + this.childNodes[index] = node; + } + public render() { - if (!this.props.queryBarRect || !this.props.show || isEmpty(this.props.suggestions)) { + if (!this.props.inputContainer || !this.props.show || isEmpty(this.props.suggestions)) { return null; } - const suggestions = this.props.suggestions.map((suggestion, index) => { - const isDescriptionFittable = - this.props.queryBarRect!.width >= SUGGESTIONS_LIST_REQUIRED_WIDTH; - return ( - (this.childNodes[index] = node)} - selected={index === this.props.index} - index={index} - suggestion={suggestion} - onClick={this.props.onClick} - onMouseEnter={() => this.props.onMouseEnter(index)} - ariaId={'suggestion-' + index} - key={`${suggestion.type} - ${suggestion.text}`} - shouldDisplayDescription={isDescriptionFittable} - /> - ); - }); - - const documentHeight = document.documentElement.clientHeight || window.innerHeight; - const { queryBarRect } = this.props; + const renderSuggestions = (containerWidth: number) => { + const isDescriptionFittable = containerWidth >= SUGGESTIONS_LIST_REQUIRED_WIDTH; + const suggestions = this.props.suggestions.map((suggestion, index) => { + return ( + + ); + }); - // reflects if the suggestions list has enough space below to be opened down - const isSuggestionsListFittable = - documentHeight - (queryBarRect.top + queryBarRect.height) > - SUGGESTIONS_LIST_REQUIRED_BOTTOM_SPACE; - const verticalListPosition = isSuggestionsListFittable - ? `top: ${window.scrollY + queryBarRect.bottom - SUGGESTIONS_LIST_REQUIRED_TOP_OFFSET}px;` - : `bottom: ${documentHeight - (window.scrollY + queryBarRect.top)}px;`; + return suggestions; + }; return ( - -
+ {(containerWidth: number) => (
-
(this.parentNode = node)} - onScroll={this.handleScroll} - > - {suggestions} -
+ {renderSuggestions(containerWidth)}
-
-
+ )} + ); } @@ -106,7 +108,7 @@ export default class SuggestionsComponent extends Component { + private scrollIntoView = onRaf(() => { if (this.props.index === null) { return; } @@ -123,9 +125,9 @@ export default class SuggestionsComponent extends Component { + private handleScroll = onRaf(() => { if (!this.props.loadMore || !this.parentNode) { return; } @@ -141,14 +143,130 @@ export default class SuggestionsComponent extends Component ` + ${(props: { left: number; width: number; verticalListPosition: string }) => ` position: absolute; z-index: 4001; - left: ${props.queryBarRect.left}px; - width: ${props.queryBarRect.width}px; + left: ${props.left}px; + width: ${props.width}px; ${props.verticalListPosition}`} `; + +const ResizableSuggestionsListDiv: React.FC<{ + inputContainer: HTMLElement; + suggestionsSize?: SuggestionsListSize; +}> = React.memo((props) => { + const inputContainer = props.inputContainer; + const children = props.children as (rect: DOMRect) => ReactNode; + + const [{ documentHeight }, { pageYOffset }, containerRect] = useDimensions(inputContainer); + + if (!containerRect) return null; + + // reflects if the suggestions list has enough space below to be opened down + const isSuggestionsListFittable = + documentHeight - (containerRect.top + containerRect.height) > + SUGGESTIONS_LIST_REQUIRED_BOTTOM_SPACE; + const verticalListPosition = isSuggestionsListFittable + ? `top: ${pageYOffset + containerRect.bottom - SUGGESTIONS_LIST_REQUIRED_TOP_OFFSET}px;` + : `bottom: ${documentHeight - (pageYOffset + containerRect.top)}px;`; + + return ( + +
+
+ {children(containerRect)} +
+
+
+ ); +}); + +function useDimensions( + container: HTMLElement | null +): [{ documentHeight: number }, { pageYOffset: number; pageXOffset: number }, DOMRect | null] { + const [documentHeight, setDocumentHeight] = useRafState( + () => document.documentElement.clientHeight || window.innerHeight + ); + + const [pageOffset, setPageOffset] = useRafState<{ pageXOffset: number; pageYOffset: number }>( + () => ({ + pageXOffset: window.pageXOffset, + pageYOffset: window.pageYOffset, + }) + ); + + const [containerRect, setContainerRect] = useRafState(() => { + return container?.getBoundingClientRect() ?? null; + }); + + const updateContainerRect = React.useCallback(() => { + setContainerRect((oldRect: DOMRect | null) => { + const newRect = container?.getBoundingClientRect() ?? null; + const rectsEqual = shallowEqual(oldRect?.toJSON(), newRect?.toJSON()); + return rectsEqual ? oldRect : newRect; + }); + }, [container, setContainerRect]); + + React.useEffect(() => { + const handler = () => { + setDocumentHeight(document.documentElement.clientHeight || window.innerHeight); + }; + + window.addEventListener('resize', handler, { passive: true }); + + return () => { + window.removeEventListener('resize', handler); + }; + }, [setDocumentHeight]); + + React.useEffect(() => { + const handler = () => { + setPageOffset((state) => { + const { pageXOffset, pageYOffset } = window; + return state.pageXOffset !== pageXOffset || state.pageYOffset !== pageYOffset + ? { + pageXOffset, + pageYOffset, + } + : state; + }); + + updateContainerRect(); + }; + + window.addEventListener('scroll', handler, { passive: true, capture: true }); + + const resizeObserver = + typeof window.ResizeObserver !== 'undefined' && + new ResizeObserver(() => { + updateContainerRect(); + }); + if (container && resizeObserver) { + resizeObserver.observe(container); + } + + return () => { + window.removeEventListener('scroll', handler, { capture: true }); + if (resizeObserver) resizeObserver.disconnect(); + }; + }, [setPageOffset, container, updateContainerRect]); + + return [{ documentHeight }, pageOffset, containerRect]; +} diff --git a/src/plugins/data/public/ui/typeahead/types.ts b/src/plugins/data/public/ui/typeahead/types.ts index d0be717b2bf9b..16a18bc68430f 100644 --- a/src/plugins/data/public/ui/typeahead/types.ts +++ b/src/plugins/data/public/ui/typeahead/types.ts @@ -9,3 +9,5 @@ import { QuerySuggestion } from '../../autocomplete'; export type SuggestionOnClick = (suggestion: QuerySuggestion, index: number) => void; + +export type SuggestionOnMouseEnter = (suggestion: QuerySuggestion, index: number) => void; diff --git a/src/plugins/data/public/ui/utils/index.ts b/src/plugins/data/public/ui/utils/index.ts new file mode 100644 index 0000000000000..38c04459aae2a --- /dev/null +++ b/src/plugins/data/public/ui/utils/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { onRaf } from './on_raf'; diff --git a/src/plugins/data/public/ui/utils/on_raf.ts b/src/plugins/data/public/ui/utils/on_raf.ts new file mode 100644 index 0000000000000..fbe37e5d5c594 --- /dev/null +++ b/src/plugins/data/public/ui/utils/on_raf.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** + * Debounce a function till next animation frame + * @param fn + */ +export function onRaf(fn: Function) { + let req: number | null; + return (...args: unknown[]) => { + if (req) window.cancelAnimationFrame(req); + req = window.requestAnimationFrame(() => { + req = null; + fn(...args); + }); + }; +} diff --git a/src/plugins/data/public/utils/shallow_equal.ts b/src/plugins/data/public/utils/shallow_equal.ts new file mode 100644 index 0000000000000..e0387e07a163c --- /dev/null +++ b/src/plugins/data/public/utils/shallow_equal.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** + * Shallow Equal check adapted from react-redux + * Copy-pasted to avoid importing copy of react-redux into data plugin async chunk + **/ +export function shallowEqual(objA: unknown, objB: unknown): boolean { + if (Object.is(objA, objB)) return true; + + if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) { + return false; + } + + const keysA = Object.keys(objA); + const keysB = Object.keys(objB); + + if (keysA.length !== keysB.length) return false; + + for (let i = 0; i < keysA.length; i++) { + if ( + !Object.prototype.hasOwnProperty.call(objB, keysA[i]) || + // @ts-ignore + !Object.is(objA[keysA[i]], objB[keysA[i]]) + ) { + return false; + } + } + + return true; +} diff --git a/yarn.lock b/yarn.lock index 2467b78c1e81b..d564d3bce5282 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6178,11 +6178,6 @@ dependencies: "@types/unist" "*" -"@types/memoize-one@^4.1.0": - version "4.1.1" - resolved "https://registry.yarnpkg.com/@types/memoize-one/-/memoize-one-4.1.1.tgz#41dd138a4335b5041f7d8fc038f9d593d88b3369" - integrity sha512-+9djKUUn8hOyktLCfCy4hLaIPgDNovaU36fsnZe9trFHr6ddlbIn2q0SEsnkCkNR+pBWEU440Molz/+Mpyf+gQ== - "@types/micromatch@^4.0.1": version "4.0.1" resolved "https://registry.yarnpkg.com/@types/micromatch/-/micromatch-4.0.1.tgz#9381449dd659fc3823fd2a4190ceacc985083bc7" @@ -19571,6 +19566,11 @@ memfs@^3.1.2: resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0" integrity sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA== +memoize-one@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045" + integrity sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw== + memoizee@0.4.X: version "0.4.14" resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.14.tgz#07a00f204699f9a95c2d9e77218271c7cd610d57" From e62768a9c884409fd0781769fca26c8b888d3823 Mon Sep 17 00:00:00 2001 From: Matthew Kime Date: Thu, 6 Jan 2022 07:15:05 -0600 Subject: [PATCH 04/25] [data views] data_views REST API (#112916) * initial stab at changing endpoints to data_view while keeping existing endpoints * partial progress * partial progress * partial progress on multiple fields returned * partial progress on multiple fields returned * partial progress on multiple fields returned * correct response - index_pattern => data_view * fix legacy key * update runtime field responses to return array of fields instead of since field value * complete tests for data views api * fix export * more tests * fix types for data view create Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- src/plugins/data_views/server/constants.ts | 27 + src/plugins/data_views/server/index.ts | 21 + src/plugins/data_views/server/routes.ts | 107 ++- .../server/routes/create_index_pattern.ts | 116 ++- .../server/routes/default_index_pattern.ts | 141 +-- .../server/routes/delete_index_pattern.ts | 95 +- .../server/routes/fields/update_fields.ts | 200 +++-- .../server/routes/get_index_pattern.ts | 110 ++- .../server/routes/has_user_index_pattern.ts | 75 +- .../runtime_fields/create_runtime_field.ts | 133 +-- .../runtime_fields/delete_runtime_field.ts | 103 ++- .../runtime_fields/get_runtime_field.ts | 126 +-- .../runtime_fields/put_runtime_field.ts | 137 +-- .../runtime_fields/update_runtime_field.ts | 154 ++-- .../server/routes/update_index_pattern.ts | 259 +++--- .../apis/index_patterns/constants.ts | 32 + .../default_index_pattern.ts | 67 +- .../fields_api/update_fields/errors.ts | 95 +- .../fields_api/update_fields/main.ts | 811 +++++++++--------- .../has_user_index_pattern.ts | 245 +++--- .../create_index_pattern/main.ts | 482 ++++++----- .../create_index_pattern/validation.ts | 123 +-- .../delete_index_pattern/errors.ts | 29 +- .../delete_index_pattern/main.ts | 81 +- .../get_index_pattern/errors.ts | 29 +- .../get_index_pattern/main.ts | 27 +- .../update_index_pattern/errors.ts | 95 +- .../update_index_pattern/main.ts | 471 +++++----- .../create_runtime_field/errors.ts | 35 +- .../create_runtime_field/main.ts | 116 +-- .../delete_runtime_field/errors.ts | 99 +-- .../delete_runtime_field/main.ts | 61 +- .../get_runtime_field/errors.ts | 99 +-- .../get_runtime_field/main.ts | 76 +- .../put_runtime_field/errors.ts | 71 +- .../put_runtime_field/main.ts | 164 ++-- .../update_runtime_field/errors.ts | 57 +- .../update_runtime_field/main.ts | 84 +- 38 files changed, 2865 insertions(+), 2388 deletions(-) create mode 100644 src/plugins/data_views/server/constants.ts create mode 100644 test/api_integration/apis/index_patterns/constants.ts diff --git a/src/plugins/data_views/server/constants.ts b/src/plugins/data_views/server/constants.ts new file mode 100644 index 0000000000000..15ca2c97f50ec --- /dev/null +++ b/src/plugins/data_views/server/constants.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const SERVICE_PATH = '/api/data_views'; +export const SERVICE_PATH_LEGACY = '/api/index_patterns'; +export const DATA_VIEW_PATH = `${SERVICE_PATH}/data_view`; +export const DATA_VIEW_PATH_LEGACY = `${SERVICE_PATH_LEGACY}/index_pattern`; +export const SPECIFIC_DATA_VIEW_PATH = `${DATA_VIEW_PATH}/{id}`; +export const SPECIFIC_DATA_VIEW_PATH_LEGACY = `${DATA_VIEW_PATH_LEGACY}/{id}`; +export const RUNTIME_FIELD_PATH = `${SPECIFIC_DATA_VIEW_PATH}/runtime_field`; +export const RUNTIME_FIELD_PATH_LEGACY = `${SPECIFIC_DATA_VIEW_PATH_LEGACY}/runtime_field`; +export const SPECIFIC_RUNTIME_FIELD_PATH = `${RUNTIME_FIELD_PATH}/{name}`; +export const SPECIFIC_RUNTIME_FIELD_PATH_LEGACY = `${RUNTIME_FIELD_PATH_LEGACY}/{name}`; + +export const SCRIPTED_FIELD_PATH = `${SPECIFIC_DATA_VIEW_PATH}/scripted_field`; +export const SCRIPTED_FIELD_PATH_LEGACY = `${SPECIFIC_DATA_VIEW_PATH_LEGACY}/scripted_field`; +export const SPECIFIC_SCRIPTED_FIELD_PATH = `${SCRIPTED_FIELD_PATH}/{name}`; +export const SPECIFIC_SCRIPTED_FIELD_PATH_LEGACY = `${SCRIPTED_FIELD_PATH_LEGACY}/{name}`; + +export const SERVICE_KEY = 'data_view'; +export const SERVICE_KEY_LEGACY = 'index_pattern'; +export type SERVICE_KEY_TYPE = typeof SERVICE_KEY | typeof SERVICE_KEY_LEGACY; diff --git a/src/plugins/data_views/server/index.ts b/src/plugins/data_views/server/index.ts index 7a4df9518b435..4ba79373ee5bb 100644 --- a/src/plugins/data_views/server/index.ts +++ b/src/plugins/data_views/server/index.ts @@ -35,3 +35,24 @@ export type { DataViewsServerPluginStart as PluginStart, }; export { DataViewsServerPlugin as Plugin }; + +export { + SERVICE_PATH, + SERVICE_PATH_LEGACY, + DATA_VIEW_PATH, + DATA_VIEW_PATH_LEGACY, + SPECIFIC_DATA_VIEW_PATH, + SPECIFIC_DATA_VIEW_PATH_LEGACY, + RUNTIME_FIELD_PATH, + RUNTIME_FIELD_PATH_LEGACY, + SPECIFIC_RUNTIME_FIELD_PATH, + SPECIFIC_RUNTIME_FIELD_PATH_LEGACY, + SCRIPTED_FIELD_PATH, + SCRIPTED_FIELD_PATH_LEGACY, + SPECIFIC_SCRIPTED_FIELD_PATH, + SPECIFIC_SCRIPTED_FIELD_PATH_LEGACY, + SERVICE_KEY, + SERVICE_KEY_LEGACY, +} from './constants'; + +export type { SERVICE_KEY_TYPE } from './constants'; diff --git a/src/plugins/data_views/server/routes.ts b/src/plugins/data_views/server/routes.ts index f6e91980ea34f..2a2f561997cd6 100644 --- a/src/plugins/data_views/server/routes.ts +++ b/src/plugins/data_views/server/routes.ts @@ -9,24 +9,62 @@ import { schema } from '@kbn/config-schema'; import { HttpServiceSetup, StartServicesAccessor } from 'kibana/server'; import { IndexPatternsFetcher } from './fetcher'; -import { registerCreateIndexPatternRoute } from './routes/create_index_pattern'; -import { registerGetIndexPatternRoute } from './routes/get_index_pattern'; -import { registerDeleteIndexPatternRoute } from './routes/delete_index_pattern'; -import { registerUpdateIndexPatternRoute } from './routes/update_index_pattern'; -import { registerUpdateFieldsRoute } from './routes/fields/update_fields'; +import { + registerCreateDataViewRoute, + registerCreateDataViewRouteLegacy, +} from './routes/create_index_pattern'; +import { + registerGetDataViewRoute, + registerGetDataViewRouteLegacy, +} from './routes/get_index_pattern'; +import { + registerDeleteDataViewRoute, + registerDeleteDataViewRouteLegacy, +} from './routes/delete_index_pattern'; +import { + registerUpdateDataViewRoute, + registerUpdateDataViewRouteLegacy, +} from './routes/update_index_pattern'; +import { + registerUpdateFieldsRoute, + registerUpdateFieldsRouteLegacy, +} from './routes/fields/update_fields'; import { registerCreateScriptedFieldRoute } from './routes/scripted_fields/create_scripted_field'; import { registerPutScriptedFieldRoute } from './routes/scripted_fields/put_scripted_field'; import { registerGetScriptedFieldRoute } from './routes/scripted_fields/get_scripted_field'; import { registerDeleteScriptedFieldRoute } from './routes/scripted_fields/delete_scripted_field'; import { registerUpdateScriptedFieldRoute } from './routes/scripted_fields/update_scripted_field'; +import { + registerManageDefaultDataViewRoute, + registerManageDefaultDataViewRouteLegacy, +} from './routes/default_index_pattern'; import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies } from './types'; -import { registerManageDefaultIndexPatternRoutes } from './routes/default_index_pattern'; -import { registerCreateRuntimeFieldRoute } from './routes/runtime_fields/create_runtime_field'; -import { registerGetRuntimeFieldRoute } from './routes/runtime_fields/get_runtime_field'; -import { registerDeleteRuntimeFieldRoute } from './routes/runtime_fields/delete_runtime_field'; -import { registerPutRuntimeFieldRoute } from './routes/runtime_fields/put_runtime_field'; -import { registerUpdateRuntimeFieldRoute } from './routes/runtime_fields/update_runtime_field'; -import { registerHasUserIndexPatternRoute } from './routes/has_user_index_pattern'; + +import { + registerCreateRuntimeFieldRoute, + registerCreateRuntimeFieldRouteLegacy, +} from './routes/runtime_fields/create_runtime_field'; +import { + registerGetRuntimeFieldRoute, + registerGetRuntimeFieldRouteLegacy, +} from './routes/runtime_fields/get_runtime_field'; +import { + registerDeleteRuntimeFieldRoute, + registerDeleteRuntimeFieldRouteLegacy, +} from './routes/runtime_fields/delete_runtime_field'; +import { + registerPutRuntimeFieldRoute, + registerPutRuntimeFieldRouteLegacy, +} from './routes/runtime_fields/put_runtime_field'; +import { + registerUpdateRuntimeFieldRoute, + registerUpdateRuntimeFieldRouteLegacy, +} from './routes/runtime_fields/update_runtime_field'; +import { + registerHasUserDataViewRoute, + registerHasUserDataViewRouteLegacy, +} from './routes/has_user_index_pattern'; + import { registerFieldForWildcard } from './fields_for'; export function registerRoutes( @@ -48,17 +86,37 @@ export function registerRoutes( const router = http.createRouter(); - // Index Patterns API - registerCreateIndexPatternRoute(router, getStartServices); - registerGetIndexPatternRoute(router, getStartServices); - registerDeleteIndexPatternRoute(router, getStartServices); - registerUpdateIndexPatternRoute(router, getStartServices); - registerManageDefaultIndexPatternRoutes(router, getStartServices); - registerHasUserIndexPatternRoute(router, getStartServices); + // Data Views API + registerCreateDataViewRoute(router, getStartServices); + registerGetDataViewRoute(router, getStartServices); + registerDeleteDataViewRoute(router, getStartServices); + registerUpdateDataViewRoute(router, getStartServices); + registerManageDefaultDataViewRoute(router, getStartServices); + registerHasUserDataViewRoute(router, getStartServices); // Fields API registerUpdateFieldsRoute(router, getStartServices); + // Runtime Fields API + registerCreateRuntimeFieldRoute(router, getStartServices); + registerGetRuntimeFieldRoute(router, getStartServices); + registerDeleteRuntimeFieldRoute(router, getStartServices); + registerPutRuntimeFieldRoute(router, getStartServices); + registerUpdateRuntimeFieldRoute(router, getStartServices); + + // ### + // Legacy Index Pattern API + // ### + registerCreateDataViewRouteLegacy(router, getStartServices); + registerGetDataViewRouteLegacy(router, getStartServices); + registerDeleteDataViewRouteLegacy(router, getStartServices); + registerUpdateDataViewRouteLegacy(router, getStartServices); + registerManageDefaultDataViewRouteLegacy(router, getStartServices); + registerHasUserDataViewRouteLegacy(router, getStartServices); + + // Fields API + registerUpdateFieldsRouteLegacy(router, getStartServices); + // Scripted Field API registerCreateScriptedFieldRoute(router, getStartServices); registerPutScriptedFieldRoute(router, getStartServices); @@ -67,11 +125,12 @@ export function registerRoutes( registerUpdateScriptedFieldRoute(router, getStartServices); // Runtime Fields API - registerCreateRuntimeFieldRoute(router, getStartServices); - registerGetRuntimeFieldRoute(router, getStartServices); - registerDeleteRuntimeFieldRoute(router, getStartServices); - registerPutRuntimeFieldRoute(router, getStartServices); - registerUpdateRuntimeFieldRoute(router, getStartServices); + registerCreateRuntimeFieldRouteLegacy(router, getStartServices); + registerGetRuntimeFieldRouteLegacy(router, getStartServices); + registerDeleteRuntimeFieldRouteLegacy(router, getStartServices); + registerPutRuntimeFieldRouteLegacy(router, getStartServices); + registerUpdateRuntimeFieldRouteLegacy(router, getStartServices); + // ### registerFieldForWildcard(router, getStartServices); diff --git a/src/plugins/data_views/server/routes/create_index_pattern.ts b/src/plugins/data_views/server/routes/create_index_pattern.ts index d50012596ee56..7f5575c8e7cae 100644 --- a/src/plugins/data_views/server/routes/create_index_pattern.ts +++ b/src/plugins/data_views/server/routes/create_index_pattern.ts @@ -7,7 +7,7 @@ */ import { schema } from '@kbn/config-schema'; -import { IndexPatternSpec } from 'src/plugins/data_views/common'; +import { DataViewSpec } from 'src/plugins/data_views/common'; import { handleErrors } from './util/handle_errors'; import { fieldSpecSchema, @@ -15,7 +15,13 @@ import { serializedFieldFormatSchema, } from './util/schemas'; import { IRouter, StartServicesAccessor } from '../../../../core/server'; -import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies } from '../types'; +import type { DataViewsServerPluginStartDependencies, DataViewsServerPluginStart } from '../types'; +import { + DATA_VIEW_PATH, + DATA_VIEW_PATH_LEGACY, + SERVICE_KEY, + SERVICE_KEY_LEGACY, +} from '../constants'; const indexPatternSpecSchema = schema.object({ title: schema.string(), @@ -46,51 +52,67 @@ const indexPatternSpecSchema = schema.object({ runtimeFieldMap: schema.maybe(schema.recordOf(schema.string(), runtimeFieldSpecSchema)), }); -export const registerCreateIndexPatternRoute = ( - router: IRouter, - getStartServices: StartServicesAccessor< - DataViewsServerPluginStartDependencies, - DataViewsServerPluginStart - > -) => { - router.post( - { - path: '/api/index_patterns/index_pattern', - validate: { - body: schema.object({ - override: schema.maybe(schema.boolean({ defaultValue: false })), - refresh_fields: schema.maybe(schema.boolean({ defaultValue: false })), - index_pattern: indexPatternSpecSchema, - }), +const registerCreateDataViewRouteFactory = + (path: string, serviceKey: string) => + ( + router: IRouter, + getStartServices: StartServicesAccessor< + DataViewsServerPluginStartDependencies, + DataViewsServerPluginStart + > + ) => { + router.post( + { + path, + validate: { + body: schema.object({ + override: schema.maybe(schema.boolean({ defaultValue: false })), + refresh_fields: schema.maybe(schema.boolean({ defaultValue: false })), + data_view: serviceKey === SERVICE_KEY ? indexPatternSpecSchema : schema.never(), + index_pattern: + serviceKey === SERVICE_KEY_LEGACY ? indexPatternSpecSchema : schema.never(), + }), + }, }, - }, - router.handleLegacyErrors( - handleErrors(async (ctx, req, res) => { - const savedObjectsClient = ctx.core.savedObjects.client; - const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; - const [, , { indexPatternsServiceFactory }] = await getStartServices(); - const indexPatternsService = await indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient, - req - ); - const body = req.body; + router.handleLegacyErrors( + handleErrors(async (ctx, req, res) => { + const savedObjectsClient = ctx.core.savedObjects.client; + const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; + const [, , { dataViewsServiceFactory }] = await getStartServices(); + const indexPatternsService = await dataViewsServiceFactory( + savedObjectsClient, + elasticsearchClient, + req + ); + const body = req.body; - const indexPattern = await indexPatternsService.createAndSave( - body.index_pattern as IndexPatternSpec, - body.override, - !body.refresh_fields - ); + const spec = serviceKey === SERVICE_KEY ? body.data_view : body.index_pattern; - return res.ok({ - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ - index_pattern: indexPattern.toSpec(), - }), - }); - }) - ) - ); -}; + const indexPattern = await indexPatternsService.createAndSave( + spec as DataViewSpec, + body.override, + !body.refresh_fields + ); + + return res.ok({ + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + [serviceKey]: indexPattern.toSpec(), + }), + }); + }) + ) + ); + }; + +export const registerCreateDataViewRoute = registerCreateDataViewRouteFactory( + DATA_VIEW_PATH, + SERVICE_KEY +); + +export const registerCreateDataViewRouteLegacy = registerCreateDataViewRouteFactory( + DATA_VIEW_PATH_LEGACY, + SERVICE_KEY_LEGACY +); diff --git a/src/plugins/data_views/server/routes/default_index_pattern.ts b/src/plugins/data_views/server/routes/default_index_pattern.ts index 1fe56db6c7488..ae6653e638ef6 100644 --- a/src/plugins/data_views/server/routes/default_index_pattern.ts +++ b/src/plugins/data_views/server/routes/default_index_pattern.ts @@ -8,76 +8,89 @@ import { schema } from '@kbn/config-schema'; import { IRouter, StartServicesAccessor } from '../../../../core/server'; -import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies } from '../types'; +import type { DataViewsServerPluginStartDependencies, DataViewsServerPluginStart } from '../types'; import { handleErrors } from './util/handle_errors'; +import { SERVICE_PATH, SERVICE_PATH_LEGACY, SERVICE_KEY, SERVICE_KEY_LEGACY } from '../constants'; -export const registerManageDefaultIndexPatternRoutes = ( - router: IRouter, - getStartServices: StartServicesAccessor< - DataViewsServerPluginStartDependencies, - DataViewsServerPluginStart - > -) => { - router.get( - { - path: '/api/index_patterns/default', - validate: {}, - }, - handleErrors(async (ctx, req, res) => { - const savedObjectsClient = ctx.core.savedObjects.client; - const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; - const [, , { indexPatternsServiceFactory }] = await getStartServices(); - const indexPatternsService = await indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient, - req - ); +const manageDefaultIndexPatternRoutesFactory = + (path: string, serviceKey: string) => + ( + router: IRouter, + getStartServices: StartServicesAccessor< + DataViewsServerPluginStartDependencies, + DataViewsServerPluginStart + > + ) => { + router.get( + { + path, + validate: {}, + }, + handleErrors(async (ctx, req, res) => { + const savedObjectsClient = ctx.core.savedObjects.client; + const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; + const [, , { dataViewsServiceFactory }] = await getStartServices(); + const indexPatternsService = await dataViewsServiceFactory( + savedObjectsClient, + elasticsearchClient, + req + ); - const defaultIndexPatternId = await indexPatternsService.getDefaultId(); + const defaultIndexPatternId = await indexPatternsService.getDefaultId(); - return res.ok({ - body: { - index_pattern_id: defaultIndexPatternId, - }, - }); - }) - ); + return res.ok({ + body: { + [`${serviceKey}_id`]: defaultIndexPatternId, + }, + }); + }) + ); - router.post( - { - path: '/api/index_patterns/default', - validate: { - body: schema.object({ - index_pattern_id: schema.nullable( - schema.string({ - minLength: 1, - maxLength: 1_000, - }) - ), - force: schema.boolean({ defaultValue: false }), - }), + router.post( + { + path, + validate: { + body: schema.object({ + [`${serviceKey}_id`]: schema.nullable( + schema.string({ + minLength: 1, + maxLength: 1_000, + }) + ), + force: schema.boolean({ defaultValue: false }), + }), + }, }, - }, - handleErrors(async (ctx, req, res) => { - const savedObjectsClient = ctx.core.savedObjects.client; - const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; - const [, , { indexPatternsServiceFactory }] = await getStartServices(); - const indexPatternsService = await indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient, - req - ); + handleErrors(async (ctx, req, res) => { + const savedObjectsClient = ctx.core.savedObjects.client; + const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; + const [, , { dataViewsServiceFactory }] = await getStartServices(); + const indexPatternsService = await dataViewsServiceFactory( + savedObjectsClient, + elasticsearchClient, + req + ); - const newDefaultId = req.body.index_pattern_id; - const force = req.body.force; + const newDefaultId = req.body[`${serviceKey}_id`] as string; + const force = req.body.force as boolean; - await indexPatternsService.setDefault(newDefaultId, force); + await indexPatternsService.setDefault(newDefaultId, force); - return res.ok({ - body: { - acknowledged: true, - }, - }); - }) - ); -}; + return res.ok({ + body: { + acknowledged: true, + }, + }); + }) + ); + }; + +export const registerManageDefaultDataViewRoute = manageDefaultIndexPatternRoutesFactory( + `${SERVICE_PATH}/default`, + SERVICE_KEY +); + +export const registerManageDefaultDataViewRouteLegacy = manageDefaultIndexPatternRoutesFactory( + `${SERVICE_PATH_LEGACY}/default`, + SERVICE_KEY_LEGACY +); diff --git a/src/plugins/data_views/server/routes/delete_index_pattern.ts b/src/plugins/data_views/server/routes/delete_index_pattern.ts index 151fb0b0224b6..077795a6b7c36 100644 --- a/src/plugins/data_views/server/routes/delete_index_pattern.ts +++ b/src/plugins/data_views/server/routes/delete_index_pattern.ts @@ -9,50 +9,59 @@ import { schema } from '@kbn/config-schema'; import { handleErrors } from './util/handle_errors'; import { IRouter, StartServicesAccessor } from '../../../../core/server'; -import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies } from '../types'; +import type { DataViewsServerPluginStartDependencies, DataViewsServerPluginStart } from '../types'; +import { SPECIFIC_DATA_VIEW_PATH, SPECIFIC_DATA_VIEW_PATH_LEGACY } from '../constants'; -export const registerDeleteIndexPatternRoute = ( - router: IRouter, - getStartServices: StartServicesAccessor< - DataViewsServerPluginStartDependencies, - DataViewsServerPluginStart - > -) => { - router.delete( - { - path: '/api/index_patterns/index_pattern/{id}', - validate: { - params: schema.object( - { - id: schema.string({ - minLength: 1, - maxLength: 1_000, - }), - }, - { unknowns: 'allow' } - ), +const deleteIndexPatternRouteFactory = + (path: string) => + ( + router: IRouter, + getStartServices: StartServicesAccessor< + DataViewsServerPluginStartDependencies, + DataViewsServerPluginStart + > + ) => { + router.delete( + { + path, + validate: { + params: schema.object( + { + id: schema.string({ + minLength: 1, + maxLength: 1_000, + }), + }, + { unknowns: 'allow' } + ), + }, }, - }, - router.handleLegacyErrors( - handleErrors(async (ctx, req, res) => { - const savedObjectsClient = ctx.core.savedObjects.client; - const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; - const [, , { indexPatternsServiceFactory }] = await getStartServices(); - const indexPatternsService = await indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient, - req - ); - const id = req.params.id; + router.handleLegacyErrors( + handleErrors(async (ctx, req, res) => { + const savedObjectsClient = ctx.core.savedObjects.client; + const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; + const [, , { dataViewsServiceFactory }] = await getStartServices(); + const indexPatternsService = await dataViewsServiceFactory( + savedObjectsClient, + elasticsearchClient, + req + ); + const id = req.params.id; - await indexPatternsService.delete(id); + await indexPatternsService.delete(id); - return res.ok({ - headers: { - 'content-type': 'application/json', - }, - }); - }) - ) - ); -}; + return res.ok({ + headers: { + 'content-type': 'application/json', + }, + }); + }) + ) + ); + }; + +export const registerDeleteDataViewRoute = deleteIndexPatternRouteFactory(SPECIFIC_DATA_VIEW_PATH); + +export const registerDeleteDataViewRouteLegacy = deleteIndexPatternRouteFactory( + SPECIFIC_DATA_VIEW_PATH_LEGACY +); diff --git a/src/plugins/data_views/server/routes/fields/update_fields.ts b/src/plugins/data_views/server/routes/fields/update_fields.ts index 258ae9ebec3af..7b0e83dbf2e9b 100644 --- a/src/plugins/data_views/server/routes/fields/update_fields.ts +++ b/src/plugins/data_views/server/routes/fields/update_fields.ts @@ -11,111 +11,129 @@ import { handleErrors } from '../util/handle_errors'; import { serializedFieldFormatSchema } from '../util/schemas'; import { IRouter, StartServicesAccessor } from '../../../../../core/server'; import type { - DataViewsServerPluginStart, DataViewsServerPluginStartDependencies, + DataViewsServerPluginStart, } from '../../types'; +import { + SPECIFIC_DATA_VIEW_PATH, + SPECIFIC_DATA_VIEW_PATH_LEGACY, + SERVICE_KEY, + SERVICE_KEY_LEGACY, +} from '../../constants'; -export const registerUpdateFieldsRoute = ( - router: IRouter, - getStartServices: StartServicesAccessor< - DataViewsServerPluginStartDependencies, - DataViewsServerPluginStart - > -) => { - router.post( - { - path: '/api/index_patterns/index_pattern/{id}/fields', - validate: { - params: schema.object( - { - id: schema.string({ - minLength: 1, - maxLength: 1_000, - }), - }, - { unknowns: 'allow' } - ), - body: schema.object({ - fields: schema.recordOf( - schema.string({ - minLength: 1, - maxLength: 1_000, - }), - schema.object({ - customLabel: schema.maybe( - schema.nullable( - schema.string({ - minLength: 1, - maxLength: 1_000, - }) - ) - ), - count: schema.maybe(schema.nullable(schema.number())), - format: schema.maybe(schema.nullable(serializedFieldFormatSchema)), - }) +const updateFieldsActionRouteFactory = (path: string, serviceKey: string) => { + return ( + router: IRouter, + getStartServices: StartServicesAccessor< + DataViewsServerPluginStartDependencies, + DataViewsServerPluginStart + > + ) => { + router.post( + { + path, + validate: { + params: schema.object( + { + id: schema.string({ + minLength: 1, + maxLength: 1_000, + }), + }, + { unknowns: 'allow' } ), - }), + body: schema.object({ + fields: schema.recordOf( + schema.string({ + minLength: 1, + maxLength: 1_000, + }), + schema.object({ + customLabel: schema.maybe( + schema.nullable( + schema.string({ + minLength: 1, + maxLength: 1_000, + }) + ) + ), + count: schema.maybe(schema.nullable(schema.number())), + format: schema.maybe(schema.nullable(serializedFieldFormatSchema)), + }) + ), + }), + }, }, - }, - router.handleLegacyErrors( - handleErrors(async (ctx, req, res) => { - const savedObjectsClient = ctx.core.savedObjects.client; - const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; - const [, , { indexPatternsServiceFactory }] = await getStartServices(); - const indexPatternsService = await indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient, - req - ); - const id = req.params.id; - const { fields } = req.body; - const fieldNames = Object.keys(fields); + router.handleLegacyErrors( + handleErrors(async (ctx, req, res) => { + const savedObjectsClient = ctx.core.savedObjects.client; + const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; + const [, , { dataViewsServiceFactory }] = await getStartServices(); + const indexPatternsService = await dataViewsServiceFactory( + savedObjectsClient, + elasticsearchClient, + req + ); + const id = req.params.id; + const { fields } = req.body; + const fieldNames = Object.keys(fields); - if (fieldNames.length < 1) { - throw new Error('No fields provided.'); - } + if (fieldNames.length < 1) { + throw new Error('No fields provided.'); + } - const indexPattern = await indexPatternsService.get(id); + const indexPattern = await indexPatternsService.get(id); - let changeCount = 0; - for (const fieldName of fieldNames) { - const field = fields[fieldName]; + let changeCount = 0; + for (const fieldName of fieldNames) { + const field = fields[fieldName]; - if (field.customLabel !== undefined) { - changeCount++; - indexPattern.setFieldCustomLabel(fieldName, field.customLabel); - } + if (field.customLabel !== undefined) { + changeCount++; + indexPattern.setFieldCustomLabel(fieldName, field.customLabel); + } - if (field.count !== undefined) { - changeCount++; - indexPattern.setFieldCount(fieldName, field.count); - } + if (field.count !== undefined) { + changeCount++; + indexPattern.setFieldCount(fieldName, field.count); + } - if (field.format !== undefined) { - changeCount++; - if (field.format) { - indexPattern.setFieldFormat(fieldName, field.format); - } else { - indexPattern.deleteFieldFormat(fieldName); + if (field.format !== undefined) { + changeCount++; + if (field.format) { + indexPattern.setFieldFormat(fieldName, field.format); + } else { + indexPattern.deleteFieldFormat(fieldName); + } } } - } - if (changeCount < 1) { - throw new Error('Change set is empty.'); - } + if (changeCount < 1) { + throw new Error('Change set is empty.'); + } - await indexPatternsService.updateSavedObject(indexPattern); + await indexPatternsService.updateSavedObject(indexPattern); - return res.ok({ - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ - index_pattern: indexPattern.toSpec(), - }), - }); - }) - ) - ); + return res.ok({ + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + [serviceKey]: indexPattern.toSpec(), + }), + }); + }) + ) + ); + }; }; + +export const registerUpdateFieldsRouteLegacy = updateFieldsActionRouteFactory( + `${SPECIFIC_DATA_VIEW_PATH}/fields`, + SERVICE_KEY +); + +export const registerUpdateFieldsRoute = updateFieldsActionRouteFactory( + `${SPECIFIC_DATA_VIEW_PATH_LEGACY}/fields`, + SERVICE_KEY_LEGACY +); diff --git a/src/plugins/data_views/server/routes/get_index_pattern.ts b/src/plugins/data_views/server/routes/get_index_pattern.ts index b7d95fe687a0a..40e67e972f5d7 100644 --- a/src/plugins/data_views/server/routes/get_index_pattern.ts +++ b/src/plugins/data_views/server/routes/get_index_pattern.ts @@ -9,52 +9,70 @@ import { schema } from '@kbn/config-schema'; import { handleErrors } from './util/handle_errors'; import { IRouter, StartServicesAccessor } from '../../../../core/server'; -import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies } from '../types'; +import type { DataViewsServerPluginStartDependencies, DataViewsServerPluginStart } from '../types'; +import { + SPECIFIC_DATA_VIEW_PATH, + SPECIFIC_DATA_VIEW_PATH_LEGACY, + SERVICE_KEY, + SERVICE_KEY_LEGACY, +} from '../constants'; -export const registerGetIndexPatternRoute = ( - router: IRouter, - getStartServices: StartServicesAccessor< - DataViewsServerPluginStartDependencies, - DataViewsServerPluginStart - > -) => { - router.get( - { - path: '/api/index_patterns/index_pattern/{id}', - validate: { - params: schema.object( - { - id: schema.string({ - minLength: 1, - maxLength: 1_000, - }), - }, - { unknowns: 'allow' } - ), +const getDataViewRouteFactory = + (path: string, serviceKey: string) => + ( + router: IRouter, + getStartServices: StartServicesAccessor< + DataViewsServerPluginStartDependencies, + DataViewsServerPluginStart + > + ) => { + router.get( + { + path, + validate: { + params: schema.object( + { + id: schema.string({ + minLength: 1, + maxLength: 1_000, + }), + }, + { unknowns: 'allow' } + ), + }, }, - }, - router.handleLegacyErrors( - handleErrors(async (ctx, req, res) => { - const savedObjectsClient = ctx.core.savedObjects.client; - const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; - const [, , { indexPatternsServiceFactory }] = await getStartServices(); - const indexPatternsService = await indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient, - req - ); - const id = req.params.id; - const indexPattern = await indexPatternsService.get(id); + router.handleLegacyErrors( + handleErrors(async (ctx, req, res) => { + const savedObjectsClient = ctx.core.savedObjects.client; + const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; + const [, , { dataViewsServiceFactory }] = await getStartServices(); + const indexPatternsService = await dataViewsServiceFactory( + savedObjectsClient, + elasticsearchClient, + req + ); + const id = req.params.id; + const indexPattern = await indexPatternsService.get(id); + + return res.ok({ + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + [serviceKey]: indexPattern.toSpec(), + }), + }); + }) + ) + ); + }; + +export const registerGetDataViewRoute = getDataViewRouteFactory( + SPECIFIC_DATA_VIEW_PATH, + SERVICE_KEY +); - return res.ok({ - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ - index_pattern: indexPattern.toSpec(), - }), - }); - }) - ) - ); -}; +export const registerGetDataViewRouteLegacy = getDataViewRouteFactory( + SPECIFIC_DATA_VIEW_PATH_LEGACY, + SERVICE_KEY_LEGACY +); diff --git a/src/plugins/data_views/server/routes/has_user_index_pattern.ts b/src/plugins/data_views/server/routes/has_user_index_pattern.ts index 6562d06df6f65..883a7ff3111ae 100644 --- a/src/plugins/data_views/server/routes/has_user_index_pattern.ts +++ b/src/plugins/data_views/server/routes/has_user_index_pattern.ts @@ -8,37 +8,48 @@ import { handleErrors } from './util/handle_errors'; import { IRouter, StartServicesAccessor } from '../../../../core/server'; -import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies } from '../types'; +import type { DataViewsServerPluginStartDependencies, DataViewsServerPluginStart } from '../types'; +import { SERVICE_PATH, SERVICE_PATH_LEGACY } from '../constants'; -export const registerHasUserIndexPatternRoute = ( - router: IRouter, - getStartServices: StartServicesAccessor< - DataViewsServerPluginStartDependencies, - DataViewsServerPluginStart - > -) => { - router.get( - { - path: '/api/index_patterns/has_user_index_pattern', - validate: {}, - }, - router.handleLegacyErrors( - handleErrors(async (ctx, req, res) => { - const savedObjectsClient = ctx.core.savedObjects.client; - const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; - const [, , { indexPatternsServiceFactory }] = await getStartServices(); - const indexPatternsService = await indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient, - req - ); +const hasUserDataViewRouteFactory = + (path: string) => + ( + router: IRouter, + getStartServices: StartServicesAccessor< + DataViewsServerPluginStartDependencies, + DataViewsServerPluginStart + > + ) => { + router.get( + { + path, + validate: {}, + }, + router.handleLegacyErrors( + handleErrors(async (ctx, req, res) => { + const savedObjectsClient = ctx.core.savedObjects.client; + const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; + const [, , { dataViewsServiceFactory }] = await getStartServices(); + const indexPatternsService = await dataViewsServiceFactory( + savedObjectsClient, + elasticsearchClient, + req + ); - return res.ok({ - body: { - result: await indexPatternsService.hasUserDataView(), - }, - }); - }) - ) - ); -}; + return res.ok({ + body: { + result: await indexPatternsService.hasUserDataView(), + }, + }); + }) + ) + ); + }; + +export const registerHasUserDataViewRoute = hasUserDataViewRouteFactory( + `${SERVICE_PATH}/has_user_data_view` +); + +export const registerHasUserDataViewRouteLegacy = hasUserDataViewRouteFactory( + `${SERVICE_PATH_LEGACY}/has_user_index_pattern` +); diff --git a/src/plugins/data_views/server/routes/runtime_fields/create_runtime_field.ts b/src/plugins/data_views/server/routes/runtime_fields/create_runtime_field.ts index 434d57f1aeecb..41890dd1c5f31 100644 --- a/src/plugins/data_views/server/routes/runtime_fields/create_runtime_field.ts +++ b/src/plugins/data_views/server/routes/runtime_fields/create_runtime_field.ts @@ -14,68 +14,95 @@ import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies, } from '../../types'; +import { + RUNTIME_FIELD_PATH, + RUNTIME_FIELD_PATH_LEGACY, + SERVICE_KEY, + SERVICE_KEY_LEGACY, + SERVICE_KEY_TYPE, +} from '../../constants'; -export const registerCreateRuntimeFieldRoute = ( - router: IRouter, - getStartServices: StartServicesAccessor< - DataViewsServerPluginStartDependencies, - DataViewsServerPluginStart - > -) => { - router.post( - { - path: '/api/index_patterns/index_pattern/{id}/runtime_field', - validate: { - params: schema.object({ - id: schema.string({ - minLength: 1, - maxLength: 1_000, +const runtimeCreateFieldRouteFactory = + (path: string, serviceKey: SERVICE_KEY_TYPE) => + ( + router: IRouter, + getStartServices: StartServicesAccessor< + DataViewsServerPluginStartDependencies, + DataViewsServerPluginStart + > + ) => { + router.post( + { + path, + validate: { + params: schema.object({ + id: schema.string({ + minLength: 1, + maxLength: 1_000, + }), }), - }), - body: schema.object({ - name: schema.string({ - minLength: 1, - maxLength: 1_000, + body: schema.object({ + name: schema.string({ + minLength: 1, + maxLength: 1_000, + }), + runtimeField: runtimeFieldSpecSchema, }), - runtimeField: runtimeFieldSpecSchema, - }), + }, }, - }, + handleErrors(async (ctx, req, res) => { + const savedObjectsClient = ctx.core.savedObjects.client; + const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; + const [, , { indexPatternsServiceFactory }] = await getStartServices(); + const indexPatternsService = await indexPatternsServiceFactory( + savedObjectsClient, + elasticsearchClient, + req + ); + const id = req.params.id; + const { name, runtimeField } = req.body; - handleErrors(async (ctx, req, res) => { - const savedObjectsClient = ctx.core.savedObjects.client; - const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; - const [, , { indexPatternsServiceFactory }] = await getStartServices(); - const indexPatternsService = await indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient, - req - ); - const id = req.params.id; - const { name, runtimeField } = req.body; + const indexPattern = await indexPatternsService.get(id); - const indexPattern = await indexPatternsService.get(id); + if (indexPattern.fields.getByName(name)) { + throw new Error(`Field [name = ${name}] already exists.`); + } - if (indexPattern.fields.getByName(name)) { - throw new Error(`Field [name = ${name}] already exists.`); - } + indexPattern.addRuntimeField(name, runtimeField); - indexPattern.addRuntimeField(name, runtimeField); + const addedField = indexPattern.fields.getByName(name); + if (!addedField) throw new Error(`Could not create a field [name = ${name}].`); - const addedField = indexPattern.fields.getByName(name); - if (!addedField) throw new Error(`Could not create a field [name = ${name}].`); + await indexPatternsService.updateSavedObject(indexPattern); - await indexPatternsService.updateSavedObject(indexPattern); + const savedField = indexPattern.fields.getByName(name); + if (!savedField) throw new Error(`Could not create a field [name = ${name}].`); - const savedField = indexPattern.fields.getByName(name); - if (!savedField) throw new Error(`Could not create a field [name = ${name}].`); + const response = { + body: { + fields: [savedField.toSpec()], + [serviceKey]: indexPattern.toSpec(), + }, + }; - return res.ok({ - body: { - field: savedField.toSpec(), - index_pattern: indexPattern.toSpec(), - }, - }); - }) - ); -}; + const legacyResponse = { + body: { + [serviceKey]: indexPattern.toSpec(), + field: savedField.toSpec(), + }, + }; + + return res.ok(serviceKey === SERVICE_KEY_LEGACY ? legacyResponse : response); + }) + ); + }; + +export const registerCreateRuntimeFieldRoute = runtimeCreateFieldRouteFactory( + RUNTIME_FIELD_PATH, + SERVICE_KEY +); + +export const registerCreateRuntimeFieldRouteLegacy = runtimeCreateFieldRouteFactory( + RUNTIME_FIELD_PATH_LEGACY, + SERVICE_KEY_LEGACY +); diff --git a/src/plugins/data_views/server/routes/runtime_fields/delete_runtime_field.ts b/src/plugins/data_views/server/routes/runtime_fields/delete_runtime_field.ts index d15365647f2a0..4c68a91e8f743 100644 --- a/src/plugins/data_views/server/routes/runtime_fields/delete_runtime_field.ts +++ b/src/plugins/data_views/server/routes/runtime_fields/delete_runtime_field.ts @@ -14,58 +14,69 @@ import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies, } from '../../types'; +import { SPECIFIC_RUNTIME_FIELD_PATH, SPECIFIC_RUNTIME_FIELD_PATH_LEGACY } from '../../constants'; -export const registerDeleteRuntimeFieldRoute = ( - router: IRouter, - getStartServices: StartServicesAccessor< - DataViewsServerPluginStartDependencies, - DataViewsServerPluginStart - > -) => { - router.delete( - { - path: '/api/index_patterns/index_pattern/{id}/runtime_field/{name}', - validate: { - params: schema.object({ - id: schema.string({ - minLength: 1, - maxLength: 1_000, +const deleteRuntimeFieldRouteFactory = + (path: string) => + ( + router: IRouter, + getStartServices: StartServicesAccessor< + DataViewsServerPluginStartDependencies, + DataViewsServerPluginStart + > + ) => { + router.delete( + { + path, + validate: { + params: schema.object({ + id: schema.string({ + minLength: 1, + maxLength: 1_000, + }), + name: schema.string({ + minLength: 1, + maxLength: 1_000, + }), }), - name: schema.string({ - minLength: 1, - maxLength: 1_000, - }), - }), + }, }, - }, - handleErrors(async (ctx, req, res) => { - const savedObjectsClient = ctx.core.savedObjects.client; - const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; - const [, , { indexPatternsServiceFactory }] = await getStartServices(); - const indexPatternsService = await indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient, - req - ); - const id = req.params.id; - const name = req.params.name; + handleErrors(async (ctx, req, res) => { + const savedObjectsClient = ctx.core.savedObjects.client; + const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; + const [, , { indexPatternsServiceFactory }] = await getStartServices(); + const indexPatternsService = await indexPatternsServiceFactory( + savedObjectsClient, + elasticsearchClient, + req + ); + const id = req.params.id; + const name = req.params.name; + + const indexPattern = await indexPatternsService.get(id); + const field = indexPattern.fields.getByName(name); + + if (!field) { + throw new ErrorIndexPatternFieldNotFound(id, name); + } - const indexPattern = await indexPatternsService.get(id); - const field = indexPattern.fields.getByName(name); + if (!field.runtimeField) { + throw new Error('Only runtime fields can be deleted.'); + } - if (!field) { - throw new ErrorIndexPatternFieldNotFound(id, name); - } + indexPattern.removeRuntimeField(name); - if (!field.runtimeField) { - throw new Error('Only runtime fields can be deleted.'); - } + await indexPatternsService.updateSavedObject(indexPattern); - indexPattern.removeRuntimeField(name); + return res.ok(); + }) + ); + }; - await indexPatternsService.updateSavedObject(indexPattern); +export const registerDeleteRuntimeFieldRoute = deleteRuntimeFieldRouteFactory( + SPECIFIC_RUNTIME_FIELD_PATH +); - return res.ok(); - }) - ); -}; +export const registerDeleteRuntimeFieldRouteLegacy = deleteRuntimeFieldRouteFactory( + SPECIFIC_RUNTIME_FIELD_PATH_LEGACY +); diff --git a/src/plugins/data_views/server/routes/runtime_fields/get_runtime_field.ts b/src/plugins/data_views/server/routes/runtime_fields/get_runtime_field.ts index a6f45b81af149..e79aa9cb76fbe 100644 --- a/src/plugins/data_views/server/routes/runtime_fields/get_runtime_field.ts +++ b/src/plugins/data_views/server/routes/runtime_fields/get_runtime_field.ts @@ -14,61 +14,89 @@ import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies, } from '../../types'; +import { + SPECIFIC_RUNTIME_FIELD_PATH, + SPECIFIC_RUNTIME_FIELD_PATH_LEGACY, + SERVICE_KEY, + SERVICE_KEY_LEGACY, + SERVICE_KEY_TYPE, +} from '../../constants'; -export const registerGetRuntimeFieldRoute = ( - router: IRouter, - getStartServices: StartServicesAccessor< - DataViewsServerPluginStartDependencies, - DataViewsServerPluginStart - > -) => { - router.get( - { - path: '/api/index_patterns/index_pattern/{id}/runtime_field/{name}', - validate: { - params: schema.object({ - id: schema.string({ - minLength: 1, - maxLength: 1_000, +const getRuntimeFieldRouteFactory = + (path: string, serviceKey: SERVICE_KEY_TYPE) => + ( + router: IRouter, + getStartServices: StartServicesAccessor< + DataViewsServerPluginStartDependencies, + DataViewsServerPluginStart + > + ) => { + router.get( + { + path, + validate: { + params: schema.object({ + id: schema.string({ + minLength: 1, + maxLength: 1_000, + }), + name: schema.string({ + minLength: 1, + maxLength: 1_000, + }), }), - name: schema.string({ - minLength: 1, - maxLength: 1_000, - }), - }), + }, }, - }, - handleErrors(async (ctx, req, res) => { - const savedObjectsClient = ctx.core.savedObjects.client; - const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; - const [, , { indexPatternsServiceFactory }] = await getStartServices(); - const indexPatternsService = await indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient, - req - ); - const id = req.params.id; - const name = req.params.name; + handleErrors(async (ctx, req, res) => { + const savedObjectsClient = ctx.core.savedObjects.client; + const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; + const [, , { indexPatternsServiceFactory }] = await getStartServices(); + const indexPatternsService = await indexPatternsServiceFactory( + savedObjectsClient, + elasticsearchClient, + req + ); + const id = req.params.id; + const name = req.params.name; - const indexPattern = await indexPatternsService.get(id); + const indexPattern = await indexPatternsService.get(id); - const field = indexPattern.fields.getByName(name); + const field = indexPattern.fields.getByName(name); - if (!field) { - throw new ErrorIndexPatternFieldNotFound(id, name); - } + if (!field) { + throw new ErrorIndexPatternFieldNotFound(id, name); + } - if (!field.runtimeField) { - throw new Error('Only runtime fields can be retrieved.'); - } + if (!field.runtimeField) { + throw new Error('Only runtime fields can be retrieved.'); + } - return res.ok({ - body: { - field: field.toSpec(), - runtimeField: indexPattern.getRuntimeField(name), - }, - }); - }) - ); -}; + const legacyResponse = { + body: { + field: field.toSpec(), + runtimeField: indexPattern.getRuntimeField(name), + }, + }; + + const response = { + body: { + fields: [field.toSpec()], + runtimeField: indexPattern.getRuntimeField(name), + }, + }; + + return res.ok(serviceKey === SERVICE_KEY_LEGACY ? legacyResponse : response); + }) + ); + }; + +export const registerGetRuntimeFieldRoute = getRuntimeFieldRouteFactory( + SPECIFIC_RUNTIME_FIELD_PATH, + SERVICE_KEY +); + +export const registerGetRuntimeFieldRouteLegacy = getRuntimeFieldRouteFactory( + SPECIFIC_RUNTIME_FIELD_PATH_LEGACY, + SERVICE_KEY_LEGACY +); diff --git a/src/plugins/data_views/server/routes/runtime_fields/put_runtime_field.ts b/src/plugins/data_views/server/routes/runtime_fields/put_runtime_field.ts index 7cea9864f17dd..7acd212f4f09c 100644 --- a/src/plugins/data_views/server/routes/runtime_fields/put_runtime_field.ts +++ b/src/plugins/data_views/server/routes/runtime_fields/put_runtime_field.ts @@ -14,70 +14,97 @@ import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies, } from '../../types'; +import { + RUNTIME_FIELD_PATH, + RUNTIME_FIELD_PATH_LEGACY, + SERVICE_KEY, + SERVICE_KEY_LEGACY, +} from '../../constants'; -export const registerPutRuntimeFieldRoute = ( - router: IRouter, - getStartServices: StartServicesAccessor< - DataViewsServerPluginStartDependencies, - DataViewsServerPluginStart - > -) => { - router.put( - { - path: '/api/index_patterns/index_pattern/{id}/runtime_field', - validate: { - params: schema.object({ - id: schema.string({ - minLength: 1, - maxLength: 1_000, +const putRuntimeFieldRouteFactory = + (path: string, serviceKey: string) => + ( + router: IRouter, + getStartServices: StartServicesAccessor< + DataViewsServerPluginStartDependencies, + DataViewsServerPluginStart + > + ) => { + router.put( + { + path, + validate: { + params: schema.object({ + id: schema.string({ + minLength: 1, + maxLength: 1_000, + }), }), - }), - body: schema.object({ - name: schema.string({ - minLength: 1, - maxLength: 1_000, + body: schema.object({ + name: schema.string({ + minLength: 1, + maxLength: 1_000, + }), + runtimeField: runtimeFieldSpecSchema, }), - runtimeField: runtimeFieldSpecSchema, - }), + }, }, - }, - handleErrors(async (ctx, req, res) => { - const savedObjectsClient = ctx.core.savedObjects.client; - const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; - const [, , { indexPatternsServiceFactory }] = await getStartServices(); - const indexPatternsService = await indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient, - req - ); - const id = req.params.id; - const { name, runtimeField } = req.body; + handleErrors(async (ctx, req, res) => { + const savedObjectsClient = ctx.core.savedObjects.client; + const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; + const [, , { indexPatternsServiceFactory }] = await getStartServices(); + const indexPatternsService = await indexPatternsServiceFactory( + savedObjectsClient, + elasticsearchClient, + req + ); + const id = req.params.id; + const { name, runtimeField } = req.body; - const indexPattern = await indexPatternsService.get(id); + const indexPattern = await indexPatternsService.get(id); - const oldFieldObject = indexPattern.fields.getByName(name); + const oldFieldObject = indexPattern.fields.getByName(name); - if (oldFieldObject && !oldFieldObject.runtimeField) { - throw new Error('Only runtime fields can be updated'); - } + if (oldFieldObject && !oldFieldObject.runtimeField) { + throw new Error('Only runtime fields can be updated'); + } - if (oldFieldObject) { - indexPattern.removeRuntimeField(name); - } + if (oldFieldObject) { + indexPattern.removeRuntimeField(name); + } - indexPattern.addRuntimeField(name, runtimeField); + indexPattern.addRuntimeField(name, runtimeField); - await indexPatternsService.updateSavedObject(indexPattern); + await indexPatternsService.updateSavedObject(indexPattern); - const fieldObject = indexPattern.fields.getByName(name); - if (!fieldObject) throw new Error(`Could not create a field [name = ${name}].`); + const fieldObject = indexPattern.fields.getByName(name); + if (!fieldObject) throw new Error(`Could not create a field [name = ${name}].`); - return res.ok({ - body: { - field: fieldObject.toSpec(), - index_pattern: indexPattern.toSpec(), - }, - }); - }) - ); -}; + const legacyResponse = { + body: { + field: fieldObject.toSpec(), + [serviceKey]: indexPattern.toSpec(), + }, + }; + + const response = { + body: { + fields: [fieldObject.toSpec()], + [serviceKey]: indexPattern.toSpec(), + }, + }; + + return res.ok(serviceKey === SERVICE_KEY_LEGACY ? legacyResponse : response); + }) + ); + }; + +export const registerPutRuntimeFieldRoute = putRuntimeFieldRouteFactory( + RUNTIME_FIELD_PATH, + SERVICE_KEY +); + +export const registerPutRuntimeFieldRouteLegacy = putRuntimeFieldRouteFactory( + RUNTIME_FIELD_PATH_LEGACY, + SERVICE_KEY_LEGACY +); diff --git a/src/plugins/data_views/server/routes/runtime_fields/update_runtime_field.ts b/src/plugins/data_views/server/routes/runtime_fields/update_runtime_field.ts index b2c6bf0576b9b..4cca07a59cfbd 100644 --- a/src/plugins/data_views/server/routes/runtime_fields/update_runtime_field.ts +++ b/src/plugins/data_views/server/routes/runtime_fields/update_runtime_field.ts @@ -16,76 +16,104 @@ import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies, } from '../../types'; +import { + SPECIFIC_RUNTIME_FIELD_PATH, + SPECIFIC_RUNTIME_FIELD_PATH_LEGACY, + SERVICE_KEY, + SERVICE_KEY_LEGACY, + SERVICE_KEY_TYPE, +} from '../../constants'; -export const registerUpdateRuntimeFieldRoute = ( - router: IRouter, - getStartServices: StartServicesAccessor< - DataViewsServerPluginStartDependencies, - DataViewsServerPluginStart - > -) => { - router.post( - { - path: '/api/index_patterns/index_pattern/{id}/runtime_field/{name}', - validate: { - params: schema.object({ - id: schema.string({ - minLength: 1, - maxLength: 1_000, +const updateRuntimeFieldRouteFactory = + (path: string, serviceKey: SERVICE_KEY_TYPE) => + ( + router: IRouter, + getStartServices: StartServicesAccessor< + DataViewsServerPluginStartDependencies, + DataViewsServerPluginStart + > + ) => { + router.post( + { + path, + validate: { + params: schema.object({ + id: schema.string({ + minLength: 1, + maxLength: 1_000, + }), + name: schema.string({ + minLength: 1, + maxLength: 1_000, + }), }), - name: schema.string({ - minLength: 1, - maxLength: 1_000, + body: schema.object({ + name: schema.never(), + runtimeField: schema.object({ + ...runtimeFieldSpec, + // We need to overwrite the below fields on top of `runtimeFieldSpec`, + // because some fields would be optional + type: schema.maybe(runtimeFieldSpecTypeSchema), + }), }), - }), - body: schema.object({ - name: schema.never(), - runtimeField: schema.object({ - ...runtimeFieldSpec, - // We need to overwrite the below fields on top of `runtimeFieldSpec`, - // because some fields would be optional - type: schema.maybe(runtimeFieldSpecTypeSchema), - }), - }), + }, }, - }, - handleErrors(async (ctx, req, res) => { - const savedObjectsClient = ctx.core.savedObjects.client; - const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; - const [, , { indexPatternsServiceFactory }] = await getStartServices(); - const indexPatternsService = await indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient, - req - ); - const id = req.params.id; - const name = req.params.name; - const runtimeField = req.body.runtimeField as Partial; + handleErrors(async (ctx, req, res) => { + const savedObjectsClient = ctx.core.savedObjects.client; + const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; + const [, , { indexPatternsServiceFactory }] = await getStartServices(); + const indexPatternsService = await indexPatternsServiceFactory( + savedObjectsClient, + elasticsearchClient, + req + ); + const id = req.params.id; + const name = req.params.name; + const runtimeField = req.body.runtimeField as Partial; - const indexPattern = await indexPatternsService.get(id); - const existingRuntimeField = indexPattern.getRuntimeField(name); + const indexPattern = await indexPatternsService.get(id); + const existingRuntimeField = indexPattern.getRuntimeField(name); - if (!existingRuntimeField) { - throw new ErrorIndexPatternFieldNotFound(id, name); - } + if (!existingRuntimeField) { + throw new ErrorIndexPatternFieldNotFound(id, name); + } - indexPattern.removeRuntimeField(name); - indexPattern.addRuntimeField(name, { - ...existingRuntimeField, - ...runtimeField, - }); + indexPattern.removeRuntimeField(name); + indexPattern.addRuntimeField(name, { + ...existingRuntimeField, + ...runtimeField, + }); - await indexPatternsService.updateSavedObject(indexPattern); + await indexPatternsService.updateSavedObject(indexPattern); - const fieldObject = indexPattern.fields.getByName(name); - if (!fieldObject) throw new Error(`Could not create a field [name = ${name}].`); + const fieldObject = indexPattern.fields.getByName(name); + if (!fieldObject) throw new Error(`Could not create a field [name = ${name}].`); - return res.ok({ - body: { - field: fieldObject.toSpec(), - index_pattern: indexPattern.toSpec(), - }, - }); - }) - ); -}; + const legacyResponse = { + body: { + field: fieldObject.toSpec(), + [serviceKey]: indexPattern.toSpec(), + }, + }; + + const response = { + body: { + fields: [fieldObject.toSpec()], + [serviceKey]: indexPattern.toSpec(), + }, + }; + + return res.ok(serviceKey === SERVICE_KEY_LEGACY ? legacyResponse : response); + }) + ); + }; + +export const registerUpdateRuntimeFieldRoute = updateRuntimeFieldRouteFactory( + SPECIFIC_RUNTIME_FIELD_PATH, + SERVICE_KEY +); + +export const registerUpdateRuntimeFieldRouteLegacy = updateRuntimeFieldRouteFactory( + SPECIFIC_RUNTIME_FIELD_PATH_LEGACY, + SERVICE_KEY_LEGACY +); diff --git a/src/plugins/data_views/server/routes/update_index_pattern.ts b/src/plugins/data_views/server/routes/update_index_pattern.ts index 25f45456b9d13..a9da81df7f911 100644 --- a/src/plugins/data_views/server/routes/update_index_pattern.ts +++ b/src/plugins/data_views/server/routes/update_index_pattern.ts @@ -7,6 +7,7 @@ */ import { schema } from '@kbn/config-schema'; +import { DataViewSpec } from 'src/plugins/data_views/common'; import { handleErrors } from './util/handle_errors'; import { fieldSpecSchema, @@ -14,7 +15,13 @@ import { serializedFieldFormatSchema, } from './util/schemas'; import { IRouter, StartServicesAccessor } from '../../../../core/server'; -import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies } from '../types'; +import type { DataViewsServerPluginStartDependencies, DataViewsServerPluginStart } from '../types'; +import { + SPECIFIC_DATA_VIEW_PATH, + SPECIFIC_DATA_VIEW_PATH_LEGACY, + SERVICE_KEY, + SERVICE_KEY_LEGACY, +} from '../constants'; const indexPatternUpdateSchema = schema.object({ title: schema.maybe(schema.string()), @@ -34,50 +41,55 @@ const indexPatternUpdateSchema = schema.object({ runtimeFieldMap: schema.maybe(schema.recordOf(schema.string(), runtimeFieldSpecSchema)), }); -export const registerUpdateIndexPatternRoute = ( - router: IRouter, - getStartServices: StartServicesAccessor< - DataViewsServerPluginStartDependencies, - DataViewsServerPluginStart - > -) => { - router.post( - { - path: '/api/index_patterns/index_pattern/{id}', - validate: { - params: schema.object( - { - id: schema.string({ - minLength: 1, - maxLength: 1_000, - }), - }, - { unknowns: 'allow' } - ), - body: schema.object({ - refresh_fields: schema.maybe(schema.boolean({ defaultValue: false })), - index_pattern: indexPatternUpdateSchema, - }), +const updateDataViewRouteFactory = + (path: string, serviceKey: string) => + ( + router: IRouter, + getStartServices: StartServicesAccessor< + DataViewsServerPluginStartDependencies, + DataViewsServerPluginStart + > + ) => { + router.post( + { + path, + validate: { + params: schema.object( + { + id: schema.string({ + minLength: 1, + maxLength: 1_000, + }), + }, + { unknowns: 'allow' } + ), + body: schema.object({ + refresh_fields: schema.maybe(schema.boolean({ defaultValue: false })), + [serviceKey]: indexPatternUpdateSchema, + }), + }, }, - }, - router.handleLegacyErrors( - handleErrors(async (ctx, req, res) => { - const savedObjectsClient = ctx.core.savedObjects.client; - const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; - const [, , { indexPatternsServiceFactory }] = await getStartServices(); - const indexPatternsService = await indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient, - req - ); - const id = req.params.id; - - const indexPattern = await indexPatternsService.get(id); - - const { - // eslint-disable-next-line @typescript-eslint/naming-convention - refresh_fields = true, - index_pattern: { + router.handleLegacyErrors( + handleErrors(async (ctx, req, res) => { + const savedObjectsClient = ctx.core.savedObjects.client; + const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; + const [, , { dataViewsServiceFactory }] = await getStartServices(); + const indexPatternsService = await dataViewsServiceFactory( + savedObjectsClient, + elasticsearchClient, + req + ); + const id = req.params.id; + + const indexPattern = await indexPatternsService.get(id); + + const { + // eslint-disable-next-line @typescript-eslint/naming-convention + refresh_fields = true, + } = req.body; + const indexPatternSpec = req.body[serviceKey] as DataViewSpec; + + const { title, timeFieldName, sourceFilters, @@ -86,78 +98,87 @@ export const registerUpdateIndexPatternRoute = ( typeMeta, fields, runtimeFieldMap, - }, - } = req.body; - - let changeCount = 0; - let doRefreshFields = false; - - if (title !== undefined && title !== indexPattern.title) { - changeCount++; - indexPattern.title = title; - } - - if (timeFieldName !== undefined && timeFieldName !== indexPattern.timeFieldName) { - changeCount++; - indexPattern.timeFieldName = timeFieldName; - } - - if (sourceFilters !== undefined) { - changeCount++; - indexPattern.sourceFilters = sourceFilters; - } - - if (fieldFormats !== undefined) { - changeCount++; - indexPattern.fieldFormatMap = fieldFormats; - } - - if (type !== undefined) { - changeCount++; - indexPattern.type = type; - } - - if (typeMeta !== undefined) { - changeCount++; - indexPattern.typeMeta = typeMeta; - } - - if (fields !== undefined) { - changeCount++; - doRefreshFields = true; - indexPattern.fields.replaceAll( - Object.values(fields || {}).map((field) => ({ - ...field, - aggregatable: true, - searchable: true, - })) - ); - } - - if (runtimeFieldMap !== undefined) { - changeCount++; - indexPattern.replaceAllRuntimeFields(runtimeFieldMap); - } - - if (changeCount < 1) { - throw new Error('Index pattern change set is empty.'); - } - - await indexPatternsService.updateSavedObject(indexPattern); - - if (doRefreshFields && refresh_fields) { - await indexPatternsService.refreshFields(indexPattern); - } - - return res.ok({ - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ - index_pattern: indexPattern.toSpec(), - }), - }); - }) - ) - ); -}; + } = indexPatternSpec; + + let changeCount = 0; + let doRefreshFields = false; + + if (title !== undefined && title !== indexPattern.title) { + changeCount++; + indexPattern.title = title; + } + + if (timeFieldName !== undefined && timeFieldName !== indexPattern.timeFieldName) { + changeCount++; + indexPattern.timeFieldName = timeFieldName; + } + + if (sourceFilters !== undefined) { + changeCount++; + indexPattern.sourceFilters = sourceFilters; + } + + if (fieldFormats !== undefined) { + changeCount++; + indexPattern.fieldFormatMap = fieldFormats; + } + + if (type !== undefined) { + changeCount++; + indexPattern.type = type; + } + + if (typeMeta !== undefined) { + changeCount++; + indexPattern.typeMeta = typeMeta; + } + + if (fields !== undefined) { + changeCount++; + doRefreshFields = true; + indexPattern.fields.replaceAll( + Object.values(fields || {}).map((field) => ({ + ...field, + aggregatable: true, + searchable: true, + })) + ); + } + + if (runtimeFieldMap !== undefined) { + changeCount++; + indexPattern.replaceAllRuntimeFields(runtimeFieldMap); + } + + if (changeCount < 1) { + throw new Error('Index pattern change set is empty.'); + } + + await indexPatternsService.updateSavedObject(indexPattern); + + if (doRefreshFields && refresh_fields) { + await indexPatternsService.refreshFields(indexPattern); + } + + return res.ok({ + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + [serviceKey]: indexPattern.toSpec(), + }), + }); + }) + ) + ); + }; + +export const registerUpdateDataViewRoute = updateDataViewRouteFactory( + SPECIFIC_DATA_VIEW_PATH, + SERVICE_KEY +); + +export const registerUpdateDataViewRouteLegacy = updateDataViewRouteFactory( + SPECIFIC_DATA_VIEW_PATH_LEGACY, + SERVICE_KEY_LEGACY +); diff --git a/test/api_integration/apis/index_patterns/constants.ts b/test/api_integration/apis/index_patterns/constants.ts new file mode 100644 index 0000000000000..3de4f80dd657d --- /dev/null +++ b/test/api_integration/apis/index_patterns/constants.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + DATA_VIEW_PATH_LEGACY, + SERVICE_KEY_LEGACY, + DATA_VIEW_PATH, + SERVICE_KEY, + SERVICE_PATH, + SERVICE_PATH_LEGACY, +} from '../../../../src/plugins/data_views/server'; + +const legacyConfig = { + name: 'legacy index pattern api', + path: DATA_VIEW_PATH_LEGACY, + basePath: SERVICE_PATH_LEGACY, + serviceKey: SERVICE_KEY_LEGACY, +}; + +const dataViewConfig = { + name: 'data view api', + path: DATA_VIEW_PATH, + basePath: SERVICE_PATH, + serviceKey: SERVICE_KEY, +}; + +export const configArray = [legacyConfig, dataViewConfig]; diff --git a/test/api_integration/apis/index_patterns/default_index_pattern/default_index_pattern.ts b/test/api_integration/apis/index_patterns/default_index_pattern/default_index_pattern.ts index b12600f5ce4f3..d34ad5ccd5f4d 100644 --- a/test/api_integration/apis/index_patterns/default_index_pattern/default_index_pattern.ts +++ b/test/api_integration/apis/index_patterns/default_index_pattern/default_index_pattern.ts @@ -8,42 +8,49 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; +import { configArray } from '../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('default index pattern api', () => { - const newId = () => `default-id-${Date.now()}-${Math.random()}`; - it('can set default index pattern', async () => { - const defaultId = newId(); - const response1 = await supertest.post('/api/index_patterns/default').send({ - index_pattern_id: defaultId, - force: true, + configArray.forEach((config) => { + describe(config.name, () => { + const newId = () => `default-id-${Date.now()}-${Math.random()}`; + it('can set default index pattern', async () => { + const defaultId = newId(); + const defaultPath = `${config.basePath}/default`; + const serviceKeyId = `${config.serviceKey}_id`; + const response1 = await supertest.post(defaultPath).send({ + [serviceKeyId]: defaultId, + force: true, + }); + expect(response1.status).to.be(200); + expect(response1.body.acknowledged).to.be(true); + + const response2 = await supertest.get(defaultPath); + expect(response2.status).to.be(200); + expect(response2.body[serviceKeyId]).to.be(defaultId); + + const response3 = await supertest.post(defaultPath).send({ + [serviceKeyId]: newId(), + // no force this time, so this new default shouldn't be set + }); + + expect(response3.status).to.be(200); + const response4 = await supertest.get(defaultPath); + expect(response4.body[serviceKeyId]).to.be(defaultId); // original default id is used + + const response5 = await supertest.post(defaultPath).send({ + [serviceKeyId]: null, + force: true, + }); + expect(response5.status).to.be(200); + + const response6 = await supertest.get(defaultPath); + expect(response6.body[serviceKeyId]).to.be(null); + }); }); - expect(response1.status).to.be(200); - expect(response1.body.acknowledged).to.be(true); - - const response2 = await supertest.get('/api/index_patterns/default'); - expect(response2.status).to.be(200); - expect(response2.body.index_pattern_id).to.be(defaultId); - - const response3 = await supertest.post('/api/index_patterns/default').send({ - index_pattern_id: newId(), - // no force this time, so this new default shouldn't be set - }); - - expect(response3.status).to.be(200); - const response4 = await supertest.get('/api/index_patterns/default'); - expect(response4.body.index_pattern_id).to.be(defaultId); // original default id is used - - const response5 = await supertest.post('/api/index_patterns/default').send({ - index_pattern_id: null, - force: true, - }); - expect(response5.status).to.be(200); - - const response6 = await supertest.get('/api/index_patterns/default'); - expect(response6.body.index_pattern_id).to.be(null); }); }); } diff --git a/test/api_integration/apis/index_patterns/fields_api/update_fields/errors.ts b/test/api_integration/apis/index_patterns/fields_api/update_fields/errors.ts index dfc3d90dcaeec..55ac2e165a9ac 100644 --- a/test/api_integration/apis/index_patterns/fields_api/update_fields/errors.ts +++ b/test/api_integration/apis/index_patterns/fields_api/update_fields/errors.ts @@ -8,63 +8,68 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('errors', () => { - it('returns 404 error on non-existing index_pattern', async () => { - const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; - const response = await supertest.post(`/api/index_patterns/index_pattern/${id}/fields`).send({ - fields: { - foo: {}, - }, - }); + configArray.forEach((config) => { + describe(config.name, () => { + it('returns 404 error on non-existing index_pattern', async () => { + const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; + const response = await supertest.post(`${config.path}/${id}/fields`).send({ + fields: { + foo: {}, + }, + }); - expect(response.status).to.be(404); - }); + expect(response.status).to.be(404); + }); - it('returns error when "fields" payload attribute is invalid', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - }, - }); - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/fields`) - .send({ - fields: 123, + it('returns error when "fields" payload attribute is invalid', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + }, + }); + const response2 = await supertest + .post(`${config.path}/${response1.body[config.serviceKey].id}/fields`) + .send({ + fields: 123, + }); + + expect(response2.status).to.be(400); + expect(response2.body.statusCode).to.be(400); + expect(response2.body.message).to.be( + '[request body.fields]: expected value of type [object] but got [number]' + ); }); - expect(response2.status).to.be(400); - expect(response2.body.statusCode).to.be(400); - expect(response2.body.message).to.be( - '[request body.fields]: expected value of type [object] but got [number]' - ); - }); + it('returns error if not changes are specified', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + }, + }); - it('returns error if not changes are specified', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - }, - }); + const response2 = await supertest + .post(`${config.path}/${response1.body[config.serviceKey].id}/fields`) + .send({ + fields: { + foo: {}, + bar: {}, + baz: {}, + }, + }); - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/fields`) - .send({ - fields: { - foo: {}, - bar: {}, - baz: {}, - }, + expect(response2.status).to.be(400); + expect(response2.body.statusCode).to.be(400); + expect(response2.body.message).to.be('Change set is empty.'); }); - - expect(response2.status).to.be(400); - expect(response2.body.statusCode).to.be(400); - expect(response2.body.message).to.be('Change set is empty.'); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/fields_api/update_fields/main.ts b/test/api_integration/apis/index_patterns/fields_api/update_fields/main.ts index 16861f3c28051..d48dd90396e16 100644 --- a/test/api_integration/apis/index_patterns/fields_api/update_fields/main.ts +++ b/test/api_integration/apis/index_patterns/fields_api/update_fields/main.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -17,474 +18,476 @@ export default function ({ getService }: FtrProviderContext) { const basicIndex = 'ba*ic_index'; let indexPattern: any; - before(async () => { - await esArchiver.load('test/api_integration/fixtures/es_archiver/index_patterns/basic_index'); - - indexPattern = ( - await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title: basicIndex, - }, - }) - ).body.index_pattern; - }); - - after(async () => { - await esArchiver.unload( - 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' - ); - - if (indexPattern) { - await supertest.delete('/api/index_patterns/index_pattern/' + indexPattern.id); - } - }); - - it('can update multiple fields', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - }, - }); - - expect(response1.status).to.be(200); - expect(response1.body.index_pattern.fieldAttrs.foo).to.be(undefined); - expect(response1.body.index_pattern.fieldAttrs.bar).to.be(undefined); - - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/fields`) - .send({ - fields: { - foo: { - count: 123, - customLabel: 'test', - }, - bar: { - count: 456, - }, - }, + configArray.forEach((config) => { + describe(config.name, () => { + before(async () => { + await esArchiver.load( + 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' + ); + + indexPattern = ( + await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title: basicIndex, + }, + }) + ).body[config.serviceKey]; }); - expect(response2.status).to.be(200); - expect(response2.body.index_pattern.fieldAttrs.foo.count).to.be(123); - expect(response2.body.index_pattern.fieldAttrs.foo.customLabel).to.be('test'); - expect(response2.body.index_pattern.fieldAttrs.bar.count).to.be(456); + after(async () => { + await esArchiver.unload( + 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' + ); - const response3 = await supertest.get( - `/api/index_patterns/index_pattern/${response1.body.index_pattern.id}` - ); - - expect(response3.status).to.be(200); - expect(response3.body.index_pattern.fieldAttrs.foo.count).to.be(123); - expect(response3.body.index_pattern.fieldAttrs.foo.customLabel).to.be('test'); - expect(response3.body.index_pattern.fieldAttrs.bar.count).to.be(456); - }); - - describe('count', () => { - it('can set field "count" attribute on non-existing field', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - }, + if (indexPattern) { + await supertest.delete(`${config.path}/${indexPattern.id}`); + } }); - expect(response1.status).to.be(200); - expect(response1.body.index_pattern.fieldAttrs.foo).to.be(undefined); - - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/fields`) - .send({ - fields: { - foo: { - count: 123, - }, + it('can update multiple fields', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, }, }); - expect(response2.status).to.be(200); - expect(response2.body.index_pattern.fieldAttrs.foo.count).to.be(123); + expect(response1.status).to.be(200); + expect(response1.body[config.serviceKey].fieldAttrs.foo).to.be(undefined); + expect(response1.body[config.serviceKey].fieldAttrs.bar).to.be(undefined); + + const response2 = await supertest + .post(`${config.path}/${response1.body[config.serviceKey].id}/fields`) + .send({ + fields: { + foo: { + count: 123, + customLabel: 'test', + }, + bar: { + count: 456, + }, + }, + }); - const response3 = await supertest.get( - `/api/index_patterns/index_pattern/${response1.body.index_pattern.id}` - ); + expect(response2.status).to.be(200); + expect(response2.body[config.serviceKey].fieldAttrs.foo.count).to.be(123); + expect(response2.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be('test'); + expect(response2.body[config.serviceKey].fieldAttrs.bar.count).to.be(456); - expect(response3.status).to.be(200); - expect(response3.body.index_pattern.fieldAttrs.foo.count).to.be(123); - }); + const response3 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); - it('can update "count" attribute in index_pattern attribute map', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - fieldAttrs: { - foo: { - count: 1, - }, - }, - }, + expect(response3.status).to.be(200); + expect(response3.body[config.serviceKey].fieldAttrs.foo.count).to.be(123); + expect(response3.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be('test'); + expect(response3.body[config.serviceKey].fieldAttrs.bar.count).to.be(456); }); - expect(response1.status).to.be(200); - expect(response1.body.index_pattern.fieldAttrs.foo.count).to.be(1); - - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/fields`) - .send({ - fields: { - foo: { - count: 2, + describe('count', () => { + it('can set field "count" attribute on non-existing field', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, }, - }, - }); - - expect(response2.status).to.be(200); - expect(response2.body.index_pattern.fieldAttrs.foo.count).to.be(2); - - const response3 = await supertest.get( - `/api/index_patterns/index_pattern/${response1.body.index_pattern.id}` - ); - - expect(response3.status).to.be(200); - expect(response3.body.index_pattern.fieldAttrs.foo.count).to.be(2); - }); + }); + + expect(response1.status).to.be(200); + expect(response1.body[config.serviceKey].fieldAttrs.foo).to.be(undefined); + + const response2 = await supertest + .post(`${config.path}/${response1.body[config.serviceKey].id}/fields`) + .send({ + fields: { + foo: { + count: 123, + }, + }, + }); - it('can delete "count" attribute from index_pattern attribute map', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - fieldAttrs: { - foo: { - count: 1, - }, - }, - }, - }); + expect(response2.status).to.be(200); + expect(response2.body[config.serviceKey].fieldAttrs.foo.count).to.be(123); - expect(response1.status).to.be(200); - expect(response1.body.index_pattern.fieldAttrs.foo.count).to.be(1); + const response3 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/fields`) - .send({ - fields: { - foo: { - count: null, - }, - }, + expect(response3.status).to.be(200); + expect(response3.body[config.serviceKey].fieldAttrs.foo.count).to.be(123); }); - expect(response2.status).to.be(200); - expect(response2.body.index_pattern.fieldAttrs.foo.count).to.be(undefined); - - const response3 = await supertest.get( - `/api/index_patterns/index_pattern/${response1.body.index_pattern.id}` - ); - - expect(response3.status).to.be(200); - expect(response3.body.index_pattern.fieldAttrs.foo.count).to.be(undefined); - }); - }); + it('can update "count" attribute in index_pattern attribute map', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + fieldAttrs: { + foo: { + count: 1, + }, + }, + }, + }); + + expect(response1.status).to.be(200); + expect(response1.body[config.serviceKey].fieldAttrs.foo.count).to.be(1); + + const response2 = await supertest + .post(`${config.path}/${response1.body[config.serviceKey].id}/fields`) + .send({ + fields: { + foo: { + count: 2, + }, + }, + }); - describe('customLabel', () => { - it('can set field "customLabel" attribute on non-existing field', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - }, - }); + expect(response2.status).to.be(200); + expect(response2.body[config.serviceKey].fieldAttrs.foo.count).to.be(2); - expect(response1.status).to.be(200); - expect(response1.body.index_pattern.fieldAttrs.foo).to.be(undefined); + const response3 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/fields`) - .send({ - fields: { - foo: { - customLabel: 'foo', - }, - }, + expect(response3.status).to.be(200); + expect(response3.body[config.serviceKey].fieldAttrs.foo.count).to.be(2); }); - expect(response2.status).to.be(200); - expect(response2.body.index_pattern.fieldAttrs.foo.customLabel).to.be('foo'); + it('can delete "count" attribute from index_pattern attribute map', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + fieldAttrs: { + foo: { + count: 1, + }, + }, + }, + }); + + expect(response1.status).to.be(200); + expect(response1.body[config.serviceKey].fieldAttrs.foo.count).to.be(1); + + const response2 = await supertest + .post(`${config.path}/${response1.body[config.serviceKey].id}/fields`) + .send({ + fields: { + foo: { + count: null, + }, + }, + }); - const response3 = await supertest.get( - `/api/index_patterns/index_pattern/${response1.body.index_pattern.id}` - ); + expect(response2.status).to.be(200); + expect(response2.body[config.serviceKey].fieldAttrs.foo.count).to.be(undefined); - expect(response3.status).to.be(200); - expect(response3.body.index_pattern.fieldAttrs.foo.customLabel).to.be('foo'); - }); + const response3 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); - it('can update "customLabel" attribute in index_pattern attribute map', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - fieldAttrs: { - foo: { - customLabel: 'foo', - }, - }, - }, + expect(response3.status).to.be(200); + expect(response3.body[config.serviceKey].fieldAttrs.foo.count).to.be(undefined); + }); }); - expect(response1.status).to.be(200); - expect(response1.body.index_pattern.fieldAttrs.foo.customLabel).to.be('foo'); - - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/fields`) - .send({ - fields: { - foo: { - customLabel: 'bar', + describe('customLabel', () => { + it('can set field "customLabel" attribute on non-existing field', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, }, - }, - }); + }); + + expect(response1.status).to.be(200); + expect(response1.body[config.serviceKey].fieldAttrs.foo).to.be(undefined); + + const response2 = await supertest + .post(`${config.path}/${response1.body[config.serviceKey].id}/fields`) + .send({ + fields: { + foo: { + customLabel: 'foo', + }, + }, + }); - expect(response2.status).to.be(200); - expect(response2.body.index_pattern.fieldAttrs.foo.customLabel).to.be('bar'); + expect(response2.status).to.be(200); + expect(response2.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be('foo'); - const response3 = await supertest.get( - `/api/index_patterns/index_pattern/${response1.body.index_pattern.id}` - ); + const response3 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); - expect(response3.status).to.be(200); - expect(response3.body.index_pattern.fieldAttrs.foo.customLabel).to.be('bar'); - }); + expect(response3.status).to.be(200); + expect(response3.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be('foo'); + }); - it('can delete "customLabel" attribute from index_pattern attribute map', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - fieldAttrs: { - foo: { - customLabel: 'foo', + it('can update "customLabel" attribute in index_pattern attribute map', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + fieldAttrs: { + foo: { + customLabel: 'foo', + }, + }, }, - }, - }, - }); + }); + + expect(response1.status).to.be(200); + expect(response1.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be('foo'); + + const response2 = await supertest + .post(`${config.path}/${response1.body[config.serviceKey].id}/fields`) + .send({ + fields: { + foo: { + customLabel: 'bar', + }, + }, + }); - expect(response1.status).to.be(200); - expect(response1.body.index_pattern.fieldAttrs.foo.customLabel).to.be('foo'); + expect(response2.status).to.be(200); + expect(response2.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be('bar'); - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/fields`) - .send({ - fields: { - foo: { - customLabel: null, - }, - }, + const response3 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); + + expect(response3.status).to.be(200); + expect(response3.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be('bar'); }); - expect(response2.status).to.be(200); - expect(response2.body.index_pattern.fieldAttrs.foo.customLabel).to.be(undefined); + it('can delete "customLabel" attribute from index_pattern attribute map', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + fieldAttrs: { + foo: { + customLabel: 'foo', + }, + }, + }, + }); + + expect(response1.status).to.be(200); + expect(response1.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be('foo'); + + const response2 = await supertest + .post(`${config.path}/${response1.body[config.serviceKey].id}/fields`) + .send({ + fields: { + foo: { + customLabel: null, + }, + }, + }); - const response3 = await supertest.get( - `/api/index_patterns/index_pattern/${response1.body.index_pattern.id}` - ); + expect(response2.status).to.be(200); + expect(response2.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be(undefined); - expect(response3.status).to.be(200); - expect(response3.body.index_pattern.fieldAttrs.foo.customLabel).to.be(undefined); - }); + const response3 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); - it('can set field "customLabel" attribute on an existing field', async () => { - await supertest.post(`/api/index_patterns/index_pattern/${indexPattern.id}/fields`).send({ - fields: { - foo: { - customLabel: 'baz', - }, - }, - }); + expect(response3.status).to.be(200); + expect(response3.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be(undefined); + }); - const response1 = await supertest.get( - `/api/index_patterns/index_pattern/${indexPattern.id}` - ); + it('can set field "customLabel" attribute on an existing field', async () => { + await supertest.post(`${config.path}/${indexPattern.id}/fields`).send({ + fields: { + foo: { + customLabel: 'baz', + }, + }, + }); - expect(response1.status).to.be(200); - expect(response1.body.index_pattern.fields.foo.customLabel).to.be('baz'); - }); - }); + const response1 = await supertest.get(`${config.path}/${indexPattern.id}`); - describe('format', () => { - it('can set field "format" attribute on non-existing field', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - }, + expect(response1.status).to.be(200); + expect(response1.body[config.serviceKey].fields.foo.customLabel).to.be('baz'); + }); }); - expect(response1.status).to.be(200); - expect(response1.body.index_pattern.fieldFormats.foo).to.be(undefined); - - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/fields`) - .send({ - fields: { - foo: { - format: { - id: 'bar', - params: { baz: 'qux' }, - }, + describe('format', () => { + it('can set field "format" attribute on non-existing field', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, }, - }, + }); + + expect(response1.status).to.be(200); + expect(response1.body[config.serviceKey].fieldFormats.foo).to.be(undefined); + + const response2 = await supertest + .post(`${config.path}/${response1.body[config.serviceKey].id}/fields`) + .send({ + fields: { + foo: { + format: { + id: 'bar', + params: { baz: 'qux' }, + }, + }, + }, + }); + + expect(response2.status).to.be(200); + expect(response2.body[config.serviceKey].fieldFormats.foo).to.eql({ + id: 'bar', + params: { baz: 'qux' }, + }); + + const response3 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); + + expect(response3.status).to.be(200); + expect(response3.body[config.serviceKey].fieldFormats.foo).to.eql({ + id: 'bar', + params: { baz: 'qux' }, + }); }); - expect(response2.status).to.be(200); - expect(response2.body.index_pattern.fieldFormats.foo).to.eql({ - id: 'bar', - params: { baz: 'qux' }, - }); - - const response3 = await supertest.get( - `/api/index_patterns/index_pattern/${response1.body.index_pattern.id}` - ); - - expect(response3.status).to.be(200); - expect(response3.body.index_pattern.fieldFormats.foo).to.eql({ - id: 'bar', - params: { baz: 'qux' }, - }); - }); - - it('can update "format" attribute in index_pattern format map', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - fieldFormats: { - foo: { - id: 'bar', - params: { - baz: 'qux', + it('can update "format" attribute in index_pattern format map', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + fieldFormats: { + foo: { + id: 'bar', + params: { + baz: 'qux', + }, + }, }, }, - }, - }, - }); + }); - expect(response1.status).to.be(200); - expect(response1.body.index_pattern.fieldFormats.foo).to.eql({ - id: 'bar', - params: { - baz: 'qux', - }, - }); - - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/fields`) - .send({ - fields: { - foo: { - format: { - id: 'bar-2', - params: { baz: 'qux-2' }, - }, + expect(response1.status).to.be(200); + expect(response1.body[config.serviceKey].fieldFormats.foo).to.eql({ + id: 'bar', + params: { + baz: 'qux', }, - }, + }); + + const response2 = await supertest + .post(`${config.path}/${response1.body[config.serviceKey].id}/fields`) + .send({ + fields: { + foo: { + format: { + id: 'bar-2', + params: { baz: 'qux-2' }, + }, + }, + }, + }); + + expect(response2.status).to.be(200); + expect(response2.body[config.serviceKey].fieldFormats.foo).to.eql({ + id: 'bar-2', + params: { baz: 'qux-2' }, + }); + + const response3 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); + + expect(response3.status).to.be(200); + expect(response3.body[config.serviceKey].fieldFormats.foo).to.eql({ + id: 'bar-2', + params: { baz: 'qux-2' }, + }); }); - expect(response2.status).to.be(200); - expect(response2.body.index_pattern.fieldFormats.foo).to.eql({ - id: 'bar-2', - params: { baz: 'qux-2' }, - }); + it('can remove "format" attribute from index_pattern format map', async () => { + const response2 = await supertest + .post(`${config.path}/${indexPattern.id}/fields`) + .send({ + fields: { + foo: { + format: null, + }, + }, + }); - const response3 = await supertest.get( - `/api/index_patterns/index_pattern/${response1.body.index_pattern.id}` - ); + expect(response2.status).to.be(200); + expect(response2.body[config.serviceKey].fieldFormats.foo).to.be(undefined); - expect(response3.status).to.be(200); - expect(response3.body.index_pattern.fieldFormats.foo).to.eql({ - id: 'bar-2', - params: { baz: 'qux-2' }, - }); - }); + const response3 = await supertest.get(`${config.path}/${indexPattern.id}`); - it('can remove "format" attribute from index_pattern format map', async () => { - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${indexPattern.id}/fields`) - .send({ - fields: { - foo: { - format: null, - }, - }, + expect(response3.status).to.be(200); + expect(response3.body[config.serviceKey].fieldFormats.foo).to.be(undefined); }); - expect(response2.status).to.be(200); - expect(response2.body.index_pattern.fieldFormats.foo).to.be(undefined); - - const response3 = await supertest.get( - `/api/index_patterns/index_pattern/${indexPattern.id}` - ); - - expect(response3.status).to.be(200); - expect(response3.body.index_pattern.fieldFormats.foo).to.be(undefined); - }); - - it('can set field "format" on an existing field', async () => { - const title = indexPattern.title; - await supertest.delete(`/api/index_patterns/index_pattern/${indexPattern.id}`); - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - fields: { - foo: { - name: 'foo', - type: 'string', - scripted: true, - format: { - id: 'string', + it('can set field "format" on an existing field', async () => { + const title = indexPattern.title; + await supertest.delete(`${config.path}/${indexPattern.id}`); + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + fields: { + foo: { + name: 'foo', + type: 'string', + scripted: true, + format: { + id: 'string', + }, + }, }, }, - }, - }, - }); - - expect(response1.status).to.be(200); - expect(response1.body.index_pattern.fieldFormats.foo).to.be(undefined); - expect(response1.body.index_pattern.fields.foo.format).to.eql({ - id: 'string', - }); - - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/fields`) - .send({ - fields: { - foo: { - format: { id: 'number' }, - }, - }, + }); + + expect(response1.status).to.be(200); + expect(response1.body[config.serviceKey].fieldFormats.foo).to.be(undefined); + expect(response1.body[config.serviceKey].fields.foo.format).to.eql({ + id: 'string', + }); + + const response2 = await supertest + .post(`${config.path}/${response1.body[config.serviceKey].id}/fields`) + .send({ + fields: { + foo: { + format: { id: 'number' }, + }, + }, + }); + + expect(response2.status).to.be(200); + expect(response2.body[config.serviceKey].fieldFormats.foo).to.eql({ + id: 'number', + }); + expect(response2.body[config.serviceKey].fields.foo.format).to.eql({ + id: 'number', + }); + + const response3 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); + + expect(response3.status).to.be(200); + expect(response3.body[config.serviceKey].fieldFormats.foo).to.eql({ + id: 'number', + }); + expect(response3.body[config.serviceKey].fields.foo.format).to.eql({ + id: 'number', + }); }); - - expect(response2.status).to.be(200); - expect(response2.body.index_pattern.fieldFormats.foo).to.eql({ - id: 'number', - }); - expect(response2.body.index_pattern.fields.foo.format).to.eql({ - id: 'number', - }); - - const response3 = await supertest.get( - `/api/index_patterns/index_pattern/${response1.body.index_pattern.id}` - ); - - expect(response3.status).to.be(200); - expect(response3.body.index_pattern.fieldFormats.foo).to.eql({ - id: 'number', - }); - expect(response3.body.index_pattern.fields.foo.format).to.eql({ - id: 'number', }); }); }); diff --git a/test/api_integration/apis/index_patterns/has_user_index_pattern/has_user_index_pattern.ts b/test/api_integration/apis/index_patterns/has_user_index_pattern/has_user_index_pattern.ts index 8fc4e860e5d9c..75ac7086bec53 100644 --- a/test/api_integration/apis/index_patterns/has_user_index_pattern/has_user_index_pattern.ts +++ b/test/api_integration/apis/index_patterns/has_user_index_pattern/has_user_index_pattern.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; +import { configArray } from '../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -15,125 +16,133 @@ export default function ({ getService }: FtrProviderContext) { const es = getService('es'); describe('has user index pattern API', () => { - beforeEach(async () => { - await esArchiver.emptyKibanaIndex(); - if (await es.indices.exists({ index: 'metrics-test' })) { - await es.indices.delete({ index: 'metrics-test' }); - } - - if (await es.indices.exists({ index: 'logs-test' })) { - await es.indices.delete({ index: 'logs-test' }); - } - }); - - it('should return false if no index patterns', async () => { - const response = await supertest.get('/api/index_patterns/has_user_index_pattern'); - expect(response.status).to.be(200); - expect(response.body.result).to.be(false); - }); - - it('should return true if has index pattern with user data', async () => { - await esArchiver.load('test/api_integration/fixtures/es_archiver/index_patterns/basic_index'); - await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title: 'basic_index', - }, - }); - - const response = await supertest.get('/api/index_patterns/has_user_index_pattern'); - expect(response.status).to.be(200); - expect(response.body.result).to.be(true); - - await esArchiver.unload( - 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' - ); - }); - - it('should return true if has user index pattern without data', async () => { - await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title: 'basic_index', - allowNoIndex: true, - }, - }); - - const response = await supertest.get('/api/index_patterns/has_user_index_pattern'); - expect(response.status).to.be(200); - expect(response.body.result).to.be(true); - }); - - it('should return false if only metric-* index pattern without data', async () => { - await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title: 'metrics-*', - allowNoIndex: true, - }, + configArray.forEach((config) => { + describe(config.name, () => { + beforeEach(async () => { + await esArchiver.emptyKibanaIndex(); + if (await es.indices.exists({ index: 'metrics-test' })) { + await es.indices.delete({ index: 'metrics-test' }); + } + + if (await es.indices.exists({ index: 'logs-test' })) { + await es.indices.delete({ index: 'logs-test' }); + } + }); + + const servicePath = `${config.basePath}/has_user_${config.serviceKey}`; + + it('should return false if no index patterns', async () => { + const response = await supertest.get(servicePath); + expect(response.status).to.be(200); + expect(response.body.result).to.be(false); + }); + + it('should return true if has index pattern with user data', async () => { + await esArchiver.load( + 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' + ); + await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title: 'basic_index', + }, + }); + + const response = await supertest.get(servicePath); + expect(response.status).to.be(200); + expect(response.body.result).to.be(true); + + await esArchiver.unload( + 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' + ); + }); + + it('should return true if has user index pattern without data', async () => { + await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title: 'basic_index', + allowNoIndex: true, + }, + }); + + const response = await supertest.get(servicePath); + expect(response.status).to.be(200); + expect(response.body.result).to.be(true); + }); + + it('should return false if only metric-* index pattern without data', async () => { + await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title: 'metrics-*', + allowNoIndex: true, + }, + }); + + const response = await supertest.get(servicePath); + expect(response.status).to.be(200); + expect(response.body.result).to.be(false); + }); + + it('should return true if metric-* index pattern with user data', async () => { + await es.index({ + index: 'metrics-test', + body: { + foo: 'bar', + }, + }); + + await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title: 'metrics-*', + }, + }); + + const response = await supertest.get(servicePath); + expect(response.status).to.be(200); + expect(response.body.result).to.be(true); + }); + + it('should return false if only logs-* index pattern without data', async () => { + await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title: 'logs-*', + }, + }); + + const response = await supertest.get(servicePath); + expect(response.status).to.be(200); + expect(response.body.result).to.be(false); + }); + + it('should return true if logs-* index pattern with user data', async () => { + await es.index({ + index: 'logs-test', + body: { + foo: 'bar', + }, + }); + + await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title: 'logs-*', + }, + }); + + const response = await supertest.get(servicePath); + expect(response.status).to.be(200); + expect(response.body.result).to.be(true); + }); + + // TODO: should setup fleet first similar to x-pack/test/fleet_functional/apps/home/welcome.ts + // but it is skipped due to flakiness https://github.com/elastic/kibana/issues/109017 + it('should return false if logs-* with .ds-logs-elastic_agent only'); + it('should return false if metrics-* with .ds-metrics-elastic_agent only'); }); - - const response = await supertest.get('/api/index_patterns/has_user_index_pattern'); - expect(response.status).to.be(200); - expect(response.body.result).to.be(false); }); - - it('should return true if metric-* index pattern with user data', async () => { - await es.index({ - index: 'metrics-test', - body: { - foo: 'bar', - }, - }); - - await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title: 'metrics-*', - }, - }); - - const response = await supertest.get('/api/index_patterns/has_user_index_pattern'); - expect(response.status).to.be(200); - expect(response.body.result).to.be(true); - }); - - it('should return false if only logs-* index pattern without data', async () => { - await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title: 'logs-*', - }, - }); - - const response = await supertest.get('/api/index_patterns/has_user_index_pattern'); - expect(response.status).to.be(200); - expect(response.body.result).to.be(false); - }); - - it('should return true if logs-* index pattern with user data', async () => { - await es.index({ - index: 'logs-test', - body: { - foo: 'bar', - }, - }); - - await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title: 'logs-*', - }, - }); - - const response = await supertest.get('/api/index_patterns/has_user_index_pattern'); - expect(response.status).to.be(200); - expect(response.body.result).to.be(true); - }); - - // TODO: should setup fleet first similar to x-pack/test/fleet_functional/apps/home/welcome.ts - // but it is skipped due to flakiness https://github.com/elastic/kibana/issues/109017 - it('should return false if logs-* with .ds-logs-elastic_agent only'); - it('should return false if metrics-* with .ds-metrics-elastic_agent only'); }); } diff --git a/test/api_integration/apis/index_patterns/index_pattern_crud/create_index_pattern/main.ts b/test/api_integration/apis/index_patterns/index_pattern_crud/create_index_pattern/main.ts index 500a642f60850..43c9696fd11f8 100644 --- a/test/api_integration/apis/index_patterns/index_pattern_crud/create_index_pattern/main.ts +++ b/test/api_integration/apis/index_patterns/index_pattern_crud/create_index_pattern/main.ts @@ -8,276 +8,286 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - describe('main', () => { - it('can create an index_pattern with just a title', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - }, - }); - - expect(response.status).to.be(200); - }); - - it('returns back the created index_pattern object', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - }, - }); - - expect(typeof response.body.index_pattern).to.be('object'); - expect(response.body.index_pattern.title).to.be(title); - expect(typeof response.body.index_pattern.id).to.be('string'); - expect(response.body.index_pattern.id.length > 0).to.be(true); - }); - - it('can specify primitive optional attributes when creating an index pattern', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const id = `test-id-${Date.now()}-${Math.random()}*`; - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - id, - type: 'test-type', - timeFieldName: 'test-timeFieldName', - }, - }); - - expect(response.status).to.be(200); - expect(response.body.index_pattern.title).to.be(title); - expect(response.body.index_pattern.id).to.be(id); - expect(response.body.index_pattern.type).to.be('test-type'); - expect(response.body.index_pattern.timeFieldName).to.be('test-timeFieldName'); - }); - - it('can specify optional sourceFilters attribute when creating an index pattern', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - sourceFilters: [ - { - value: 'foo', + configArray.forEach((config) => { + describe(config.name, () => { + it('can create an index_pattern with just a title', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, }, - ], - }, - }); + }); - expect(response.status).to.be(200); - expect(response.body.index_pattern.title).to.be(title); - expect(response.body.index_pattern.sourceFilters[0].value).to.be('foo'); - }); + expect(response.status).to.be(200); + }); - describe('creating fields', () => { - before(async () => { - await esArchiver.load( - 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' - ); - }); + it('returns back the created index_pattern object', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + }, + }); - after(async () => { - await esArchiver.unload( - 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' - ); - }); + expect(typeof response.body[config.serviceKey]).to.be('object'); + expect(response.body[config.serviceKey].title).to.be(title); + expect(typeof response.body[config.serviceKey].id).to.be('string'); + expect(response.body[config.serviceKey].id.length > 0).to.be(true); + }); - it('can specify optional fields attribute when creating an index pattern', async () => { - const title = `basic_index*`; - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - fields: { - foo: { - name: 'foo', - type: 'string', - scripted: true, - script: "doc['field_name'].value", - }, + it('can specify primitive optional attributes when creating an index pattern', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const id = `test-id-${Date.now()}-${Math.random()}*`; + const response = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + id, + type: 'test-type', + timeFieldName: 'test-timeFieldName', }, - }, + }); + + expect(response.status).to.be(200); + expect(response.body[config.serviceKey].title).to.be(title); + expect(response.body[config.serviceKey].id).to.be(id); + expect(response.body[config.serviceKey].type).to.be('test-type'); + expect(response.body[config.serviceKey].timeFieldName).to.be('test-timeFieldName'); }); - expect(response.status).to.be(200); - expect(response.body.index_pattern.title).to.be(title); - expect(response.body.index_pattern.fields.foo.name).to.be('foo'); - expect(response.body.index_pattern.fields.foo.type).to.be('string'); - expect(response.body.index_pattern.fields.foo.scripted).to.be(true); - expect(response.body.index_pattern.fields.foo.script).to.be("doc['field_name'].value"); + it('can specify optional sourceFilters attribute when creating an index pattern', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + sourceFilters: [ + { + value: 'foo', + }, + ], + }, + }); - expect(response.body.index_pattern.fields.bar.name).to.be('bar'); // created from es index - expect(response.body.index_pattern.fields.bar.type).to.be('boolean'); - }); + expect(response.status).to.be(200); + expect(response.body[config.serviceKey].title).to.be(title); + expect(response.body[config.serviceKey].sourceFilters[0].value).to.be('foo'); + }); - it('can add scripted fields, other fields created from es index', async () => { - const title = `basic_index*`; - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - fields: { - foo: { - name: 'foo', - type: 'string', + describe('creating fields', () => { + before(async () => { + await esArchiver.load( + 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' + ); + }); + + after(async () => { + await esArchiver.unload( + 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' + ); + }); + + it('can specify optional fields attribute when creating an index pattern', async () => { + const title = `basic_index*`; + const response = await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title, + fields: { + foo: { + name: 'foo', + type: 'string', + scripted: true, + script: "doc['field_name'].value", + }, + }, }, - fake: { - name: 'fake', - type: 'string', + }); + + expect(response.status).to.be(200); + expect(response.body[config.serviceKey].title).to.be(title); + expect(response.body[config.serviceKey].fields.foo.name).to.be('foo'); + expect(response.body[config.serviceKey].fields.foo.type).to.be('string'); + expect(response.body[config.serviceKey].fields.foo.scripted).to.be(true); + expect(response.body[config.serviceKey].fields.foo.script).to.be( + "doc['field_name'].value" + ); + + expect(response.body[config.serviceKey].fields.bar.name).to.be('bar'); // created from es index + expect(response.body[config.serviceKey].fields.bar.type).to.be('boolean'); + }); + + it('can add scripted fields, other fields created from es index', async () => { + const title = `basic_index*`; + const response = await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title, + fields: { + foo: { + name: 'foo', + type: 'string', + }, + fake: { + name: 'fake', + type: 'string', + }, + bar: { + name: 'bar', + type: 'number', + count: 123, + script: '', + esTypes: ['test-type'], + scripted: true, + }, + }, }, - bar: { - name: 'bar', - type: 'number', - count: 123, - script: '', - esTypes: ['test-type'], - scripted: true, + }); + + expect(response.status).to.be(200); + expect(response.body[config.serviceKey].title).to.be(title); + + expect(response.body[config.serviceKey].fields.foo.name).to.be('foo'); + expect(response.body[config.serviceKey].fields.foo.type).to.be('number'); // picked up from index + + expect(response.body[config.serviceKey].fields.fake).to.be(undefined); // not in index, so not created + + expect(response.body[config.serviceKey].fields.bar.name).to.be('bar'); + expect(response.body[config.serviceKey].fields.bar.type).to.be('number'); + expect(response.body[config.serviceKey].fields.bar.count).to.be(123); + expect(response.body[config.serviceKey].fields.bar.script).to.be(''); + expect(response.body[config.serviceKey].fields.bar.esTypes[0]).to.be('test-type'); + expect(response.body[config.serviceKey].fields.bar.scripted).to.be(true); + }); + + it('can add runtime fields', async () => { + const title = `basic_index*`; + const response = await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title, + runtimeFieldMap: { + runtimeFoo: { + type: 'keyword', + script: { + source: 'emit(doc["foo"].value)', + }, + }, + }, }, - }, - }, + }); + + expect(response.status).to.be(200); + expect(response.body[config.serviceKey].title).to.be(title); + + expect(response.body[config.serviceKey].runtimeFieldMap.runtimeFoo.type).to.be( + 'keyword' + ); + expect(response.body[config.serviceKey].runtimeFieldMap.runtimeFoo.script.source).to.be( + 'emit(doc["foo"].value)' + ); + }); }); - expect(response.status).to.be(200); - expect(response.body.index_pattern.title).to.be(title); - - expect(response.body.index_pattern.fields.foo.name).to.be('foo'); - expect(response.body.index_pattern.fields.foo.type).to.be('number'); // picked up from index - - expect(response.body.index_pattern.fields.fake).to.be(undefined); // not in index, so not created + it('can specify optional typeMeta attribute when creating an index pattern', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + typeMeta: {}, + }, + }); - expect(response.body.index_pattern.fields.bar.name).to.be('bar'); - expect(response.body.index_pattern.fields.bar.type).to.be('number'); - expect(response.body.index_pattern.fields.bar.count).to.be(123); - expect(response.body.index_pattern.fields.bar.script).to.be(''); - expect(response.body.index_pattern.fields.bar.esTypes[0]).to.be('test-type'); - expect(response.body.index_pattern.fields.bar.scripted).to.be(true); - }); + expect(response.status).to.be(200); + }); - it('can add runtime fields', async () => { - const title = `basic_index*`; - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - runtimeFieldMap: { - runtimeFoo: { - type: 'keyword', - script: { - source: 'emit(doc["foo"].value)', + it('can specify optional fieldFormats attribute when creating an index pattern', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + fieldFormats: { + foo: { + id: 'test-id', + params: {}, }, }, }, - }, - }); - - expect(response.status).to.be(200); - expect(response.body.index_pattern.title).to.be(title); - - expect(response.body.index_pattern.runtimeFieldMap.runtimeFoo.type).to.be('keyword'); - expect(response.body.index_pattern.runtimeFieldMap.runtimeFoo.script.source).to.be( - 'emit(doc["foo"].value)' - ); - }); - }); - - it('can specify optional typeMeta attribute when creating an index pattern', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - typeMeta: {}, - }, - }); - - expect(response.status).to.be(200); - }); - - it('can specify optional fieldFormats attribute when creating an index pattern', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - fieldFormats: { - foo: { - id: 'test-id', - params: {}, - }, - }, - }, - }); + }); - expect(response.status).to.be(200); - expect(response.body.index_pattern.fieldFormats.foo.id).to.be('test-id'); - expect(response.body.index_pattern.fieldFormats.foo.params).to.eql({}); - }); + expect(response.status).to.be(200); + expect(response.body[config.serviceKey].fieldFormats.foo.id).to.be('test-id'); + expect(response.body[config.serviceKey].fieldFormats.foo.params).to.eql({}); + }); - it('can specify optional fieldFormats attribute when creating an index pattern', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - fieldAttrs: { - foo: { - count: 123, - customLabel: 'test', + it('can specify optional fieldFormats attribute when creating an index pattern', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + fieldAttrs: { + foo: { + count: 123, + customLabel: 'test', + }, + }, }, - }, - }, - }); + }); - expect(response.status).to.be(200); - expect(response.body.index_pattern.fieldAttrs.foo.count).to.be(123); - expect(response.body.index_pattern.fieldAttrs.foo.customLabel).to.be('test'); - }); - - describe('when creating index pattern with existing title', () => { - it('returns error, by default', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - }, + expect(response.status).to.be(200); + expect(response.body[config.serviceKey].fieldAttrs.foo.count).to.be(123); + expect(response.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be('test'); }); - const response2 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - }, - }); - - expect(response1.status).to.be(200); - expect(response2.status).to.be(400); - }); - it('succeeds, override flag is set', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - timeFieldName: 'foo', - }, - }); - const response2 = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - timeFieldName: 'bar', - }, - }); + describe('when creating index pattern with existing title', () => { + it('returns error, by default', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + }, + }); + const response2 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + }, + }); + + expect(response1.status).to.be(200); + expect(response2.status).to.be(400); + }); + + it('succeeds, override flag is set', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + timeFieldName: 'foo', + }, + }); + const response2 = await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title, + timeFieldName: 'bar', + }, + }); - expect(response1.status).to.be(200); - expect(response2.status).to.be(200); + expect(response1.status).to.be(200); + expect(response2.status).to.be(200); - expect(response1.body.index_pattern.timeFieldName).to.be('foo'); - expect(response2.body.index_pattern.timeFieldName).to.be('bar'); + expect(response1.body[config.serviceKey].timeFieldName).to.be('foo'); + expect(response2.body[config.serviceKey].timeFieldName).to.be('bar'); - expect(response1.body.index_pattern.id).to.be(response1.body.index_pattern.id); + expect(response1.body[config.serviceKey].id).to.be( + response1.body[config.serviceKey].id + ); + }); + }); }); }); }); diff --git a/test/api_integration/apis/index_patterns/index_pattern_crud/create_index_pattern/validation.ts b/test/api_integration/apis/index_patterns/index_pattern_crud/create_index_pattern/validation.ts index 598001644eedb..536e366655c24 100644 --- a/test/api_integration/apis/index_patterns/index_pattern_crud/create_index_pattern/validation.ts +++ b/test/api_integration/apis/index_patterns/index_pattern_crud/create_index_pattern/validation.ts @@ -8,81 +8,86 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('validation', () => { - it('returns error when index_pattern object is not provided', async () => { - const response = await supertest.post('/api/index_patterns/index_pattern'); + configArray.forEach((config) => { + describe(config.name, () => { + it('returns error when index_pattern object is not provided', async () => { + const response = await supertest.post(config.path); - expect(response.status).to.be(400); - expect(response.body.statusCode).to.be(400); - expect(response.body.message).to.be( - '[request body]: expected a plain object value, but found [null] instead.' - ); - }); + expect(response.status).to.be(400); + expect(response.body.statusCode).to.be(400); + expect(response.body.message).to.be( + '[request body]: expected a plain object value, but found [null] instead.' + ); + }); - it('returns error on empty index_pattern object', async () => { - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: {}, - }); + it('returns error on empty index_pattern object', async () => { + const response = await supertest.post(config.path).send({ + [config.serviceKey]: {}, + }); - expect(response.status).to.be(400); - expect(response.body.statusCode).to.be(400); - expect(response.body.message).to.be( - '[request body.index_pattern.title]: expected value of type [string] but got [undefined]' - ); - }); + expect(response.status).to.be(400); + expect(response.body.statusCode).to.be(400); + expect(response.body.message).to.be( + `[request body.${config.serviceKey}.title]: expected value of type [string] but got [undefined]` + ); + }); - it('returns error when "override" parameter is not a boolean', async () => { - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - override: 123, - index_pattern: { - title: 'foo', - }, - }); + it('returns error when "override" parameter is not a boolean', async () => { + const response = await supertest.post(config.path).send({ + override: 123, + [config.serviceKey]: { + title: 'foo', + }, + }); - expect(response.status).to.be(400); - expect(response.body.statusCode).to.be(400); - expect(response.body.message).to.be( - '[request body.override]: expected value of type [boolean] but got [number]' - ); - }); + expect(response.status).to.be(400); + expect(response.body.statusCode).to.be(400); + expect(response.body.message).to.be( + '[request body.override]: expected value of type [boolean] but got [number]' + ); + }); - it('returns error when "refresh_fields" parameter is not a boolean', async () => { - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - refresh_fields: 123, - index_pattern: { - title: 'foo', - }, - }); + it('returns error when "refresh_fields" parameter is not a boolean', async () => { + const response = await supertest.post(config.path).send({ + refresh_fields: 123, + [config.serviceKey]: { + title: 'foo', + }, + }); - expect(response.status).to.be(400); - expect(response.body.statusCode).to.be(400); - expect(response.body.message).to.be( - '[request body.refresh_fields]: expected value of type [boolean] but got [number]' - ); - }); + expect(response.status).to.be(400); + expect(response.body.statusCode).to.be(400); + expect(response.body.message).to.be( + '[request body.refresh_fields]: expected value of type [boolean] but got [number]' + ); + }); - it('returns an error when unknown runtime field type', async () => { - const title = `basic_index*`; - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - runtimeFieldMap: { - runtimeFoo: { - type: 'wrong-type', - script: { - source: 'emit(doc["foo"].value)', + it('returns an error when unknown runtime field type', async () => { + const title = `basic_index*`; + const response = await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title, + runtimeFieldMap: { + runtimeFoo: { + type: 'wrong-type', + script: { + source: 'emit(doc["foo"].value)', + }, + }, }, }, - }, - }, - }); + }); - expect(response.status).to.be(400); + expect(response.status).to.be(400); + }); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/index_pattern_crud/delete_index_pattern/errors.ts b/test/api_integration/apis/index_patterns/index_pattern_crud/delete_index_pattern/errors.ts index c746f0ce59359..5a7a12fc80201 100644 --- a/test/api_integration/apis/index_patterns/index_pattern_crud/delete_index_pattern/errors.ts +++ b/test/api_integration/apis/index_patterns/index_pattern_crud/delete_index_pattern/errors.ts @@ -8,26 +8,31 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('errors', () => { - it('returns 404 error on non-existing index_pattern', async () => { - const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; - const response = await supertest.delete(`/api/index_patterns/index_pattern/${id}`); + configArray.forEach((config) => { + describe(config.name, () => { + it('returns 404 error on non-existing index_pattern', async () => { + const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; + const response = await supertest.delete(`${config.path}/${id}`); - expect(response.status).to.be(404); - }); + expect(response.status).to.be(404); + }); - it('returns error when ID is too long', async () => { - const id = `xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx`; - const response = await supertest.delete(`/api/index_patterns/index_pattern/${id}`); + it('returns error when ID is too long', async () => { + const id = `xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx`; + const response = await supertest.delete(`${config.path}/${id}`); - expect(response.status).to.be(400); - expect(response.body.message).to.be( - '[request params.id]: value has length [1759] but it must have a maximum length of [1000].' - ); + expect(response.status).to.be(400); + expect(response.body.message).to.be( + '[request params.id]: value has length [1759] but it must have a maximum length of [1000].' + ); + }); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/index_pattern_crud/delete_index_pattern/main.ts b/test/api_integration/apis/index_patterns/index_pattern_crud/delete_index_pattern/main.ts index c47f9269e4b15..fa82d745937bf 100644 --- a/test/api_integration/apis/index_patterns/index_pattern_crud/delete_index_pattern/main.ts +++ b/test/api_integration/apis/index_patterns/index_pattern_crud/delete_index_pattern/main.ts @@ -8,50 +8,55 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('main', () => { - it('deletes an index_pattern', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - }, + configArray.forEach((config) => { + describe(config.name, () => { + it('deletes an index_pattern', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + }, + }); + const response2 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); + + expect(response2.status).to.be(200); + + const response3 = await supertest.delete( + `${config.path}/${response1.body[config.serviceKey].id}` + ); + + expect(response3.status).to.be(200); + + const response4 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); + + expect(response4.status).to.be(404); + }); + + it('returns nothing', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + }, + }); + await supertest.get(`${config.path}/${response1.body[config.serviceKey].id}`); + const response2 = await supertest.delete( + `${config.path}/${response1.body[config.serviceKey].id}` + ); + + expect(!!response2.body).to.be(false); + }); }); - const response2 = await supertest.get( - '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id - ); - - expect(response2.status).to.be(200); - - const response3 = await supertest.delete( - '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id - ); - - expect(response3.status).to.be(200); - - const response4 = await supertest.get( - '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id - ); - - expect(response4.status).to.be(404); - }); - - it('returns nothing', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - }, - }); - await supertest.get('/api/index_patterns/index_pattern/' + response1.body.index_pattern.id); - const response2 = await supertest.delete( - '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id - ); - - expect(!!response2.body).to.be(false); }); }); } diff --git a/test/api_integration/apis/index_patterns/index_pattern_crud/get_index_pattern/errors.ts b/test/api_integration/apis/index_patterns/index_pattern_crud/get_index_pattern/errors.ts index 4c6cd783ca6f0..70c39288f0461 100644 --- a/test/api_integration/apis/index_patterns/index_pattern_crud/get_index_pattern/errors.ts +++ b/test/api_integration/apis/index_patterns/index_pattern_crud/get_index_pattern/errors.ts @@ -8,26 +8,31 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('errors', () => { - it('returns 404 error on non-existing index_pattern', async () => { - const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; - const response = await supertest.get(`/api/index_patterns/index_pattern/${id}`); + configArray.forEach((config) => { + describe(config.name, () => { + it('returns 404 error on non-existing index_pattern', async () => { + const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; + const response = await supertest.get(`${config.path}/${id}`); - expect(response.status).to.be(404); - }); + expect(response.status).to.be(404); + }); - it('returns error when ID is too long', async () => { - const id = `xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx`; - const response = await supertest.get(`/api/index_patterns/index_pattern/${id}`); + it('returns error when ID is too long', async () => { + const id = `xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx`; + const response = await supertest.get(`${config.path}/${id}`); - expect(response.status).to.be(400); - expect(response.body.message).to.be( - '[request params.id]: value has length [1759] but it must have a maximum length of [1000].' - ); + expect(response.status).to.be(400); + expect(response.body.message).to.be( + '[request params.id]: value has length [1759] but it must have a maximum length of [1000].' + ); + }); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/index_pattern_crud/get_index_pattern/main.ts b/test/api_integration/apis/index_patterns/index_pattern_crud/get_index_pattern/main.ts index 9d4ee4b740703..92d4f613d1247 100644 --- a/test/api_integration/apis/index_patterns/index_pattern_crud/get_index_pattern/main.ts +++ b/test/api_integration/apis/index_patterns/index_pattern_crud/get_index_pattern/main.ts @@ -8,23 +8,28 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('main', () => { - it('can retrieve an index_pattern', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - }, - }); - const response2 = await supertest.get( - '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id - ); + configArray.forEach((config) => { + describe(config.name, () => { + it('can retrieve an index_pattern', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + }, + }); + const response2 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); - expect(response2.body.index_pattern.title).to.be(title); + expect(response2.body[config.serviceKey].title).to.be(title); + }); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/index_pattern_crud/update_index_pattern/errors.ts b/test/api_integration/apis/index_patterns/index_pattern_crud/update_index_pattern/errors.ts index d8cfe902a5d10..54f61cba1cfbe 100644 --- a/test/api_integration/apis/index_patterns/index_pattern_crud/update_index_pattern/errors.ts +++ b/test/api_integration/apis/index_patterns/index_pattern_crud/update_index_pattern/errors.ts @@ -8,65 +8,68 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('errors', () => { - it('returns error when index_pattern object is not provided', async () => { - const response = await supertest.post('/api/index_patterns/index_pattern/foo'); + configArray.forEach((config) => { + describe(config.name, () => { + it('returns error when index_pattern object is not provided', async () => { + const response = await supertest.post(`${config.path}/foo`); - expect(response.status).to.be(400); - expect(response.body.statusCode).to.be(400); - expect(response.body.message).to.be( - '[request body]: expected a plain object value, but found [null] instead.' - ); - }); + expect(response.status).to.be(400); + expect(response.body.statusCode).to.be(400); + expect(response.body.message).to.be( + '[request body]: expected a plain object value, but found [null] instead.' + ); + }); + + it('returns error on non-existing index_pattern', async () => { + const response = await supertest.post(`${config.path}/non-existing-index-pattern`).send({ + [config.serviceKey]: {}, + }); - it('returns error on non-existing index_pattern', async () => { - const response = await supertest - .post('/api/index_patterns/index_pattern/non-existing-index-pattern') - .send({ - index_pattern: {}, + expect(response.status).to.be(404); + expect(response.body.statusCode).to.be(404); + expect(response.body.message).to.be( + 'Saved object [index-pattern/non-existing-index-pattern] not found' + ); }); - expect(response.status).to.be(404); - expect(response.body.statusCode).to.be(404); - expect(response.body.message).to.be( - 'Saved object [index-pattern/non-existing-index-pattern] not found' - ); - }); + it('returns error when "refresh_fields" parameter is not a boolean', async () => { + const response = await supertest.post(`${config.path}/foo`).send({ + refresh_fields: 123, + [config.serviceKey]: { + title: 'foo', + }, + }); - it('returns error when "refresh_fields" parameter is not a boolean', async () => { - const response = await supertest.post('/api/index_patterns/index_pattern/foo`').send({ - refresh_fields: 123, - index_pattern: { - title: 'foo', - }, - }); + expect(response.status).to.be(400); + expect(response.body.statusCode).to.be(400); + expect(response.body.message).to.be( + '[request body.refresh_fields]: expected value of type [boolean] but got [number]' + ); + }); - expect(response.status).to.be(400); - expect(response.body.statusCode).to.be(400); - expect(response.body.message).to.be( - '[request body.refresh_fields]: expected value of type [boolean] but got [number]' - ); - }); + it('returns error when update patch is empty', async () => { + const title1 = `foo-${Date.now()}-${Math.random()}*`; + const response = await supertest.post(config.path).send({ + [config.serviceKey]: { + title: title1, + }, + }); + const id = response.body[config.serviceKey].id; + const response2 = await supertest.post(`${config.path}/${id}`).send({ + [config.serviceKey]: {}, + }); - it('returns error when update patch is empty', async () => { - const title1 = `foo-${Date.now()}-${Math.random()}*`; - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title: title1, - }, - }); - const id = response.body.index_pattern.id; - const response2 = await supertest.post('/api/index_patterns/index_pattern/' + id).send({ - index_pattern: {}, + expect(response2.status).to.be(400); + expect(response2.body.statusCode).to.be(400); + expect(response2.body.message).to.be('Index pattern change set is empty.'); + }); }); - - expect(response2.status).to.be(400); - expect(response2.body.statusCode).to.be(400); - expect(response2.body.message).to.be('Index pattern change set is empty.'); }); }); } diff --git a/test/api_integration/apis/index_patterns/index_pattern_crud/update_index_pattern/main.ts b/test/api_integration/apis/index_patterns/index_pattern_crud/update_index_pattern/main.ts index 94ca63956e1f2..7548d09580f45 100644 --- a/test/api_integration/apis/index_patterns/index_pattern_crud/update_index_pattern/main.ts +++ b/test/api_integration/apis/index_patterns/index_pattern_crud/update_index_pattern/main.ts @@ -8,301 +8,312 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('main', () => { - it('can update index_pattern title', async () => { - const title1 = `foo-${Date.now()}-${Math.random()}*`; - const title2 = `bar-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title: title1, - }, - }); + configArray.forEach((config) => { + describe(config.name, () => { + it('can update index_pattern title', async () => { + const title1 = `foo-${Date.now()}-${Math.random()}*`; + const title2 = `bar-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title: title1, + }, + }); - expect(response1.body.index_pattern.title).to.be(title1); + expect(response1.body[config.serviceKey].title).to.be(title1); - const id = response1.body.index_pattern.id; - const response2 = await supertest.post('/api/index_patterns/index_pattern/' + id).send({ - index_pattern: { - title: title2, - }, - }); + const id = response1.body[config.serviceKey].id; + const response2 = await supertest.post(`${config.path}/${id}`).send({ + [config.serviceKey]: { + title: title2, + }, + }); - expect(response2.body.index_pattern.title).to.be(title2); + expect(response2.body[config.serviceKey].title).to.be(title2); - const response3 = await supertest.get('/api/index_patterns/index_pattern/' + id); + const response3 = await supertest.get(`${config.path}/${id}`); - expect(response3.body.index_pattern.title).to.be(title2); - }); + expect(response3.body[config.serviceKey].title).to.be(title2); + }); - it('can update index_pattern timeFieldName', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - timeFieldName: 'timeFieldName1', - }, - }); + it('can update index_pattern timeFieldName', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + timeFieldName: 'timeFieldName1', + }, + }); - expect(response1.body.index_pattern.timeFieldName).to.be('timeFieldName1'); + expect(response1.body[config.serviceKey].timeFieldName).to.be('timeFieldName1'); - const id = response1.body.index_pattern.id; - const response2 = await supertest.post('/api/index_patterns/index_pattern/' + id).send({ - index_pattern: { - timeFieldName: 'timeFieldName2', - }, - }); + const id = response1.body[config.serviceKey].id; + const response2 = await supertest.post(`${config.path}/${id}`).send({ + [config.serviceKey]: { + timeFieldName: 'timeFieldName2', + }, + }); - expect(response2.body.index_pattern.timeFieldName).to.be('timeFieldName2'); + expect(response2.body[config.serviceKey].timeFieldName).to.be('timeFieldName2'); - const response3 = await supertest.get('/api/index_patterns/index_pattern/' + id); + const response3 = await supertest.get(`${config.path}/${id}`); - expect(response3.body.index_pattern.timeFieldName).to.be('timeFieldName2'); - }); + expect(response3.body[config.serviceKey].timeFieldName).to.be('timeFieldName2'); + }); + + it('can update index_pattern sourceFilters', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + sourceFilters: [ + { + value: 'foo', + }, + ], + }, + }); - it('can update index_pattern sourceFilters', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - sourceFilters: [ + expect(response1.body[config.serviceKey].sourceFilters).to.eql([ { value: 'foo', }, - ], - }, - }); - - expect(response1.body.index_pattern.sourceFilters).to.eql([ - { - value: 'foo', - }, - ]); + ]); + + const id = response1.body[config.serviceKey].id; + const response2 = await supertest.post(`${config.path}/${id}`).send({ + [config.serviceKey]: { + sourceFilters: [ + { + value: 'bar', + }, + { + value: 'baz', + }, + ], + }, + }); - const id = response1.body.index_pattern.id; - const response2 = await supertest.post('/api/index_patterns/index_pattern/' + id).send({ - index_pattern: { - sourceFilters: [ + expect(response2.body[config.serviceKey].sourceFilters).to.eql([ { value: 'bar', }, { value: 'baz', }, - ], - }, - }); + ]); - expect(response2.body.index_pattern.sourceFilters).to.eql([ - { - value: 'bar', - }, - { - value: 'baz', - }, - ]); - - const response3 = await supertest.get('/api/index_patterns/index_pattern/' + id); - - expect(response3.body.index_pattern.sourceFilters).to.eql([ - { - value: 'bar', - }, - { - value: 'baz', - }, - ]); - }); + const response3 = await supertest.get(`${config.path}/${id}`); - it('can update index_pattern fieldFormats', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - fieldFormats: { + expect(response3.body[config.serviceKey].sourceFilters).to.eql([ + { + value: 'bar', + }, + { + value: 'baz', + }, + ]); + }); + + it('can update index_pattern fieldFormats', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + fieldFormats: { + foo: { + id: 'foo', + params: { + bar: 'baz', + }, + }, + }, + }, + }); + + expect(response1.body[config.serviceKey].fieldFormats).to.eql({ foo: { id: 'foo', params: { bar: 'baz', }, }, - }, - }, - }); - - expect(response1.body.index_pattern.fieldFormats).to.eql({ - foo: { - id: 'foo', - params: { - bar: 'baz', - }, - }, - }); + }); + + const id = response1.body[config.serviceKey].id; + const response2 = await supertest.post(`${config.path}/${id}`).send({ + [config.serviceKey]: { + fieldFormats: { + a: { + id: 'a', + params: { + b: 'v', + }, + }, + }, + }, + }); - const id = response1.body.index_pattern.id; - const response2 = await supertest.post('/api/index_patterns/index_pattern/' + id).send({ - index_pattern: { - fieldFormats: { + expect(response2.body[config.serviceKey].fieldFormats).to.eql({ a: { id: 'a', params: { b: 'v', }, }, - }, - }, - }); - - expect(response2.body.index_pattern.fieldFormats).to.eql({ - a: { - id: 'a', - params: { - b: 'v', - }, - }, - }); - - const response3 = await supertest.get('/api/index_patterns/index_pattern/' + id); - - expect(response3.body.index_pattern.fieldFormats).to.eql({ - a: { - id: 'a', - params: { - b: 'v', - }, - }, - }); - }); + }); - it('can update index_pattern type', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - type: 'foo', - }, - }); - - expect(response1.body.index_pattern.type).to.be('foo'); - - const id = response1.body.index_pattern.id; - const response2 = await supertest.post('/api/index_patterns/index_pattern/' + id).send({ - index_pattern: { - type: 'bar', - }, - }); + const response3 = await supertest.get(`${config.path}/${id}`); - expect(response2.body.index_pattern.type).to.be('bar'); + expect(response3.body[config.serviceKey].fieldFormats).to.eql({ + a: { + id: 'a', + params: { + b: 'v', + }, + }, + }); + }); + + it('can update index_pattern type', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + type: 'foo', + }, + }); - const response3 = await supertest.get('/api/index_patterns/index_pattern/' + id); + expect(response1.body[config.serviceKey].type).to.be('foo'); - expect(response3.body.index_pattern.type).to.be('bar'); - }); - - it('can update index_pattern typeMeta', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - typeMeta: { foo: 'bar' }, - }, - }); + const id = response1.body[config.serviceKey].id; + const response2 = await supertest.post(`${config.path}/${id}`).send({ + [config.serviceKey]: { + type: 'bar', + }, + }); - expect(response1.body.index_pattern.typeMeta).to.eql({ foo: 'bar' }); + expect(response2.body[config.serviceKey].type).to.be('bar'); - const id = response1.body.index_pattern.id; - const response2 = await supertest.post('/api/index_patterns/index_pattern/' + id).send({ - index_pattern: { - typeMeta: { foo: 'baz' }, - }, - }); + const response3 = await supertest.get(`${config.path}/${id}`); - expect(response2.body.index_pattern.typeMeta).to.eql({ foo: 'baz' }); + expect(response3.body[config.serviceKey].type).to.be('bar'); + }); - const response3 = await supertest.get('/api/index_patterns/index_pattern/' + id); + it('can update index_pattern typeMeta', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + typeMeta: { foo: 'bar' }, + }, + }); - expect(response3.body.index_pattern.typeMeta).to.eql({ foo: 'baz' }); - }); + expect(response1.body[config.serviceKey].typeMeta).to.eql({ foo: 'bar' }); - it('can update multiple index pattern fields at once', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - timeFieldName: 'timeFieldName1', - typeMeta: { foo: 'bar' }, - }, - }); + const id = response1.body[config.serviceKey].id; + const response2 = await supertest.post(`${config.path}/${id}`).send({ + [config.serviceKey]: { + typeMeta: { foo: 'baz' }, + }, + }); - expect(response1.body.index_pattern.timeFieldName).to.be('timeFieldName1'); - expect(response1.body.index_pattern.typeMeta.foo).to.be('bar'); + expect(response2.body[config.serviceKey].typeMeta).to.eql({ foo: 'baz' }); - const id = response1.body.index_pattern.id; - const response2 = await supertest.post('/api/index_patterns/index_pattern/' + id).send({ - index_pattern: { - timeFieldName: 'timeFieldName2', - typeMeta: { baz: 'qux' }, - }, - }); + const response3 = await supertest.get(`${config.path}/${id}`); - expect(response2.body.index_pattern.timeFieldName).to.be('timeFieldName2'); - expect(response2.body.index_pattern.typeMeta.baz).to.be('qux'); + expect(response3.body[config.serviceKey].typeMeta).to.eql({ foo: 'baz' }); + }); - const response3 = await supertest.get('/api/index_patterns/index_pattern/' + id); + it('can update multiple index pattern fields at once', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + timeFieldName: 'timeFieldName1', + typeMeta: { foo: 'bar' }, + }, + }); - expect(response3.body.index_pattern.timeFieldName).to.be('timeFieldName2'); - expect(response3.body.index_pattern.typeMeta.baz).to.be('qux'); - }); + expect(response1.body[config.serviceKey].timeFieldName).to.be('timeFieldName1'); + expect(response1.body[config.serviceKey].typeMeta.foo).to.be('bar'); - it('can update runtime fields', async () => { - const title = `basic_index*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - runtimeFieldMap: { - runtimeFoo: { - type: 'keyword', - script: { - source: 'emit(doc["foo"].value)', + const id = response1.body[config.serviceKey].id; + const response2 = await supertest.post(`${config.path}/${id}`).send({ + [config.serviceKey]: { + timeFieldName: 'timeFieldName2', + typeMeta: { baz: 'qux' }, + }, + }); + + expect(response2.body[config.serviceKey].timeFieldName).to.be('timeFieldName2'); + expect(response2.body[config.serviceKey].typeMeta.baz).to.be('qux'); + + const response3 = await supertest.get(`${config.path}/${id}`); + + expect(response3.body[config.serviceKey].timeFieldName).to.be('timeFieldName2'); + expect(response3.body[config.serviceKey].typeMeta.baz).to.be('qux'); + }); + + it('can update runtime fields', async () => { + const title = `basic_index*`; + const response1 = await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title, + runtimeFieldMap: { + runtimeFoo: { + type: 'keyword', + script: { + source: 'emit(doc["foo"].value)', + }, + }, }, }, - }, - }, - }); - - expect(response1.status).to.be(200); - expect(response1.body.index_pattern.title).to.be(title); - - expect(response1.body.index_pattern.runtimeFieldMap.runtimeFoo.type).to.be('keyword'); - expect(response1.body.index_pattern.runtimeFieldMap.runtimeFoo.script.source).to.be( - 'emit(doc["foo"].value)' - ); - - const id = response1.body.index_pattern.id; - const response2 = await supertest.post('/api/index_patterns/index_pattern/' + id).send({ - index_pattern: { - runtimeFieldMap: { - runtimeBar: { - type: 'keyword', - script: { - source: 'emit(doc["foo"].value)', + }); + + expect(response1.status).to.be(200); + expect(response1.body[config.serviceKey].title).to.be(title); + + expect(response1.body[config.serviceKey].runtimeFieldMap.runtimeFoo.type).to.be( + 'keyword' + ); + expect(response1.body[config.serviceKey].runtimeFieldMap.runtimeFoo.script.source).to.be( + 'emit(doc["foo"].value)' + ); + + const id = response1.body[config.serviceKey].id; + const response2 = await supertest.post(`${config.path}/${id}`).send({ + [config.serviceKey]: { + runtimeFieldMap: { + runtimeBar: { + type: 'keyword', + script: { + source: 'emit(doc["foo"].value)', + }, + }, }, }, - }, - }, - }); + }); - expect(response2.body.index_pattern.runtimeFieldMap.runtimeBar.type).to.be('keyword'); - expect(response2.body.index_pattern.runtimeFieldMap.runtimeFoo).to.be(undefined); + expect(response2.body[config.serviceKey].runtimeFieldMap.runtimeBar.type).to.be( + 'keyword' + ); + expect(response2.body[config.serviceKey].runtimeFieldMap.runtimeFoo).to.be(undefined); - const response3 = await supertest.get('/api/index_patterns/index_pattern/' + id); + const response3 = await supertest.get(`${config.path}/${id}`); - expect(response3.body.index_pattern.runtimeFieldMap.runtimeBar.type).to.be('keyword'); - expect(response3.body.index_pattern.runtimeFieldMap.runtimeFoo).to.be(undefined); + expect(response3.body[config.serviceKey].runtimeFieldMap.runtimeBar.type).to.be( + 'keyword' + ); + expect(response3.body[config.serviceKey].runtimeFieldMap.runtimeFoo).to.be(undefined); + }); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/runtime_fields_crud/create_runtime_field/errors.ts b/test/api_integration/apis/index_patterns/runtime_fields_crud/create_runtime_field/errors.ts index 8ce9e3b36b5c8..3d53588e2fb37 100644 --- a/test/api_integration/apis/index_patterns/runtime_fields_crud/create_runtime_field/errors.ts +++ b/test/api_integration/apis/index_patterns/runtime_fields_crud/create_runtime_field/errors.ts @@ -8,28 +8,31 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('errors', () => { - it('returns an error field object is not provided', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - }, - }); - const id = response1.body.index_pattern.id; - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${id}/runtime_field`) - .send({}); + configArray.forEach((config) => { + describe(config.name, () => { + it('returns an error field object is not provided', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + }, + }); + const id = response1.body[config.serviceKey].id; + const response2 = await supertest.post(`${config.path}/${id}/runtime_field`).send({}); - expect(response2.status).to.be(400); - expect(response2.body.statusCode).to.be(400); - expect(response2.body.message).to.be( - '[request body.name]: expected value of type [string] but got [undefined]' - ); + expect(response2.status).to.be(400); + expect(response2.body.statusCode).to.be(400); + expect(response2.body.message).to.be( + '[request body.name]: expected value of type [string] but got [undefined]' + ); + }); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/runtime_fields_crud/create_runtime_field/main.ts b/test/api_integration/apis/index_patterns/runtime_fields_crud/create_runtime_field/main.ts index e262b9d838e97..f9dd5bd7105b4 100644 --- a/test/api_integration/apis/index_patterns/runtime_fields_crud/create_runtime_field/main.ts +++ b/test/api_integration/apis/index_patterns/runtime_fields_crud/create_runtime_field/main.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -24,72 +25,73 @@ export default function ({ getService }: FtrProviderContext) { ); }); - it('can create a new runtime field', async () => { - const title = `basic_index*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - }, - }); - const id = response1.body.index_pattern.id; - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${id}/runtime_field`) - .send({ - name: 'runtimeBar', - runtimeField: { - type: 'long', - script: { - source: "emit(doc['field_name'].value)", + configArray.forEach((config) => { + describe(config.name, () => { + it('can create a new runtime field', async () => { + const title = `basic_index*`; + const response1 = await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title, }, - }, - }); + }); + const id = response1.body[config.serviceKey].id; + const response2 = await supertest.post(`${config.path}/${id}/runtime_field`).send({ + name: 'runtimeBar', + runtimeField: { + type: 'long', + script: { + source: "emit(doc['field_name'].value)", + }, + }, + }); - expect(response2.status).to.be(200); - expect(response2.body.field.name).to.be('runtimeBar'); - expect(response2.body.field.runtimeField.type).to.be('long'); - expect(response2.body.field.runtimeField.script.source).to.be( - "emit(doc['field_name'].value)" - ); - expect(response2.body.field.scripted).to.be(false); - }); + expect(response2.status).to.be(200); + const field = + config.serviceKey === 'index_pattern' ? response2.body.field : response2.body.fields[0]; - it('newly created runtime field is available in the index_pattern object', async () => { - const title = `basic_index`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - }, - }); + expect(field.name).to.be('runtimeBar'); + expect(field.runtimeField.type).to.be('long'); + expect(field.runtimeField.script.source).to.be("emit(doc['field_name'].value)"); + expect(field.scripted).to.be(false); + }); - await supertest - .post(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/runtime_field`) - .send({ - name: 'runtimeBar', - runtimeField: { - type: 'long', - script: { - source: "emit(doc['field_name'].value)", + it('newly created runtime field is available in the index_pattern object', async () => { + const title = `basic_index`; + const response1 = await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title, }, - }, - }); + }); - const response2 = await supertest.get( - '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id - ); + await supertest + .post(`${config.path}/${response1.body[config.serviceKey].id}/runtime_field`) + .send({ + name: 'runtimeBar', + runtimeField: { + type: 'long', + script: { + source: "emit(doc['field_name'].value)", + }, + }, + }); - expect(response2.status).to.be(200); + const response2 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); - const field = response2.body.index_pattern.fields.runtimeBar; + expect(response2.status).to.be(200); - expect(field.name).to.be('runtimeBar'); - expect(field.runtimeField.type).to.be('long'); - expect(field.runtimeField.script.source).to.be("emit(doc['field_name'].value)"); - expect(field.scripted).to.be(false); - await supertest.delete( - '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id - ); + const field = response2.body[config.serviceKey].fields.runtimeBar; + + expect(field.name).to.be('runtimeBar'); + expect(field.runtimeField.type).to.be('long'); + expect(field.runtimeField.script.source).to.be("emit(doc['field_name'].value)"); + expect(field.scripted).to.be(false); + await supertest.delete(`${config.path}/${response1.body[config.serviceKey].id}`); + }); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/runtime_fields_crud/delete_runtime_field/errors.ts b/test/api_integration/apis/index_patterns/runtime_fields_crud/delete_runtime_field/errors.ts index b41a630889ff8..54c982ec7f325 100644 --- a/test/api_integration/apis/index_patterns/runtime_fields_crud/delete_runtime_field/errors.ts +++ b/test/api_integration/apis/index_patterns/runtime_fields_crud/delete_runtime_field/errors.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); @@ -17,65 +18,67 @@ export default function ({ getService }: FtrProviderContext) { const basicIndex = 'b*sic_index'; let indexPattern: any; - before(async () => { - await esArchiver.load('test/api_integration/fixtures/es_archiver/index_patterns/basic_index'); + configArray.forEach((config) => { + describe(config.name, () => { + before(async () => { + await esArchiver.load( + 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' + ); - indexPattern = ( - await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title: basicIndex, - }, - }) - ).body.index_pattern; - }); + indexPattern = ( + await supertest.post(config.path).send({ + [config.serviceKey]: { + title: basicIndex, + }, + }) + ).body[config.serviceKey]; + }); - after(async () => { - await esArchiver.unload( - 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' - ); + after(async () => { + await esArchiver.unload( + 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' + ); - if (indexPattern) { - await supertest.delete('/api/index_patterns/index_pattern/' + indexPattern.id); - } - }); + if (indexPattern) { + await supertest.delete(`${config.path}/${indexPattern.id}`); + } + }); - it('returns 404 error on non-existing index_pattern', async () => { - const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; - const response = await supertest.delete( - `/api/index_patterns/index_pattern/${id}/runtime_field/foo` - ); + it('returns 404 error on non-existing index_pattern', async () => { + const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; + const response = await supertest.delete(`${config.path}/${id}/runtime_field/foo`); - expect(response.status).to.be(404); - }); + expect(response.status).to.be(404); + }); - it('returns 404 error on non-existing runtime field', async () => { - const response1 = await supertest.delete( - `/api/index_patterns/index_pattern/${indexPattern.id}/runtime_field/test` - ); + it('returns 404 error on non-existing runtime field', async () => { + const response1 = await supertest.delete( + `${config.path}/${indexPattern.id}/runtime_field/test` + ); - expect(response1.status).to.be(404); - }); + expect(response1.status).to.be(404); + }); - it('returns error when attempting to delete a field which is not a runtime field', async () => { - const response2 = await supertest.delete( - `/api/index_patterns/index_pattern/${indexPattern.id}/runtime_field/foo` - ); + it('returns error when attempting to delete a field which is not a runtime field', async () => { + const response2 = await supertest.delete( + `${config.path}/${indexPattern.id}/runtime_field/foo` + ); - expect(response2.status).to.be(400); - expect(response2.body.statusCode).to.be(400); - expect(response2.body.message).to.be('Only runtime fields can be deleted.'); - }); + expect(response2.status).to.be(400); + expect(response2.body.statusCode).to.be(400); + expect(response2.body.message).to.be('Only runtime fields can be deleted.'); + }); - it('returns error when ID is too long', async () => { - const id = `xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx`; - const response = await supertest.delete( - `/api/index_patterns/index_pattern/${id}/runtime_field/foo` - ); + it('returns error when ID is too long', async () => { + const id = `xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx`; + const response = await supertest.delete(`${config.path}/${id}/runtime_field/foo`); - expect(response.status).to.be(400); - expect(response.body.message).to.be( - '[request params.id]: value has length [1759] but it must have a maximum length of [1000].' - ); + expect(response.status).to.be(400); + expect(response.body.message).to.be( + '[request params.id]: value has length [1759] but it must have a maximum length of [1000].' + ); + }); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/runtime_fields_crud/delete_runtime_field/main.ts b/test/api_integration/apis/index_patterns/runtime_fields_crud/delete_runtime_field/main.ts index 3c74aa336e440..7a66e6a613db5 100644 --- a/test/api_integration/apis/index_patterns/runtime_fields_crud/delete_runtime_field/main.ts +++ b/test/api_integration/apis/index_patterns/runtime_fields_crud/delete_runtime_field/main.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -24,43 +25,45 @@ export default function ({ getService }: FtrProviderContext) { ); }); - it('can delete a runtime field', async () => { - const title = `basic_index*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - runtimeFieldMap: { - runtimeBar: { - type: 'long', - script: { - source: "emit(doc['field_name'].value)", + configArray.forEach((config) => { + describe(config.name, () => { + it('can delete a runtime field', async () => { + const title = `basic_index*`; + const response1 = await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title, + runtimeFieldMap: { + runtimeBar: { + type: 'long', + script: { + source: "emit(doc['field_name'].value)", + }, + }, }, }, - }, - }, - }); + }); - const response2 = await supertest.get( - '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id - ); + const response2 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); - expect(typeof response2.body.index_pattern.fields.runtimeBar).to.be('object'); + expect(typeof response2.body[config.serviceKey].fields.runtimeBar).to.be('object'); - const response3 = await supertest.delete( - `/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/runtime_field/runtimeBar` - ); + const response3 = await supertest.delete( + `${config.path}/${response1.body[config.serviceKey].id}/runtime_field/runtimeBar` + ); - expect(response3.status).to.be(200); + expect(response3.status).to.be(200); - const response4 = await supertest.get( - '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id - ); + const response4 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); - expect(typeof response4.body.index_pattern.fields.runtimeBar).to.be('undefined'); - await supertest.delete( - '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id - ); + expect(typeof response4.body[config.serviceKey].fields.runtimeBar).to.be('undefined'); + await supertest.delete(`${config.path}/${response1.body[config.serviceKey].id}`); + }); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/runtime_fields_crud/get_runtime_field/errors.ts b/test/api_integration/apis/index_patterns/runtime_fields_crud/get_runtime_field/errors.ts index 3608089e4641a..b6bebb224b33f 100644 --- a/test/api_integration/apis/index_patterns/runtime_fields_crud/get_runtime_field/errors.ts +++ b/test/api_integration/apis/index_patterns/runtime_fields_crud/get_runtime_field/errors.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); @@ -17,65 +18,67 @@ export default function ({ getService }: FtrProviderContext) { const basicIndex = '*asic_index'; let indexPattern: any; - before(async () => { - await esArchiver.load('test/api_integration/fixtures/es_archiver/index_patterns/basic_index'); + configArray.forEach((config) => { + describe(config.name, () => { + before(async () => { + await esArchiver.load( + 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' + ); - indexPattern = ( - await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title: basicIndex, - }, - }) - ).body.index_pattern; - }); + indexPattern = ( + await supertest.post(config.path).send({ + [config.serviceKey]: { + title: basicIndex, + }, + }) + ).body[config.serviceKey]; + }); - after(async () => { - await esArchiver.unload( - 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' - ); + after(async () => { + await esArchiver.unload( + 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' + ); - if (indexPattern) { - await supertest.delete('/api/index_patterns/index_pattern/' + indexPattern.id); - } - }); + if (indexPattern) { + await supertest.delete(`${config.path}/${indexPattern.id}`); + } + }); - it('returns 404 error on non-existing index_pattern', async () => { - const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; - const response = await supertest.get( - `/api/index_patterns/index_pattern/${id}/runtime_field/foo` - ); + it('returns 404 error on non-existing index_pattern', async () => { + const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; + const response = await supertest.get(`${config.path}/${id}/runtime_field/foo`); - expect(response.status).to.be(404); - }); + expect(response.status).to.be(404); + }); - it('returns 404 error on non-existing runtime field', async () => { - const response1 = await supertest.get( - `/api/index_patterns/index_pattern/${indexPattern.id}/runtime_field/sf` - ); + it('returns 404 error on non-existing runtime field', async () => { + const response1 = await supertest.get( + `${config.path}/${indexPattern.id}/runtime_field/sf` + ); - expect(response1.status).to.be(404); - }); + expect(response1.status).to.be(404); + }); - it('returns error when ID is too long', async () => { - const id = `xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx`; - const response = await supertest.get( - `/api/index_patterns/index_pattern/${id}/runtime_field/foo` - ); + it('returns error when ID is too long', async () => { + const id = `xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx`; + const response = await supertest.get(`${config.path}/${id}/runtime_field/foo`); - expect(response.status).to.be(400); - expect(response.body.message).to.be( - '[request params.id]: value has length [1759] but it must have a maximum length of [1000].' - ); - }); + expect(response.status).to.be(400); + expect(response.body.message).to.be( + '[request params.id]: value has length [1759] but it must have a maximum length of [1000].' + ); + }); - it('returns error when attempting to fetch a field which is not a runtime field', async () => { - const response2 = await supertest.get( - `/api/index_patterns/index_pattern/${indexPattern.id}/runtime_field/foo` - ); + it('returns error when attempting to fetch a field which is not a runtime field', async () => { + const response2 = await supertest.get( + `${config.path}/${indexPattern.id}/runtime_field/foo` + ); - expect(response2.status).to.be(400); - expect(response2.body.statusCode).to.be(400); - expect(response2.body.message).to.be('Only runtime fields can be retrieved.'); + expect(response2.status).to.be(400); + expect(response2.body.statusCode).to.be(400); + expect(response2.body.message).to.be('Only runtime fields can be retrieved.'); + }); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/runtime_fields_crud/get_runtime_field/main.ts b/test/api_integration/apis/index_patterns/runtime_fields_crud/get_runtime_field/main.ts index fa0283d69d8e3..8733b679c2302 100644 --- a/test/api_integration/apis/index_patterns/runtime_fields_crud/get_runtime_field/main.ts +++ b/test/api_integration/apis/index_patterns/runtime_fields_crud/get_runtime_field/main.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -24,48 +25,49 @@ export default function ({ getService }: FtrProviderContext) { ); }); - it('can fetch a runtime field', async () => { - const title = `basic_index*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - runtimeFieldMap: { - runtimeFoo: { - type: 'keyword', - script: { - source: "emit(doc['field_name'].value)", + configArray.forEach((config) => { + describe(config.name, () => { + it('can fetch a runtime field', async () => { + const title = `basic_index*`; + const response1 = await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title, + runtimeFieldMap: { + runtimeFoo: { + type: 'keyword', + script: { + source: "emit(doc['field_name'].value)", + }, + }, + runtimeBar: { + type: 'keyword', + script: { + source: "emit(doc['field_name'].value)", + }, + }, }, }, - runtimeBar: { - type: 'keyword', - script: { - source: "emit(doc['field_name'].value)", - }, - }, - }, - }, - }); + }); - expect(response1.status).to.be(200); + expect(response1.status).to.be(200); - const response2 = await supertest.get( - '/api/index_patterns/index_pattern/' + - response1.body.index_pattern.id + - '/runtime_field/runtimeFoo' - ); + const response2 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}/runtime_field/runtimeFoo` + ); - expect(response2.status).to.be(200); - expect(typeof response2.body.field).to.be('object'); - expect(response2.body.field.name).to.be('runtimeFoo'); - expect(response2.body.field.type).to.be('string'); - expect(response2.body.field.scripted).to.be(false); - expect(response2.body.field.runtimeField.script.source).to.be( - "emit(doc['field_name'].value)" - ); - await supertest.delete( - '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id - ); + const field = + config.serviceKey === 'index_pattern' ? response2.body.field : response2.body.fields[0]; + + expect(response2.status).to.be(200); + expect(typeof field).to.be('object'); + expect(field.name).to.be('runtimeFoo'); + expect(field.type).to.be('string'); + expect(field.scripted).to.be(false); + expect(field.runtimeField.script.source).to.be("emit(doc['field_name'].value)"); + await supertest.delete(`${config.path}/${response1.body[config.serviceKey].id}`); + }); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/runtime_fields_crud/put_runtime_field/errors.ts b/test/api_integration/apis/index_patterns/runtime_fields_crud/put_runtime_field/errors.ts index 9faca08238033..c43fe55369992 100644 --- a/test/api_integration/apis/index_patterns/runtime_fields_crud/put_runtime_field/errors.ts +++ b/test/api_integration/apis/index_patterns/runtime_fields_crud/put_runtime_field/errors.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -24,46 +25,48 @@ export default function ({ getService }: FtrProviderContext) { ); }); - it('returns 404 error on non-existing index_pattern', async () => { - const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; - const response = await supertest - .put(`/api/index_patterns/index_pattern/${id}/runtime_field`) - .send({ - name: 'runtimeBar', - runtimeField: { - type: 'long', - script: { - source: "emit(doc['field_name'].value)", + configArray.forEach((config) => { + describe(config.name, () => { + it('returns 404 error on non-existing index_pattern', async () => { + const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; + const response = await supertest.put(`${config.path}/${id}/runtime_field`).send({ + name: 'runtimeBar', + runtimeField: { + type: 'long', + script: { + source: "emit(doc['field_name'].value)", + }, }, - }, + }); + + expect(response.status).to.be(404); }); - expect(response.status).to.be(404); - }); + it('returns error on non-runtime field update attempt', async () => { + const title = `basic_index`; + const response1 = await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title, + }, + }); - it('returns error on non-runtime field update attempt', async () => { - const title = `basic_index`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - }, - }); + const response2 = await supertest + .put(`${config.path}/${response1.body[config.serviceKey].id}/runtime_field`) + .send({ + name: 'bar', + runtimeField: { + type: 'long', + script: { + source: "emit(doc['field_name'].value)", + }, + }, + }); - const response2 = await supertest - .put(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/runtime_field`) - .send({ - name: 'bar', - runtimeField: { - type: 'long', - script: { - source: "emit(doc['field_name'].value)", - }, - }, + expect(response2.status).to.be(400); + expect(response2.body.message).to.be('Only runtime fields can be updated'); }); - - expect(response2.status).to.be(400); - expect(response2.body.message).to.be('Only runtime fields can be updated'); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/runtime_fields_crud/put_runtime_field/main.ts b/test/api_integration/apis/index_patterns/runtime_fields_crud/put_runtime_field/main.ts index 92d8c6fd6d3c2..4cbef92c49a31 100644 --- a/test/api_integration/apis/index_patterns/runtime_fields_crud/put_runtime_field/main.ts +++ b/test/api_integration/apis/index_patterns/runtime_fields_crud/put_runtime_field/main.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -24,99 +25,106 @@ export default function ({ getService }: FtrProviderContext) { ); }); - it('can overwrite an existing field', async () => { - const title = `basic_index`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - runtimeFieldMap: { - runtimeFoo: { - type: 'keyword', - script: { - source: "doc['field_name'].value", + configArray.forEach((config) => { + describe(config.name, () => { + it('can overwrite an existing field', async () => { + const title = `basic_index`; + const response1 = await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title, + runtimeFieldMap: { + runtimeFoo: { + type: 'keyword', + script: { + source: "doc['field_name'].value", + }, + }, + runtimeBar: { + type: 'keyword', + script: { + source: "doc['field_name'].value", + }, + }, }, }, - runtimeBar: { - type: 'keyword', - script: { - source: "doc['field_name'].value", + }); + + const response2 = await supertest + .put(`${config.path}/${response1.body[config.serviceKey].id}/runtime_field`) + .send({ + name: 'runtimeFoo', + runtimeField: { + type: 'long', + script: { + source: "doc['field_name'].value", + }, }, - }, - }, - }, - }); + }); - const response2 = await supertest - .put(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/runtime_field`) - .send({ - name: 'runtimeFoo', - runtimeField: { - type: 'long', - script: { - source: "doc['field_name'].value", - }, - }, - }); + expect(response2.status).to.be(200); - expect(response2.status).to.be(200); + const response3 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}/runtime_field/runtimeFoo` + ); - const response3 = await supertest.get( - '/api/index_patterns/index_pattern/' + - response1.body.index_pattern.id + - '/runtime_field/runtimeFoo' - ); + const field3 = + config.serviceKey === 'index_pattern' ? response3.body.field : response3.body.fields[0]; - expect(response3.status).to.be(200); - expect(response3.body.field.type).to.be('number'); + expect(response3.status).to.be(200); + expect(field3.type).to.be('number'); - const response4 = await supertest.get( - '/api/index_patterns/index_pattern/' + - response1.body.index_pattern.id + - '/runtime_field/runtimeBar' - ); + const response4 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}/runtime_field/runtimeBar` + ); - expect(response4.status).to.be(200); - expect(response4.body.field.type).to.be('string'); - }); + const field4 = + config.serviceKey === 'index_pattern' ? response4.body.field : response4.body.fields[0]; + + expect(response4.status).to.be(200); + expect(field4.type).to.be('string'); + }); - it('can add a new runtime field', async () => { - const title = `basic_index`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - runtimeFieldMap: { - runtimeFoo: { - type: 'keyword', - script: { - source: "doc['field_name'].value", + it('can add a new runtime field', async () => { + const title = `basic_index`; + const response1 = await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title, + runtimeFieldMap: { + runtimeFoo: { + type: 'keyword', + script: { + source: "doc['field_name'].value", + }, + }, }, }, - }, - }, - }); + }); - await supertest - .put(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/runtime_field`) - .send({ - name: 'runtimeBar', - runtimeField: { - type: 'long', - script: { - source: "doc['field_name'].value", - }, - }, - }); + await supertest + .put(`${config.path}/${response1.body[config.serviceKey].id}/runtime_field`) + .send({ + name: 'runtimeBar', + runtimeField: { + type: 'long', + script: { + source: "doc['field_name'].value", + }, + }, + }); - const response2 = await supertest.get( - '/api/index_patterns/index_pattern/' + - response1.body.index_pattern.id + - '/runtime_field/runtimeBar' - ); + const response2 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}/runtime_field/runtimeBar` + ); + + const field = + config.serviceKey === 'index_pattern' ? response2.body.field : response2.body.fields[0]; - expect(response2.status).to.be(200); - expect(typeof response2.body.field.runtimeField).to.be('object'); + expect(response2.status).to.be(200); + expect(typeof field.runtimeField).to.be('object'); + }); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/runtime_fields_crud/update_runtime_field/errors.ts b/test/api_integration/apis/index_patterns/runtime_fields_crud/update_runtime_field/errors.ts index 3980821c0fd09..09e781d70bb8d 100644 --- a/test/api_integration/apis/index_patterns/runtime_fields_crud/update_runtime_field/errors.ts +++ b/test/api_integration/apis/index_patterns/runtime_fields_crud/update_runtime_field/errors.ts @@ -8,44 +8,45 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('errors', () => { - it('returns 404 error on non-existing index_pattern', async () => { - const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; - const response = await supertest - .post(`/api/index_patterns/index_pattern/${id}/runtime_field/foo`) - .send({ - runtimeField: { - script: { - source: "doc['something_new'].value", + configArray.forEach((config) => { + describe(config.name, () => { + it('returns 404 error on non-existing index_pattern', async () => { + const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; + const response = await supertest.post(`${config.path}/${id}/runtime_field/foo`).send({ + runtimeField: { + script: { + source: "doc['something_new'].value", + }, }, - }, - }); + }); - expect(response.status).to.be(404); - }); + expect(response.status).to.be(404); + }); - it('returns error when field name is specified', async () => { - const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; - const response = await supertest - .post(`/api/index_patterns/index_pattern/${id}/runtime_field/foo`) - .send({ - name: 'foo', - runtimeField: { - script: { - source: "doc['something_new'].value", + it('returns error when field name is specified', async () => { + const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; + const response = await supertest.post(`${config.path}/${id}/runtime_field/foo`).send({ + name: 'foo', + runtimeField: { + script: { + source: "doc['something_new'].value", + }, }, - }, - }); + }); - expect(response.status).to.be(400); - expect(response.body.statusCode).to.be(400); - expect(response.body.message).to.be( - "[request body.name]: a value wasn't expected to be present" - ); + expect(response.status).to.be(400); + expect(response.body.statusCode).to.be(400); + expect(response.body.message).to.be( + "[request body.name]: a value wasn't expected to be present" + ); + }); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/runtime_fields_crud/update_runtime_field/main.ts b/test/api_integration/apis/index_patterns/runtime_fields_crud/update_runtime_field/main.ts index 6b924570a0e45..d1f569472d104 100644 --- a/test/api_integration/apis/index_patterns/runtime_fields_crud/update_runtime_field/main.ts +++ b/test/api_integration/apis/index_patterns/runtime_fields_crud/update_runtime_field/main.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -24,53 +25,56 @@ export default function ({ getService }: FtrProviderContext) { ); }); - it('can update an existing field', async () => { - const title = `basic_index`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - runtimeFieldMap: { - runtimeFoo: { - type: 'keyword', - script: { - source: "doc['field_name'].value", + configArray.forEach((config) => { + describe(config.name, () => { + it('can update an existing field', async () => { + const title = `basic_index`; + const response1 = await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title, + runtimeFieldMap: { + runtimeFoo: { + type: 'keyword', + script: { + source: "doc['field_name'].value", + }, + }, + runtimeBar: { + type: 'keyword', + script: { + source: "doc['field_name'].value", + }, + }, }, }, - runtimeBar: { - type: 'keyword', - script: { - source: "doc['field_name'].value", + }); + + const response2 = await supertest + .post(`${config.path}/${response1.body[config.serviceKey].id}/runtime_field/runtimeFoo`) + .send({ + runtimeField: { + script: { + source: "doc['something_new'].value", + }, }, - }, - }, - }, - }); + }); - const response2 = await supertest - .post( - `/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/runtime_field/runtimeFoo` - ) - .send({ - runtimeField: { - script: { - source: "doc['something_new'].value", - }, - }, - }); + expect(response2.status).to.be(200); - expect(response2.status).to.be(200); + const response3 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}/runtime_field/runtimeFoo` + ); - const response3 = await supertest.get( - '/api/index_patterns/index_pattern/' + - response1.body.index_pattern.id + - '/runtime_field/runtimeFoo' - ); + const field = + config.serviceKey === 'index_pattern' ? response3.body.field : response3.body.fields[0]; - expect(response3.status).to.be(200); - expect(response3.body.field.type).to.be('string'); - expect(response3.body.field.runtimeField.type).to.be('keyword'); - expect(response3.body.field.runtimeField.script.source).to.be("doc['something_new'].value"); + expect(response3.status).to.be(200); + expect(field.type).to.be('string'); + expect(field.runtimeField.type).to.be('keyword'); + expect(field.runtimeField.script.source).to.be("doc['something_new'].value"); + }); + }); }); }); } From 2d1755439de02483671f75f3b2ccb38c802ab782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Thu, 6 Jan 2022 14:37:43 +0100 Subject: [PATCH 05/25] [Telemetry] Use server's `lastReported` on the browser (#121656) --- .../__snapshots__/home.test.tsx.snap | 4 ++ .../common/is_report_interval_expired.test.ts | 67 ++++++++++++++++++ .../common/is_report_interval_expired.ts | 19 +++++ .../public/services/telemetry_sender.test.ts | 70 +++++++++++-------- .../public/services/telemetry_sender.ts | 61 ++++++++++------ .../public/services/telemetry_service.ts | 11 +++ src/plugins/telemetry/server/fetcher.ts | 15 ++-- src/plugins/telemetry/server/plugin.ts | 39 +++++++---- src/plugins/telemetry/server/routes/index.ts | 13 ++-- .../server/routes/telemetry_last_reported.ts | 55 +++++++++++++++ ...telemetry_management_section.test.tsx.snap | 2 + test/api_integration/apis/telemetry/index.js | 1 + .../apis/telemetry/telemetry_last_reported.ts | 60 ++++++++++++++++ 13 files changed, 343 insertions(+), 74 deletions(-) create mode 100644 src/plugins/telemetry/common/is_report_interval_expired.test.ts create mode 100644 src/plugins/telemetry/common/is_report_interval_expired.ts create mode 100644 src/plugins/telemetry/server/routes/telemetry_last_reported.ts create mode 100644 test/api_integration/apis/telemetry/telemetry_last_reported.ts diff --git a/src/plugins/home/public/application/components/__snapshots__/home.test.tsx.snap b/src/plugins/home/public/application/components/__snapshots__/home.test.tsx.snap index f38bdb9ac53f0..373fc8ea59b6f 100644 --- a/src/plugins/home/public/application/components/__snapshots__/home.test.tsx.snap +++ b/src/plugins/home/public/application/components/__snapshots__/home.test.tsx.snap @@ -442,6 +442,7 @@ exports[`home isNewKibanaInstance should set isNewKibanaInstance to true when th "userCanChangeSettings": true, }, "fetchExample": [Function], + "fetchLastReported": [Function], "fetchTelemetry": [Function], "getCanChangeOptInStatus": [Function], "getIsOptedIn": [Function], @@ -492,6 +493,7 @@ exports[`home isNewKibanaInstance should set isNewKibanaInstance to true when th "reportOptInStatusChange": true, "setOptIn": [Function], "setUserHasSeenNotice": [Function], + "updateLastReported": [Function], "updatedConfig": undefined, }, }, @@ -509,6 +511,7 @@ exports[`home isNewKibanaInstance should set isNewKibanaInstance to true when th "userCanChangeSettings": true, }, "fetchExample": [Function], + "fetchLastReported": [Function], "fetchTelemetry": [Function], "getCanChangeOptInStatus": [Function], "getIsOptedIn": [Function], @@ -559,6 +562,7 @@ exports[`home isNewKibanaInstance should set isNewKibanaInstance to true when th "reportOptInStatusChange": true, "setOptIn": [Function], "setUserHasSeenNotice": [Function], + "updateLastReported": [Function], "updatedConfig": undefined, }, } diff --git a/src/plugins/telemetry/common/is_report_interval_expired.test.ts b/src/plugins/telemetry/common/is_report_interval_expired.test.ts new file mode 100644 index 0000000000000..68c252a959c0e --- /dev/null +++ b/src/plugins/telemetry/common/is_report_interval_expired.test.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { REPORT_INTERVAL_MS } from './constants'; +import { isReportIntervalExpired } from './is_report_interval_expired'; + +describe('isReportIntervalExpired', () => { + test('true when undefined', () => { + expect(isReportIntervalExpired(undefined)).toBe(true); + expect(isReportIntervalExpired(void 0)).toBe(true); + }); + + describe('true when NaN', () => { + test('NaN', () => { + expect(isReportIntervalExpired(NaN)).toBe(true); + }); + + test('parseInt(undefined)', () => { + expect(isReportIntervalExpired(parseInt(undefined as unknown as string, 10))).toBe(true); + }); + + test('parseInt(null)', () => { + expect(isReportIntervalExpired(parseInt(null as unknown as string, 10))).toBe(true); + }); + + test('parseInt("")', () => { + expect(isReportIntervalExpired(parseInt('', 10))).toBe(true); + }); + + test('empty string', () => { + expect(isReportIntervalExpired('' as unknown as number)).toBe(true); + }); + + test('malformed string', () => { + expect(isReportIntervalExpired(`random_malformed_string` as unknown as number)).toBe(true); + }); + + test('other object', () => { + expect(isReportIntervalExpired({} as unknown as number)).toBe(true); + }); + }); + + test('true when 0', () => { + expect(isReportIntervalExpired(0)).toBe(true); + }); + + test('true when actually expired', () => { + expect(isReportIntervalExpired(Date.now() - REPORT_INTERVAL_MS - 1000)).toBe(true); + }); + + test('false when close but not yet', () => { + expect(isReportIntervalExpired(Date.now() - REPORT_INTERVAL_MS + 1000)).toBe(false); + }); + + test('false when date in the future', () => { + expect(isReportIntervalExpired(Date.now() + 1000)).toBe(false); + }); + + test('false when date is now', () => { + expect(isReportIntervalExpired(Date.now())).toBe(false); + }); +}); diff --git a/src/plugins/telemetry/common/is_report_interval_expired.ts b/src/plugins/telemetry/common/is_report_interval_expired.ts new file mode 100644 index 0000000000000..d91916c12c449 --- /dev/null +++ b/src/plugins/telemetry/common/is_report_interval_expired.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { REPORT_INTERVAL_MS } from './constants'; + +/** + * The report is considered expired if: + * - `lastReportAt` does not exist, is NaN or `REPORT_INTERVAL_MS` have passed ever since. + * @param lastReportAt + * @returns `true` if the report interval is considered expired + */ +export function isReportIntervalExpired(lastReportAt: number | undefined) { + return !lastReportAt || isNaN(lastReportAt) || Date.now() - lastReportAt > REPORT_INTERVAL_MS; +} diff --git a/src/plugins/telemetry/public/services/telemetry_sender.test.ts b/src/plugins/telemetry/public/services/telemetry_sender.test.ts index d4678ce0ea23a..03b7fdb6f556d 100644 --- a/src/plugins/telemetry/public/services/telemetry_sender.test.ts +++ b/src/plugins/telemetry/public/services/telemetry_sender.test.ts @@ -42,87 +42,98 @@ describe('TelemetrySender', () => { }); it('uses lastReport if set', () => { - const lastReport = `${Date.now()}`; - mockLocalStorage.getItem.mockReturnValueOnce(JSON.stringify({ lastReport })); + const lastReport = Date.now(); + mockLocalStorage.getItem.mockReturnValueOnce(JSON.stringify({ lastReport: `${lastReport}` })); const telemetryService = mockTelemetryService(); const telemetrySender = new TelemetrySender(telemetryService); expect(telemetrySender['lastReported']).toBe(lastReport); }); }); - describe('saveToBrowser', () => { - it('uses lastReport', () => { - const lastReport = `${Date.now()}`; + describe('updateLastReported', () => { + it('stores the new lastReported value in the storage', () => { + const lastReport = Date.now(); const telemetryService = mockTelemetryService(); const telemetrySender = new TelemetrySender(telemetryService); - telemetrySender['lastReported'] = lastReport; - telemetrySender['saveToBrowser'](); + telemetrySender['updateLastReported'](lastReport); expect(mockLocalStorage.setItem).toHaveBeenCalledTimes(1); expect(mockLocalStorage.setItem).toHaveBeenCalledWith( LOCALSTORAGE_KEY, - JSON.stringify({ lastReport }) + JSON.stringify({ lastReport: `${lastReport}` }) ); }); }); describe('shouldSendReport', () => { - it('returns false whenever optIn is false', () => { + it('returns false whenever optIn is false', async () => { const telemetryService = mockTelemetryService(); telemetryService.getIsOptedIn = jest.fn().mockReturnValue(false); const telemetrySender = new TelemetrySender(telemetryService); - const shouldSendReport = telemetrySender['shouldSendReport'](); + const shouldSendReport = await telemetrySender['shouldSendReport'](); expect(telemetryService.getIsOptedIn).toBeCalledTimes(1); expect(shouldSendReport).toBe(false); }); - it('returns true if lastReported is undefined', () => { + it('returns true if lastReported is undefined (both local and global)', async () => { const telemetryService = mockTelemetryService(); telemetryService.getIsOptedIn = jest.fn().mockReturnValue(true); + telemetryService.fetchLastReported = jest.fn().mockResolvedValue(undefined); const telemetrySender = new TelemetrySender(telemetryService); - const shouldSendReport = telemetrySender['shouldSendReport'](); + const shouldSendReport = await telemetrySender['shouldSendReport'](); expect(telemetrySender['lastReported']).toBeUndefined(); expect(shouldSendReport).toBe(true); + expect(telemetryService.fetchLastReported).toHaveBeenCalledTimes(1); }); - it('returns true if lastReported passed REPORT_INTERVAL_MS', () => { + it('returns true if lastReported passed REPORT_INTERVAL_MS', async () => { const lastReported = Date.now() - (REPORT_INTERVAL_MS + 1000); const telemetryService = mockTelemetryService(); telemetryService.getIsOptedIn = jest.fn().mockReturnValue(true); const telemetrySender = new TelemetrySender(telemetryService); - telemetrySender['lastReported'] = `${lastReported}`; - const shouldSendReport = telemetrySender['shouldSendReport'](); + telemetrySender['lastReported'] = lastReported; + const shouldSendReport = await telemetrySender['shouldSendReport'](); expect(shouldSendReport).toBe(true); }); - it('returns false if lastReported is within REPORT_INTERVAL_MS', () => { + it('returns false if local lastReported is within REPORT_INTERVAL_MS', async () => { const lastReported = Date.now() + 1000; const telemetryService = mockTelemetryService(); telemetryService.getIsOptedIn = jest.fn().mockReturnValue(true); const telemetrySender = new TelemetrySender(telemetryService); - telemetrySender['lastReported'] = `${lastReported}`; - const shouldSendReport = telemetrySender['shouldSendReport'](); + telemetrySender['lastReported'] = lastReported; + const shouldSendReport = await telemetrySender['shouldSendReport'](); expect(shouldSendReport).toBe(false); }); - it('returns true if lastReported is malformed', () => { + it('returns false if local lastReported is expired but the remote is within REPORT_INTERVAL_MS', async () => { const telemetryService = mockTelemetryService(); telemetryService.getIsOptedIn = jest.fn().mockReturnValue(true); + telemetryService.fetchLastReported = jest.fn().mockResolvedValue(Date.now() + 1000); const telemetrySender = new TelemetrySender(telemetryService); - telemetrySender['lastReported'] = `random_malformed_string`; - const shouldSendReport = telemetrySender['shouldSendReport'](); + telemetrySender['lastReported'] = Date.now() - (REPORT_INTERVAL_MS + 1000); + const shouldSendReport = await telemetrySender['shouldSendReport'](); + expect(shouldSendReport).toBe(false); + }); + + it('returns true if lastReported is malformed', async () => { + const telemetryService = mockTelemetryService(); + telemetryService.getIsOptedIn = jest.fn().mockReturnValue(true); + const telemetrySender = new TelemetrySender(telemetryService); + telemetrySender['lastReported'] = `random_malformed_string` as unknown as number; + const shouldSendReport = await telemetrySender['shouldSendReport'](); expect(shouldSendReport).toBe(true); }); - it('returns false if we are in screenshot mode', () => { + it('returns false if we are in screenshot mode', async () => { const telemetryService = mockTelemetryService({ isScreenshotMode: true }); telemetryService.getIsOptedIn = jest.fn().mockReturnValue(false); const telemetrySender = new TelemetrySender(telemetryService); - const shouldSendReport = telemetrySender['shouldSendReport'](); + const shouldSendReport = await telemetrySender['shouldSendReport'](); expect(telemetryService.getIsOptedIn).toBeCalledTimes(0); expect(shouldSendReport).toBe(false); @@ -165,13 +176,14 @@ describe('TelemetrySender', () => { const telemetrySender = new TelemetrySender(telemetryService); telemetrySender['shouldSendReport'] = jest.fn().mockReturnValue(true); telemetrySender['sendUsageData'] = jest.fn().mockReturnValue(true); - telemetrySender['saveToBrowser'] = jest.fn(); - telemetrySender['lastReported'] = `${lastReported}`; + telemetrySender['updateLastReported'] = jest.fn().mockImplementation((value) => { + expect(value).not.toBe(lastReported); + }); + telemetrySender['lastReported'] = lastReported; await telemetrySender['sendIfDue'](); - expect(telemetrySender['lastReported']).not.toBe(lastReported); - expect(telemetrySender['saveToBrowser']).toBeCalledTimes(1); + expect(telemetrySender['updateLastReported']).toBeCalledTimes(1); expect(telemetrySender['retryCount']).toEqual(0); expect(telemetrySender['sendUsageData']).toHaveBeenCalledTimes(1); }); @@ -181,7 +193,7 @@ describe('TelemetrySender', () => { const telemetrySender = new TelemetrySender(telemetryService); telemetrySender['shouldSendReport'] = jest.fn().mockReturnValue(true); telemetrySender['sendUsageData'] = jest.fn(); - telemetrySender['saveToBrowser'] = jest.fn(); + telemetrySender['updateLastReported'] = jest.fn(); telemetrySender['retryCount'] = 9; await telemetrySender['sendIfDue'](); @@ -272,7 +284,7 @@ describe('TelemetrySender', () => { telemetryService.getTelemetryUrl = jest.fn().mockReturnValue(mockTelemetryUrl); telemetryService.fetchTelemetry = jest.fn().mockReturnValue(mockTelemetryPayload); telemetrySender['shouldSendReport'] = jest.fn().mockReturnValue(true); - telemetrySender['saveToBrowser'] = jest.fn(); + telemetrySender['updateLastReported'] = jest.fn(); await telemetrySender['sendUsageData'](); diff --git a/src/plugins/telemetry/public/services/telemetry_sender.ts b/src/plugins/telemetry/public/services/telemetry_sender.ts index fb87b0b23ad56..d0eb9142e724a 100644 --- a/src/plugins/telemetry/public/services/telemetry_sender.ts +++ b/src/plugins/telemetry/public/services/telemetry_sender.ts @@ -6,18 +6,15 @@ * Side Public License, v 1. */ -import { - REPORT_INTERVAL_MS, - LOCALSTORAGE_KEY, - PAYLOAD_CONTENT_ENCODING, -} from '../../common/constants'; +import { LOCALSTORAGE_KEY, PAYLOAD_CONTENT_ENCODING } from '../../common/constants'; import { TelemetryService } from './telemetry_service'; import { Storage } from '../../../kibana_utils/public'; import type { EncryptedTelemetryPayload } from '../../common/types'; +import { isReportIntervalExpired } from '../../common/is_report_interval_expired'; export class TelemetrySender { private readonly telemetryService: TelemetryService; - private lastReported?: string; + private lastReported?: number; private readonly storage: Storage; private intervalId: number = 0; // setInterval returns a positive integer, 0 means no interval is set private retryCount: number = 0; @@ -32,38 +29,56 @@ export class TelemetrySender { const attributes = this.storage.get(LOCALSTORAGE_KEY); if (attributes) { - this.lastReported = attributes.lastReport; + this.lastReported = parseInt(attributes.lastReport, 10); } } - private saveToBrowser = () => { + private updateLastReported = (lastReported: number) => { + this.lastReported = lastReported; // we are the only code that manipulates this key, so it's safe to blindly overwrite the whole object - this.storage.set(LOCALSTORAGE_KEY, { lastReport: this.lastReported }); + this.storage.set(LOCALSTORAGE_KEY, { lastReport: `${this.lastReported}` }); }; - private shouldSendReport = (): boolean => { + /** + * Using the local and SO's `lastReported` values, it decides whether the last report should be considered as expired + * @returns `true` if a new report should be generated. `false` otherwise. + */ + private isReportDue = async (): Promise => { + // Try to decide with the local `lastReported` to avoid querying the server + if (!isReportIntervalExpired(this.lastReported)) { + // If it is not expired locally, there's no need to send it again yet. + return false; + } + + // Double-check with the server's value + const globalLastReported = await this.telemetryService.fetchLastReported(); + + if (globalLastReported) { + // Update the local value to avoid repetitions of this request (it was already expired, so it doesn't really matter if the server's value is older) + this.updateLastReported(globalLastReported); + } + + return isReportIntervalExpired(globalLastReported); + }; + + /** + * Using configuration and the lastReported dates, it decides whether a new telemetry report should be sent. + * @returns `true` if a new report should be sent. `false` otherwise. + */ + private shouldSendReport = async (): Promise => { if (this.telemetryService.canSendTelemetry()) { - if (!this.lastReported) { - return true; - } - // returns NaN for any malformed or unset (null/undefined) value - const lastReported = parseInt(this.lastReported, 10); - // If it's been a day since we last sent telemetry - if (isNaN(lastReported) || Date.now() - lastReported > REPORT_INTERVAL_MS) { - return true; - } + return await this.isReportDue(); } return false; }; private sendIfDue = async (): Promise => { - if (!this.shouldSendReport()) { + if (!(await this.shouldSendReport())) { return; } // optimistically update the report date and reset the retry counter for a new time report interval window - this.lastReported = `${Date.now()}`; - this.saveToBrowser(); + this.updateLastReported(Date.now()); this.retryCount = 0; await this.sendUsageData(); }; @@ -89,6 +104,8 @@ export class TelemetrySender { }) ) ); + + await this.telemetryService.updateLastReported().catch(() => {}); // Let's catch the error. Worst-case scenario another Telemetry report will be generated somewhere else. } catch (err) { // ignore err and try again but after a longer wait period. this.retryCount = this.retryCount + 1; diff --git a/src/plugins/telemetry/public/services/telemetry_service.ts b/src/plugins/telemetry/public/services/telemetry_service.ts index d8732b3d4bba9..55dc623a8ccf8 100644 --- a/src/plugins/telemetry/public/services/telemetry_service.ts +++ b/src/plugins/telemetry/public/services/telemetry_service.ts @@ -138,6 +138,17 @@ export class TelemetryService { return !this.isScreenshotMode && this.getIsOptedIn(); }; + public fetchLastReported = async (): Promise => { + const response = await this.http.get<{ lastReported?: number }>( + '/api/telemetry/v2/last_reported' + ); + return response?.lastReported; + }; + + public updateLastReported = async (): Promise => { + return this.http.put('/api/telemetry/v2/last_reported'); + }; + /** Fetches an unencrypted telemetry payload so we can show it to the user **/ public fetchExample = async (): Promise => { return await this.fetchTelemetry({ unencrypted: true, refreshCache: true }); diff --git a/src/plugins/telemetry/server/fetcher.ts b/src/plugins/telemetry/server/fetcher.ts index a232ad4050793..db890d2ea12ec 100644 --- a/src/plugins/telemetry/server/fetcher.ts +++ b/src/plugins/telemetry/server/fetcher.ts @@ -24,9 +24,10 @@ import { getTelemetryFailureDetails, } from '../common/telemetry_config'; import { getTelemetrySavedObject, updateTelemetrySavedObject } from './telemetry_repository'; -import { REPORT_INTERVAL_MS, PAYLOAD_CONTENT_ENCODING } from '../common/constants'; +import { PAYLOAD_CONTENT_ENCODING } from '../common/constants'; import type { EncryptedTelemetryPayload } from '../common/types'; import { TelemetryConfigType } from './config'; +import { isReportIntervalExpired } from '../common/is_report_interval_expired'; export interface FetcherTaskDepsStart { telemetryCollectionManager: TelemetryCollectionManagerPluginStart; @@ -39,6 +40,7 @@ interface TelemetryConfig { failureCount: number; failureVersion: string | undefined; currentVersion: string; + lastReported: number | undefined; } export class FetcherTask { @@ -59,10 +61,7 @@ export class FetcherTask { this.logger = initializerContext.logger.get('fetcher'); } - public start( - { savedObjects, elasticsearch }: CoreStart, - { telemetryCollectionManager }: FetcherTaskDepsStart - ) { + public start({ savedObjects }: CoreStart, { telemetryCollectionManager }: FetcherTaskDepsStart) { this.internalRepository = new SavedObjectsClient(savedObjects.createInternalRepository()); this.telemetryCollectionManager = telemetryCollectionManager; @@ -148,6 +147,7 @@ export class FetcherTask { failureCount, failureVersion, currentVersion: currentKibanaVersion, + lastReported: telemetrySavedObject ? telemetrySavedObject.lastReported : void 0, }; } @@ -178,13 +178,16 @@ export class FetcherTask { failureCount, failureVersion, currentVersion, + lastReported, }: TelemetryConfig) { if (failureCount > 2 && failureVersion === currentVersion) { return false; } if (telemetryOptIn && telemetrySendUsageFrom === 'server') { - if (!this.lastReported || Date.now() - this.lastReported > REPORT_INTERVAL_MS) { + // Check both: in-memory and SO-driven value. + // This will avoid the server retrying over and over when it has issues with storing the state in the SO. + if (isReportIntervalExpired(this.lastReported) && isReportIntervalExpired(lastReported)) { return true; } } diff --git a/src/plugins/telemetry/server/plugin.ts b/src/plugins/telemetry/server/plugin.ts index aa22410358f72..cfd91af73d747 100644 --- a/src/plugins/telemetry/server/plugin.ts +++ b/src/plugins/telemetry/server/plugin.ts @@ -7,22 +7,23 @@ */ import { URL } from 'url'; -import { Observable } from 'rxjs'; -import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { +import type { Observable } from 'rxjs'; +import { ReplaySubject } from 'rxjs'; +import { take } from 'rxjs/operators'; +import type { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import type { TelemetryCollectionManagerPluginSetup, TelemetryCollectionManagerPluginStart, } from 'src/plugins/telemetry_collection_manager/server'; -import { take } from 'rxjs/operators'; -import { +import type { CoreSetup, PluginInitializerContext, ISavedObjectsRepository, CoreStart, - SavedObjectsClient, Plugin, Logger, -} from '../../../core/server'; +} from 'src/core/server'; +import { SavedObjectsClient } from '../../../core/server'; import { registerRoutes } from './routes'; import { registerCollection } from './telemetry_collection'; import { @@ -77,7 +78,17 @@ export class TelemetryPlugin implements Plugin(1); constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); @@ -107,6 +118,7 @@ export class TelemetryPlugin implements Plugin savedObjects.registerType(opts)); @@ -128,13 +140,16 @@ export class TelemetryPlugin implements Plugin { - const internalRepository = new SavedObjectsClient(savedObjectsInternalRepository); - const telemetrySavedObject = await getTelemetrySavedObject(internalRepository); + const internalRepositoryClient = await this.savedObjectsInternalClient$ + .pipe(take(1)) + .toPromise(); + const telemetrySavedObject = await getTelemetrySavedObject(internalRepositoryClient); const config = await this.config$.pipe(take(1)).toPromise(); const allowChangingOptInStatus = config.allowChangingOptInStatus; @@ -197,7 +212,7 @@ export class TelemetryPlugin implements Plugin this.savedObjectsClient; + const getSavedObjectsClient = () => this.savedObjectsInternalRepository; registerTelemetryPluginUsageCollector(usageCollection, { currentKibanaVersion: this.currentKibanaVersion, diff --git a/src/plugins/telemetry/server/routes/index.ts b/src/plugins/telemetry/server/routes/index.ts index 5d073a7146c92..995bcd491634f 100644 --- a/src/plugins/telemetry/server/routes/index.ts +++ b/src/plugins/telemetry/server/routes/index.ts @@ -6,14 +6,15 @@ * Side Public License, v 1. */ -import { Observable } from 'rxjs'; -import { IRouter, Logger } from 'kibana/server'; -import { TelemetryCollectionManagerPluginSetup } from 'src/plugins/telemetry_collection_manager/server'; +import type { Observable } from 'rxjs'; +import type { IRouter, Logger, SavedObjectsClient } from 'kibana/server'; +import type { TelemetryCollectionManagerPluginSetup } from 'src/plugins/telemetry_collection_manager/server'; import { registerTelemetryOptInRoutes } from './telemetry_opt_in'; import { registerTelemetryUsageStatsRoutes } from './telemetry_usage_stats'; import { registerTelemetryOptInStatsRoutes } from './telemetry_opt_in_stats'; import { registerTelemetryUserHasSeenNotice } from './telemetry_user_has_seen_notice'; -import { TelemetryConfigType } from '../config'; +import type { TelemetryConfigType } from '../config'; +import { registerTelemetryLastReported } from './telemetry_last_reported'; interface RegisterRoutesParams { isDev: boolean; @@ -22,12 +23,14 @@ interface RegisterRoutesParams { currentKibanaVersion: string; router: IRouter; telemetryCollectionManager: TelemetryCollectionManagerPluginSetup; + savedObjectsInternalClient$: Observable; } export function registerRoutes(options: RegisterRoutesParams) { - const { isDev, telemetryCollectionManager, router } = options; + const { isDev, telemetryCollectionManager, router, savedObjectsInternalClient$ } = options; registerTelemetryOptInRoutes(options); registerTelemetryUsageStatsRoutes(router, telemetryCollectionManager, isDev); registerTelemetryOptInStatsRoutes(router, telemetryCollectionManager); registerTelemetryUserHasSeenNotice(router); + registerTelemetryLastReported(router, savedObjectsInternalClient$); } diff --git a/src/plugins/telemetry/server/routes/telemetry_last_reported.ts b/src/plugins/telemetry/server/routes/telemetry_last_reported.ts new file mode 100644 index 0000000000000..2086327476ba2 --- /dev/null +++ b/src/plugins/telemetry/server/routes/telemetry_last_reported.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { IRouter, SavedObjectsClient } from 'kibana/server'; +import type { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { getTelemetrySavedObject, updateTelemetrySavedObject } from '../telemetry_repository'; + +export function registerTelemetryLastReported( + router: IRouter, + savedObjectsInternalClient$: Observable +) { + // GET to retrieve + router.get( + { + path: '/api/telemetry/v2/last_reported', + validate: false, + }, + async (context, req, res) => { + const savedObjectsInternalClient = await savedObjectsInternalClient$ + .pipe(take(1)) + .toPromise(); + const telemetrySavedObject = await getTelemetrySavedObject(savedObjectsInternalClient); + + return res.ok({ + body: { + lastReported: telemetrySavedObject && telemetrySavedObject?.lastReported, + }, + }); + } + ); + + // PUT to update + router.put( + { + path: '/api/telemetry/v2/last_reported', + validate: false, + }, + async (context, req, res) => { + const savedObjectsInternalClient = await savedObjectsInternalClient$ + .pipe(take(1)) + .toPromise(); + await updateTelemetrySavedObject(savedObjectsInternalClient, { + lastReported: Date.now(), + }); + + return res.ok(); + } + ); +} diff --git a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap index 72947b1514911..0edad23d3312b 100644 --- a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap +++ b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap @@ -258,6 +258,7 @@ exports[`TelemetryManagementSectionComponent renders null because allowChangingO "sendUsageTo": "staging", }, "fetchExample": [Function], + "fetchLastReported": [Function], "fetchTelemetry": [Function], "getCanChangeOptInStatus": [Function], "getIsOptedIn": [Function], @@ -308,6 +309,7 @@ exports[`TelemetryManagementSectionComponent renders null because allowChangingO "reportOptInStatusChange": false, "setOptIn": [Function], "setUserHasSeenNotice": [Function], + "updateLastReported": [Function], "updatedConfig": undefined, } } diff --git a/test/api_integration/apis/telemetry/index.js b/test/api_integration/apis/telemetry/index.js index 5394b54062d89..94ada69b93322 100644 --- a/test/api_integration/apis/telemetry/index.js +++ b/test/api_integration/apis/telemetry/index.js @@ -9,6 +9,7 @@ export default function ({ loadTestFile }) { describe('Telemetry', () => { loadTestFile(require.resolve('./opt_in')); + loadTestFile(require.resolve('./telemetry_last_reported')); loadTestFile(require.resolve('./telemetry_optin_notice_seen')); }); } diff --git a/test/api_integration/apis/telemetry/telemetry_last_reported.ts b/test/api_integration/apis/telemetry/telemetry_last_reported.ts new file mode 100644 index 0000000000000..d190726e6db0b --- /dev/null +++ b/test/api_integration/apis/telemetry/telemetry_last_reported.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function optInTest({ getService }: FtrProviderContext) { + const client = getService('es'); + const supertest = getService('supertest'); + + describe('/api/telemetry/v2/last_reported API Telemetry lastReported', () => { + before(async () => { + await client.delete( + { + index: '.kibana', + id: 'telemetry:telemetry', + }, + { ignore: [404] } + ); + }); + + it('GET should return undefined when there is no stored telemetry.lastReported value', async () => { + await supertest + .get('/api/telemetry/v2/last_reported') + .set('kbn-xsrf', 'xxx') + .expect(200, { lastReported: undefined }); + }); + + it('PUT should update telemetry.lastReported to now', async () => { + await supertest.put('/api/telemetry/v2/last_reported').set('kbn-xsrf', 'xxx').expect(200); + + const { _source } = await client.get<{ telemetry: { lastReported: number } }>({ + index: '.kibana', + id: 'telemetry:telemetry', + }); + + expect(_source?.telemetry.lastReported).to.be.a('number'); + }); + + it('GET should return the previously stored lastReported value', async () => { + const { _source } = await client.get<{ telemetry: { lastReported: number } }>({ + index: '.kibana', + id: 'telemetry:telemetry', + }); + + expect(_source?.telemetry.lastReported).to.be.a('number'); + const lastReported = _source?.telemetry.lastReported; + + await supertest + .get('/api/telemetry/v2/last_reported') + .set('kbn-xsrf', 'xxx') + .expect(200, { lastReported }); + }); + }); +} From 9f469d0b92f6dee816654d40900e2e463e8e28ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= Date: Thu, 6 Jan 2022 15:55:38 +0100 Subject: [PATCH 06/25] [Security Solution][Endpoint] Allow user see exception full details filtering event filters list by item_id (#122241) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../pages/event_filters/constants.ts | 1 + .../list/policy_event_filters_list.test.tsx | 12 +++++++++++ .../list/policy_event_filters_list.tsx | 20 ++++++++++++++++++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts index 6fb6b8081596e..df77915e5de59 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts @@ -28,6 +28,7 @@ export const SEARCHABLE_FIELDS: Readonly = [ `entries.value`, `entries.entries.value`, `comments.comment`, + `item_id`, ]; export { ENDPOINT_EVENT_FILTERS_LIST_ID, EXCEPTION_LIST_URL, EXCEPTION_LIST_ITEM_URL }; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.test.tsx index b85c77b4c8edf..1479eca21c1c8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.test.tsx @@ -121,4 +121,16 @@ describe('Policy details event filters list', () => { ) ); }); + + it('should enable the "view full details" action', async () => { + mockedApi.responseProvider.eventFiltersList.mockReturnValue( + getFoundExceptionListItemSchemaMock() + ); + await render(); + // click the actions button + userEvent.click( + renderResult.getByTestId('eventFilters-collapsed-list-card-header-actions-button') + ); + expect(renderResult.queryByTestId('view-full-details-action')).toBeTruthy(); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.tsx index 04e24fa3f48b4..b837a99370218 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.tsx @@ -9,6 +9,8 @@ import React, { useCallback, useMemo, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiSpacer, EuiText, Pagination } from '@elastic/eui'; import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { useAppUrl } from '../../../../../../common/lib/kibana'; +import { APP_UI_ID } from '../../../../../../../common/constants'; import { useSearchAssignedEventFilters } from '../hooks'; import { SearchExceptions } from '../../../../../components/search_exceptions'; import { useEndpointPoliciesToArtifactPolicies } from '../../../../../components/artifact_entry_card/hooks/use_endpoint_policies_to_artifact_policies'; @@ -27,11 +29,13 @@ import { } from '../../policy_hooks'; import { getCurrentArtifactsLocation } from '../../../store/policy_details/selectors'; import { ImmutableObject, PolicyData } from '../../../../../../../common/endpoint/types'; +import { getEventFiltersListPath } from '../../../../../common/routing'; interface PolicyEventFiltersListProps { policy: ImmutableObject; } export const PolicyEventFiltersList = React.memo(({ policy }) => { + const { getAppUrl } = useAppUrl(); const policiesRequest = useGetEndpointSpecificPolicies(); const navigateCallback = usePolicyDetailsEventFiltersNavigateCallback(); const urlParams = usePolicyDetailsSelector(getCurrentArtifactsLocation); @@ -93,10 +97,24 @@ export const PolicyEventFiltersList = React.memo(({ const artifactCardPolicies = useEndpointPoliciesToArtifactPolicies(policiesRequest.data?.items); const provideCardProps: ArtifactCardGridProps['cardComponentProps'] = (artifact) => { + const viewUrlPath = getEventFiltersListPath({ + filter: (artifact as ExceptionListItemSchema).item_id, + }); + const fullDetailsAction = { + icon: 'controlsHorizontal', + children: i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.list.fullDetailsAction', + { defaultMessage: 'View full details' } + ), + href: getAppUrl({ appId: APP_UI_ID, path: viewUrlPath }), + navigateAppId: APP_UI_ID, + navigateOptions: { path: viewUrlPath }, + 'data-test-subj': 'view-full-details-action', + }; const item = artifact as ExceptionListItemSchema; return { expanded: expandedItemsMap.get(item.id) || false, - actions: [], + actions: [fullDetailsAction], policies: artifactCardPolicies, }; }; From 7dfad91dc77f9ad30ecb5fc346a46ffa482aab4d Mon Sep 17 00:00:00 2001 From: Vitalii Dmyterko <92328789+vitaliidm@users.noreply.github.com> Date: Thu, 6 Jan 2022 15:21:10 +0000 Subject: [PATCH 07/25] [Security Solution][Detections] Adds Bulk edit API (#120472) [Security Solution][Detections] Adds Bulk edit API (#120472) --- .../security_solution/common/constants.ts | 27 ++ .../schemas/common/schemas.ts | 47 +++ .../perform_bulk_action_schema.mock.ts | 8 +- .../perform_bulk_action_schema.test.ts | 393 +++++++++++++++--- .../request/perform_bulk_action_schema.ts | 34 +- .../common/endpoint/constants.ts | 2 - .../rules/rules_table/rules_table_reducer.ts | 1 + .../detection_engine/rules/types.ts | 1 + .../endpoint/routes/limited_concurrency.ts | 73 ---- .../routes/__mocks__/request_responses.ts | 12 +- .../routes/rules/patch_rules_bulk_route.ts | 3 - .../routes/rules/patch_rules_route.ts | 3 - .../rules/perform_bulk_action_route.test.ts | 189 ++++++++- .../routes/rules/perform_bulk_action_route.ts | 219 +++++++++- .../routes/rules/update_rules_bulk_route.ts | 3 - .../routes/rules/update_rules_route.ts | 3 - .../routes/rules/utils/import_rules_utils.ts | 3 - .../rules/bulk_action_edit.test.ts | 138 ++++++ .../rules/bulk_action_edit.ts | 73 ++++ .../rules/patch_rules.mock.ts | 8 - .../lib/detection_engine/rules/patch_rules.ts | 3 - .../lib/detection_engine/rules/types.ts | 12 +- .../rules/update_prepacked_rules.ts | 31 +- .../detection_engine/rules/update_rules.ts | 2 - .../lib/detection_engine/rules/utils.ts | 2 +- .../security_solution/server/plugin.ts | 2 +- .../server/routes/limited_concurrency.ts | 114 +++++ .../server/utils/promise_pool.test.ts | 51 ++- .../server/utils/promise_pool.ts | 18 +- .../utils/route_limited_concurrency_tag.ts | 17 + .../tests/perform_bulk_action.ts | 226 ++++++++-- 31 files changed, 1458 insertions(+), 260 deletions(-) delete mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/limited_concurrency.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/bulk_action_edit.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/bulk_action_edit.ts create mode 100644 x-pack/plugins/security_solution/server/routes/limited_concurrency.ts create mode 100644 x-pack/plugins/security_solution/server/utils/route_limited_concurrency_tag.ts diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 7bb433738b30a..a99a3f8ee2fe9 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -375,3 +375,30 @@ export const WARNING_TRANSFORM_STATES = new Set([ TRANSFORM_STATES.STOPPED, TRANSFORM_STATES.STOPPING, ]); + +/** + * How many rules to update at a time is set to 50 from errors coming from + * the slow environments such as cloud when the rule updates are > 100 we were + * seeing timeout issues. + * + * Since there is not timeout options at the alerting API level right now, we are + * at the mercy of the Elasticsearch server client/server default timeouts and what + * we are doing could be considered a workaround to not being able to increase the timeouts. + * + * However, other bad effects and saturation of connections beyond 50 makes this a "noisy neighbor" + * if we don't limit its number of connections as we increase the number of rules that can be + * installed at a time. + * + * Lastly, we saw weird issues where Chrome on upstream 408 timeouts will re-call the REST route + * which in turn could create additional connections we want to avoid. + * + * See file import_rules_route.ts for another area where 50 was chosen, therefore I chose + * 50 here to mimic it as well. If you see this re-opened or what similar to it, consider + * reducing the 50 above to a lower number. + * + * See the original ticket here: + * https://github.com/elastic/kibana/issues/94418 + */ +export const MAX_RULES_TO_UPDATE_IN_PARALLEL = 50; + +export const LIMITED_CONCURRENCY_ROUTE_TAG_PREFIX = `${APP_ID}:limitedConcurrency`; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts index 23c45c03b62a0..7e4a4fd1295bd 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts @@ -453,6 +453,53 @@ export enum BulkAction { 'export' = 'export', 'delete' = 'delete', 'duplicate' = 'duplicate', + 'edit' = 'edit', } export const bulkAction = enumeration('BulkAction', BulkAction); + +export enum BulkActionEditType { + 'add_tags' = 'add_tags', + 'delete_tags' = 'delete_tags', + 'set_tags' = 'set_tags', + 'add_index_patterns' = 'add_index_patterns', + 'delete_index_patterns' = 'delete_index_patterns', + 'set_index_patterns' = 'set_index_patterns', + 'set_timeline' = 'set_timeline', +} + +export const bulkActionEditType = enumeration('BulkActionEditType', BulkActionEditType); + +const bulkActionEditPayloadTags = t.type({ + type: t.union([ + t.literal(BulkActionEditType.add_tags), + t.literal(BulkActionEditType.delete_tags), + t.literal(BulkActionEditType.set_tags), + ]), + value: tags, +}); + +const bulkActionEditPayloadIndexPatterns = t.type({ + type: t.union([ + t.literal(BulkActionEditType.add_index_patterns), + t.literal(BulkActionEditType.delete_index_patterns), + t.literal(BulkActionEditType.set_index_patterns), + ]), + value: index, +}); + +const bulkActionEditPayloadTimeline = t.type({ + type: t.literal(BulkActionEditType.set_timeline), + value: t.type({ + timeline_id, + timeline_title, + }), +}); + +export const bulkActionEditPayload = t.union([ + bulkActionEditPayloadTags, + bulkActionEditPayloadIndexPatterns, + bulkActionEditPayloadTimeline, +]); + +export type BulkActionEditPayload = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.mock.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.mock.ts index cb78168fbec6e..b6c241dfd15d2 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.mock.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.mock.ts @@ -5,10 +5,16 @@ * 2.0. */ -import { BulkAction } from '../common/schemas'; +import { BulkAction, BulkActionEditType } from '../common/schemas'; import { PerformBulkActionSchema } from './perform_bulk_action_schema'; export const getPerformBulkActionSchemaMock = (): PerformBulkActionSchema => ({ query: '', action: BulkAction.disable, }); + +export const getPerformBulkActionEditSchemaMock = (): PerformBulkActionSchema => ({ + query: '', + action: BulkAction.edit, + [BulkAction.edit]: [{ type: BulkActionEditType.add_tags, value: ['tag1'] }], +}); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.test.ts index a9707b88f5240..855b7ea506d81 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.test.ts @@ -8,61 +8,358 @@ import { performBulkActionSchema, PerformBulkActionSchema } from './perform_bulk_action_schema'; import { exactCheck, foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils'; import { left } from 'fp-ts/lib/Either'; -import { BulkAction } from '../common/schemas'; +import { BulkAction, BulkActionEditType } from '../common/schemas'; + +const retrieveValidationMessage = (payload: unknown) => { + const decoded = performBulkActionSchema.decode(payload); + const checked = exactCheck(payload, decoded); + return foldLeftRight(checked); +}; describe('perform_bulk_action_schema', () => { - test('query and action is valid', () => { - const payload: PerformBulkActionSchema = { - query: 'name: test', - action: BulkAction.enable, - }; - - const decoded = performBulkActionSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = foldLeftRight(checked); - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(payload); + describe('cases common to every bulk action', () => { + // missing query means it will request for all rules + test('valid request: missing query', () => { + const payload: PerformBulkActionSchema = { + query: undefined, + action: BulkAction.enable, + }; + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('invalid request: missing action', () => { + const payload: Omit = { + query: 'name: test', + }; + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "action"', + 'Invalid value "undefined" supplied to "edit"', + ]); + expect(message.schema).toEqual({}); + }); + + test('invalid request: unknown action', () => { + const payload: Omit & { action: 'unknown' } = { + query: 'name: test', + action: 'unknown', + }; + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "unknown" supplied to "action"', + 'Invalid value "undefined" supplied to "edit"', + ]); + expect(message.schema).toEqual({}); + }); + + test('invalid request: unknown property', () => { + const payload = { + query: 'name: test', + action: BulkAction.enable, + ids: ['id'], + }; + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual(['invalid keys "ids,["id"]"']); + expect(message.schema).toEqual({}); + }); + }); + + describe('bulk enable', () => { + test('valid request', () => { + const payload: PerformBulkActionSchema = { + query: 'name: test', + action: BulkAction.enable, + }; + const message = retrieveValidationMessage(payload); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + }); + + describe('bulk disable', () => { + test('valid request', () => { + const payload: PerformBulkActionSchema = { + query: 'name: test', + action: BulkAction.disable, + }; + const message = retrieveValidationMessage(payload); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + }); + + describe('bulk export', () => { + test('valid request', () => { + const payload: PerformBulkActionSchema = { + query: 'name: test', + action: BulkAction.export, + }; + const message = retrieveValidationMessage(payload); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); }); - test('missing query is valid', () => { - const payload: PerformBulkActionSchema = { - query: undefined, - action: BulkAction.enable, - }; - - const decoded = performBulkActionSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = foldLeftRight(checked); - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(payload); + describe('bulk delete', () => { + test('valid request', () => { + const payload: PerformBulkActionSchema = { + query: 'name: test', + action: BulkAction.delete, + }; + const message = retrieveValidationMessage(payload); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); }); - test('missing action is invalid', () => { - const payload: Omit = { - query: 'name: test', - }; - - const decoded = performBulkActionSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = foldLeftRight(checked); - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "action"', - ]); - expect(message.schema).toEqual({}); + describe('bulk duplicate', () => { + test('valid request', () => { + const payload: PerformBulkActionSchema = { + query: 'name: test', + action: BulkAction.duplicate, + }; + const message = retrieveValidationMessage(payload); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); }); - test('unknown action is invalid', () => { - const payload: Omit & { action: 'unknown' } = { - query: 'name: test', - action: 'unknown', - }; - - const decoded = performBulkActionSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = foldLeftRight(checked); - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "unknown" supplied to "action"', - ]); - expect(message.schema).toEqual({}); + describe('bulk edit', () => { + describe('cases common to every type of editing', () => { + test('invalid request: missing edit payload', () => { + const payload = { + query: 'name: test', + action: BulkAction.edit, + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "edit" supplied to "action"', + 'Invalid value "undefined" supplied to "edit"', + ]); + expect(message.schema).toEqual({}); + }); + + test('invalid request: specified edit payload for another action', () => { + const payload = { + query: 'name: test', + action: BulkAction.enable, + [BulkAction.edit]: [{ type: BulkActionEditType.set_tags, value: ['test-tag'] }], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([ + 'invalid keys "edit,[{"type":"set_tags","value":["test-tag"]}]"', + ]); + expect(message.schema).toEqual({}); + }); + + test('invalid request: wrong type for edit payload', () => { + const payload = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: { type: BulkActionEditType.set_tags, value: ['test-tag'] }, + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "edit" supplied to "action"', + 'Invalid value "{"type":"set_tags","value":["test-tag"]}" supplied to "edit"', + ]); + expect(message.schema).toEqual({}); + }); + }); + + describe('tags', () => { + test('invalid request: wrong tags type', () => { + const payload = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [{ type: BulkActionEditType.set_tags, value: 'test-tag' }], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "edit" supplied to "action"', + 'Invalid value "test-tag" supplied to "edit,value"', + 'Invalid value "set_tags" supplied to "edit,type"', + ]); + expect(message.schema).toEqual({}); + }); + + test('valid request: add_tags edit action', () => { + const payload = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [{ type: BulkActionEditType.add_tags, value: ['test-tag'] }], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('valid request: set_tags edit action', () => { + const payload = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [{ type: BulkActionEditType.set_tags, value: ['test-tag'] }], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('valid request: delete_tags edit action', () => { + const payload = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [{ type: BulkActionEditType.delete_tags, value: ['test-tag'] }], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + }); + + describe('index_patterns', () => { + test('invalid request: wrong index_patterns type', () => { + const payload = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [{ type: BulkActionEditType.set_tags, value: 'logs-*' }], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "edit" supplied to "action"', + 'Invalid value "logs-*" supplied to "edit,value"', + 'Invalid value "set_tags" supplied to "edit,type"', + ]); + expect(message.schema).toEqual({}); + }); + + test('valid request: set_index_patterns edit action', () => { + const payload: PerformBulkActionSchema = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [{ type: BulkActionEditType.set_index_patterns, value: ['logs-*'] }], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('valid request: add_index_patterns edit action', () => { + const payload: PerformBulkActionSchema = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [{ type: BulkActionEditType.add_index_patterns, value: ['logs-*'] }], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('valid request: delete_index_patterns edit action', () => { + const payload: PerformBulkActionSchema = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [ + { type: BulkActionEditType.delete_index_patterns, value: ['logs-*'] }, + ], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + }); + + describe('timeline', () => { + test('invalid request: wrong timeline payload type', () => { + const payload = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [{ type: BulkActionEditType.set_timeline, value: [] }], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "edit" supplied to "action"', + 'Invalid value "set_timeline" supplied to "edit,type"', + 'Invalid value "[]" supplied to "edit,value"', + ]); + expect(message.schema).toEqual({}); + }); + + test('invalid request: missing timeline_id', () => { + const payload = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.set_timeline, + value: { + timeline_title: 'Test timeline title', + }, + }, + ], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "edit" supplied to "action"', + 'Invalid value "set_timeline" supplied to "edit,type"', + 'Invalid value "{"timeline_title":"Test timeline title"}" supplied to "edit,value"', + 'Invalid value "undefined" supplied to "edit,value,timeline_id"', + ]); + expect(message.schema).toEqual({}); + }); + + test('valid request: set_timeline edit action', () => { + const payload: PerformBulkActionSchema = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.set_timeline, + value: { + timeline_id: 'timelineid', + timeline_title: 'Test timeline title', + }, + }, + ], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.ts index adb26f107c8cd..02de2f845b85d 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.ts @@ -6,13 +6,33 @@ */ import * as t from 'io-ts'; -import { bulkAction, queryOrUndefined } from '../common/schemas'; +import { BulkAction, queryOrUndefined, bulkActionEditPayload } from '../common/schemas'; -export const performBulkActionSchema = t.exact( - t.type({ - query: queryOrUndefined, - action: bulkAction, - }) -); +export const performBulkActionSchema = t.intersection([ + t.exact( + t.type({ + query: queryOrUndefined, + }) + ), + t.union([ + t.exact( + t.type({ + action: t.union([ + t.literal(BulkAction.delete), + t.literal(BulkAction.disable), + t.literal(BulkAction.duplicate), + t.literal(BulkAction.enable), + t.literal(BulkAction.export), + ]), + }) + ), + t.exact( + t.type({ + action: t.literal(BulkAction.edit), + [BulkAction.edit]: t.array(bulkActionEditPayload), + }) + ), + ]), +]); export type PerformBulkActionSchema = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index 2b5182578d4b2..c8af729ec3a68 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -38,8 +38,6 @@ export const METADATA_UNITED_INDEX = '.metrics-endpoint.metadata_united_default' export const policyIndexPattern = 'metrics-endpoint.policy-*'; export const telemetryIndexPattern = 'metrics-endpoint.telemetry-*'; -export const LIMITED_CONCURRENCY_ENDPOINT_ROUTE_TAG = 'endpoint:limited-concurrency'; -export const LIMITED_CONCURRENCY_ENDPOINT_COUNT = 100; export const BASE_ENDPOINT_ROUTE = '/api/endpoint'; export const HOST_METADATA_LIST_ROUTE = `${BASE_ENDPOINT_ROUTE}/metadata`; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_reducer.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_reducer.ts index 7d32785222fed..2cc022ca7412c 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_reducer.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_reducer.ts @@ -14,6 +14,7 @@ export type LoadingRuleAction = | 'disable' | 'export' | 'delete' + | 'edit' | null; export interface RulesTableState { diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts index 1411ed25b6e89..2507d5a9596b6 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts @@ -235,6 +235,7 @@ export type BulkActionResponse = { [BulkAction.enable]: BulkActionResult; [BulkAction.duplicate]: BulkActionResult; [BulkAction.export]: Blob; + [BulkAction.edit]: BulkActionResult; }[Action]; export interface BasicFetchProps { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/limited_concurrency.ts b/x-pack/plugins/security_solution/server/endpoint/routes/limited_concurrency.ts deleted file mode 100644 index 916d78107e20f..0000000000000 --- a/x-pack/plugins/security_solution/server/endpoint/routes/limited_concurrency.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - CoreSetup, - KibanaRequest, - LifecycleResponseFactory, - OnPreAuthToolkit, -} from 'kibana/server'; -import { - LIMITED_CONCURRENCY_ENDPOINT_ROUTE_TAG, - LIMITED_CONCURRENCY_ENDPOINT_COUNT, -} from '../../../common/endpoint/constants'; - -class MaxCounter { - constructor(private readonly max: number = 1) {} - private counter = 0; - valueOf() { - return this.counter; - } - increase() { - if (this.counter < this.max) { - this.counter += 1; - } - } - decrease() { - if (this.counter > 0) { - this.counter -= 1; - } - } - lessThanMax() { - return this.counter < this.max; - } -} - -function shouldHandleRequest(request: KibanaRequest) { - const tags = request.route.options.tags; - return tags.includes(LIMITED_CONCURRENCY_ENDPOINT_ROUTE_TAG); -} - -export function registerLimitedConcurrencyRoutes(core: CoreSetup) { - const counter = new MaxCounter(LIMITED_CONCURRENCY_ENDPOINT_COUNT); - core.http.registerOnPreAuth(function preAuthHandler( - request: KibanaRequest, - response: LifecycleResponseFactory, - toolkit: OnPreAuthToolkit - ) { - if (!shouldHandleRequest(request)) { - return toolkit.next(); - } - - if (!counter.lessThanMax()) { - return response.customError({ - body: 'Too Many Requests', - statusCode: 429, - }); - } - - counter.increase(); - - // requests.events.aborted$ has a bug (but has test which explicitly verifies) where it's fired even when the request completes - // https://github.com/elastic/kibana/pull/70495#issuecomment-656288766 - request.events.aborted$.toPromise().then(() => { - counter.decrease(); - }); - - return toolkit.next(); - }); -} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 073a0bcebdc7a..d186c88e8458e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -36,7 +36,10 @@ import { getSignalsMigrationStatusSchemaMock } from '../../../../../common/detec import { RuleParams } from '../../schemas/rule_schemas'; import { SanitizedAlert, ResolvedSanitizedRule } from '../../../../../../alerting/common'; import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; -import { getPerformBulkActionSchemaMock } from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema.mock'; +import { + getPerformBulkActionSchemaMock, + getPerformBulkActionEditSchemaMock, +} from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema.mock'; import { RuleExecutionStatus } from '../../../../../common/detection_engine/schemas/common/schemas'; import { GetCurrentStatusBulkResult } from '../../rule_execution_log/types'; // eslint-disable-next-line no-restricted-imports @@ -127,6 +130,13 @@ export const getBulkActionRequest = () => body: getPerformBulkActionSchemaMock(), }); +export const getBulkActionEditRequest = () => + requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_BULK_ACTION, + body: getPerformBulkActionEditSchemaMock(), + }); + export const getDeleteBulkRequest = () => requestMock.create({ method: 'delete', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts index 9e821c8f686f6..1a79d12ae1b18 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts @@ -144,7 +144,6 @@ export const patchRulesBulkRoute = ( const rule = await patchRules({ rule: migratedRule, rulesClient, - savedObjectsClient, author, buildingBlockType, description, @@ -157,8 +156,6 @@ export const patchRulesBulkRoute = ( license, outputIndex, savedId, - spaceId: context.securitySolution.getSpaceId(), - ruleStatusClient, timelineId, timelineTitle, meta, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts index da3e4ccc99b99..6d11fc5851625 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts @@ -144,7 +144,6 @@ export const patchRulesRoute = ( const rule = await patchRules({ rulesClient, - savedObjectsClient, author, buildingBlockType, description, @@ -157,8 +156,6 @@ export const patchRulesRoute = ( license, outputIndex, savedId, - spaceId: context.securitySolution.getSpaceId(), - ruleStatusClient, timelineId, timelineTitle, meta, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.test.ts index 3e85b4898d01c..c99760b72b56b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.test.ts @@ -11,6 +11,7 @@ import { buildMlAuthz } from '../../../machine_learning/authz'; import { getEmptyFindResult, getBulkActionRequest, + getBulkActionEditRequest, getFindResultWithSingleHit, getFindResultWithMultiHits, } from '../__mocks__/request_responses'; @@ -18,24 +19,28 @@ import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { performBulkActionRoute } from './perform_bulk_action_route'; import { getPerformBulkActionSchemaMock } from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema.mock'; import { loggingSystemMock } from 'src/core/server/mocks'; +import { isElasticRule } from '../../../../usage/detections'; jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); +jest.mock('../../../../usage/detections', () => ({ isElasticRule: jest.fn() })); describe.each([ ['Legacy', false], ['RAC', true], ])('perform_bulk_action - %s', (_, isRuleRegistryEnabled) => { + const isElasticRuleMock = isElasticRule as jest.Mock; let server: ReturnType; let { clients, context } = requestContextMock.createTools(); let ml: ReturnType; let logger: ReturnType; + const mockRule = getFindResultWithSingleHit(isRuleRegistryEnabled).data[0]; beforeEach(() => { server = serverMock.create(); logger = loggingSystemMock.createLogger(); ({ clients, context } = requestContextMock.createTools()); ml = mlServicesMock.createSetupContract(); - + isElasticRuleMock.mockReturnValue(false); clients.rulesClient.find.mockResolvedValue(getFindResultWithSingleHit(isRuleRegistryEnabled)); performBulkActionRoute(server.router, ml, logger, isRuleRegistryEnabled); @@ -73,20 +78,78 @@ describe.each([ expect(response.status).toEqual(404); expect(response.body).toEqual({ message: 'Not Found', status_code: 404 }); }); + }); + + describe('rules execution failures', () => { + it('returns error if rule is immutable/elastic', async () => { + isElasticRuleMock.mockReturnValue(true); + clients.rulesClient.find.mockResolvedValue( + getFindResultWithMultiHits({ + data: [mockRule], + total: 1, + }) + ); - it('catches error if disable throws error', async () => { + const response = await server.inject(getBulkActionEditRequest(), context); + + expect(response.status).toEqual(500); + expect(response.body).toEqual({ + message: 'Bulk edit failed', + status_code: 500, + attributes: { + errors: [ + { + message: 'Elastic rule can`t be edited', + status_code: 403, + rules: [ + { + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + name: 'Detect Root/Admin Users', + }, + ], + }, + ], + rules: { + failed: 1, + succeeded: 0, + total: 1, + }, + }, + }); + }); + + it('returns error if disable rule throws error', async () => { clients.rulesClient.disable.mockImplementation(async () => { throw new Error('Test error'); }); const response = await server.inject(getBulkActionRequest(), context); expect(response.status).toEqual(500); expect(response.body).toEqual({ - message: 'Test error', + message: 'Bulk edit failed', status_code: 500, + attributes: { + errors: [ + { + message: 'Test error', + status_code: 500, + rules: [ + { + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + name: 'Detect Root/Admin Users', + }, + ], + }, + ], + rules: { + failed: 1, + succeeded: 0, + total: 1, + }, + }, }); }); - it('rejects patching a rule if mlAuthz fails', async () => { + it('returns error if machine learning rule validation fails', async () => { (buildMlAuthz as jest.Mock).mockReturnValueOnce({ validateRuleType: jest .fn() @@ -94,12 +157,105 @@ describe.each([ }); const response = await server.inject(getBulkActionRequest(), context); - expect(response.status).toEqual(403); + expect(response.status).toEqual(500); + expect(response.body).toEqual({ + attributes: { + errors: [ + { + message: 'mocked validation message', + status_code: 403, + rules: [ + { + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + name: 'Detect Root/Admin Users', + }, + ], + }, + ], + rules: { + failed: 1, + succeeded: 0, + total: 1, + }, + }, + message: 'Bulk edit failed', + status_code: 500, + }); + }); + + it('returns partial failure error if couple of rule validations fail and the rest are successfull', async () => { + clients.rulesClient.find.mockResolvedValue( + getFindResultWithMultiHits({ + data: [ + { ...mockRule, id: 'failed-rule-id-1' }, + { ...mockRule, id: 'failed-rule-id-2' }, + { ...mockRule, id: 'failed-rule-id-3' }, + mockRule, + mockRule, + ], + total: 5, + }) + ); + + (buildMlAuthz as jest.Mock).mockReturnValueOnce({ + validateRuleType: jest + .fn() + .mockImplementationOnce(() => ({ valid: false, message: 'mocked validation message' })) + .mockImplementationOnce(() => ({ valid: false, message: 'mocked validation message' })) + .mockImplementationOnce(() => ({ valid: false, message: 'test failure' })) + .mockImplementationOnce(() => ({ valid: true })) + .mockImplementationOnce(() => ({ valid: true })), + }); + const response = await server.inject(getBulkActionEditRequest(), context); + + expect(response.status).toEqual(500); expect(response.body).toEqual({ - message: 'mocked validation message', - status_code: 403, + attributes: { + rules: { + failed: 3, + succeeded: 2, + total: 5, + }, + errors: [ + { + message: 'mocked validation message', + status_code: 403, + rules: [ + { + id: 'failed-rule-id-1', + name: 'Detect Root/Admin Users', + }, + { + id: 'failed-rule-id-2', + name: 'Detect Root/Admin Users', + }, + ], + }, + { + message: 'test failure', + status_code: 403, + rules: [ + { + id: 'failed-rule-id-3', + name: 'Detect Root/Admin Users', + }, + ], + }, + ], + }, + message: 'Bulk edit partially failed', + status_code: 500, }); }); + + it('return error message limited to length of 1000, to prevent large response size', async () => { + clients.rulesClient.disable.mockImplementation(async () => { + throw new Error('a'.repeat(1_300)); + }); + const response = await server.inject(getBulkActionRequest(), context); + expect(response.status).toEqual(500); + expect(response.body.attributes.errors[0].message.length).toEqual(1000); + }); }); describe('request validation', () => { @@ -111,7 +267,7 @@ describe.each([ }); const result = server.validate(request); expect(result.badRequest).toHaveBeenCalledWith( - 'Invalid value "undefined" supplied to "action"' + 'Invalid value "undefined" supplied to "action",Invalid value "undefined" supplied to "edit"' ); }); @@ -123,7 +279,7 @@ describe.each([ }); const result = server.validate(request); expect(result.badRequest).toHaveBeenCalledWith( - 'Invalid value "unknown" supplied to "action"' + 'Invalid value "unknown" supplied to "action",Invalid value "undefined" supplied to "edit"' ); }); @@ -149,4 +305,19 @@ describe.each([ expect(result.ok).toHaveBeenCalled(); }); }); + + it('should process large number of rules, larger than configured concurrency', async () => { + const rulesNumber = 6_000; + clients.rulesClient.find.mockResolvedValue( + getFindResultWithMultiHits({ + data: Array.from({ length: rulesNumber }).map(() => mockRule), + total: rulesNumber, + }) + ); + + const response = await server.inject(getBulkActionEditRequest(), context); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ success: true, rules_count: rulesNumber }); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts index fb766124ea6ee..f263cd7b9cec1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts @@ -5,25 +5,129 @@ * 2.0. */ +import moment from 'moment'; import { transformError } from '@kbn/securitysolution-es-utils'; import { Logger } from 'src/core/server'; -import { DETECTION_ENGINE_RULES_BULK_ACTION } from '../../../../../common/constants'; +import { RuleAlertType as Rule } from '../../rules/types'; + +import { + DETECTION_ENGINE_RULES_BULK_ACTION, + MAX_RULES_TO_UPDATE_IN_PARALLEL, +} from '../../../../../common/constants'; import { BulkAction } from '../../../../../common/detection_engine/schemas/common/schemas'; import { performBulkActionSchema } from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema'; import { SetupPlugins } from '../../../../plugin'; import type { SecuritySolutionPluginRouter } from '../../../../types'; import { buildRouteValidation } from '../../../../utils/build_validation/route_validation'; +import { routeLimitedConcurrencyTag } from '../../../../utils/route_limited_concurrency_tag'; +import { initPromisePool } from '../../../../utils/promise_pool'; +import { isElasticRule } from '../../../../usage/detections'; import { buildMlAuthz } from '../../../machine_learning/authz'; import { throwHttpError } from '../../../machine_learning/validation'; import { deleteRules } from '../../rules/delete_rules'; import { duplicateRule } from '../../rules/duplicate_rule'; import { enableRule } from '../../rules/enable_rule'; import { findRules } from '../../rules/find_rules'; +import { patchRules } from '../../rules/patch_rules'; +import { appplyBulkActionEditToRule } from '../../rules/bulk_action_edit'; import { getExportByObjectIds } from '../../rules/get_export_by_object_ids'; import { buildSiemResponse } from '../utils'; -const BULK_ACTION_RULES_LIMIT = 10000; +const MAX_RULES_TO_PROCESS_TOTAL = 10000; +const MAX_ERROR_MESSAGE_LENGTH = 1000; +const MAX_ROUTE_CONCURRENCY = 5; + +type RuleActionFn = (rule: Rule) => Promise; + +type RuleActionSuccess = undefined; + +type RuleActionResult = RuleActionSuccess | RuleActionError; + +interface RuleActionError { + error: { + message: string; + statusCode: number; + }; + rule: { + id: string; + name: string; + }; +} + +interface NormalizedRuleError { + message: string; + status_code: number; + rules: Array<{ + id: string; + name: string; + }>; +} + +const normalizeErrorResponse = (errors: RuleActionError[]): NormalizedRuleError[] => { + const errorsMap = new Map(); + + errors.forEach((ruleError) => { + const { message } = ruleError.error; + if (errorsMap.has(message)) { + errorsMap.get(message).rules.push(ruleError.rule); + } else { + const { error, rule } = ruleError; + errorsMap.set(message, { + message: error.message, + status_code: error.statusCode, + rules: [rule], + }); + } + }); + + return Array.from(errorsMap, ([_, normalizedError]) => normalizedError); +}; + +const getErrorResponseBody = (errors: RuleActionError[], rulesCount: number) => { + const errorsCount = errors.length; + return { + message: errorsCount === rulesCount ? 'Bulk edit failed' : 'Bulk edit partially failed', + status_code: 500, + attributes: { + errors: normalizeErrorResponse(errors).map(({ message, ...error }) => ({ + ...error, + message: + message.length > MAX_ERROR_MESSAGE_LENGTH + ? `${message.slice(0, MAX_ERROR_MESSAGE_LENGTH - 3)}...` + : message, + })), + rules: { + total: rulesCount, + failed: errorsCount, + succeeded: rulesCount - errorsCount, + }, + }, + }; +}; + +const executeActionAndHandleErrors = async ( + rule: Rule, + action: RuleActionFn +): Promise => { + try { + await action(rule); + } catch (err) { + const { message, statusCode } = transformError(err); + return { + error: { message, statusCode }, + rule: { id: rule.id, name: rule.name }, + }; + } +}; + +const executeBulkAction = async (rules: Rule[], action: RuleActionFn, abortSignal: AbortSignal) => + initPromisePool({ + concurrency: MAX_RULES_TO_UPDATE_IN_PARALLEL, + items: rules, + executor: async (rule) => executeActionAndHandleErrors(rule, action), + abortSignal, + }); export const performBulkActionRoute = ( router: SecuritySolutionPluginRouter, @@ -38,12 +142,20 @@ export const performBulkActionRoute = ( body: buildRouteValidation(performBulkActionSchema), }, options: { - tags: ['access:securitySolution'], + tags: ['access:securitySolution', routeLimitedConcurrencyTag(MAX_ROUTE_CONCURRENCY)], + timeout: { + idleSocket: moment.duration(15, 'minutes').asMilliseconds(), + }, }, }, async (context, request, response) => { const { body } = request; const siemResponse = buildSiemResponse(response); + const abortController = new AbortController(); + + // subscribing to completed$, because it handles both cases when request was completed and aborted. + // when route is finished by timeout, aborted$ is not getting fired + request.events.completed$.subscribe(() => abortController.abort()); try { const rulesClient = context.alerting?.getRulesClient(); @@ -65,7 +177,7 @@ export const performBulkActionRoute = ( const rules = await findRules({ isRuleRegistryEnabled, rulesClient, - perPage: BULK_ACTION_RULES_LIMIT, + perPage: MAX_RULES_TO_PROCESS_TOTAL, filter: body.query !== '' ? body.query : undefined, page: undefined, sortField: undefined, @@ -73,17 +185,23 @@ export const performBulkActionRoute = ( fields: undefined, }); - if (rules.total > BULK_ACTION_RULES_LIMIT) { + if (rules.total > MAX_RULES_TO_PROCESS_TOTAL) { return siemResponse.error({ - body: `More than ${BULK_ACTION_RULES_LIMIT} rules matched the filter query. Try to narrow it down.`, + body: `More than ${MAX_RULES_TO_PROCESS_TOTAL} rules matched the filter query. Try to narrow it down.`, statusCode: 400, }); } + let processingResponse: { + results: RuleActionResult[]; + } = { + results: [], + }; switch (body.action) { case BulkAction.enable: - await Promise.all( - rules.data.map(async (rule) => { + processingResponse = await executeBulkAction( + rules.data, + async (rule) => { if (!rule.enabled) { throwHttpError(await mlAuthz.validateRuleType(rule.params.type)); await enableRule({ @@ -91,39 +209,46 @@ export const performBulkActionRoute = ( rulesClient, }); } - }) + }, + abortController.signal ); break; case BulkAction.disable: - await Promise.all( - rules.data.map(async (rule) => { + processingResponse = await executeBulkAction( + rules.data, + async (rule) => { if (rule.enabled) { throwHttpError(await mlAuthz.validateRuleType(rule.params.type)); await rulesClient.disable({ id: rule.id }); } - }) + }, + abortController.signal ); break; case BulkAction.delete: - await Promise.all( - rules.data.map(async (rule) => { + processingResponse = await executeBulkAction( + rules.data, + async (rule) => { await deleteRules({ ruleId: rule.id, rulesClient, ruleStatusClient, }); - }) + }, + abortController.signal ); break; case BulkAction.duplicate: - await Promise.all( - rules.data.map(async (rule) => { + processingResponse = await executeBulkAction( + rules.data, + async (rule) => { throwHttpError(await mlAuthz.validateRuleType(rule.params.type)); await rulesClient.create({ data: duplicateRule(rule, isRuleRegistryEnabled), }); - }) + }, + abortController.signal ); break; case BulkAction.export: @@ -145,9 +270,65 @@ export const performBulkActionRoute = ( }, body: responseBody, }); + case BulkAction.edit: + processingResponse = await executeBulkAction( + rules.data, + async (rule) => { + throwHttpError({ + valid: !isElasticRule(rule.tags), + message: 'Elastic rule can`t be edited', + }); + + throwHttpError(await mlAuthz.validateRuleType(rule.params.type)); + + const editedRule = body[BulkAction.edit].reduce( + (acc, action) => appplyBulkActionEditToRule(acc, action), + rule + ); + + const { tags, params: { timelineTitle, timelineId } = {} } = editedRule; + const index = 'index' in editedRule.params ? editedRule.params.index : undefined; + + await patchRules({ + rulesClient, + rule, + tags, + index, + timelineTitle, + timelineId, + }); + }, + abortController.signal + ); + } + + if (abortController.signal.aborted === true) { + throw Error('Bulk action was aborted'); + } + + const errors = processingResponse.results.filter( + (resp): resp is RuleActionError => resp?.error !== undefined + ); + const rulesCount = rules.data.length; + + if (errors.length > 0) { + const responseBody = getErrorResponseBody(errors, rulesCount); + + return response.custom({ + headers: { + 'content-type': 'application/json', + }, + body: Buffer.from(JSON.stringify(responseBody)), + statusCode: 500, + }); } - return response.ok({ body: { success: true, rules_count: rules.data.length } }); + return response.ok({ + body: { + success: true, + rules_count: rulesCount, + }, + }); } catch (err) { const error = transformError(err); return siemResponse.error({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts index db4887f14108e..e3a125e50bfe9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts @@ -85,13 +85,10 @@ export const updateRulesBulkRoute = ( }); const rule = await updateRules({ - spaceId: context.securitySolution.getSpaceId(), rulesClient, - ruleStatusClient, defaultOutputIndex: siemClient.getSignalsIndex(), existingRule: migratedRule, ruleUpdate: payloadRule, - isRuleRegistryEnabled, }); if (rule != null) { const ruleStatus = await ruleStatusClient.getCurrentStatus({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts index d18171c489512..f8bb60eb5f77f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts @@ -76,12 +76,9 @@ export const updateRulesRoute = ( }); const rule = await updateRules({ defaultOutputIndex: siemClient.getSignalsIndex(), - isRuleRegistryEnabled, rulesClient, - ruleStatusClient, existingRule: migratedRule, ruleUpdate: request.body, - spaceId: context.securitySolution.getSpaceId(), }); if (rule != null) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/import_rules_utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/import_rules_utils.ts index 02f3ab46f7cf2..3f0adaf58a2fd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/import_rules_utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/import_rules_utils.ts @@ -243,11 +243,8 @@ export const importRules = async ({ }); await patchRules({ rulesClient, - savedObjectsClient, author, buildingBlockType, - spaceId, - ruleStatusClient, description, enabled, eventCategoryOverride, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/bulk_action_edit.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/bulk_action_edit.test.ts new file mode 100644 index 0000000000000..db6ef37cade36 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/bulk_action_edit.test.ts @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + addItemsToArray, + deleteItemsFromArray, + appplyBulkActionEditToRule, +} from './bulk_action_edit'; +import { BulkActionEditType } from '../../../../common/detection_engine/schemas/common/schemas'; +import { RuleAlertType } from './types'; +describe('bulk_action_edit', () => { + describe('addItemsToArray', () => { + test('should add single item to array', () => { + expect(addItemsToArray(['a', 'b', 'c'], ['d'])).toEqual(['a', 'b', 'c', 'd']); + }); + + test('should add multiple items to array', () => { + expect(addItemsToArray(['a', 'b', 'c'], ['d', 'e'])).toEqual(['a', 'b', 'c', 'd', 'e']); + }); + + test('should not allow to add duplicated items', () => { + expect(addItemsToArray(['a', 'c'], ['b', 'c'])).toEqual(['a', 'c', 'b']); + }); + }); + + describe('deleteItemsFromArray', () => { + test('should remove single item from array', () => { + expect(deleteItemsFromArray(['a', 'b', 'c'], ['c'])).toEqual(['a', 'b']); + }); + + test('should remove multiple items from array', () => { + expect(deleteItemsFromArray(['a', 'b', 'c'], ['b', 'c'])).toEqual(['a']); + }); + + test('should return array unchanged if items to remove absent in array', () => { + expect(deleteItemsFromArray(['a', 'c'], ['x', 'z'])).toEqual(['a', 'c']); + }); + }); + + describe('appplyBulkActionEditToRule', () => { + const ruleMock = { + tags: ['tag1', 'tag2'], + params: { index: ['initial-index-*'] }, + }; + describe('tags', () => { + test('should add new tags to rule', () => { + const editedRule = appplyBulkActionEditToRule(ruleMock as RuleAlertType, { + type: BulkActionEditType.add_tags, + value: ['new_tag'], + }); + expect(editedRule.tags).toEqual(['tag1', 'tag2', 'new_tag']); + }); + test('should remove tag from rule', () => { + const editedRule = appplyBulkActionEditToRule(ruleMock as RuleAlertType, { + type: BulkActionEditType.delete_tags, + value: ['tag1'], + }); + expect(editedRule.tags).toEqual(['tag2']); + }); + + test('should rewrite tags in rule', () => { + const editedRule = appplyBulkActionEditToRule(ruleMock as RuleAlertType, { + type: BulkActionEditType.set_tags, + value: ['tag_r_1', 'tag_r_2'], + }); + expect(editedRule.tags).toEqual(['tag_r_1', 'tag_r_2']); + }); + }); + + describe('index_patterns', () => { + test('should add new index pattern to rule', () => { + const editedRule = appplyBulkActionEditToRule(ruleMock as RuleAlertType, { + type: BulkActionEditType.add_index_patterns, + value: ['my-index-*'], + }); + expect(editedRule.params).toHaveProperty('index', ['initial-index-*', 'my-index-*']); + }); + test('should remove index pattern from rule', () => { + const editedRule = appplyBulkActionEditToRule(ruleMock as RuleAlertType, { + type: BulkActionEditType.delete_index_patterns, + value: ['initial-index-*'], + }); + expect(editedRule.params).toHaveProperty('index', []); + }); + + test('should rewrite index pattern in rule', () => { + const editedRule = appplyBulkActionEditToRule(ruleMock as RuleAlertType, { + type: BulkActionEditType.set_index_patterns, + value: ['index'], + }); + expect(editedRule.params).toHaveProperty('index', ['index']); + }); + + test('should not add new index pattern to rule if index pattern is absent', () => { + const editedRule = appplyBulkActionEditToRule({ params: {} } as RuleAlertType, { + type: BulkActionEditType.add_index_patterns, + value: ['my-index-*'], + }); + expect(editedRule.params).not.toHaveProperty('index'); + }); + + test('should not remove index pattern to rule if index pattern is absent', () => { + const editedRule = appplyBulkActionEditToRule({ params: {} } as RuleAlertType, { + type: BulkActionEditType.delete_index_patterns, + value: ['initial-index-*'], + }); + expect(editedRule.params).not.toHaveProperty('index'); + }); + + test('should not set index pattern to rule if index pattern is absent', () => { + const editedRule = appplyBulkActionEditToRule({ params: {} } as RuleAlertType, { + type: BulkActionEditType.set_index_patterns, + value: ['index-*'], + }); + expect(editedRule.params).not.toHaveProperty('index'); + }); + }); + + describe('timeline', () => { + test('should set timeline', () => { + const editedRule = appplyBulkActionEditToRule(ruleMock as RuleAlertType, { + type: BulkActionEditType.set_timeline, + value: { + timeline_id: '91832785-286d-4ebe-b884-1a208d111a70', + timeline_title: 'Test timeline', + }, + }); + + expect(editedRule.params.timelineId).toBe('91832785-286d-4ebe-b884-1a208d111a70'); + expect(editedRule.params.timelineTitle).toBe('Test timeline'); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/bulk_action_edit.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/bulk_action_edit.ts new file mode 100644 index 0000000000000..0f56fd86be8ed --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/bulk_action_edit.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RuleAlertType } from './types'; + +import { + BulkActionEditPayload, + BulkActionEditType, +} from '../../../../common/detection_engine/schemas/common/schemas'; + +export const addItemsToArray = (arr: T[], items: T[]): T[] => + Array.from(new Set([...arr, ...items])); + +export const deleteItemsFromArray = (arr: T[], items: T[]): T[] => { + const itemsSet = new Set(items); + return arr.filter((item) => !itemsSet.has(item)); +}; + +export const appplyBulkActionEditToRule = ( + existingRule: RuleAlertType, + action: BulkActionEditPayload +): RuleAlertType => { + const rule = { ...existingRule, params: { ...existingRule.params } }; + switch (action.type) { + // tags actions + case BulkActionEditType.add_tags: + rule.tags = addItemsToArray(rule.tags ?? [], action.value); + break; + + case BulkActionEditType.delete_tags: + rule.tags = deleteItemsFromArray(rule.tags ?? [], action.value); + break; + + case BulkActionEditType.set_tags: + rule.tags = action.value; + break; + + // index_patterns actions + // index is not present in all rule types(machine learning). But it's mandatory for the rest. + // So we check if index is present and only in that case apply action + case BulkActionEditType.add_index_patterns: + if (rule.params && 'index' in rule.params) { + rule.params.index = addItemsToArray(rule.params.index ?? [], action.value); + } + break; + + case BulkActionEditType.delete_index_patterns: + if (rule.params && 'index' in rule.params) { + rule.params.index = deleteItemsFromArray(rule.params.index ?? [], action.value); + } + break; + + case BulkActionEditType.set_index_patterns: + if (rule.params && 'index' in rule.params) { + rule.params.index = action.value; + } + break; + + // timeline actions + case BulkActionEditType.set_timeline: + rule.params = { + ...rule.params, + timelineId: action.value.timeline_id, + timelineTitle: action.value.timeline_title, + }; + } + + return rule; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts index 3a602a54ca099..2bd59abb1ea6f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts @@ -7,18 +7,13 @@ import { PatchRulesOptions } from './types'; import { rulesClientMock } from '../../../../../alerting/server/mocks'; -import { savedObjectsClientMock } from '../../../../../../../src/core/server/mocks'; import { getAlertMock } from '../routes/__mocks__/request_responses'; import { getMlRuleParams, getQueryRuleParams } from '../schemas/rule_schemas.mock'; -import { ruleExecutionLogClientMock } from '../rule_execution_log/__mocks__/rule_execution_log_client'; export const getPatchRulesOptionsMock = (isRuleRegistryEnabled: boolean): PatchRulesOptions => ({ author: ['Elastic'], buildingBlockType: undefined, rulesClient: rulesClientMock.create(), - savedObjectsClient: savedObjectsClientMock.create(), - spaceId: 'default', - ruleStatusClient: ruleExecutionLogClientMock.create(), anomalyThreshold: undefined, description: 'some description', enabled: true, @@ -71,9 +66,6 @@ export const getPatchMlRulesOptionsMock = (isRuleRegistryEnabled: boolean): Patc author: ['Elastic'], buildingBlockType: undefined, rulesClient: rulesClientMock.create(), - savedObjectsClient: savedObjectsClientMock.create(), - spaceId: 'default', - ruleStatusClient: ruleExecutionLogClientMock.create(), anomalyThreshold: 55, description: 'some description', enabled: true, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts index 8c256c54c24ab..a10247005c826 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts @@ -37,11 +37,8 @@ class PatchError extends Error { export const patchRules = async ({ rulesClient, - savedObjectsClient, author, buildingBlockType, - ruleStatusClient, - spaceId, description, eventCategoryOverride, falsePositives, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts index d4d96098477d1..e2ea5aefaea1a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts @@ -257,20 +257,17 @@ export interface CreateRulesOptions { } export interface UpdateRulesOptions { - isRuleRegistryEnabled: boolean; - spaceId: string; - ruleStatusClient: IRuleExecutionLogClient; rulesClient: RulesClient; defaultOutputIndex: string; existingRule: SanitizedAlert | null | undefined; ruleUpdate: UpdateRulesSchema; } -export interface PatchRulesOptions { - spaceId: string; - ruleStatusClient: IRuleExecutionLogClient; +export interface PatchRulesOptions extends Partial { rulesClient: RulesClient; - savedObjectsClient: SavedObjectsClientContract; + rule: SanitizedAlert | null | undefined; +} +interface PatchRulesFieldsOptions { anomalyThreshold: AnomalyThresholdOrUndefined; author: AuthorOrUndefined; buildingBlockType: BuildingBlockTypeOrUndefined; @@ -318,7 +315,6 @@ export interface PatchRulesOptions { version: VersionOrUndefined; exceptionsList: ListArrayOrUndefined; actions: RuleAlertAction[] | undefined; - rule: SanitizedAlert | null | undefined; namespace?: NamespaceOrUndefined; } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts index e24a6a883b6df..71ca8cf8f1dfa 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts @@ -8,6 +8,7 @@ import { chunk } from 'lodash/fp'; import { SavedObjectsClientContract } from 'kibana/server'; import { AddPrepackagedRulesSchemaDecoded } from '../../../../common/detection_engine/schemas/request/add_prepackaged_rules_schema'; +import { MAX_RULES_TO_UPDATE_IN_PARALLEL } from '../../../../common/constants'; import { RulesClient, PartialAlert } from '../../../../../alerting/server'; import { patchRules } from './patch_rules'; import { readRules } from './read_rules'; @@ -16,31 +17,6 @@ import { RuleParams } from '../schemas/rule_schemas'; import { IRuleExecutionLogClient } from '../rule_execution_log/types'; import { legacyMigrate } from './utils'; -/** - * How many rules to update at a time is set to 50 from errors coming from - * the slow environments such as cloud when the rule updates are > 100 we were - * seeing timeout issues. - * - * Since there is not timeout options at the alerting API level right now, we are - * at the mercy of the Elasticsearch server client/server default timeouts and what - * we are doing could be considered a workaround to not being able to increase the timeouts. - * - * However, other bad effects and saturation of connections beyond 50 makes this a "noisy neighbor" - * if we don't limit its number of connections as we increase the number of rules that can be - * installed at a time. - * - * Lastly, we saw weird issues where Chrome on upstream 408 timeouts will re-call the REST route - * which in turn could create additional connections we want to avoid. - * - * See file import_rules_route.ts for another area where 50 was chosen, therefore I chose - * 50 here to mimic it as well. If you see this re-opened or what similar to it, consider - * reducing the 50 above to a lower number. - * - * See the original ticket here: - * https://github.com/elastic/kibana/issues/94418 - */ -export const UPDATE_CHUNK_SIZE = 50; - /** * Updates the prepackaged rules given a set of rules and output index. * This implements a chunked approach to not saturate network connections and @@ -60,7 +36,7 @@ export const updatePrepackagedRules = async ( outputIndex: string, isRuleRegistryEnabled: boolean ): Promise => { - const ruleChunks = chunk(UPDATE_CHUNK_SIZE, rules); + const ruleChunks = chunk(MAX_RULES_TO_UPDATE_IN_PARALLEL, rules); for (const ruleChunk of ruleChunks) { const rulePromises = createPromises( rulesClient, @@ -162,7 +138,6 @@ export const createPromises = ( // or enable rules on the user when they were not expecting it if a rule updates return patchRules({ rulesClient, - savedObjectsClient, author, buildingBlockType, description, @@ -175,8 +150,6 @@ export const createPromises = ( outputIndex, rule: migratedRule, savedId, - spaceId, - ruleStatusClient, meta, filters, index, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts index 476a9e4d615f2..62c59bc6a698f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts @@ -27,9 +27,7 @@ class UpdateError extends Error { } export const updateRules = async ({ - spaceId, rulesClient, - ruleStatusClient, defaultOutputIndex, existingRule, ruleUpdate, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts index 4ab8afd796f6d..dee2006669f85 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts @@ -98,7 +98,7 @@ export interface UpdateProperties { timelineTitle: TimelineTitleOrUndefined; meta: MetaOrUndefined; machineLearningJobId: MachineLearningJobIdOrUndefined; - filters: PartialFilter[]; + filters: PartialFilter[] | undefined; index: IndexOrUndefined; interval: IntervalOrUndefined; maxSignals: MaxSignalsOrUndefined; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index a33d43f7e080d..dffb984763818 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -37,6 +37,7 @@ import { createThresholdAlertType, } from './lib/detection_engine/rule_types'; import { initRoutes } from './routes'; +import { registerLimitedConcurrencyRoutes } from './routes/limited_concurrency'; import { isAlertExecutor } from './lib/detection_engine/signals/types'; import { signalRulesAlertType } from './lib/detection_engine/signals/signal_rule_alert_type'; import { ManifestTask } from './endpoint/lib/artifacts'; @@ -52,7 +53,6 @@ import { DEFAULT_ALERTS_INDEX, } from '../common/constants'; import { registerEndpointRoutes } from './endpoint/routes/metadata'; -import { registerLimitedConcurrencyRoutes } from './endpoint/routes/limited_concurrency'; import { registerResolverRoutes } from './endpoint/routes/resolver'; import { registerPolicyRoutes } from './endpoint/routes/policy'; import { registerActionRoutes } from './endpoint/routes/actions'; diff --git a/x-pack/plugins/security_solution/server/routes/limited_concurrency.ts b/x-pack/plugins/security_solution/server/routes/limited_concurrency.ts new file mode 100644 index 0000000000000..7e0b1686ee467 --- /dev/null +++ b/x-pack/plugins/security_solution/server/routes/limited_concurrency.ts @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + CoreSetup, + KibanaRequest, + LifecycleResponseFactory, + OnPreAuthToolkit, +} from 'kibana/server'; +import { LIMITED_CONCURRENCY_ROUTE_TAG_PREFIX } from '../../common/constants'; + +class MaxCounter { + constructor(private readonly max: number = 1) {} + private counter = 0; + valueOf() { + return this.counter; + } + increase() { + if (this.counter < this.max) { + this.counter += 1; + } + } + decrease() { + if (this.counter > 0) { + this.counter -= 1; + } + } + lessThanMax() { + return this.counter < this.max; + } +} + +function getRouteConcurrencyTag(request: KibanaRequest) { + const tags = request.route.options.tags; + return tags.find((tag) => tag.startsWith(LIMITED_CONCURRENCY_ROUTE_TAG_PREFIX)); +} + +function shouldHandleRequest(request: KibanaRequest) { + return getRouteConcurrencyTag(request) !== undefined; +} + +function getRouteMaxConcurrency(request: KibanaRequest) { + const tag = getRouteConcurrencyTag(request); + return parseInt(tag?.split(':')[2] || '', 10); +} + +const initCounterMap = () => { + const counterMap = new Map(); + const getCounter = (request: KibanaRequest) => { + const path = request.route.path; + + if (!counterMap.has(path)) { + const maxCount = getRouteMaxConcurrency(request); + if (isNaN(maxCount)) { + return null; + } + + counterMap.set(path, new MaxCounter(maxCount)); + } + + return counterMap.get(path) as MaxCounter; + }; + + return { + getCounter, + }; +}; + +/** + * This method limits concurrency for routes + * It checks if route has tag that begins LIMITED_CONCURRENCY_ROUTE_TAG_PREFIX prefix + * If tag is found and has concurrency number separated by colon ':', this max concurrency number will be applied to the route + * If tag is malformed, i.e. not valid concurrency number, max concurency will not be applied to the route + * @param core CoreSetup Context passed to the `setup` method of `standard` plugins. + */ +export function registerLimitedConcurrencyRoutes(core: CoreSetup) { + const countersMap = initCounterMap(); + + core.http.registerOnPreAuth(function preAuthHandler( + request: KibanaRequest, + response: LifecycleResponseFactory, + toolkit: OnPreAuthToolkit + ) { + if (!shouldHandleRequest(request)) { + return toolkit.next(); + } + + const counter = countersMap.getCounter(request); + + if (counter === null) { + return toolkit.next(); + } + + if (!counter.lessThanMax()) { + return response.customError({ + body: 'Too Many Requests', + statusCode: 429, + }); + } + + counter.increase(); + + // when request is completed or aborted, decrease counter + request.events.completed$.subscribe(() => { + counter.decrease(); + }); + + return toolkit.next(); + }); +} diff --git a/x-pack/plugins/security_solution/server/utils/promise_pool.test.ts b/x-pack/plugins/security_solution/server/utils/promise_pool.test.ts index 3a2e7ad160bd2..585044de5856a 100644 --- a/x-pack/plugins/security_solution/server/utils/promise_pool.test.ts +++ b/x-pack/plugins/security_solution/server/utils/promise_pool.test.ts @@ -9,7 +9,7 @@ import { initPromisePool } from './promise_pool'; const nextTick = () => new Promise((resolve) => setImmediate(resolve)); -const initPoolWithTasks = ({ concurrency = 1, items = [1, 2, 3] }) => { +const initPoolWithTasks = ({ concurrency = 1, items = [1, 2, 3] }, abortSignal?: AbortSignal) => { const asyncTasks: Record< number, { @@ -36,6 +36,7 @@ const initPoolWithTasks = ({ concurrency = 1, items = [1, 2, 3] }) => { }, }; }), + abortSignal, }); return [promisePool, asyncTasks] as const; @@ -112,7 +113,7 @@ describe('initPromisePool', () => { const { results, errors } = await promisePool; - // Check final reesuts + // Check final results expect(results).toEqual([1, 3]); expect(errors).toEqual([new Error(`Error processing 2`)]); }); @@ -167,8 +168,52 @@ describe('initPromisePool', () => { const { results, errors } = await promisePool; - // Check final reesuts + // Check final results expect(results).toEqual([1, 4, 5]); expect(errors).toEqual([new Error(`Error processing 2`), new Error(`Error processing 3`)]); }); + + it('should not execute tasks if abortSignal is aborted', async () => { + const abortSignal = { aborted: true }; + const [promisePool] = initPoolWithTasks( + { + concurrency: 2, + items: [1, 2, 3, 4, 5], + }, + abortSignal as AbortSignal + ); + + const { results, errors, abortedExecutionsCount } = await promisePool; + + // Check final results + expect(results).toEqual([]); + expect(errors).toEqual([]); + expect(abortedExecutionsCount).toEqual(5); + }); + + it('should abort executions of tasks if abortSignal was set to aborted during execution', async () => { + const abortSignal = { aborted: false }; + const [promisePool, asyncTasks] = initPoolWithTasks( + { + concurrency: 1, + items: [1, 2, 3], + }, + abortSignal as AbortSignal + ); + + // resolve first task, and abort execution + asyncTasks[1].resolve(); + expect(asyncTasks).toEqual({ + 1: expect.objectContaining({ status: 'resolved' }), + }); + + abortSignal.aborted = true; + + const { results, errors, abortedExecutionsCount } = await promisePool; + + // Check final results + expect(results).toEqual([1]); + expect(errors).toEqual([]); + expect(abortedExecutionsCount).toEqual(2); + }); }); diff --git a/x-pack/plugins/security_solution/server/utils/promise_pool.ts b/x-pack/plugins/security_solution/server/utils/promise_pool.ts index d0c848bc11787..ed0922b952c77 100644 --- a/x-pack/plugins/security_solution/server/utils/promise_pool.ts +++ b/x-pack/plugins/security_solution/server/utils/promise_pool.ts @@ -9,6 +9,7 @@ interface PromisePoolArgs { concurrency?: number; items: Item[]; executor: (item: Item) => Promise; + abortSignal?: AbortSignal; } /** @@ -18,13 +19,16 @@ interface PromisePoolArgs { * @param concurrency - number of tasks run in parallel * @param items - array of items to be passes to async executor * @param executor - an async function to be called with each provided item + * @param abortSignal - AbortSignal a signal object that allows to abort executing actions * - * @returns Struct holding results or errors of async tasks + * @returns Struct holding results or errors of async tasks, aborted executions count if applicable */ + export const initPromisePool = async ({ concurrency = 1, items, executor, + abortSignal, }: PromisePoolArgs) => { const tasks: Array> = []; const results: Result[] = []; @@ -37,6 +41,11 @@ export const initPromisePool = async ({ await Promise.race(tasks); } + // if abort signal was sent stop processing tasks further + if (abortSignal?.aborted === true) { + break; + } + const task: Promise = executor(item) .then((result) => { results.push(result); @@ -54,5 +63,10 @@ export const initPromisePool = async ({ // Wait for all remaining tasks to finish await Promise.all(tasks); - return { results, errors }; + const aborted = + abortSignal?.aborted === true + ? { abortedExecutionsCount: items.length - results.length - errors.length } + : undefined; + + return { results, errors, ...aborted }; }; diff --git a/x-pack/plugins/security_solution/server/utils/route_limited_concurrency_tag.ts b/x-pack/plugins/security_solution/server/utils/route_limited_concurrency_tag.ts new file mode 100644 index 0000000000000..95092a0e08218 --- /dev/null +++ b/x-pack/plugins/security_solution/server/utils/route_limited_concurrency_tag.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LIMITED_CONCURRENCY_ROUTE_TAG_PREFIX } from '../../common/constants'; + +/** + * Generates max concurrency tag, that can be passed to route tags + * @param maxConcurrency - number max concurrency to add to tag + * @returns string generetad route tag + * + */ +export const routeLimitedConcurrencyTag = (maxConcurrency: number) => + [LIMITED_CONCURRENCY_ROUTE_TAG_PREFIX, maxConcurrency].join(':'); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/perform_bulk_action.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/perform_bulk_action.ts index bb117b50d5aed..1643c4851c024 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/perform_bulk_action.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/perform_bulk_action.ts @@ -11,7 +11,10 @@ import { DETECTION_ENGINE_RULES_BULK_ACTION, DETECTION_ENGINE_RULES_URL, } from '../../../../plugins/security_solution/common/constants'; -import { BulkAction } from '../../../../plugins/security_solution/common/detection_engine/schemas/common/schemas'; +import { + BulkAction, + BulkActionEditType, +} from '../../../../plugins/security_solution/common/detection_engine/schemas/common/schemas'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { binaryToString, @@ -29,6 +32,11 @@ export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const log = getService('log'); + const postBulkAction = () => + supertest.post(DETECTION_ENGINE_RULES_BULK_ACTION).set('kbn-xsrf', 'true'); + const fetchRule = (ruleId: string) => + supertest.get(`${DETECTION_ENGINE_RULES_URL}?rule_id=${ruleId}`).set('kbn-xsrf', 'true'); + describe('perform_bulk_action', () => { beforeEach(async () => { await createSignalsIndex(supertest, log); @@ -42,9 +50,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should export rules', async () => { await createRule(supertest, log, getSimpleRule()); - const { body } = await supertest - .post(DETECTION_ENGINE_RULES_BULK_ACTION) - .set('kbn-xsrf', 'true') + const { body } = await postBulkAction() .send({ query: '', action: BulkAction.export }) .expect(200) .expect('Content-Type', 'application/ndjson') @@ -75,36 +81,26 @@ export default ({ getService }: FtrProviderContext): void => { const ruleId = 'ruleId'; await createRule(supertest, log, getSimpleRule(ruleId)); - const { body } = await supertest - .post(DETECTION_ENGINE_RULES_BULK_ACTION) - .set('kbn-xsrf', 'true') + const { body } = await postBulkAction() .send({ query: '', action: BulkAction.delete }) .expect(200); expect(body).to.eql({ success: true, rules_count: 1 }); - await supertest - .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=${ruleId}`) - .set('kbn-xsrf', 'true') - .expect(404); + await await fetchRule(ruleId).expect(404); }); it('should enable rules', async () => { const ruleId = 'ruleId'; await createRule(supertest, log, getSimpleRule(ruleId)); - const { body } = await supertest - .post(DETECTION_ENGINE_RULES_BULK_ACTION) - .set('kbn-xsrf', 'true') + const { body } = await postBulkAction() .send({ query: '', action: BulkAction.enable }) .expect(200); expect(body).to.eql({ success: true, rules_count: 1 }); - const { body: ruleBody } = await supertest - .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=${ruleId}`) - .set('kbn-xsrf', 'true') - .expect(200); + const { body: ruleBody } = await fetchRule(ruleId).expect(200); const referenceRule = getSimpleRuleOutput(ruleId); referenceRule.enabled = true; @@ -118,18 +114,13 @@ export default ({ getService }: FtrProviderContext): void => { const ruleId = 'ruleId'; await createRule(supertest, log, getSimpleRule(ruleId, true)); - const { body } = await supertest - .post(DETECTION_ENGINE_RULES_BULK_ACTION) - .set('kbn-xsrf', 'true') + const { body } = await postBulkAction() .send({ query: '', action: BulkAction.disable }) .expect(200); expect(body).to.eql({ success: true, rules_count: 1 }); - const { body: ruleBody } = await supertest - .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=${ruleId}`) - .set('kbn-xsrf', 'true') - .expect(200); + const { body: ruleBody } = await fetchRule(ruleId).expect(200); const referenceRule = getSimpleRuleOutput(ruleId); const storedRule = removeServerGeneratedProperties(ruleBody); @@ -141,9 +132,7 @@ export default ({ getService }: FtrProviderContext): void => { const ruleId = 'ruleId'; await createRule(supertest, log, getSimpleRule(ruleId)); - const { body } = await supertest - .post(DETECTION_ENGINE_RULES_BULK_ACTION) - .set('kbn-xsrf', 'true') + const { body } = await postBulkAction() .send({ query: '', action: BulkAction.duplicate }) .expect(200); @@ -156,5 +145,186 @@ export default ({ getService }: FtrProviderContext): void => { expect(rulesResponse.total).to.eql(2); }); + + describe('edit action', () => { + it('should set, add and delete tags in rules', async () => { + const ruleId = 'ruleId'; + const tags = ['tag1', 'tag2']; + await createRule(supertest, log, getSimpleRule(ruleId)); + + const { body: setTagsBody } = await postBulkAction() + .send({ + query: '', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.set_tags, + value: ['reset-tag'], + }, + ], + }) + .expect(200); + + expect(setTagsBody).to.eql({ success: true, rules_count: 1 }); + + const { body: setTagsRule } = await fetchRule(ruleId).expect(200); + + expect(setTagsRule.tags).to.eql(['reset-tag']); + + const { body: addTagsBody } = await postBulkAction() + .send({ + query: '', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.add_tags, + value: tags, + }, + ], + }) + .expect(200); + + expect(addTagsBody).to.eql({ success: true, rules_count: 1 }); + + const { body: addedTagsRule } = await fetchRule(ruleId).expect(200); + + expect(addedTagsRule.tags).to.eql(['reset-tag', ...tags]); + + await postBulkAction() + .send({ + query: '', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.delete_tags, + value: ['reset-tag', 'tag1'], + }, + ], + }) + .expect(200); + + const { body: deletedTagsRule } = await fetchRule(ruleId).expect(200); + + expect(deletedTagsRule.tags).to.eql(['tag2']); + }); + + it('should set, add and delete index patterns in rules', async () => { + const ruleId = 'ruleId'; + const indices = ['index1-*', 'index2-*']; + await createRule(supertest, log, getSimpleRule(ruleId)); + + const { body: setIndexBody } = await postBulkAction() + .send({ + query: '', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.set_index_patterns, + value: ['initial-index-*'], + }, + ], + }) + .expect(200); + + expect(setIndexBody).to.eql({ success: true, rules_count: 1 }); + + const { body: setIndexRule } = await fetchRule(ruleId).expect(200); + + expect(setIndexRule.index).to.eql(['initial-index-*']); + + const { body: addIndexBody } = await postBulkAction() + .send({ + query: '', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.add_index_patterns, + value: indices, + }, + ], + }) + .expect(200); + + expect(addIndexBody).to.eql({ success: true, rules_count: 1 }); + + const { body: addIndexRule } = await fetchRule(ruleId).expect(200); + + expect(addIndexRule.index).to.eql(['initial-index-*', ...indices]); + + await postBulkAction() + .send({ + query: '', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.delete_index_patterns, + value: ['index1-*'], + }, + ], + }) + .expect(200); + + const { body: deleteIndexRule } = await fetchRule(ruleId).expect(200); + + expect(deleteIndexRule.index).to.eql(['initial-index-*', 'index2-*']); + }); + + it('should set timeline values in rule', async () => { + const ruleId = 'ruleId'; + const timelineId = '91832785-286d-4ebe-b884-1a208d111a70'; + const timelineTitle = 'Test timeline'; + await createRule(supertest, log, getSimpleRule(ruleId)); + + const { body } = await postBulkAction() + .send({ + query: '', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.set_timeline, + value: { + timeline_id: timelineId, + timeline_title: timelineTitle, + }, + }, + ], + }) + .expect(200); + + expect(body).to.eql({ success: true, rules_count: 1 }); + + const { body: rule } = await fetchRule(ruleId).expect(200); + + expect(rule.timeline_id).to.eql(timelineId); + expect(rule.timeline_title).to.eql(timelineTitle); + }); + }); + + it('should limit concurrent requests to 5', async () => { + const ruleId = 'ruleId'; + const timelineId = '91832785-286d-4ebe-b884-1a208d111a70'; + const timelineTitle = 'Test timeline'; + await createRule(supertest, log, getSimpleRule(ruleId)); + + const responses = await Promise.all( + Array.from({ length: 10 }).map(() => + postBulkAction().send({ + query: '', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.set_timeline, + value: { + timeline_id: timelineId, + timeline_title: timelineTitle, + }, + }, + ], + }) + ) + ); + + expect(responses.filter((r) => r.body.statusCode === 429).length).to.eql(5); + }); }); }; From 7580c12d07f19a2b1b17d46e4abf3ab07da3629c Mon Sep 17 00:00:00 2001 From: Peter Pisljar Date: Thu, 6 Jan 2022 16:55:41 +0100 Subject: [PATCH 08/25] fixing broken links (#122415) --- docs/developer/best-practices/index.asciidoc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/developer/best-practices/index.asciidoc b/docs/developer/best-practices/index.asciidoc index 04422a613475a..c3f8239e9af91 100644 --- a/docs/developer/best-practices/index.asciidoc +++ b/docs/developer/best-practices/index.asciidoc @@ -73,16 +73,16 @@ services you should consider: * {kib-repo}tree/{branch}/src/plugins/data/README.md[Data services] -** {kib-repo}tree/{branch}/src/plugins/data/public/search/README.md[Search +** {kib-repo}tree/{branch}/src/plugins/data/README.mdx#search[Search strategies] *** Use the `esSearchStrategy` to make raw queries to ES that will support async searching and partial results, as well as injecting the right advanced settings like whether to include frozen indices or not. -* {kib-repo}tree/{branch}/src/plugins/embeddable/README.md[Embeddables] +* {kib-repo}tree/{branch}/src/plugins/embeddable/README.asciidoc[Embeddables] ** Rendering maps, visualizations, dashboards in your application ** Register new widgets that will can be added to a dashboard or Canvas workpad, or rendered in another plugin. -* {kib-repo}tree/{branch}/src/plugins/ui_actions/README.md[UiActions] +* {kib-repo}tree/{branch}/src/plugins/ui_actions/README.asciidoc[UiActions] ** Let other plugins inject functionality into your application ** Inject custom functionality into other plugins * Stateless helper utilities From 302566e441e73f8ce37447786cf18ec9caf12b6b Mon Sep 17 00:00:00 2001 From: Chris Cowan Date: Thu, 6 Jan 2022 09:16:27 -0700 Subject: [PATCH 09/25] [Metrics UI] Honor time unit for Inventory Threshold (#122294) * [Metrics UI] Honor time unit for Inventory Threshold * Adding tests * fixing the threshold on the tests * Update x-pack/test/api_integration/apis/metrics_ui/constants.ts Co-authored-by: Claudio Procida * fixing double quotes * moving the conversion into the code Co-authored-by: Claudio Procida --- .../evaluate_condition.ts | 11 +- .../apis/metrics_ui/constants.ts | 4 + .../api_integration/apis/metrics_ui/index.js | 1 + .../metrics_ui/inventory_threshold_alert.ts | 128 + .../infra/8.0.0/hosts_only/data.json.gz | Bin 0 -> 345 bytes .../infra/8.0.0/hosts_only/mappings.json | 15338 ++++++++++++++++ 6 files changed, 15479 insertions(+), 3 deletions(-) create mode 100644 x-pack/test/api_integration/apis/metrics_ui/inventory_threshold_alert.ts create mode 100644 x-pack/test/functional/es_archives/infra/8.0.0/hosts_only/data.json.gz create mode 100644 x-pack/test/functional/es_archives/infra/8.0.0/hosts_only/mappings.json diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts index 364c6b5a0d23a..b78c5eb291adb 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts @@ -42,6 +42,7 @@ export const evaluateCondition = async ({ compositeSize, filterQuery, lookbackSize, + startTime, }: { condition: InventoryMetricConditions; nodeType: InventoryItemType; @@ -51,14 +52,18 @@ export const evaluateCondition = async ({ compositeSize: number; filterQuery?: string; lookbackSize?: number; + startTime?: number; }): Promise> => { const { comparator, warningComparator, metric, customMetric } = condition; let { threshold, warningThreshold } = condition; + const to = startTime ? moment(startTime) : moment(); + const timerange = { - to: Date.now(), - from: moment().subtract(condition.timeSize, condition.timeUnit).toDate().getTime(), - interval: condition.timeUnit, + to: to.valueOf(), + from: to.clone().subtract(condition.timeSize, condition.timeUnit).valueOf(), + interval: `${condition.timeSize}${condition.timeUnit}`, + forceInterval: true, } as InfraTimerangeInput; if (lookbackSize) { timerange.lookbackSize = lookbackSize; diff --git a/x-pack/test/api_integration/apis/metrics_ui/constants.ts b/x-pack/test/api_integration/apis/metrics_ui/constants.ts index 90db71ae08130..57963179aa8e4 100644 --- a/x-pack/test/api_integration/apis/metrics_ui/constants.ts +++ b/x-pack/test/api_integration/apis/metrics_ui/constants.ts @@ -19,6 +19,10 @@ export const DATES = { }, }, '8.0.0': { + hosts_only: { + min: new Date('2022-01-02T00:00:00.000Z').getTime(), + max: new Date('2022-01-02T00:05:30.000Z').getTime(), + }, logs_and_metrics: { min: 1562786660845, max: 1562786716965, diff --git a/x-pack/test/api_integration/apis/metrics_ui/index.js b/x-pack/test/api_integration/apis/metrics_ui/index.js index 72c79faaa4372..77560d966350e 100644 --- a/x-pack/test/api_integration/apis/metrics_ui/index.js +++ b/x-pack/test/api_integration/apis/metrics_ui/index.js @@ -23,5 +23,6 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./metrics_process_list')); loadTestFile(require.resolve('./metrics_process_list_chart')); loadTestFile(require.resolve('./infra_log_analysis_validation_log_entry_datasets')); + loadTestFile(require.resolve('./inventory_threshold_alert')); }); } diff --git a/x-pack/test/api_integration/apis/metrics_ui/inventory_threshold_alert.ts b/x-pack/test/api_integration/apis/metrics_ui/inventory_threshold_alert.ts new file mode 100644 index 0000000000000..a6e0ce1bc628f --- /dev/null +++ b/x-pack/test/api_integration/apis/metrics_ui/inventory_threshold_alert.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { convertToKibanaClient } from '@kbn/test'; +import { + Comparator, + InventoryMetricConditions, +} from '../../../../plugins/infra/server/lib/alerting/inventory_metric_threshold/types'; +import { InfraSource } from '../../../../plugins/infra/server/lib/sources'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { DATES } from './constants'; +import { evaluateCondition } from '../../../../plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition'; +import { InventoryItemType } from '../../../../plugins/infra/common/inventory_models/types'; + +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const esClient = getService('es'); + + const baseCondition: InventoryMetricConditions = { + metric: 'cpu', + timeSize: 1, + timeUnit: 'm', + sourceId: 'default', + threshold: [100], + comparator: Comparator.GT, + }; + + const source: InfraSource = { + id: 'default', + origin: 'internal', + configuration: { + name: 'Default', + description: '', + logIndices: { + type: 'index_pattern', + indexPatternId: 'some-test-id', + }, + metricAlias: 'metricbeat-*', + inventoryDefaultView: 'default', + metricsExplorerDefaultView: 'default', + anomalyThreshold: 70, + fields: { + message: ['message'], + }, + logColumns: [ + { + timestampColumn: { + id: '5e7f964a-be8a-40d8-88d2-fbcfbdca0e2f', + }, + }, + { + fieldColumn: { + id: ' eb9777a8-fcd3-420e-ba7d-172fff6da7a2', + field: 'event.dataset', + }, + }, + { + messageColumn: { + id: 'b645d6da-824b-4723-9a2a-e8cece1645c0', + }, + }, + ], + }, + }; + + const baseOptions = { + condition: baseCondition, + nodeType: 'host' as InventoryItemType, + source, + logQueryFields: void 0, + compositeSize: 10000, + startTime: DATES['8.0.0'].hosts_only.max, + }; + + describe('Inventory Threshold Rule Executor', () => { + before(() => esArchiver.load('x-pack/test/functional/es_archives/infra/8.0.0/hosts_only')); + after(() => esArchiver.unload('x-pack/test/functional/es_archives/infra/8.0.0/hosts_only')); + it('should work FOR LAST 1 minute', async () => { + const results = await evaluateCondition({ + ...baseOptions, + esClient: convertToKibanaClient(esClient), + }); + expect(results).to.eql({ + 'host-01': { + metric: 'cpu', + timeSize: 1, + timeUnit: 'm', + sourceId: 'default', + threshold: [100], + comparator: '>', + shouldFire: [true], + shouldWarn: [false], + isNoData: [false], + isError: false, + currentValue: 1.01, + }, + }); + }); + it('should work FOR LAST 5 minute', async () => { + const options = { + ...baseOptions, + condition: { ...baseCondition, timeSize: 5 }, + esClient: convertToKibanaClient(esClient), + }; + const results = await evaluateCondition(options); + expect(results).to.eql({ + 'host-01': { + metric: 'cpu', + timeSize: 5, + timeUnit: 'm', + sourceId: 'default', + threshold: [100], + comparator: '>', + shouldFire: [false], + shouldWarn: [false], + isNoData: [false], + isError: false, + currentValue: 0.24000000000000002, + }, + }); + }); + }); +} diff --git a/x-pack/test/functional/es_archives/infra/8.0.0/hosts_only/data.json.gz b/x-pack/test/functional/es_archives/infra/8.0.0/hosts_only/data.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..bdd31f088a41ab7c0f81423cdedb4a5d508af7a6 GIT binary patch literal 345 zcmV-f0jB;RiwFp`-PBQOZ*BnXn9pj2Fc8M?K857GE|a*nT~B?8J@ry- z9H>xNF|q6-zI!vrD4hV$`6Y(rV~Hm^sZn>8e8EB|0)*HCH*1QTia$@)ji#Euliv-D@~EpCl`iU* zVyTL0m4G+^{!%j4aKY=bygbd zd0pA|Gl8V0LuEw1vDIE-cN0n1lGN7Bz%6~8CH~O$m(=L?>+?;|F&d+2_&Zjy0M;~M zb&WOkj+FK^O;0$77vonw9Qj&&E@ r>K^NU0j$BT&arav&NW+yUH=Q!IZ^*V4zqPQ)d$fJM Date: Thu, 6 Jan 2022 17:18:38 +0100 Subject: [PATCH 10/25] [Uptime][Monitor Management]Only show minutes as monitor interval plus do not show Zip Url browser monitor source type (for tech preview). (#122407) https://github.com/elastic/uptime/issues/427 https://github.com/elastic/uptime/issues/428 --- .../browser/source_field.test.tsx | 27 +++++-- .../fleet_package/browser/source_field.tsx | 20 ++++-- .../contexts/policy_config_context.tsx | 16 ++++- .../fleet_package/schedule_field.test.tsx | 72 ++++++++++++++++--- .../fleet_package/schedule_field.tsx | 25 +++++-- .../edit_monitor_config.tsx | 10 ++- .../pages/monitor_management/add_monitor.tsx | 8 ++- 7 files changed, 148 insertions(+), 30 deletions(-) diff --git a/x-pack/plugins/uptime/public/components/fleet_package/browser/source_field.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/browser/source_field.test.tsx index 402bd175a09ea..3d1d50abb487f 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/browser/source_field.test.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/browser/source_field.test.tsx @@ -9,8 +9,9 @@ import 'jest-canvas-mock'; import React from 'react'; import { fireEvent, screen, waitFor } from '@testing-library/react'; import { render } from '../../../lib/helper/rtl_helpers'; +import { IPolicyConfigContextProvider } from '../contexts/policy_config_context'; import { SourceField, defaultValues } from './source_field'; -import { BrowserSimpleFieldsContextProvider } from '../contexts'; +import { BrowserSimpleFieldsContextProvider, PolicyConfigContextProvider } from '../contexts'; jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ ...jest.requireActual('@elastic/eui/lib/services/accessibility/html_id_generator'), @@ -43,11 +44,15 @@ jest.mock('../../../../../../../src/plugins/kibana_react/public', () => { const onChange = jest.fn(); describe('', () => { - const WrappedComponent = () => { + const WrappedComponent = ({ + isZipUrlSourceEnabled, + }: Omit) => { return ( - - - + + + + + ); }; @@ -66,4 +71,16 @@ describe('', () => { expect(onChange).toBeCalledWith({ ...defaultValues, zipUrl }); }); }); + + it('shows ZipUrl source type by default', async () => { + render(); + + expect(screen.getByTestId('syntheticsSourceTab__zipUrl')).toBeInTheDocument(); + }); + + it('does not show ZipUrl source type when isZipUrlSourceEnabled = false', async () => { + render(); + + expect(screen.queryByTestId('syntheticsSourceTab__zipUrl')).not.toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/browser/source_field.tsx b/x-pack/plugins/uptime/public/components/fleet_package/browser/source_field.tsx index a3de661a49a5b..3861537a72c11 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/browser/source_field.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/browser/source_field.tsx @@ -10,6 +10,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { EuiTabbedContent, + EuiTabbedContentTab, EuiFormRow, EuiFieldText, EuiFieldPassword, @@ -18,6 +19,7 @@ import { EuiFlexGroup, EuiFlexItem, } from '@elastic/eui'; +import { usePolicyConfigContext } from '../contexts'; import { OptionalLabel } from '../optional_label'; import { CodeEditor } from '../code_editor'; import { ScriptRecorderFields } from './script_recorder_fields'; @@ -59,18 +61,21 @@ export const defaultValues = { fileName: '', }; -const getDefaultTab = (defaultConfig: SourceConfig) => { +const getDefaultTab = (defaultConfig: SourceConfig, isZipUrlSourceEnabled = true) => { if (defaultConfig.inlineScript && defaultConfig.isGeneratedScript) { return SourceType.SCRIPT_RECORDER; } else if (defaultConfig.inlineScript) { return SourceType.INLINE; } - return SourceType.ZIP; + return isZipUrlSourceEnabled ? SourceType.ZIP : SourceType.INLINE; }; export const SourceField = ({ onChange, defaultConfig = defaultValues }: Props) => { - const [sourceType, setSourceType] = useState(getDefaultTab(defaultConfig)); + const { isZipUrlSourceEnabled } = usePolicyConfigContext(); + const [sourceType, setSourceType] = useState( + getDefaultTab(defaultConfig, isZipUrlSourceEnabled) + ); const [config, setConfig] = useState(defaultConfig); useEffect(() => { @@ -84,9 +89,10 @@ export const SourceField = ({ onChange, defaultConfig = defaultValues }: Props) /> ); - const tabs = [ + const zipUrlSourceTabId = 'syntheticsBrowserZipURLConfig'; + const allTabs = [ { - id: 'syntheticsBrowserZipURLConfig', + id: zipUrlSourceTabId, name: zipUrlLabel, 'data-test-subj': `syntheticsSourceTab__zipUrl`, content: ( @@ -329,6 +335,10 @@ export const SourceField = ({ onChange, defaultConfig = defaultValues }: Props) }, ]; + const tabs = isZipUrlSourceEnabled + ? allTabs + : allTabs.filter((tab: EuiTabbedContentTab) => tab.id !== zipUrlSourceTabId); + return ( ({ defaultName = '', defaultLocations = [], isEditable = false, + isZipUrlSourceEnabled = true, + allowedScheduleUnits = [ScheduleUnit.MINUTES, ScheduleUnit.SECONDS], }: IPolicyConfigContextProvider) { const [monitorType, setMonitorType] = useState(defaultMonitorType); const [name, setName] = useState(defaultName); @@ -102,11 +110,14 @@ export function PolicyConfigContextProvider({ defaultLocations, locations, setLocations, - }; + isZipUrlSourceEnabled, + allowedScheduleUnits, + } as IPolicyConfigContext; }, [ monitorType, defaultMonitorType, isTLSEnabled, + isZipUrlSourceEnabled, isZipUrlTLSEnabled, defaultIsTLSEnabled, defaultIsZipUrlTLSEnabled, @@ -115,6 +126,7 @@ export function PolicyConfigContextProvider({ defaultName, locations, defaultLocations, + allowedScheduleUnits, ]); return ; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/schedule_field.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/schedule_field.test.tsx index 3358d1edabcc9..c2f99aaee2160 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/schedule_field.test.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/schedule_field.test.tsx @@ -5,31 +5,80 @@ * 2.0. */ +import { waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React, { useState } from 'react'; -import { fireEvent, waitFor } from '@testing-library/react'; import { render } from '../../lib/helper/rtl_helpers'; +import { PolicyConfigContextProvider } from './contexts'; +import { IPolicyConfigContextProvider } from './contexts/policy_config_context'; import { ScheduleField } from './schedule_field'; import { ScheduleUnit } from './types'; describe('', () => { const number = '1'; const unit = ScheduleUnit.MINUTES; - const WrappedComponent = () => { + const WrappedComponent = ({ + allowedScheduleUnits, + }: Omit) => { const [config, setConfig] = useState({ number, unit, }); return ( - setConfig(value)} - /> + + setConfig(value)} + /> + ); }; - it('hanles schedule', () => { + it('shows all options by default (allowedScheduleUnits is not provided)', () => { + const { getByText } = render(); + expect(getByText('Minutes')).toBeInTheDocument(); + expect(getByText('Seconds')).toBeInTheDocument(); + }); + + it('shows only Minutes when allowedScheduleUnits = [ScheduleUnit.Minutes])', () => { + const { queryByText } = render( + + ); + expect(queryByText('Minutes')).toBeInTheDocument(); + expect(queryByText('Seconds')).not.toBeInTheDocument(); + }); + + it('shows only Seconds when allowedScheduleUnits = [ScheduleUnit.Seconds])', () => { + const { queryByText } = render( + + ); + expect(queryByText('Minutes')).not.toBeInTheDocument(); + expect(queryByText('Seconds')).toBeInTheDocument(); + }); + + it('only accepts whole number when allowedScheduleUnits = [ScheduleUnit.Minutes])', async () => { + const { getByTestId } = render( + + ); + const input = getByTestId('scheduleFieldInput') as HTMLInputElement; + const select = getByTestId('scheduleFieldSelect') as HTMLInputElement; + expect(input.value).toBe(number); + expect(select.value).toBe(ScheduleUnit.MINUTES); + + userEvent.clear(input); + userEvent.type(input, '1.5'); + + // Click away to cause blur on input + userEvent.click(select); + + await waitFor(() => { + expect(input.value).toBe('2'); + }); + }); + + it('handles schedule', () => { const { getByText, getByTestId } = render(); const input = getByTestId('scheduleFieldInput') as HTMLInputElement; const select = getByTestId('scheduleFieldSelect') as HTMLInputElement; @@ -38,7 +87,7 @@ describe('', () => { expect(getByText('Minutes')).toBeInTheDocument(); }); - it('hanles on change', async () => { + it('handles on change', async () => { const { getByText, getByTestId } = render(); const input = getByTestId('scheduleFieldInput') as HTMLInputElement; const select = getByTestId('scheduleFieldSelect') as HTMLInputElement; @@ -47,13 +96,14 @@ describe('', () => { expect(input.value).toBe(number); expect(select.value).toBe(unit); - fireEvent.change(input, { target: { value: newNumber } }); + userEvent.clear(input); + userEvent.type(input, newNumber); await waitFor(() => { expect(input.value).toBe(newNumber); }); - fireEvent.change(select, { target: { value: newUnit } }); + userEvent.selectOptions(select, newUnit); await waitFor(() => { expect(select.value).toBe(newUnit); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/schedule_field.tsx b/x-pack/plugins/uptime/public/components/fleet_package/schedule_field.tsx index 267127c59e6dc..4042821834f3f 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/schedule_field.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/schedule_field.tsx @@ -5,10 +5,10 @@ * 2.0. */ -import React from 'react'; -import { i18n } from '@kbn/i18n'; - import { EuiFieldNumber, EuiFlexGroup, EuiFlexItem, EuiSelect } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { usePolicyConfigContext } from './contexts'; import { ConfigKey, MonitorFields, ScheduleUnit } from './types'; interface Props { @@ -18,6 +18,14 @@ interface Props { } export const ScheduleField = ({ number, onChange, unit }: Props) => { + const { allowedScheduleUnits } = usePolicyConfigContext(); + const options = !allowedScheduleUnits?.length + ? allOptions + : allOptions.filter((opt) => allowedScheduleUnits.includes(opt.value)); + + // When only minutes are allowed, don't allow user to input fractional value + const allowedStep = options.length === 1 && options[0].value === ScheduleUnit.MINUTES ? 1 : 'any'; + return ( @@ -30,13 +38,20 @@ export const ScheduleField = ({ number, onChange, unit }: Props) => { )} id="syntheticsFleetScheduleField--number" data-test-subj="scheduleFieldInput" - step={'any'} + step={allowedStep} min={1} value={number} onChange={(event) => { const updatedNumber = event.target.value; onChange({ number: updatedNumber, unit }); }} + onBlur={(event) => { + // Enforce whole number + if (allowedStep === 1) { + const updatedNumber = `${Math.ceil(+event.target.value)}`; + onChange({ number: updatedNumber, unit }); + } + }} /> @@ -61,7 +76,7 @@ export const ScheduleField = ({ number, onChange, unit }: Props) => { ); }; -const options = [ +const allOptions = [ { text: i18n.translate('xpack.uptime.createPackagePolicy.stepConfigure.scheduleField.seconds', { defaultMessage: 'Seconds', diff --git a/x-pack/plugins/uptime/public/components/monitor_management/edit_monitor_config.tsx b/x-pack/plugins/uptime/public/components/monitor_management/edit_monitor_config.tsx index fb9d2302b5b35..46932dd1fc5fd 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/edit_monitor_config.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/edit_monitor_config.tsx @@ -6,7 +6,13 @@ */ import React, { useMemo } from 'react'; -import { ConfigKey, MonitorFields, TLSFields, DataStream } from '../../../common/runtime_types'; +import { + ConfigKey, + MonitorFields, + TLSFields, + DataStream, + ScheduleUnit, +} from '../../../common/runtime_types'; import { useTrackPageview } from '../../../../observability/public'; import { SyntheticsProviders } from '../fleet_package/contexts'; import { PolicyConfig } from '../fleet_package/types'; @@ -71,6 +77,8 @@ export const EditMonitorConfig = ({ monitor }: Props) => { defaultName: defaultConfig?.name || '', // TODO - figure out typing concerns for name defaultLocations: defaultConfig.locations, isEditable: true, + isZipUrlSourceEnabled: false, + allowedScheduleUnits: [ScheduleUnit.MINUTES], }} httpDefaultValues={fullDefaultConfig[DataStream.HTTP]} tcpDefaultValues={fullDefaultConfig[DataStream.TCP]} diff --git a/x-pack/plugins/uptime/public/pages/monitor_management/add_monitor.tsx b/x-pack/plugins/uptime/public/pages/monitor_management/add_monitor.tsx index 9d4546436defa..749a109dffda2 100644 --- a/x-pack/plugins/uptime/public/pages/monitor_management/add_monitor.tsx +++ b/x-pack/plugins/uptime/public/pages/monitor_management/add_monitor.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { useTrackPageview } from '../../../../observability/public'; +import { ScheduleUnit } from '../../../common/runtime_types'; import { SyntheticsProviders } from '../../components/fleet_package/contexts'; import { Loader } from '../../components/monitor_management/loader/loader'; import { MonitorConfig } from '../../components/monitor_management/monitor_config/monitor_config'; @@ -27,7 +28,12 @@ export const AddMonitorPage: React.FC = () => { errorTitle={ERROR_HEADING_LABEL} errorBody={ERROR_BODY_LABEL} > - + From a03430187709b05213878041ec168b299ee347cd Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 6 Jan 2022 12:46:06 -0500 Subject: [PATCH 11/25] skip failing test suite (#100236) --- .../security_solution_endpoint/apps/endpoint/policy_details.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts index 54fbf9d63b2bd..0cf86e891e143 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts @@ -236,7 +236,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { ); }; - describe('When on the Endpoint Policy Details Page', function () { + // Failing: See https://github.com/elastic/kibana/issues/100236 + describe.skip('When on the Endpoint Policy Details Page', function () { let indexedData: IndexedHostsAndAlertsResponse; before(async () => { From 47c39a26082f75aeea061e0008910315c6959420 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= Date: Thu, 6 Jan 2022 19:04:32 +0100 Subject: [PATCH 12/25] [Security Solution] [Endpoint] Allows user remove event filter from policy using a modal (#122315) * Allows user remove event filter from policy using a modal. Adds unit tests and includes some code improvements * Remove unused code from service * Remove unused code, fix ts errors and use eventFiltersHttpMock instead of a service mock Co-authored-by: Ashokaditya <1849116+ashokaditya@users.noreply.github.com> --- .../pages/event_filters/service/index.ts | 37 +++--- .../view/event_filters/delete_modal/index.ts | 8 ++ ...policy_event_filters_delete_modal.test.tsx | 117 ++++++++++++++++++ .../policy_event_filters_delete_modal.tsx | 116 +++++++++++++++++ .../policy_event_filters_flyout.test.tsx | 36 ++---- .../pages/policy/view/event_filters/hooks.ts | 10 +- .../policy_event_filters_layout.test.tsx | 70 ++++------- .../list/policy_event_filters_list.tsx | 41 +++++- .../fleet_event_filters_card.test.tsx | 3 +- 9 files changed, 346 insertions(+), 92 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/delete_modal/index.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/delete_modal/policy_event_filters_delete_modal.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/delete_modal/policy_event_filters_delete_modal.tsx diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/service/index.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/service/index.ts index a4ac378610e49..7b2d98d72b0c9 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/service/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/service/index.ts @@ -90,22 +90,8 @@ export class EventFiltersHttpService implements EventFiltersService { async updateOne( exception: Immutable ): Promise { - const exceptionToUpdateCleaned = { ...exception }; - // Clean unnecessary fields for update action - [ - 'created_at', - 'created_by', - 'created_at', - 'created_by', - 'list_id', - 'tie_breaker_id', - 'updated_at', - 'updated_by', - ].forEach((field) => { - delete exceptionToUpdateCleaned[field as keyof UpdateExceptionListItemSchema]; - }); return (await this.httpWrapper()).put(EXCEPTION_LIST_ITEM_URL, { - body: JSON.stringify(exceptionToUpdateCleaned), + body: JSON.stringify(EventFiltersHttpService.cleanEventFilterToUpdate(exception)), }); } @@ -129,4 +115,25 @@ export class EventFiltersHttpService implements EventFiltersService { } ); } + + static cleanEventFilterToUpdate( + exception: Immutable + ): UpdateExceptionListItemSchema { + const exceptionToUpdateCleaned = { ...exception }; + // Clean unnecessary fields for update action + [ + 'created_at', + 'created_by', + 'created_at', + 'created_by', + 'list_id', + 'tie_breaker_id', + 'updated_at', + 'updated_by', + ].forEach((field) => { + delete exceptionToUpdateCleaned[field as keyof UpdateExceptionListItemSchema]; + }); + + return exceptionToUpdateCleaned as UpdateExceptionListItemSchema; + } } diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/delete_modal/index.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/delete_modal/index.ts new file mode 100644 index 0000000000000..4dd64e5c2f938 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/delete_modal/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { PolicyEventFiltersDeleteModal } from './policy_event_filters_delete_modal'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/delete_modal/policy_event_filters_delete_modal.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/delete_modal/policy_event_filters_delete_modal.test.tsx new file mode 100644 index 0000000000000..6711b48326bbf --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/delete_modal/policy_event_filters_delete_modal.test.tsx @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { act, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import uuid from 'uuid'; +import { getExceptionListItemSchemaMock } from '../../../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; +import { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../../common/mock/endpoint'; +import { PolicyEventFiltersDeleteModal } from './policy_event_filters_delete_modal'; +import { eventFiltersListQueryHttpMock } from '../../../../event_filters/test_utils'; +import { EventFiltersHttpService } from '../../../../event_filters/service'; + +describe('Policy details event filter delete modal', () => { + let policyId: string; + let render: () => Promise>; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + let exception: ExceptionListItemSchema; + let mockedApi: ReturnType; + let onCancel: () => void; + + beforeEach(() => { + policyId = uuid.v4(); + mockedContext = createAppRootMockRenderer(); + exception = getExceptionListItemSchemaMock(); + onCancel = jest.fn(); + mockedApi = eventFiltersListQueryHttpMock(mockedContext.coreStart.http); + render = async () => { + await act(async () => { + renderResult = mockedContext.render( + + ); + await waitFor(mockedApi.responseProvider.eventFiltersList); + }); + return renderResult; + }; + }); + + it('should render with enabled buttons', async () => { + await render(); + expect(renderResult.getByTestId('confirmModalCancelButton')).toBeEnabled(); + expect(renderResult.getByTestId('confirmModalConfirmButton')).toBeEnabled(); + }); + + it('should disable the submit button while deleting ', async () => { + await render(); + const confirmButton = renderResult.getByTestId('confirmModalConfirmButton'); + userEvent.click(confirmButton); + + await waitFor(() => { + expect(confirmButton).toBeDisabled(); + }); + }); + + it('should call the API with the removed policy from the exception tags', async () => { + exception.tags = ['policy:1234', 'policy:4321', `policy:${policyId}`, 'not-a-policy-tag']; + await render(); + const confirmButton = renderResult.getByTestId('confirmModalConfirmButton'); + userEvent.click(confirmButton); + await waitFor(() => { + expect(mockedApi.responseProvider.eventFiltersUpdateOne).toHaveBeenLastCalledWith({ + body: JSON.stringify( + EventFiltersHttpService.cleanEventFilterToUpdate({ + ...exception, + tags: ['policy:1234', 'policy:4321', 'not-a-policy-tag'], + }) + ), + path: '/api/exception_lists/items', + }); + }); + }); + + it('should show a success toast if the operation was success', async () => { + await render(); + const confirmButton = renderResult.getByTestId('confirmModalConfirmButton'); + userEvent.click(confirmButton); + + await waitFor(() => { + expect(mockedApi.responseProvider.eventFiltersUpdateOne).toHaveBeenCalled(); + }); + + expect(onCancel).toHaveBeenCalled(); + expect(mockedContext.coreStart.notifications.toasts.addSuccess).toHaveBeenCalled(); + }); + + it('should show an error toast if the operation failed', async () => { + const error = new Error('the server is too far away'); + mockedApi.responseProvider.eventFiltersUpdateOne.mockImplementation(() => { + throw error; + }); + + await render(); + const confirmButton = renderResult.getByTestId('confirmModalConfirmButton'); + userEvent.click(confirmButton); + + await waitFor(() => { + expect(mockedApi.responseProvider.eventFiltersUpdateOne).toHaveBeenCalled(); + }); + + expect(mockedContext.coreStart.notifications.toasts.addError).toHaveBeenCalledWith(error, { + title: 'Error while attempt to remove event filter', + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/delete_modal/policy_event_filters_delete_modal.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/delete_modal/policy_event_filters_delete_modal.tsx new file mode 100644 index 0000000000000..eca26a0026dd1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/delete_modal/policy_event_filters_delete_modal.tsx @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiCallOut, EuiConfirmModal, EuiSpacer, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import React, { useCallback } from 'react'; +import { useToasts } from '../../../../../../common/lib/kibana'; +import { ServerApiError } from '../../../../../../common/types'; +import { useBulkUpdateEventFilters } from '../hooks'; + +export const PolicyEventFiltersDeleteModal = ({ + policyId, + exception, + onCancel, +}: { + policyId: string; + exception: ExceptionListItemSchema; + onCancel: () => void; +}) => { + const toasts = useToasts(); + + const { mutate: updateEventFilter, isLoading: isUpdateEventFilterLoading } = + useBulkUpdateEventFilters({ + onUpdateSuccess: () => { + toasts.addSuccess({ + title: i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.list.removeDialog.successToastTitle', + { defaultMessage: 'Successfully removed' } + ), + text: i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.list.removeDialog.successToastText', + { + defaultMessage: '"{exception}" has been removed from policy', + values: { exception: exception.name }, + } + ), + }); + onCancel(); + }, + onUpdateError: (error?: ServerApiError) => { + toasts.addError(error as unknown as Error, { + title: i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.list.removeDialog.errorToastTitle', + { + defaultMessage: 'Error while attempt to remove event filter', + } + ), + }); + onCancel(); + }, + onSettledCallback: onCancel, + }); + + const handleModalConfirm = useCallback(() => { + const modifiedException = { + ...exception, + tags: exception.tags.filter((tag) => tag !== `policy:${policyId}`), + }; + updateEventFilter([modifiedException]); + }, [exception, policyId, updateEventFilter]); + + const handleCancel = useCallback(() => { + if (!isUpdateEventFilterLoading) { + onCancel(); + } + }, [isUpdateEventFilterLoading, onCancel]); + + return ( + + +

+ +

+
+ + + + +

+ +

+
+
+ ); +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/flyout/policy_event_filters_flyout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/flyout/policy_event_filters_flyout.test.tsx index 092a787d1f7e0..ef1cbc8163705 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/flyout/policy_event_filters_flyout.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/flyout/policy_event_filters_flyout.test.tsx @@ -17,7 +17,6 @@ import { } from '../../../../../../common/mock/endpoint'; import { EndpointDocGenerator } from '../../../../../../../common/endpoint/generate_data'; import { PolicyData } from '../../../../../../../common/endpoint/types'; -import { getPolicyEventFiltersPath } from '../../../../../common/routing'; import { eventFiltersListQueryHttpMock } from '../../../../event_filters/test_utils'; import { PolicyEventFiltersFlyout } from './policy_event_filters_flyout'; import { parseQueryFilterToKQL, parsePoliciesAndFilterToKql } from '../../../../../common/utils'; @@ -26,8 +25,8 @@ import { FoundExceptionListItemSchema, UpdateExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; +import { EventFiltersHttpService } from '../../../../event_filters/service'; -const endpointGenerator = new EndpointDocGenerator('seed'); const getDefaultQueryParameters = (customFilter: string | undefined = '') => ({ path: '/api/exception_lists/items/_find', query: { @@ -40,53 +39,40 @@ const getDefaultQueryParameters = (customFilter: string | undefined = '') => ({ sort_order: undefined, }, }); -const emptyList = { +const getEmptyList = () => ({ data: [], page: 1, per_page: 10, total: 0, -}; +}); const getCleanedExceptionWithNewTags = ( exception: UpdateExceptionListItemSchema, testTags: string[], policy: PolicyData ) => { - const exceptionToUpdateCleaned = { + const exceptionToUpdateWithNewTags = { ...exception, tags: [...testTags, `policy:${policy.id}`], }; - // Clean unnecessary fields for update action - [ - 'created_at', - 'created_by', - 'created_at', - 'created_by', - 'list_id', - 'tie_breaker_id', - 'updated_at', - 'updated_by', - ].forEach((field) => { - delete exceptionToUpdateCleaned[field as keyof UpdateExceptionListItemSchema]; - }); - return exceptionToUpdateCleaned; + + return EventFiltersHttpService.cleanEventFilterToUpdate(exceptionToUpdateWithNewTags); }; describe('Policy details event filters flyout', () => { let render: () => Promise>; let renderResult: ReturnType; - let history: AppContextTestRender['history']; let mockedContext: AppContextTestRender; let mockedApi: ReturnType; let policy: PolicyData; let onCloseMock: jest.Mock; beforeEach(() => { + const endpointGenerator = new EndpointDocGenerator('seed'); policy = endpointGenerator.generatePolicyPackagePolicy(); mockedContext = createAppRootMockRenderer(); mockedApi = eventFiltersListQueryHttpMock(mockedContext.coreStart.http); onCloseMock = jest.fn(); - ({ history } = mockedContext); render = async () => { await act(async () => { renderResult = mockedContext.render( @@ -96,8 +82,6 @@ describe('Policy details event filters flyout', () => { }); return renderResult; }; - - history.push(getPolicyEventFiltersPath(policy.id)); }); it('should render a list of assignable policies and searchbar', async () => { @@ -126,7 +110,7 @@ describe('Policy details event filters flyout', () => { expect(await renderResult.findByTestId('artifactsList')).toBeTruthy(); // results for search - mockedApi.responseProvider.eventFiltersList.mockImplementationOnce(() => emptyList); + mockedApi.responseProvider.eventFiltersList.mockImplementationOnce(() => getEmptyList()); // do a search userEvent.type(renderResult.getByTestId('searchField'), 'no results with this{enter}'); @@ -146,7 +130,7 @@ describe('Policy details event filters flyout', () => { it('should render "not assignable items" when no possible exceptions can be assigned', async () => { // both exceptions list requests will return no results - mockedApi.responseProvider.eventFiltersList.mockImplementation(() => emptyList); + mockedApi.responseProvider.eventFiltersList.mockImplementation(() => getEmptyList()); await render(); expect(await renderResult.findByTestId('eventFilters-no-assignable-items')).toBeTruthy(); }); @@ -194,7 +178,7 @@ describe('Policy details event filters flyout', () => { beforeEach(async () => { exceptions = { - ...emptyList, + ...getEmptyList(), total: 2, data: [ getExceptionListItemSchemaMock({ diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/hooks.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/hooks.ts index 571dd8779d093..fd5e00668c164 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/hooks.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/hooks.ts @@ -71,7 +71,6 @@ export function useSearchAssignedEventFilters( } ); } - export function useSearchNotAssignedEventFilters( policyId: string, options: { filter?: string; perPage?: number; enabled?: boolean } @@ -103,7 +102,7 @@ export function useSearchNotAssignedEventFilters( export function useBulkUpdateEventFilters( callbacks: { onUpdateSuccess?: (updatedExceptions: ExceptionListItemSchema[]) => void; - onUpdateError?: () => void; + onUpdateError?: (error?: ServerApiError) => void; onSettledCallback?: () => void; } = {} ) { @@ -116,7 +115,12 @@ export function useBulkUpdateEventFilters( onSettledCallback = () => {}, } = callbacks; - return useMutation( + return useMutation< + ExceptionListItemSchema[], + ServerApiError, + ExceptionListItemSchema[], + () => void + >( (eventFilters: ExceptionListItemSchema[]) => { return pMap( eventFilters, diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/layout/policy_event_filters_layout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/layout/policy_event_filters_layout.test.tsx index e834838cc37ee..f1aedf3e2d045 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/layout/policy_event_filters_layout.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/layout/policy_event_filters_layout.test.tsx @@ -12,24 +12,22 @@ import { createAppRootMockRenderer, } from '../../../../../../common/mock/endpoint'; import { EndpointDocGenerator } from '../../../../../../../common/endpoint/generate_data'; -import { EventFilterGenerator } from '../../../../../../../common/endpoint/data_generators/event_filter_generator'; -import { EventFiltersHttpService } from '../../../../event_filters/service'; import { ImmutableObject, PolicyData } from '../../../../../../../common/endpoint/types'; import { parsePoliciesAndFilterToKql } from '../../../../../common/utils'; - -jest.mock('../../../../event_filters/service'); -const EventFiltersHttpServiceMock = EventFiltersHttpService as jest.Mock; +import { eventFiltersListQueryHttpMock } from '../../../../event_filters/test_utils'; +import { getFoundExceptionListItemSchemaMock } from '../../../../../../../../lists/common/schemas/response/found_exception_list_item_schema.mock'; let render: () => ReturnType; let mockedContext: AppContextTestRender; let policyItem: ImmutableObject; const generator = new EndpointDocGenerator(); -const eventFilterGenerator = new EventFilterGenerator(); +let mockedApi: ReturnType; describe('Policy event filters layout', () => { beforeEach(() => { mockedContext = createAppRootMockRenderer(); + mockedApi = eventFiltersListQueryHttpMock(mockedContext.coreStart.http); policyItem = generator.generatePolicyPackagePolicy(); render = () => mockedContext.render(); }); @@ -42,44 +40,29 @@ describe('Policy event filters layout', () => { }); it('should renders layout with no assigned event filters data when there are not event filters', async () => { - EventFiltersHttpServiceMock.mockImplementation(() => { - return { - getList: () => ({ - total: 0, - data: [], - }), - }; - }); + mockedApi.responseProvider.eventFiltersList.mockReturnValue( + getFoundExceptionListItemSchemaMock(0) + ); const component = render(); expect(await component.findByTestId('policy-event-filters-empty-unexisting')).not.toBeNull(); }); it('should renders layout with no assigned event filters data when there are event filters', async () => { - EventFiltersHttpServiceMock.mockImplementation(() => { - return { - getList: ( - params: Partial<{ - filter: string; - }> - ) => { - if ( - params && - params.filter === parsePoliciesAndFilterToKql({ policies: [policyItem.id, 'all'] }) - ) { - return { - total: 0, - data: [], - }; - } else { - return { - total: 1, - data: [eventFilterGenerator.generate()], - }; - } - }, - }; - }); + mockedApi.responseProvider.eventFiltersList.mockImplementation( + // @ts-expect-error + (args) => { + const params = args.query; + if ( + params && + params.filter === parsePoliciesAndFilterToKql({ policies: [policyItem.id, 'all'] }) + ) { + return getFoundExceptionListItemSchemaMock(0); + } else { + return getFoundExceptionListItemSchemaMock(1); + } + } + ); const component = render(); @@ -87,14 +70,9 @@ describe('Policy event filters layout', () => { }); it('should renders layout with data', async () => { - EventFiltersHttpServiceMock.mockImplementation(() => { - return { - getList: () => ({ - total: 3, - data: Array.from({ length: 3 }, () => eventFilterGenerator.generate()), - }), - }; - }); + mockedApi.responseProvider.eventFiltersList.mockReturnValue( + getFoundExceptionListItemSchemaMock(3) + ); const component = render(); expect(await component.findByTestId('policy-event-filters-header-section')).not.toBeNull(); expect(await component.findByTestId('policy-event-filters-layout-about')).not.toBeNull(); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.tsx index b837a99370218..4153eb45200ed 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.tsx @@ -29,6 +29,8 @@ import { } from '../../policy_hooks'; import { getCurrentArtifactsLocation } from '../../../store/policy_details/selectors'; import { ImmutableObject, PolicyData } from '../../../../../../../common/endpoint/types'; +import { PolicyEventFiltersDeleteModal } from '../delete_modal'; +import { isGlobalPolicyEffected } from '../../../../../components/effected_policy_select/utils'; import { getEventFiltersListPath } from '../../../../../common/routing'; interface PolicyEventFiltersListProps { @@ -40,6 +42,9 @@ export const PolicyEventFiltersList = React.memo(({ const navigateCallback = usePolicyDetailsEventFiltersNavigateCallback(); const urlParams = usePolicyDetailsSelector(getCurrentArtifactsLocation); const [expandedItemsMap, setExpandedItemsMap] = useState>(new Map()); + const [exceptionItemToDelete, setExceptionItemToDelete] = useState< + ExceptionListItemSchema | undefined + >(); const { data: eventFilters, @@ -112,15 +117,49 @@ export const PolicyEventFiltersList = React.memo(({ 'data-test-subj': 'view-full-details-action', }; const item = artifact as ExceptionListItemSchema; + + const isGlobal = isGlobalPolicyEffected(item.tags); + const deleteAction = { + icon: 'trash', + children: i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.list.removeAction', + { defaultMessage: 'Remove from policy' } + ), + onClick: () => { + setExceptionItemToDelete(item); + }, + disabled: isGlobal, + toolTipContent: isGlobal + ? i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.list.removeActionNotAllowed', + { + defaultMessage: 'Globally applied event filters cannot be removed from policy.', + } + ) + : undefined, + toolTipPosition: 'top' as const, + 'data-test-subj': 'remove-from-policy-action', + }; return { expanded: expandedItemsMap.get(item.id) || false, - actions: [fullDetailsAction], + actions: [fullDetailsAction, deleteAction], policies: artifactCardPolicies, }; }; + const handleDeleteModalClose = useCallback(() => { + setExceptionItemToDelete(undefined); + }, [setExceptionItemToDelete]); + return ( <> + {exceptionItemToDelete && ( + + )} Date: Thu, 6 Jan 2022 13:33:52 -0500 Subject: [PATCH 13/25] [Controls] Move Controls To Their Own Plugin (#121668) * Moved controls out of Presentation Util and into their own plugin --- .github/CODEOWNERS | 1 + .i18nrc.json | 1 + docs/developer/plugin-list.asciidoc | 4 + packages/kbn-optimizer/limits.yml | 3 +- src/dev/storybook/aliases.ts | 1 + src/plugins/controls/README.mdx | 25 +++ .../control_group_persistable_state.ts | 4 +- .../common}/control_group/types.ts | 2 +- .../options_list_persistable_state.ts | 6 +- .../control_types/options_list/types.ts | 0 src/plugins/controls/common/index.ts | 14 ++ .../controls => controls/common}/types.ts | 4 +- src/plugins/controls/kibana.json | 23 ++ .../public}/__stories__/controls.stories.tsx | 31 ++- .../public}/__stories__/decorators.tsx | 0 .../storybook_control_factories.ts | 6 +- .../component/control_frame_component.tsx | 8 +- .../component/control_group_component.tsx | 8 +- .../component/control_group_sortable_item.tsx | 2 +- .../public}/control_group/control_group.scss | 0 .../control_group/control_group_strings.ts | 194 +++++++++++++++++ .../control_group/editor/control_editor.tsx | 0 .../control_group/editor/create_control.tsx | 10 +- .../control_group/editor/edit_control.tsx | 10 +- .../editor/edit_control_group.tsx | 4 +- .../control_group/editor/editor_constants.ts | 0 .../editor/forward_all_context.tsx | 17 +- .../embeddable/control_group_container.tsx | 25 ++- .../control_group_container_factory.ts | 7 +- .../public}/control_group/index.ts | 4 +- .../public}/control_group/opt_a.svg | 0 .../state/control_group_reducers.ts | 0 .../public}/control_group/types.ts | 9 +- .../public}/control_types/index.ts | 0 .../control_types/options_list/index.ts | 3 + .../options_list/options_list.scss | 0 .../options_list/options_list_component.tsx | 2 +- .../options_list/options_list_editor.tsx | 18 +- .../options_list/options_list_embeddable.tsx | 47 ++-- .../options_list_embeddable_factory.tsx | 4 +- .../options_list_popover_component.tsx | 2 +- .../options_list/options_list_reducers.ts | 0 .../options_list/options_list_strings.ts | 28 +-- .../control_types/options_list/types.ts | 2 +- .../public}/controls_service.ts | 4 +- .../public}/hooks/use_child_embeddable.ts | 0 .../public}/hooks/use_state_observable.ts | 0 src/plugins/controls/public/index.ts | 44 ++++ src/plugins/controls/public/plugin.ts | 95 ++++++++ .../public/services/controls.ts | 9 +- .../public/services/data.ts | 2 +- .../controls/public/services/data_views.ts | 15 ++ src/plugins/controls/public/services/index.ts | 32 +++ .../public/services/kibana}/controls.ts | 6 +- .../public/services/kibana/data.ts | 10 +- .../public/services/kibana/data_views.ts | 28 +++ .../controls/public/services/kibana/index.ts | 36 +++ .../public/services/kibana/overlays.ts | 10 +- .../public/services/overlays.ts | 2 +- .../public/services/storybook}/controls.ts | 6 +- .../public/services/storybook/data.ts | 6 +- .../public/services/storybook/data_views.ts | 29 +++ .../public/services/storybook/index.ts | 32 +++ .../public/services/storybook/overlays.tsx | 8 +- .../public/services/stub}/controls.ts | 6 +- .../controls/public/services/stub/index.ts | 28 +++ .../public/services/stub/overlays.ts | 6 +- .../controls => controls/public}/types.ts | 38 +++- .../control_group_container_factory.ts | 8 +- .../options_list_embeddable_factory.ts | 6 +- .../controls => controls/server}/index.ts | 6 +- src/plugins/controls/server/plugin.ts | 33 +++ src/plugins/controls/storybook/decorator.tsx | 46 ++++ src/plugins/controls/storybook/main.ts | 16 ++ src/plugins/controls/storybook/manager.ts | 26 +++ src/plugins/controls/storybook/preview.tsx | 29 +++ src/plugins/controls/tsconfig.json | 27 +++ .../dashboard_container_persistable_state.ts | 2 +- .../common/saved_dashboard_references.ts | 2 +- src/plugins/dashboard/common/types.ts | 2 +- src/plugins/dashboard/kibana.json | 1 + .../embeddable/dashboard_container.tsx | 2 +- .../dashboard_container_factory.tsx | 2 +- .../viewport/dashboard_viewport.tsx | 2 +- .../lib/convert_dashboard_state.ts | 2 +- .../lib/dashboard_control_group.ts | 2 +- .../dashboard/public/dashboard_constants.ts | 2 +- .../saved_dashboards/saved_dashboard.ts | 2 +- src/plugins/dashboard/tsconfig.json | 13 +- .../expression_renderers/image_renderer.tsx | 2 +- src/plugins/presentation_util/README.mdx | 95 ++++++++ .../presentation_util/common/lib/index.ts | 1 - src/plugins/presentation_util/kibana.json | 13 +- .../__stories__/fixtures/flights.ts | 8 +- .../__stories__/fixtures/flights_data.ts | 0 .../public/__stories__/index.tsx | 2 + .../control_group/control_group_strings.ts | 206 ------------------ .../data_view_picker.stories.tsx | 2 +- .../data_view_picker/data_view_picker.tsx | 4 + .../field_picker/field_picker.stories.tsx | 2 +- .../components/field_picker/field_picker.tsx | 4 + .../public/components/index.tsx | 9 + .../components/redux_embeddables}/index.ts | 11 +- .../redux_embeddable_context.ts | 4 +- .../redux_embeddable_wrapper.tsx | 25 ++- .../components/redux_embeddables/types.ts | 6 + src/plugins/presentation_util/public/index.ts | 11 +- src/plugins/presentation_util/public/mocks.ts | 7 +- .../presentation_util/public/plugin.ts | 52 ----- .../public/services/index.ts | 7 - .../public/services/kibana/index.ts | 6 - .../public/services/storybook/index.ts | 6 - .../public/services/stub/index.ts | 6 - src/plugins/presentation_util/public/types.ts | 15 +- .../presentation_util/server/plugin.ts | 17 +- src/plugins/presentation_util/tsconfig.json | 7 +- .../page_objects/dashboard_page_controls.ts | 3 +- .../canvas/public/functions/pie.test.js | 2 +- .../canvas/public/functions/plot.test.js | 2 +- 119 files changed, 1203 insertions(+), 544 deletions(-) create mode 100644 src/plugins/controls/README.mdx rename src/plugins/{presentation_util/common/controls => controls/common}/control_group/control_group_persistable_state.ts (96%) rename src/plugins/{presentation_util/common/controls => controls/common}/control_group/types.ts (91%) rename src/plugins/{presentation_util/common/controls => controls/common}/control_types/options_list/options_list_persistable_state.ts (89%) rename src/plugins/{presentation_util/common/controls => controls/common}/control_types/options_list/types.ts (100%) create mode 100644 src/plugins/controls/common/index.ts rename src/plugins/{presentation_util/common/controls => controls/common}/types.ts (87%) create mode 100644 src/plugins/controls/kibana.json rename src/plugins/{presentation_util/public/components/controls => controls/public}/__stories__/controls.stories.tsx (83%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/__stories__/decorators.tsx (100%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/__stories__/storybook_control_factories.ts (83%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/control_group/component/control_frame_component.tsx (96%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/control_group/component/control_group_component.tsx (96%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/control_group/component/control_group_sortable_item.tsx (98%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/control_group/control_group.scss (100%) create mode 100644 src/plugins/controls/public/control_group/control_group_strings.ts rename src/plugins/{presentation_util/public/components/controls => controls/public}/control_group/editor/control_editor.tsx (100%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/control_group/editor/create_control.tsx (94%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/control_group/editor/edit_control.tsx (93%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/control_group/editor/edit_control_group.tsx (97%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/control_group/editor/editor_constants.ts (100%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/control_group/editor/forward_all_context.tsx (67%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/control_group/embeddable/control_group_container.tsx (89%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/control_group/embeddable/control_group_container_factory.ts (91%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/control_group/index.ts (92%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/control_group/opt_a.svg (100%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/control_group/state/control_group_reducers.ts (100%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/control_group/types.ts (69%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/control_types/index.ts (100%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/control_types/options_list/index.ts (72%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/control_types/options_list/options_list.scss (100%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/control_types/options_list/options_list_component.tsx (97%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/control_types/options_list/options_list_editor.tsx (90%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/control_types/options_list/options_list_embeddable.tsx (89%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/control_types/options_list/options_list_embeddable_factory.tsx (93%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/control_types/options_list/options_list_popover_component.tsx (98%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/control_types/options_list/options_list_reducers.ts (100%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/control_types/options_list/options_list_strings.ts (59%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/control_types/options_list/types.ts (81%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/controls_service.ts (88%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/hooks/use_child_embeddable.ts (100%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/hooks/use_state_observable.ts (100%) create mode 100644 src/plugins/controls/public/index.ts create mode 100644 src/plugins/controls/public/plugin.ts rename src/plugins/{presentation_util => controls}/public/services/controls.ts (89%) rename src/plugins/{presentation_util => controls}/public/services/data.ts (91%) create mode 100644 src/plugins/controls/public/services/data_views.ts create mode 100644 src/plugins/controls/public/services/index.ts rename src/plugins/{presentation_util/public/services/stub => controls/public/services/kibana}/controls.ts (65%) rename src/plugins/{presentation_util => controls}/public/services/kibana/data.ts (71%) create mode 100644 src/plugins/controls/public/services/kibana/data_views.ts create mode 100644 src/plugins/controls/public/services/kibana/index.ts rename src/plugins/{presentation_util => controls}/public/services/kibana/overlays.ts (71%) rename src/plugins/{presentation_util => controls}/public/services/overlays.ts (93%) rename src/plugins/{presentation_util/public/services/kibana => controls/public/services/storybook}/controls.ts (65%) rename src/plugins/{presentation_util => controls}/public/services/storybook/data.ts (83%) create mode 100644 src/plugins/controls/public/services/storybook/data_views.ts create mode 100644 src/plugins/controls/public/services/storybook/index.ts rename src/plugins/{presentation_util => controls}/public/services/storybook/overlays.tsx (95%) rename src/plugins/{presentation_util/public/services/storybook => controls/public/services/stub}/controls.ts (65%) create mode 100644 src/plugins/controls/public/services/stub/index.ts rename src/plugins/{presentation_util => controls}/public/services/stub/overlays.ts (81%) rename src/plugins/{presentation_util/public/components/controls => controls/public}/types.ts (60%) rename src/plugins/{presentation_util/server/controls => controls/server}/control_group/control_group_container_factory.ts (71%) rename src/plugins/{presentation_util/server/controls => controls/server}/control_types/options_list/options_list_embeddable_factory.ts (72%) rename src/plugins/{presentation_util/public/components/controls => controls/server}/index.ts (79%) create mode 100644 src/plugins/controls/server/plugin.ts create mode 100644 src/plugins/controls/storybook/decorator.tsx create mode 100644 src/plugins/controls/storybook/main.ts create mode 100644 src/plugins/controls/storybook/manager.ts create mode 100644 src/plugins/controls/storybook/preview.tsx create mode 100644 src/plugins/controls/tsconfig.json rename src/plugins/presentation_util/public/{components/controls => }/__stories__/fixtures/flights.ts (93%) rename src/plugins/presentation_util/public/{components/controls => }/__stories__/fixtures/flights_data.ts (100%) delete mode 100644 src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts rename src/plugins/presentation_util/{common/controls => public/components/redux_embeddables}/index.ts (59%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 74d2138a9404f..37b0763c5e96e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -163,6 +163,7 @@ /src/plugins/input_control_vis/ @elastic/kibana-presentation /src/plugins/vis_type_markdown/ @elastic/kibana-presentation /src/plugins/presentation_util/ @elastic/kibana-presentation +/src/plugins/controls/ @elastic/kibana-presentation /test/functional/apps/dashboard/ @elastic/kibana-presentation /test/functional/apps/dashboard_elements/ @elastic/kibana-presentation /x-pack/plugins/canvas/ @elastic/kibana-presentation diff --git a/.i18nrc.json b/.i18nrc.json index 207e1778213bb..c348fc2c2b60c 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -9,6 +9,7 @@ "discover": "src/plugins/discover", "bfetch": "src/plugins/bfetch", "dashboard": "src/plugins/dashboard", + "controls": "src/plugins/controls", "data": "src/plugins/data", "dataViews": "src/plugins/data_views", "embeddableApi": "src/plugins/embeddable", diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index eb4fd6e30f304..f94b68fe9ab36 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -40,6 +40,10 @@ as uiSettings within the code. |Console provides the user with tools for storing and executing requests against Elasticsearch. +|{kib-repo}blob/{branch}/src/plugins/controls/README.mdx[controls] +|The Controls plugin contains Embeddables which can be used to add user-friendly interactivity to apps. + + |{kib-repo}blob/{branch}/src/plugins/custom_integrations/README.md[customIntegrations] |Register add-data cards diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index dccf2f2c14169..8865258b36d1b 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -75,7 +75,7 @@ pageLoadAssetSize: watcher: 43598 runtimeFields: 41752 stackAlerts: 29684 - presentationUtil: 84606 + presentationUtil: 58834 osquery: 107090 fileUpload: 25664 dataVisualizer: 27530 @@ -119,4 +119,5 @@ pageLoadAssetSize: visTypeHeatmap: 25340 screenshotting: 17017 expressionGauge: 25000 + controls: 34788 expressionPie: 26338 diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index d6f7fedccd2a2..5043312d0b25c 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -33,5 +33,6 @@ export const storybookAliases = { ui_actions_enhanced: 'x-pack/plugins/ui_actions_enhanced/.storybook', observability: 'x-pack/plugins/observability/.storybook', presentation: 'src/plugins/presentation_util/storybook', + controls: 'src/plugins/controls/storybook', lists: 'x-pack/plugins/lists/.storybook', }; diff --git a/src/plugins/controls/README.mdx b/src/plugins/controls/README.mdx new file mode 100644 index 0000000000000..46ba1ed3ba9e7 --- /dev/null +++ b/src/plugins/controls/README.mdx @@ -0,0 +1,25 @@ +--- +id: controls +slug: /kibana-dev-docs/controls +title: Controls Plugin +summary: Introduction to the Controls Plugin. +date: 2020-01-12 +tags: ['kibana', 'controls', 'dashboard'] +related: [] +--- + +## Introduction + +The Controls plugin contains Embeddables which can be used to add user-friendly interactivity to apps. + +## The Control Group + +The Control group is an embeddable container which provides the ability to add, remove, reorder, and edit multiple types of control embeddable. In any implementation, the control group embeddable should be the main point of contact between the application and the controls. The list of filters it sends to its output observable should be considered the final output of the current state of the controls, and can then be sent to other embeddables, or combined with filters from other sources. + +## Control Types + +Multiple types of controls can be registered to work with the Control Group. The current implementations are as follows: + +### Options List + +The options list is the most basic, and most used control type. It allows the dashboard author to specify a data view and field, and create a searchable dropdown. diff --git a/src/plugins/presentation_util/common/controls/control_group/control_group_persistable_state.ts b/src/plugins/controls/common/control_group/control_group_persistable_state.ts similarity index 96% rename from src/plugins/presentation_util/common/controls/control_group/control_group_persistable_state.ts rename to src/plugins/controls/common/control_group/control_group_persistable_state.ts index 2da488acdc436..0fd24bd234327 100644 --- a/src/plugins/presentation_util/common/controls/control_group/control_group_persistable_state.ts +++ b/src/plugins/controls/common/control_group/control_group_persistable_state.ts @@ -10,9 +10,9 @@ import { EmbeddableInput, EmbeddablePersistableStateService, EmbeddableStateWithType, -} from '../../../../embeddable/common/types'; +} from '../../../embeddable/common/types'; import { ControlGroupInput, ControlPanelState } from './types'; -import { SavedObjectReference } from '../../../../../core/types'; +import { SavedObjectReference } from '../../../../core/types'; type ControlGroupInputWithType = Partial & { type: string }; diff --git a/src/plugins/presentation_util/common/controls/control_group/types.ts b/src/plugins/controls/common/control_group/types.ts similarity index 91% rename from src/plugins/presentation_util/common/controls/control_group/types.ts rename to src/plugins/controls/common/control_group/types.ts index da1cec0391102..4e1bddc08143f 100644 --- a/src/plugins/presentation_util/common/controls/control_group/types.ts +++ b/src/plugins/controls/common/control_group/types.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { EmbeddableInput, PanelState } from '../../../../embeddable/common/types'; +import { EmbeddableInput, PanelState } from '../../../embeddable/common/types'; import { ControlInput, ControlStyle, ControlWidth } from '../types'; export const CONTROL_GROUP_TYPE = 'control_group'; diff --git a/src/plugins/presentation_util/common/controls/control_types/options_list/options_list_persistable_state.ts b/src/plugins/controls/common/control_types/options_list/options_list_persistable_state.ts similarity index 89% rename from src/plugins/presentation_util/common/controls/control_types/options_list/options_list_persistable_state.ts rename to src/plugins/controls/common/control_types/options_list/options_list_persistable_state.ts index 90390256325ae..a41eb788d71e2 100644 --- a/src/plugins/presentation_util/common/controls/control_types/options_list/options_list_persistable_state.ts +++ b/src/plugins/controls/common/control_types/options_list/options_list_persistable_state.ts @@ -9,10 +9,10 @@ import { EmbeddableStateWithType, EmbeddablePersistableStateService, -} from '../../../../../embeddable/common/types'; +} from '../../../../embeddable/common'; import { OptionsListEmbeddableInput } from './types'; -import { SavedObjectReference } from '../../../../../../core/types'; -import { DATA_VIEW_SAVED_OBJECT_TYPE } from '../../../../../data_views/common'; +import { SavedObjectReference } from '../../../../../core/types'; +import { DATA_VIEW_SAVED_OBJECT_TYPE } from '../../../../data_views/common'; type OptionsListInputWithType = Partial & { type: string }; const dataViewReferenceName = 'optionsListDataView'; diff --git a/src/plugins/presentation_util/common/controls/control_types/options_list/types.ts b/src/plugins/controls/common/control_types/options_list/types.ts similarity index 100% rename from src/plugins/presentation_util/common/controls/control_types/options_list/types.ts rename to src/plugins/controls/common/control_types/options_list/types.ts diff --git a/src/plugins/controls/common/index.ts b/src/plugins/controls/common/index.ts new file mode 100644 index 0000000000000..aa06259cf855e --- /dev/null +++ b/src/plugins/controls/common/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export type { ControlPanelState, ControlsPanels, ControlGroupInput } from './control_group/types'; +export type { OptionsListEmbeddableInput } from './control_types/options_list/types'; +export type { ControlWidth } from './types'; + +export { OPTIONS_LIST_CONTROL } from './control_types/options_list/types'; +export { CONTROL_GROUP_TYPE } from './control_group/types'; diff --git a/src/plugins/presentation_util/common/controls/types.ts b/src/plugins/controls/common/types.ts similarity index 87% rename from src/plugins/presentation_util/common/controls/types.ts rename to src/plugins/controls/common/types.ts index 288324e30b47c..ad03bb642f798 100644 --- a/src/plugins/presentation_util/common/controls/types.ts +++ b/src/plugins/controls/common/types.ts @@ -7,8 +7,8 @@ */ import { Filter, Query } from '@kbn/es-query'; -import { TimeRange } from '../../../data/common'; -import { EmbeddableInput } from '../../../embeddable/common/types'; +import { TimeRange } from '../../data/common'; +import { EmbeddableInput } from '../../embeddable/common/types'; export type ControlWidth = 'auto' | 'small' | 'medium' | 'large'; export type ControlStyle = 'twoLine' | 'oneLine'; diff --git a/src/plugins/controls/kibana.json b/src/plugins/controls/kibana.json new file mode 100644 index 0000000000000..20afd63505a73 --- /dev/null +++ b/src/plugins/controls/kibana.json @@ -0,0 +1,23 @@ +{ + "id": "controls", + "owner": { + "name": "Kibana Presentation", + "githubTeam": "kibana-presentation" + }, + "description": "The Controls Plugin contains embeddable components intended to create a simple query interface for end users, and a powerful editing suite that allows dashboard authors to build controls", + "version": "1.0.0", + "kibanaVersion": "kibana", + "server": true, + "ui": true, + "extraPublicDirs": ["common"], + "requiredPlugins": [ + "presentationUtil", + "savedObjects", + "kibanaReact", + "expressions", + "embeddable", + "dataViews", + "data" + ], + "optionalPlugins": [] +} diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/controls.stories.tsx b/src/plugins/controls/public/__stories__/controls.stories.tsx similarity index 83% rename from src/plugins/presentation_util/public/components/controls/__stories__/controls.stories.tsx rename to src/plugins/controls/public/__stories__/controls.stories.tsx index e6fa5ef630904..ac181f2ab32dd 100644 --- a/src/plugins/presentation_util/public/components/controls/__stories__/controls.stories.tsx +++ b/src/plugins/controls/public/__stories__/controls.stories.tsx @@ -9,19 +9,28 @@ import { EuiFlexGroup, EuiFlexItem, EuiSwitch, EuiTextAlign } from '@elastic/eui'; import React, { useEffect, useMemo, useState, useCallback, FC } from 'react'; import useEffectOnce from 'react-use/lib/useEffectOnce'; - import uuid from 'uuid'; + +import { + getFlightOptionsAsync, + storybookFlightsDataView, +} from '../../../presentation_util/public/mocks'; +import { + ControlGroupContainerFactory, + OptionsListEmbeddableInput, + OPTIONS_LIST_CONTROL, +} from '../'; + +import { ViewMode } from '../../../embeddable/public'; +import { EmbeddablePersistableStateService } from '../../../embeddable/common'; + import { decorators } from './decorators'; import { ControlsPanels } from '../control_group/types'; -import { ViewMode } from '../../../../../embeddable/public'; -import { getFlightOptionsAsync, storybookFlightsDataView } from './fixtures/flights'; -import { pluginServices, registry } from '../../../services/storybook'; -import { OptionsListEmbeddableInput, OPTIONS_LIST_CONTROL } from '../../..'; -import { replaceValueSuggestionMethod } from '../../../services/storybook/data'; -import { injectStorybookDataView } from '../../../services/storybook/data_views'; +import { ControlGroupContainer } from '../control_group'; +import { pluginServices, registry } from '../services/storybook'; +import { replaceValueSuggestionMethod } from '../services/storybook/data'; +import { injectStorybookDataView } from '../services/storybook/data_views'; import { populateStorybookControlFactories } from './storybook_control_factories'; -import { EmbeddablePersistableStateService } from '../../../../../embeddable/common'; -import { ControlGroupContainerFactory } from '../control_group/embeddable/control_group_container_factory'; export default { title: 'Controls', @@ -29,8 +38,6 @@ export default { decorators, }; -type EmbeddableType = Awaited>; - injectStorybookDataView(storybookFlightsDataView); replaceValueSuggestionMethod(getFlightOptionsAsync); @@ -39,7 +46,7 @@ const ControlGroupStoryComponent: FC<{ edit?: boolean; }> = ({ panels, edit }) => { const embeddableRoot: React.RefObject = useMemo(() => React.createRef(), []); - const [embeddable, setEmbeddable] = useState(); + const [embeddable, setEmbeddable] = useState(); const [viewMode, setViewMode] = useState( edit === undefined || edit ? ViewMode.EDIT : ViewMode.VIEW ); diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/decorators.tsx b/src/plugins/controls/public/__stories__/decorators.tsx similarity index 100% rename from src/plugins/presentation_util/public/components/controls/__stories__/decorators.tsx rename to src/plugins/controls/public/__stories__/decorators.tsx diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/storybook_control_factories.ts b/src/plugins/controls/public/__stories__/storybook_control_factories.ts similarity index 83% rename from src/plugins/presentation_util/public/components/controls/__stories__/storybook_control_factories.ts rename to src/plugins/controls/public/__stories__/storybook_control_factories.ts index e4429c1d69b13..9809e90bd12fc 100644 --- a/src/plugins/presentation_util/public/components/controls/__stories__/storybook_control_factories.ts +++ b/src/plugins/controls/public/__stories__/storybook_control_factories.ts @@ -7,12 +7,10 @@ */ import { OptionsListEmbeddableFactory } from '../control_types/options_list'; -import { PresentationControlsService } from '../../../services/controls'; +import { ControlsService } from '../services/controls'; import { ControlFactory } from '..'; -export const populateStorybookControlFactories = ( - controlsServiceStub: PresentationControlsService -) => { +export const populateStorybookControlFactories = (controlsServiceStub: ControlsService) => { const optionsListFactoryStub = new OptionsListEmbeddableFactory(); // cast to unknown because the stub cannot use the embeddable start contract to transform the EmbeddableFactoryDefinition into an EmbeddableFactory diff --git a/src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx b/src/plugins/controls/public/control_group/component/control_frame_component.tsx similarity index 96% rename from src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx rename to src/plugins/controls/public/control_group/component/control_frame_component.tsx index bdc3b2978f888..e921cbd90d298 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx +++ b/src/plugins/controls/public/control_group/component/control_frame_component.tsx @@ -18,11 +18,11 @@ import { } from '@elastic/eui'; import { ControlGroupInput } from '../types'; +import { pluginServices } from '../../services'; import { EditControlButton } from '../editor/edit_control'; -import { useChildEmbeddable } from '../../hooks/use_child_embeddable'; -import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context'; import { ControlGroupStrings } from '../control_group_strings'; -import { pluginServices } from '../../../../services'; +import { useChildEmbeddable } from '../../hooks/use_child_embeddable'; +import { useReduxContainerContext } from '../../../../presentation_util/public'; export interface ControlFrameProps { customPrepend?: JSX.Element; @@ -38,7 +38,7 @@ export const ControlFrame = ({ customPrepend, enableActions, embeddableId }: Con } = useReduxContainerContext(); const { controlStyle } = useEmbeddableSelector((state) => state); - // Presentation Services Context + // Controls Services Context const { overlays } = pluginServices.getHooks(); const { openConfirm } = overlays.useService(); diff --git a/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_component.tsx b/src/plugins/controls/public/control_group/component/control_group_component.tsx similarity index 96% rename from src/plugins/presentation_util/public/components/controls/control_group/component/control_group_component.tsx rename to src/plugins/controls/public/control_group/component/control_group_component.tsx index 026f3154fe50e..a28807a36eb49 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_component.tsx +++ b/src/plugins/controls/public/control_group/component/control_group_component.tsx @@ -37,18 +37,18 @@ import { } from '@dnd-kit/core'; import { ControlGroupInput } from '../types'; -import { pluginServices } from '../../../../services'; +import { pluginServices } from '../../services'; +import { ViewMode } from '../../../../embeddable/public'; import { ControlGroupStrings } from '../control_group_strings'; import { CreateControlButton } from '../editor/create_control'; -import { ViewMode } from '../../../../../../embeddable/public'; import { EditControlGroup } from '../editor/edit_control_group'; import { forwardAllContext } from '../editor/forward_all_context'; import { controlGroupReducers } from '../state/control_group_reducers'; import { ControlClone, SortableControl } from './control_group_sortable_item'; -import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context'; +import { useReduxContainerContext } from '../../../../presentation_util/public'; export const ControlGroup = () => { - // Presentation Services Context + // Controls Services Context const { overlays } = pluginServices.getHooks(); const { openFlyout } = overlays.useService(); diff --git a/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_sortable_item.tsx b/src/plugins/controls/public/control_group/component/control_group_sortable_item.tsx similarity index 98% rename from src/plugins/presentation_util/public/components/controls/control_group/component/control_group_sortable_item.tsx rename to src/plugins/controls/public/control_group/component/control_group_sortable_item.tsx index f4c28e840556a..ecba29bb5f1db 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_sortable_item.tsx +++ b/src/plugins/controls/public/control_group/component/control_group_sortable_item.tsx @@ -14,7 +14,7 @@ import classNames from 'classnames'; import { ControlGroupInput } from '../types'; import { ControlFrame, ControlFrameProps } from './control_frame_component'; -import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context'; +import { useReduxContainerContext } from '../../../../presentation_util/public'; interface DragInfo { isOver?: boolean; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/control_group.scss b/src/plugins/controls/public/control_group/control_group.scss similarity index 100% rename from src/plugins/presentation_util/public/components/controls/control_group/control_group.scss rename to src/plugins/controls/public/control_group/control_group.scss diff --git a/src/plugins/controls/public/control_group/control_group_strings.ts b/src/plugins/controls/public/control_group/control_group_strings.ts new file mode 100644 index 0000000000000..91e857d083f7f --- /dev/null +++ b/src/plugins/controls/public/control_group/control_group_strings.ts @@ -0,0 +1,194 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; + +export const ControlGroupStrings = { + getEmbeddableTitle: () => + i18n.translate('controls.controlGroup.title', { + defaultMessage: 'Control group', + }), + emptyState: { + getCallToAction: () => + i18n.translate('controls.controlGroup.emptyState.callToAction', { + defaultMessage: 'Controls let you filter and interact with your dashboard data', + }), + getAddControlButtonTitle: () => + i18n.translate('controls.controlGroup.emptyState.addControlButtonTitle', { + defaultMessage: 'Add control', + }), + getTwoLineLoadingTitle: () => + i18n.translate('controls.controlGroup.emptyState.twoLineLoadingTitle', { + defaultMessage: '...', + }), + }, + manageControl: { + getFlyoutCreateTitle: () => + i18n.translate('controls.controlGroup.manageControl.createFlyoutTitle', { + defaultMessage: 'Create control', + }), + getFlyoutEditTitle: () => + i18n.translate('controls.controlGroup.manageControl.editFlyoutTitle', { + defaultMessage: 'Edit control', + }), + getTitleInputTitle: () => + i18n.translate('controls.controlGroup.manageControl.titleInputTitle', { + defaultMessage: 'Title', + }), + getWidthInputTitle: () => + i18n.translate('controls.controlGroup.manageControl.widthInputTitle', { + defaultMessage: 'Control size', + }), + getSaveChangesTitle: () => + i18n.translate('controls.controlGroup.manageControl.saveChangesTitle', { + defaultMessage: 'Save and close', + }), + getCancelTitle: () => + i18n.translate('controls.controlGroup.manageControl.cancelTitle', { + defaultMessage: 'Cancel', + }), + }, + management: { + getAddControlTitle: () => + i18n.translate('controls.controlGroup.management.addControl', { + defaultMessage: 'Add control', + }), + getManageButtonTitle: () => + i18n.translate('controls.controlGroup.management.buttonTitle', { + defaultMessage: 'Configure controls', + }), + getFlyoutTitle: () => + i18n.translate('controls.controlGroup.management.flyoutTitle', { + defaultMessage: 'Configure controls', + }), + getDefaultWidthTitle: () => + i18n.translate('controls.controlGroup.management.defaultWidthTitle', { + defaultMessage: 'Default size', + }), + getLayoutTitle: () => + i18n.translate('controls.controlGroup.management.layoutTitle', { + defaultMessage: 'Layout', + }), + getDeleteButtonTitle: () => + i18n.translate('controls.controlGroup.management.delete', { + defaultMessage: 'Delete control', + }), + getSetAllWidthsToDefaultTitle: () => + i18n.translate('controls.controlGroup.management.setAllWidths', { + defaultMessage: 'Set all sizes to default', + }), + getDeleteAllButtonTitle: () => + i18n.translate('controls.controlGroup.management.deleteAll', { + defaultMessage: 'Delete all', + }), + controlWidth: { + getWidthSwitchLegend: () => + i18n.translate('controls.controlGroup.management.layout.controlWidthLegend', { + defaultMessage: 'Change control size', + }), + getAutoWidthTitle: () => + i18n.translate('controls.controlGroup.management.layout.auto', { + defaultMessage: 'Auto', + }), + getSmallWidthTitle: () => + i18n.translate('controls.controlGroup.management.layout.small', { + defaultMessage: 'Small', + }), + getMediumWidthTitle: () => + i18n.translate('controls.controlGroup.management.layout.medium', { + defaultMessage: 'Medium', + }), + getLargeWidthTitle: () => + i18n.translate('controls.controlGroup.management.layout.large', { + defaultMessage: 'Large', + }), + }, + controlStyle: { + getDesignSwitchLegend: () => + i18n.translate('controls.controlGroup.management.layout.designSwitchLegend', { + defaultMessage: 'Switch control designs', + }), + getSingleLineTitle: () => + i18n.translate('controls.controlGroup.management.layout.singleLine', { + defaultMessage: 'Single line', + }), + getTwoLineTitle: () => + i18n.translate('controls.controlGroup.management.layout.twoLine', { + defaultMessage: 'Double line', + }), + }, + deleteControls: { + getDeleteAllTitle: () => + i18n.translate('controls.controlGroup.management.delete.deleteAllTitle', { + defaultMessage: 'Delete all controls?', + }), + getDeleteTitle: () => + i18n.translate('controls.controlGroup.management.delete.deleteTitle', { + defaultMessage: 'Delete control?', + }), + getSubtitle: () => + i18n.translate('controls.controlGroup.management.delete.sub', { + defaultMessage: 'Controls are not recoverable once removed.', + }), + getConfirm: () => + i18n.translate('controls.controlGroup.management.delete.confirm', { + defaultMessage: 'Delete', + }), + getCancel: () => + i18n.translate('controls.controlGroup.management.delete.cancel', { + defaultMessage: 'Cancel', + }), + }, + discardChanges: { + getTitle: () => + i18n.translate('controls.controlGroup.management.discard.title', { + defaultMessage: 'Discard changes?', + }), + getSubtitle: () => + i18n.translate('controls.controlGroup.management.discard.sub', { + defaultMessage: `Changes that you've made to this control will be discarded, are you sure you want to continue?`, + }), + getConfirm: () => + i18n.translate('controls.controlGroup.management.discard.confirm', { + defaultMessage: 'Discard changes', + }), + getCancel: () => + i18n.translate('controls.controlGroup.management.discard.cancel', { + defaultMessage: 'Cancel', + }), + }, + discardNewControl: { + getTitle: () => + i18n.translate('controls.controlGroup.management.deleteNew.title', { + defaultMessage: 'Discard new control', + }), + getSubtitle: () => + i18n.translate('controls.controlGroup.management.deleteNew.sub', { + defaultMessage: `Changes that you've made to this control will be discarded, are you sure you want to continue?`, + }), + getConfirm: () => + i18n.translate('controls.controlGroup.management.deleteNew.confirm', { + defaultMessage: 'Discard control', + }), + getCancel: () => + i18n.translate('controls.controlGroup.management.deleteNew.cancel', { + defaultMessage: 'Cancel', + }), + }, + }, + floatingActions: { + getEditButtonTitle: () => + i18n.translate('controls.controlGroup.floatingActions.editTitle', { + defaultMessage: 'Edit control', + }), + getRemoveButtonTitle: () => + i18n.translate('controls.controlGroup.floatingActions.removeTitle', { + defaultMessage: 'Remove control', + }), + }, +}; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/control_editor.tsx b/src/plugins/controls/public/control_group/editor/control_editor.tsx similarity index 100% rename from src/plugins/presentation_util/public/components/controls/control_group/editor/control_editor.tsx rename to src/plugins/controls/public/control_group/editor/control_editor.tsx diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/create_control.tsx b/src/plugins/controls/public/control_group/editor/create_control.tsx similarity index 94% rename from src/plugins/presentation_util/public/components/controls/control_group/editor/create_control.tsx rename to src/plugins/controls/public/control_group/editor/create_control.tsx index de7e38400f6bc..b97ebb9aa519b 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/editor/create_control.tsx +++ b/src/plugins/controls/public/control_group/editor/create_control.tsx @@ -17,19 +17,19 @@ import { import React, { useState, ReactElement } from 'react'; import { ControlGroupInput } from '../types'; +import { pluginServices } from '../../services'; import { ControlEditor } from './control_editor'; -import { pluginServices } from '../../../../services'; +import { OverlayRef } from '../../../../../core/public'; import { forwardAllContext } from './forward_all_context'; import { DEFAULT_CONTROL_WIDTH } from './editor_constants'; -import { OverlayRef } from '../../../../../../../core/public'; import { ControlGroupStrings } from '../control_group_strings'; import { controlGroupReducers } from '../state/control_group_reducers'; +import { EmbeddableFactoryNotFoundError } from '../../../../embeddable/public'; +import { useReduxContainerContext } from '../../../../presentation_util/public'; import { ControlWidth, IEditableControlFactory, ControlInput } from '../../types'; -import { EmbeddableFactoryNotFoundError } from '../../../../../../embeddable/public'; -import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context'; export const CreateControlButton = ({ isIconButton }: { isIconButton: boolean }) => { - // Presentation Services Context + // Controls Services Context const { overlays, controls } = pluginServices.getHooks(); const { getControlTypes, getControlFactory } = controls.useService(); const { openFlyout, openConfirm } = overlays.useService(); diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control.tsx b/src/plugins/controls/public/control_group/editor/edit_control.tsx similarity index 93% rename from src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control.tsx rename to src/plugins/controls/public/control_group/editor/edit_control.tsx index eb628049f7c93..210000e4f617c 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control.tsx +++ b/src/plugins/controls/public/control_group/editor/edit_control.tsx @@ -12,17 +12,17 @@ import React, { useEffect, useRef } from 'react'; import { ControlGroupInput } from '../types'; import { ControlEditor } from './control_editor'; -import { pluginServices } from '../../../../services'; +import { pluginServices } from '../../services'; import { forwardAllContext } from './forward_all_context'; -import { OverlayRef } from '../../../../../../../core/public'; +import { OverlayRef } from '../../../../../core/public'; import { ControlGroupStrings } from '../control_group_strings'; import { IEditableControlFactory, ControlInput } from '../../types'; import { controlGroupReducers } from '../state/control_group_reducers'; -import { EmbeddableFactoryNotFoundError } from '../../../../../../embeddable/public'; -import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context'; +import { EmbeddableFactoryNotFoundError } from '../../../../embeddable/public'; +import { useReduxContainerContext } from '../../../../presentation_util/public'; export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => { - // Presentation Services Context + // Controls Services Context const { overlays, controls } = pluginServices.getHooks(); const { getControlFactory } = controls.useService(); const { openFlyout, openConfirm } = overlays.useService(); diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx b/src/plugins/controls/public/control_group/editor/edit_control_group.tsx similarity index 97% rename from src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx rename to src/plugins/controls/public/control_group/editor/edit_control_group.tsx index 9828f6317ad53..87a2a1407a761 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx +++ b/src/plugins/controls/public/control_group/editor/edit_control_group.tsx @@ -28,11 +28,11 @@ import { DEFAULT_CONTROL_WIDTH, } from './editor_constants'; import { ControlGroupInput } from '../types'; -import { pluginServices } from '../../../../services'; +import { pluginServices } from '../../services'; import { ControlStyle, ControlWidth } from '../../types'; import { ControlGroupStrings } from '../control_group_strings'; import { controlGroupReducers } from '../state/control_group_reducers'; -import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context'; +import { useReduxContainerContext } from '../../../../presentation_util/public'; interface EditControlGroupState { newControlStyle: ControlGroupInput['controlStyle']; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/editor_constants.ts b/src/plugins/controls/public/control_group/editor/editor_constants.ts similarity index 100% rename from src/plugins/presentation_util/public/components/controls/control_group/editor/editor_constants.ts rename to src/plugins/controls/public/control_group/editor/editor_constants.ts diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/forward_all_context.tsx b/src/plugins/controls/public/control_group/editor/forward_all_context.tsx similarity index 67% rename from src/plugins/presentation_util/public/components/controls/control_group/editor/forward_all_context.tsx rename to src/plugins/controls/public/control_group/editor/forward_all_context.tsx index bb7356c240648..23d7e60573323 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/editor/forward_all_context.tsx +++ b/src/plugins/controls/public/control_group/editor/forward_all_context.tsx @@ -6,16 +6,16 @@ * Side Public License, v 1. */ -import { Provider } from 'react-redux'; import { ReactElement } from 'react'; import React from 'react'; import { ControlGroupInput } from '../types'; -import { pluginServices } from '../../../../services'; -import { toMountPoint } from '../../../../../../kibana_react/public'; -import { ReduxContainerContextServices } from '../../../redux_embeddables/types'; -import { ReduxEmbeddableContext } from '../../../redux_embeddables/redux_embeddable_context'; -import { getManagedEmbeddablesStore } from '../../../redux_embeddables/generic_embeddable_store'; +import { pluginServices } from '../../services'; +import { toMountPoint } from '../../../../kibana_react/public'; +import { + ReduxContainerContextServices, + ReduxEmbeddableContext, +} from '../../../../presentation_util/public'; /** * The overlays service creates its divs outside the flow of the component. This necessitates @@ -26,11 +26,12 @@ export const forwardAllContext = ( reduxContainerContext: ReduxContainerContextServices ) => { const PresentationUtilProvider = pluginServices.getContextProvider(); + const StoreProvider = reduxContainerContext.ReduxEmbeddableStoreProvider; return toMountPoint( - + {component} - + ); }; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container.tsx b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx similarity index 89% rename from src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container.tsx rename to src/plugins/controls/public/control_group/embeddable/control_group_container.tsx index ff25286a75211..65c93e42a472f 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container.tsx +++ b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx @@ -27,14 +27,22 @@ import { ControlPanelState, CONTROL_GROUP_TYPE, } from '../types'; -import { pluginServices } from '../../../../services'; -import { DataView } from '../../../../../../data_views/public'; +import { + withSuspense, + LazyReduxEmbeddableWrapper, + ReduxEmbeddableWrapperPropsWithChildren, +} from '../../../../presentation_util/public'; +import { pluginServices } from '../../services'; +import { DataView } from '../../../../data_views/public'; +import { DEFAULT_CONTROL_WIDTH } from '../editor/editor_constants'; import { ControlGroup } from '../component/control_group_component'; import { controlGroupReducers } from '../state/control_group_reducers'; import { ControlEmbeddable, ControlInput, ControlOutput } from '../../types'; -import { Container, EmbeddableFactory } from '../../../../../../embeddable/public'; -import { ReduxEmbeddableWrapper } from '../../../redux_embeddables/redux_embeddable_wrapper'; -import { DEFAULT_CONTROL_WIDTH } from '../editor/editor_constants'; +import { Container, EmbeddableFactory } from '../../../../embeddable/public'; + +const ControlGroupReduxWrapper = withSuspense< + ReduxEmbeddableWrapperPropsWithChildren +>(LazyReduxEmbeddableWrapper); export class ControlGroupContainer extends Container< ControlInput, @@ -145,12 +153,9 @@ export class ControlGroupContainer extends Container< const PresentationUtilProvider = pluginServices.getContextProvider(); ReactDOM.render( - - embeddable={this} - reducers={controlGroupReducers} - > + - + , dom ); diff --git a/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container_factory.ts b/src/plugins/controls/public/control_group/embeddable/control_group_container_factory.ts similarity index 91% rename from src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container_factory.ts rename to src/plugins/controls/public/control_group/embeddable/control_group_container_factory.ts index 5a71355da8bbe..d2e057a613070 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container_factory.ts +++ b/src/plugins/controls/public/control_group/embeddable/control_group_container_factory.ts @@ -14,14 +14,15 @@ * Side Public License, v 1. */ -import { Container, EmbeddableFactoryDefinition } from '../../../../../../embeddable/public'; -import { EmbeddablePersistableStateService } from '../../../../../../embeddable/common'; +import { Container, EmbeddableFactoryDefinition } from '../../../../embeddable/public'; +import { EmbeddablePersistableStateService } from '../../../../embeddable/common'; + import { ControlGroupInput, CONTROL_GROUP_TYPE } from '../types'; import { ControlGroupStrings } from '../control_group_strings'; import { createControlGroupExtract, createControlGroupInject, -} from '../../../../../common/controls/control_group/control_group_persistable_state'; +} from '../../../common/control_group/control_group_persistable_state'; export class ControlGroupContainerFactory implements EmbeddableFactoryDefinition { public readonly isContainerType = true; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/index.ts b/src/plugins/controls/public/control_group/index.ts similarity index 92% rename from src/plugins/presentation_util/public/components/controls/control_group/index.ts rename to src/plugins/controls/public/control_group/index.ts index 95988d2e8143c..60050786d7c11 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/index.ts +++ b/src/plugins/controls/public/control_group/index.ts @@ -6,6 +6,8 @@ * Side Public License, v 1. */ -export type { ControlGroupInput, ControlGroupOutput } from './types'; export type { ControlGroupContainer } from './embeddable/control_group_container'; +export type { ControlGroupInput, ControlGroupOutput } from './types'; + +export { CONTROL_GROUP_TYPE } from './types'; export { ControlGroupContainerFactory } from './embeddable/control_group_container_factory'; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/opt_a.svg b/src/plugins/controls/public/control_group/opt_a.svg similarity index 100% rename from src/plugins/presentation_util/public/components/controls/control_group/opt_a.svg rename to src/plugins/controls/public/control_group/opt_a.svg diff --git a/src/plugins/presentation_util/public/components/controls/control_group/state/control_group_reducers.ts b/src/plugins/controls/public/control_group/state/control_group_reducers.ts similarity index 100% rename from src/plugins/presentation_util/public/components/controls/control_group/state/control_group_reducers.ts rename to src/plugins/controls/public/control_group/state/control_group_reducers.ts diff --git a/src/plugins/presentation_util/public/components/controls/control_group/types.ts b/src/plugins/controls/public/control_group/types.ts similarity index 69% rename from src/plugins/presentation_util/public/components/controls/control_group/types.ts rename to src/plugins/controls/public/control_group/types.ts index 3d0123eb4192f..5f6ec00efffab 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/types.ts +++ b/src/plugins/controls/public/control_group/types.ts @@ -7,8 +7,13 @@ */ import { CommonControlOutput } from '../types'; -import { ContainerOutput } from '../../../../../embeddable/public'; +import { ContainerOutput } from '../../../embeddable/public'; export type ControlGroupOutput = ContainerOutput & CommonControlOutput; -export * from '../../../../common/controls/control_group/types'; +export { + type ControlsPanels, + type ControlGroupInput, + type ControlPanelState, + CONTROL_GROUP_TYPE, +} from '../../common/control_group/types'; diff --git a/src/plugins/presentation_util/public/components/controls/control_types/index.ts b/src/plugins/controls/public/control_types/index.ts similarity index 100% rename from src/plugins/presentation_util/public/components/controls/control_types/index.ts rename to src/plugins/controls/public/control_types/index.ts diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/index.ts b/src/plugins/controls/public/control_types/options_list/index.ts similarity index 72% rename from src/plugins/presentation_util/public/components/controls/control_types/options_list/index.ts rename to src/plugins/controls/public/control_types/options_list/index.ts index f2d9c29701a5f..7a254bf423ce4 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/index.ts +++ b/src/plugins/controls/public/control_types/options_list/index.ts @@ -6,5 +6,8 @@ * Side Public License, v 1. */ +export { OPTIONS_LIST_CONTROL } from '../../../common/control_types/options_list/types'; export { OptionsListEmbeddableFactory } from './options_list_embeddable_factory'; + export type { OptionsListEmbeddable } from './options_list_embeddable'; +export type { OptionsListEmbeddableInput } from '../../../common/control_types/options_list/types'; diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list.scss b/src/plugins/controls/public/control_types/options_list/options_list.scss similarity index 100% rename from src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list.scss rename to src/plugins/controls/public/control_types/options_list/options_list.scss diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_component.tsx b/src/plugins/controls/public/control_types/options_list/options_list_component.tsx similarity index 97% rename from src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_component.tsx rename to src/plugins/controls/public/control_types/options_list/options_list_component.tsx index 1c79d1ce3e9b0..74f2130528e97 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_component.tsx +++ b/src/plugins/controls/public/control_types/options_list/options_list_component.tsx @@ -15,7 +15,7 @@ import { debounce } from 'lodash'; import { OptionsListStrings } from './options_list_strings'; import { optionsListReducers } from './options_list_reducers'; import { OptionsListPopover } from './options_list_popover_component'; -import { useReduxEmbeddableContext } from '../../../redux_embeddables/redux_embeddable_context'; +import { useReduxEmbeddableContext } from '../../../../presentation_util/public'; import './options_list.scss'; import { useStateObservable } from '../../hooks/use_state_observable'; diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_editor.tsx b/src/plugins/controls/public/control_types/options_list/options_list_editor.tsx similarity index 90% rename from src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_editor.tsx rename to src/plugins/controls/public/control_types/options_list/options_list_editor.tsx index 86f4f85b3b0b2..d9231a5d8b2e5 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_editor.tsx +++ b/src/plugins/controls/public/control_types/options_list/options_list_editor.tsx @@ -10,13 +10,16 @@ import useMount from 'react-use/lib/useMount'; import React, { useEffect, useState } from 'react'; import { EuiFormRow, EuiSwitch } from '@elastic/eui'; +import { pluginServices } from '../../services'; import { ControlEditorProps } from '../../types'; -import { DataViewListItem, DataView } from '../../../../../../data_views/common'; -import { DataViewPicker } from '../../../data_view_picker/data_view_picker'; -import { OptionsListStrings } from './options_list_strings'; -import { pluginServices } from '../../../../services'; import { OptionsListEmbeddableInput } from './types'; -import { FieldPicker } from '../../../field_picker/field_picker'; +import { OptionsListStrings } from './options_list_strings'; +import { DataViewListItem, DataView } from '../../../../data_views/common'; +import { + LazyDataViewPicker, + LazyFieldPicker, + withSuspense, +} from '../../../../presentation_util/public'; interface OptionsListEditorState { singleSelect?: boolean; @@ -27,13 +30,16 @@ interface OptionsListEditorState { fieldName?: string; } +const FieldPicker = withSuspense(LazyFieldPicker, null); +const DataViewPicker = withSuspense(LazyDataViewPicker, null); + export const OptionsListEditor = ({ onChange, initialInput, setValidState, setDefaultTitle, }: ControlEditorProps) => { - // Presentation Services Context + // Controls Services Context const { dataViews } = pluginServices.getHooks(); const { getIdsWithTitle, getDefaultId, get } = dataViews.useService(); diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx b/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx similarity index 89% rename from src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx rename to src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx index b980ee10293e5..ce570fcbf769e 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx +++ b/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx @@ -6,31 +6,39 @@ * Side Public License, v 1. */ -import React from 'react'; -import ReactDOM from 'react-dom'; -import { isEqual } from 'lodash'; -import deepEqual from 'fast-deep-equal'; import { + Filter, buildEsQuery, + compareFilters, buildPhraseFilter, buildPhrasesFilter, - compareFilters, - Filter, } from '@kbn/es-query'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { isEqual } from 'lodash'; +import deepEqual from 'fast-deep-equal'; import { merge, Subject, Subscription, BehaviorSubject } from 'rxjs'; import { tap, debounceTime, map, distinctUntilChanged, skip } from 'rxjs/operators'; -import { ReduxEmbeddableWrapper } from '../../../redux_embeddables/redux_embeddable_wrapper'; import { OptionsListComponent, OptionsListComponentState } from './options_list_component'; -import { PresentationDataViewsService } from '../../../../services/data_views'; -import { Embeddable, IContainer } from '../../../../../../embeddable/public'; +import { + withSuspense, + LazyReduxEmbeddableWrapper, + ReduxEmbeddableWrapperPropsWithChildren, +} from '../../../../presentation_util/public'; import { OptionsListEmbeddableInput, OPTIONS_LIST_CONTROL } from './types'; -import { PresentationDataService } from '../../../../services/data'; -import { DataView } from '../../../../../../data_views/public'; +import { ControlsDataViewsService } from '../../services/data_views'; +import { Embeddable, IContainer } from '../../../../embeddable/public'; +import { ControlsDataService } from '../../services/data'; import { optionsListReducers } from './options_list_reducers'; import { OptionsListStrings } from './options_list_strings'; -import { pluginServices } from '../../../../services'; +import { DataView } from '../../../../data_views/public'; import { ControlInput, ControlOutput } from '../..'; +import { pluginServices } from '../../services'; + +const OptionsListReduxWrapper = withSuspense< + ReduxEmbeddableWrapperPropsWithChildren +>(LazyReduxEmbeddableWrapper); const diffDataFetchProps = ( current?: OptionsListDataFetchProps, @@ -62,9 +70,9 @@ export class OptionsListEmbeddable extends Embeddable = new Subject(); @@ -80,7 +88,7 @@ export class OptionsListEmbeddable extends Embeddable - embeddable={this} - reducers={optionsListReducers} - > + - , + , node ); }; diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable_factory.tsx b/src/plugins/controls/public/control_types/options_list/options_list_embeddable_factory.tsx similarity index 93% rename from src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable_factory.tsx rename to src/plugins/controls/public/control_types/options_list/options_list_embeddable_factory.tsx index cb53ac463be3f..98d616faadc58 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable_factory.tsx +++ b/src/plugins/controls/public/control_types/options_list/options_list_embeddable_factory.tsx @@ -11,11 +11,11 @@ import deepEqual from 'fast-deep-equal'; import { OptionsListEditor } from './options_list_editor'; import { ControlEmbeddable, IEditableControlFactory } from '../../types'; import { OptionsListEmbeddableInput, OPTIONS_LIST_CONTROL } from './types'; -import { EmbeddableFactoryDefinition, IContainer } from '../../../../../../embeddable/public'; +import { EmbeddableFactoryDefinition, IContainer } from '../../../../embeddable/public'; import { createOptionsListExtract, createOptionsListInject, -} from '../../../../../common/controls/control_types/options_list/options_list_persistable_state'; +} from '../../../common/control_types/options_list/options_list_persistable_state'; export class OptionsListEmbeddableFactory implements EmbeddableFactoryDefinition, IEditableControlFactory diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx b/src/plugins/controls/public/control_types/options_list/options_list_popover_component.tsx similarity index 98% rename from src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx rename to src/plugins/controls/public/control_types/options_list/options_list_popover_component.tsx index 4aae049a5d446..0634d676f57a2 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx +++ b/src/plugins/controls/public/control_types/options_list/options_list_popover_component.tsx @@ -24,7 +24,7 @@ import { OptionsListEmbeddableInput } from './types'; import { OptionsListStrings } from './options_list_strings'; import { optionsListReducers } from './options_list_reducers'; import { OptionsListComponentState } from './options_list_component'; -import { useReduxEmbeddableContext } from '../../../redux_embeddables/redux_embeddable_context'; +import { useReduxEmbeddableContext } from '../../../../presentation_util/public'; export const OptionsListPopover = ({ loading, diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_reducers.ts b/src/plugins/controls/public/control_types/options_list/options_list_reducers.ts similarity index 100% rename from src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_reducers.ts rename to src/plugins/controls/public/control_types/options_list/options_list_reducers.ts diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts b/src/plugins/controls/public/control_types/options_list/options_list_strings.ts similarity index 59% rename from src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts rename to src/plugins/controls/public/control_types/options_list/options_list_strings.ts index dee0d4e7e1807..0a6e46c514d11 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts +++ b/src/plugins/controls/public/control_types/options_list/options_list_strings.ts @@ -11,65 +11,65 @@ import { i18n } from '@kbn/i18n'; export const OptionsListStrings = { summary: { getSeparator: () => - i18n.translate('presentationUtil.controls.optionsList.summary.separator', { + i18n.translate('controls.optionsList.summary.separator', { defaultMessage: ', ', }), getPlaceholder: () => - i18n.translate('presentationUtil.controls.optionsList.summary.placeholder', { + i18n.translate('controls.optionsList.summary.placeholder', { defaultMessage: 'Select...', }), }, editor: { getIndexPatternTitle: () => - i18n.translate('presentationUtil.controls.optionsList.editor.indexPatternTitle', { + i18n.translate('controls.optionsList.editor.indexPatternTitle', { defaultMessage: 'Index pattern', }), getDataViewTitle: () => - i18n.translate('presentationUtil.controls.optionsList.editor.dataViewTitle', { + i18n.translate('controls.optionsList.editor.dataViewTitle', { defaultMessage: 'Data view', }), getNoDataViewTitle: () => - i18n.translate('presentationUtil.controls.optionsList.editor.noDataViewTitle', { + i18n.translate('controls.optionsList.editor.noDataViewTitle', { defaultMessage: 'Select data view', }), getFieldTitle: () => - i18n.translate('presentationUtil.controls.optionsList.editor.fieldTitle', { + i18n.translate('controls.optionsList.editor.fieldTitle', { defaultMessage: 'Field', }), getAllowMultiselectTitle: () => - i18n.translate('presentationUtil.inputControls.optionsList.editor.allowMultiselectTitle', { + i18n.translate('controls.optionsList.editor.allowMultiselectTitle', { defaultMessage: 'Allow multiple selections in dropdown', }), }, popover: { getLoadingMessage: () => - i18n.translate('presentationUtil.controls.optionsList.popover.loading', { + i18n.translate('controls.optionsList.popover.loading', { defaultMessage: 'Loading filters', }), getEmptyMessage: () => - i18n.translate('presentationUtil.controls.optionsList.popover.empty', { + i18n.translate('controls.optionsList.popover.empty', { defaultMessage: 'No filters found', }), getSelectionsEmptyMessage: () => - i18n.translate('presentationUtil.controls.optionsList.popover.selectionsEmpty', { + i18n.translate('controls.optionsList.popover.selectionsEmpty', { defaultMessage: 'You have no selections', }), getAllOptionsButtonTitle: () => - i18n.translate('presentationUtil.controls.optionsList.popover.allOptionsTitle', { + i18n.translate('controls.optionsList.popover.allOptionsTitle', { defaultMessage: 'Show all options', }), getSelectedOptionsButtonTitle: () => - i18n.translate('presentationUtil.inputControls.optionsList.popover.selectedOptionsTitle', { + i18n.translate('controls.optionsList.popover.selectedOptionsTitle', { defaultMessage: 'Show only selected options', }), getClearAllSelectionsButtonTitle: () => - i18n.translate('presentationUtil.controls.optionsList.popover.clearAllSelectionsTitle', { + i18n.translate('controls.optionsList.popover.clearAllSelectionsTitle', { defaultMessage: 'Clear selections', }), }, errors: { getDataViewNotFoundError: (dataViewId: string) => - i18n.translate('presentationUtil.controls.optionsList.errors.dataViewNotFound', { + i18n.translate('controls.optionsList.errors.dataViewNotFound', { defaultMessage: 'Could not locate data view: {dataViewId}', values: { dataViewId }, }), diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/types.ts b/src/plugins/controls/public/control_types/options_list/types.ts similarity index 81% rename from src/plugins/presentation_util/public/components/controls/control_types/options_list/types.ts rename to src/plugins/controls/public/control_types/options_list/types.ts index 06b6526f38db4..f537cccf3d690 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/types.ts +++ b/src/plugins/controls/public/control_types/options_list/types.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export * from '../../../../../common/controls/control_types/options_list/types'; +export * from '../../../common/control_types/options_list/types'; diff --git a/src/plugins/presentation_util/public/components/controls/controls_service.ts b/src/plugins/controls/public/controls_service.ts similarity index 88% rename from src/plugins/presentation_util/public/components/controls/controls_service.ts rename to src/plugins/controls/public/controls_service.ts index 436d36fcc9db0..69efacbef2915 100644 --- a/src/plugins/presentation_util/public/components/controls/controls_service.ts +++ b/src/plugins/controls/public/controls_service.ts @@ -7,8 +7,8 @@ */ import { ControlEmbeddable, ControlFactory, ControlInput, ControlOutput } from '.'; -import { EmbeddableFactory } from '../../../../embeddable/public'; -import { ControlTypeRegistry } from '../../services/controls'; +import { EmbeddableFactory } from '../../embeddable/public'; +import { ControlTypeRegistry } from './services/controls'; export class ControlsService { private controlsFactoriesMap: ControlTypeRegistry = {}; diff --git a/src/plugins/presentation_util/public/components/controls/hooks/use_child_embeddable.ts b/src/plugins/controls/public/hooks/use_child_embeddable.ts similarity index 100% rename from src/plugins/presentation_util/public/components/controls/hooks/use_child_embeddable.ts rename to src/plugins/controls/public/hooks/use_child_embeddable.ts diff --git a/src/plugins/presentation_util/public/components/controls/hooks/use_state_observable.ts b/src/plugins/controls/public/hooks/use_state_observable.ts similarity index 100% rename from src/plugins/presentation_util/public/components/controls/hooks/use_state_observable.ts rename to src/plugins/controls/public/hooks/use_state_observable.ts diff --git a/src/plugins/controls/public/index.ts b/src/plugins/controls/public/index.ts new file mode 100644 index 0000000000000..c8118fcdb1d77 --- /dev/null +++ b/src/plugins/controls/public/index.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ControlsPlugin } from './plugin'; + +export type { + ControlOutput, + ControlFactory, + ControlEmbeddable, + ControlEditorProps, + CommonControlOutput, + IEditableControlFactory, +} from './types'; + +export type { + ControlWidth, + ControlStyle, + ParentIgnoreSettings, + ControlInput, +} from '../common/types'; + +export { OPTIONS_LIST_CONTROL, CONTROL_GROUP_TYPE } from '../common'; + +export { + ControlGroupContainer, + ControlGroupContainerFactory, + type ControlGroupInput, + type ControlGroupOutput, +} from './control_group'; + +export { + OptionsListEmbeddableFactory, + OptionsListEmbeddable, + type OptionsListEmbeddableInput, +} from './control_types'; + +export function plugin() { + return new ControlsPlugin(); +} diff --git a/src/plugins/controls/public/plugin.ts b/src/plugins/controls/public/plugin.ts new file mode 100644 index 0000000000000..c4ff865a05e47 --- /dev/null +++ b/src/plugins/controls/public/plugin.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CoreSetup, CoreStart, Plugin } from '../../../core/public'; +import { pluginServices } from './services'; +import { registry } from './services/kibana'; +import { + ControlsPluginSetup, + ControlsPluginStart, + ControlsPluginSetupDeps, + ControlsPluginStartDeps, + IEditableControlFactory, + ControlEditorProps, + ControlEmbeddable, + ControlInput, +} from './types'; +import { OptionsListEmbeddableFactory } from './control_types/options_list'; +import { ControlGroupContainerFactory, CONTROL_GROUP_TYPE, OPTIONS_LIST_CONTROL } from '.'; + +export class ControlsPlugin + implements + Plugin< + ControlsPluginSetup, + ControlsPluginStart, + ControlsPluginSetupDeps, + ControlsPluginStartDeps + > +{ + private inlineEditors: { + [key: string]: { + controlEditorComponent?: (props: ControlEditorProps) => JSX.Element; + presaveTransformFunction?: ( + newInput: Partial, + embeddable?: ControlEmbeddable + ) => Partial; + }; + } = {}; + + public setup( + _coreSetup: CoreSetup, + _setupPlugins: ControlsPluginSetupDeps + ): ControlsPluginSetup { + _coreSetup.getStartServices().then(([coreStart, deps]) => { + // register control group embeddable factory + embeddable.registerEmbeddableFactory( + CONTROL_GROUP_TYPE, + new ControlGroupContainerFactory(deps.embeddable) + ); + }); + + const { embeddable } = _setupPlugins; + + // create control type embeddable factories. + const optionsListFactory = new OptionsListEmbeddableFactory(); + const editableOptionsListFactory = optionsListFactory as IEditableControlFactory; + this.inlineEditors[OPTIONS_LIST_CONTROL] = { + controlEditorComponent: editableOptionsListFactory.controlEditorComponent, + presaveTransformFunction: editableOptionsListFactory.presaveTransformFunction, + }; + embeddable.registerEmbeddableFactory(OPTIONS_LIST_CONTROL, optionsListFactory); + + return {}; + } + + public start(coreStart: CoreStart, startPlugins: ControlsPluginStartDeps): ControlsPluginStart { + pluginServices.setRegistry(registry.start({ coreStart, startPlugins })); + const { controls: controlsService } = pluginServices.getServices(); + const { embeddable } = startPlugins; + + // register control types with controls service. + const optionsListFactory = embeddable.getEmbeddableFactory(OPTIONS_LIST_CONTROL); + // Temporarily pass along inline editors - inline editing should be made a first-class feature of embeddables + const editableOptionsListFactory = optionsListFactory as IEditableControlFactory; + const { + controlEditorComponent: optionsListControlEditor, + presaveTransformFunction: optionsListPresaveTransform, + } = this.inlineEditors[OPTIONS_LIST_CONTROL]; + editableOptionsListFactory.controlEditorComponent = optionsListControlEditor; + editableOptionsListFactory.presaveTransformFunction = optionsListPresaveTransform; + + if (optionsListFactory) controlsService.registerControlType(optionsListFactory); + + return { + ContextProvider: pluginServices.getContextProvider(), + controlsService, + }; + } + + public stop() {} +} diff --git a/src/plugins/presentation_util/public/services/controls.ts b/src/plugins/controls/public/services/controls.ts similarity index 89% rename from src/plugins/presentation_util/public/services/controls.ts rename to src/plugins/controls/public/services/controls.ts index 76af24960bfe3..83a3c8eec98d3 100644 --- a/src/plugins/presentation_util/public/services/controls.ts +++ b/src/plugins/controls/public/services/controls.ts @@ -7,18 +7,13 @@ */ import { EmbeddableFactory } from '../../../embeddable/public'; -import { - ControlEmbeddable, - ControlFactory, - ControlOutput, - ControlInput, -} from '../components/controls/types'; +import { ControlEmbeddable, ControlFactory, ControlOutput, ControlInput } from '../types'; export interface ControlTypeRegistry { [key: string]: ControlFactory; } -export interface PresentationControlsService { +export interface ControlsService { registerControlType: (factory: ControlFactory) => void; getControlFactory: < diff --git a/src/plugins/presentation_util/public/services/data.ts b/src/plugins/controls/public/services/data.ts similarity index 91% rename from src/plugins/presentation_util/public/services/data.ts rename to src/plugins/controls/public/services/data.ts index 44f29dcd2d3ad..0a99317e29a26 100644 --- a/src/plugins/presentation_util/public/services/data.ts +++ b/src/plugins/controls/public/services/data.ts @@ -8,6 +8,6 @@ import { DataPublicPluginStart } from '../../../data/public'; -export interface PresentationDataService { +export interface ControlsDataService { autocomplete: DataPublicPluginStart['autocomplete']; } diff --git a/src/plugins/controls/public/services/data_views.ts b/src/plugins/controls/public/services/data_views.ts new file mode 100644 index 0000000000000..2308366e27660 --- /dev/null +++ b/src/plugins/controls/public/services/data_views.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { DataViewsPublicPluginStart } from '../../../data_views/public'; + +export interface ControlsDataViewsService { + get: DataViewsPublicPluginStart['get']; + getDefaultId: DataViewsPublicPluginStart['getDefaultId']; + getIdsWithTitle: DataViewsPublicPluginStart['getIdsWithTitle']; +} diff --git a/src/plugins/controls/public/services/index.ts b/src/plugins/controls/public/services/index.ts new file mode 100644 index 0000000000000..5730dbf68cefb --- /dev/null +++ b/src/plugins/controls/public/services/index.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PluginServices } from '../../../presentation_util/public'; +import { ControlsDataViewsService } from './data_views'; +import { ControlsOverlaysService } from './overlays'; +import { registry as stubRegistry } from './stub'; +import { ControlsPluginStart } from '../types'; +import { ControlsDataService } from './data'; +import { ControlsService } from './controls'; + +export interface ControlsServices { + dataViews: ControlsDataViewsService; + overlays: ControlsOverlaysService; + data: ControlsDataService; + controls: ControlsService; +} + +export const pluginServices = new PluginServices(); + +export const getStubPluginServices = (): ControlsPluginStart => { + pluginServices.setRegistry(stubRegistry.start({})); + return { + ContextProvider: pluginServices.getContextProvider(), + controlsService: pluginServices.getServices().controls, + }; +}; diff --git a/src/plugins/presentation_util/public/services/stub/controls.ts b/src/plugins/controls/public/services/kibana/controls.ts similarity index 65% rename from src/plugins/presentation_util/public/services/stub/controls.ts rename to src/plugins/controls/public/services/kibana/controls.ts index e5dc84a3dd645..7c33ee8c33527 100644 --- a/src/plugins/presentation_util/public/services/stub/controls.ts +++ b/src/plugins/controls/public/services/kibana/controls.ts @@ -6,8 +6,8 @@ * Side Public License, v 1. */ -import { PluginServiceFactory } from '../create'; -import { getCommonControlsService, PresentationControlsService } from '../controls'; +import { PluginServiceFactory } from '../../../../presentation_util/public'; +import { getCommonControlsService, ControlsService } from '../controls'; -export type ControlsServiceFactory = PluginServiceFactory; +export type ControlsServiceFactory = PluginServiceFactory; export const controlsServiceFactory = () => getCommonControlsService(); diff --git a/src/plugins/presentation_util/public/services/kibana/data.ts b/src/plugins/controls/public/services/kibana/data.ts similarity index 71% rename from src/plugins/presentation_util/public/services/kibana/data.ts rename to src/plugins/controls/public/services/kibana/data.ts index 408e59fd4906c..4b4b9ad8afd81 100644 --- a/src/plugins/presentation_util/public/services/kibana/data.ts +++ b/src/plugins/controls/public/services/kibana/data.ts @@ -6,13 +6,13 @@ * Side Public License, v 1. */ -import { PresentationUtilPluginStartDeps } from '../../types'; -import { KibanaPluginServiceFactory } from '../create'; -import { PresentationDataService } from '../data'; +import { ControlsDataService } from '../data'; +import { ControlsPluginStartDeps } from '../../types'; +import { KibanaPluginServiceFactory } from '../../../../presentation_util/public'; export type DataServiceFactory = KibanaPluginServiceFactory< - PresentationDataService, - PresentationUtilPluginStartDeps + ControlsDataService, + ControlsPluginStartDeps >; export const dataServiceFactory: DataServiceFactory = ({ startPlugins }) => { diff --git a/src/plugins/controls/public/services/kibana/data_views.ts b/src/plugins/controls/public/services/kibana/data_views.ts new file mode 100644 index 0000000000000..c878423390a64 --- /dev/null +++ b/src/plugins/controls/public/services/kibana/data_views.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ControlsPluginStartDeps } from '../../types'; +import { ControlsDataViewsService } from '../data_views'; +import { KibanaPluginServiceFactory } from '../../../../presentation_util/public'; + +export type DataViewsServiceFactory = KibanaPluginServiceFactory< + ControlsDataViewsService, + ControlsPluginStartDeps +>; + +export const dataViewsServiceFactory: DataViewsServiceFactory = ({ startPlugins }) => { + const { + dataViews: { get, getIdsWithTitle, getDefaultId }, + } = startPlugins; + + return { + get, + getDefaultId, + getIdsWithTitle, + }; +}; diff --git a/src/plugins/controls/public/services/kibana/index.ts b/src/plugins/controls/public/services/kibana/index.ts new file mode 100644 index 0000000000000..5f7f05705203e --- /dev/null +++ b/src/plugins/controls/public/services/kibana/index.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + PluginServiceProviders, + KibanaPluginServiceParams, + PluginServiceProvider, + PluginServiceRegistry, +} from '../../../../presentation_util/public'; +import { ControlsPluginStartDeps } from '../../types'; +import { ControlsServices } from '..'; + +import { dataViewsServiceFactory } from './data_views'; +import { controlsServiceFactory } from './controls'; +import { overlaysServiceFactory } from './overlays'; +import { dataServiceFactory } from './data'; + +export const providers: PluginServiceProviders< + ControlsServices, + KibanaPluginServiceParams +> = { + data: new PluginServiceProvider(dataServiceFactory), + overlays: new PluginServiceProvider(overlaysServiceFactory), + controls: new PluginServiceProvider(controlsServiceFactory), + dataViews: new PluginServiceProvider(dataViewsServiceFactory), +}; + +export const registry = new PluginServiceRegistry< + ControlsServices, + KibanaPluginServiceParams +>(providers); diff --git a/src/plugins/presentation_util/public/services/kibana/overlays.ts b/src/plugins/controls/public/services/kibana/overlays.ts similarity index 71% rename from src/plugins/presentation_util/public/services/kibana/overlays.ts rename to src/plugins/controls/public/services/kibana/overlays.ts index b3a8d3a6e040a..43b8bd61ccb4a 100644 --- a/src/plugins/presentation_util/public/services/kibana/overlays.ts +++ b/src/plugins/controls/public/services/kibana/overlays.ts @@ -6,13 +6,13 @@ * Side Public License, v 1. */ -import { PresentationUtilPluginStartDeps } from '../../types'; -import { KibanaPluginServiceFactory } from '../create'; -import { PresentationOverlaysService } from '../overlays'; +import { ControlsPluginStartDeps } from '../../types'; +import { ControlsOverlaysService } from '../overlays'; +import { KibanaPluginServiceFactory } from '../../../../presentation_util/public'; export type OverlaysServiceFactory = KibanaPluginServiceFactory< - PresentationOverlaysService, - PresentationUtilPluginStartDeps + ControlsOverlaysService, + ControlsPluginStartDeps >; export const overlaysServiceFactory: OverlaysServiceFactory = ({ coreStart }) => { const { diff --git a/src/plugins/presentation_util/public/services/overlays.ts b/src/plugins/controls/public/services/overlays.ts similarity index 93% rename from src/plugins/presentation_util/public/services/overlays.ts rename to src/plugins/controls/public/services/overlays.ts index ee90de5231896..9a30fca209c2f 100644 --- a/src/plugins/presentation_util/public/services/overlays.ts +++ b/src/plugins/controls/public/services/overlays.ts @@ -13,7 +13,7 @@ import { OverlayRef, } from '../../../../core/public'; -export interface PresentationOverlaysService { +export interface ControlsOverlaysService { openFlyout(mount: MountPoint, options?: OverlayFlyoutOpenOptions): OverlayRef; openConfirm(message: MountPoint | string, options?: OverlayModalConfirmOptions): Promise; } diff --git a/src/plugins/presentation_util/public/services/kibana/controls.ts b/src/plugins/controls/public/services/storybook/controls.ts similarity index 65% rename from src/plugins/presentation_util/public/services/kibana/controls.ts rename to src/plugins/controls/public/services/storybook/controls.ts index e5dc84a3dd645..7c33ee8c33527 100644 --- a/src/plugins/presentation_util/public/services/kibana/controls.ts +++ b/src/plugins/controls/public/services/storybook/controls.ts @@ -6,8 +6,8 @@ * Side Public License, v 1. */ -import { PluginServiceFactory } from '../create'; -import { getCommonControlsService, PresentationControlsService } from '../controls'; +import { PluginServiceFactory } from '../../../../presentation_util/public'; +import { getCommonControlsService, ControlsService } from '../controls'; -export type ControlsServiceFactory = PluginServiceFactory; +export type ControlsServiceFactory = PluginServiceFactory; export const controlsServiceFactory = () => getCommonControlsService(); diff --git a/src/plugins/presentation_util/public/services/storybook/data.ts b/src/plugins/controls/public/services/storybook/data.ts similarity index 83% rename from src/plugins/presentation_util/public/services/storybook/data.ts rename to src/plugins/controls/public/services/storybook/data.ts index 841ee1bd9be71..6d4942b358ac3 100644 --- a/src/plugins/presentation_util/public/services/storybook/data.ts +++ b/src/plugins/controls/public/services/storybook/data.ts @@ -6,10 +6,10 @@ * Side Public License, v 1. */ +import { PluginServiceFactory } from '../../../../presentation_util/public'; import { DataPublicPluginStart } from '../../../../data/public'; import { DataViewField } from '../../../../data_views/common'; -import { PresentationDataService } from '../data'; -import { PluginServiceFactory } from '../create'; +import { ControlsDataService } from '../data'; let valueSuggestionMethod = ({ field, query }: { field: DataViewField; query: string }) => Promise.resolve(['storybook', 'default', 'values']); @@ -17,7 +17,7 @@ export const replaceValueSuggestionMethod = ( newMethod: ({ field, query }: { field: DataViewField; query: string }) => Promise ) => (valueSuggestionMethod = newMethod); -export type DataServiceFactory = PluginServiceFactory; +export type DataServiceFactory = PluginServiceFactory; export const dataServiceFactory: DataServiceFactory = () => ({ autocomplete: { getValueSuggestions: valueSuggestionMethod, diff --git a/src/plugins/controls/public/services/storybook/data_views.ts b/src/plugins/controls/public/services/storybook/data_views.ts new file mode 100644 index 0000000000000..5248dfbd70507 --- /dev/null +++ b/src/plugins/controls/public/services/storybook/data_views.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PluginServiceFactory } from '../../../../presentation_util/public'; +import { DataViewsPublicPluginStart } from '../../../../data_views/public'; +import { ControlsDataViewsService } from '../data_views'; +import { DataView } from '../../../../data_views/common'; + +export type DataViewsServiceFactory = PluginServiceFactory; + +let currentDataView: DataView; +export const injectStorybookDataView = (dataView: DataView) => (currentDataView = dataView); + +export const dataViewsServiceFactory: DataViewsServiceFactory = () => ({ + get: (() => + new Promise((r) => + setTimeout(() => r(currentDataView), 100) + ) as unknown) as DataViewsPublicPluginStart['get'], + getIdsWithTitle: (() => + new Promise((r) => + setTimeout(() => r([{ id: currentDataView.id, title: currentDataView.title }]), 100) + ) as unknown) as DataViewsPublicPluginStart['getIdsWithTitle'], + getDefaultId: () => Promise.resolve(currentDataView?.id ?? null), +}); diff --git a/src/plugins/controls/public/services/storybook/index.ts b/src/plugins/controls/public/services/storybook/index.ts new file mode 100644 index 0000000000000..36d8e7e78869d --- /dev/null +++ b/src/plugins/controls/public/services/storybook/index.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + PluginServices, + PluginServiceProviders, + PluginServiceProvider, + PluginServiceRegistry, +} from '../../../../presentation_util/public'; +import { ControlsServices } from '..'; +import { dataServiceFactory } from './data'; +import { overlaysServiceFactory } from './overlays'; +import { controlsServiceFactory } from './controls'; +import { dataViewsServiceFactory } from './data_views'; + +export type { ControlsServices } from '..'; + +export const providers: PluginServiceProviders = { + dataViews: new PluginServiceProvider(dataViewsServiceFactory), + data: new PluginServiceProvider(dataServiceFactory), + overlays: new PluginServiceProvider(overlaysServiceFactory), + controls: new PluginServiceProvider(controlsServiceFactory), +}; + +export const pluginServices = new PluginServices(); + +export const registry = new PluginServiceRegistry(providers); diff --git a/src/plugins/presentation_util/public/services/storybook/overlays.tsx b/src/plugins/controls/public/services/storybook/overlays.tsx similarity index 95% rename from src/plugins/presentation_util/public/services/storybook/overlays.tsx rename to src/plugins/controls/public/services/storybook/overlays.tsx index 50194fb636fa4..9ab4ea0b2c450 100644 --- a/src/plugins/presentation_util/public/services/storybook/overlays.tsx +++ b/src/plugins/controls/public/services/storybook/overlays.tsx @@ -7,20 +7,20 @@ */ import { EuiConfirmModal, EuiFlyout } from '@elastic/eui'; -import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { Subject } from 'rxjs'; +import React from 'react'; import { MountPoint, OverlayFlyoutOpenOptions, OverlayModalConfirmOptions, OverlayRef, } from '../../../../../core/public'; +import { ControlsOverlaysService } from '../overlays'; import { MountWrapper } from '../../../../../core/public/utils'; -import { PluginServiceFactory } from '../create'; -import { PresentationOverlaysService } from '../overlays'; +import { PluginServiceFactory } from '../../../../presentation_util/public'; -type OverlaysServiceFactory = PluginServiceFactory; +type OverlaysServiceFactory = PluginServiceFactory; /** * This code is a storybook stub version of src/core/public/overlays/overlay_service.ts diff --git a/src/plugins/presentation_util/public/services/storybook/controls.ts b/src/plugins/controls/public/services/stub/controls.ts similarity index 65% rename from src/plugins/presentation_util/public/services/storybook/controls.ts rename to src/plugins/controls/public/services/stub/controls.ts index e5dc84a3dd645..7c33ee8c33527 100644 --- a/src/plugins/presentation_util/public/services/storybook/controls.ts +++ b/src/plugins/controls/public/services/stub/controls.ts @@ -6,8 +6,8 @@ * Side Public License, v 1. */ -import { PluginServiceFactory } from '../create'; -import { getCommonControlsService, PresentationControlsService } from '../controls'; +import { PluginServiceFactory } from '../../../../presentation_util/public'; +import { getCommonControlsService, ControlsService } from '../controls'; -export type ControlsServiceFactory = PluginServiceFactory; +export type ControlsServiceFactory = PluginServiceFactory; export const controlsServiceFactory = () => getCommonControlsService(); diff --git a/src/plugins/controls/public/services/stub/index.ts b/src/plugins/controls/public/services/stub/index.ts new file mode 100644 index 0000000000000..6927aa65c12b8 --- /dev/null +++ b/src/plugins/controls/public/services/stub/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + PluginServiceProviders, + PluginServiceProvider, + PluginServiceRegistry, +} from '../../../../presentation_util/public'; +import { ControlsServices } from '..'; +import { overlaysServiceFactory } from './overlays'; +import { controlsServiceFactory } from './controls'; + +import { dataServiceFactory } from '../storybook/data'; +import { dataViewsServiceFactory } from '../storybook/data_views'; + +export const providers: PluginServiceProviders = { + data: new PluginServiceProvider(dataServiceFactory), + overlays: new PluginServiceProvider(overlaysServiceFactory), + controls: new PluginServiceProvider(controlsServiceFactory), + dataViews: new PluginServiceProvider(dataViewsServiceFactory), +}; + +export const registry = new PluginServiceRegistry(providers); diff --git a/src/plugins/presentation_util/public/services/stub/overlays.ts b/src/plugins/controls/public/services/stub/overlays.ts similarity index 81% rename from src/plugins/presentation_util/public/services/stub/overlays.ts rename to src/plugins/controls/public/services/stub/overlays.ts index ecdec96d600d8..3c363111a6646 100644 --- a/src/plugins/presentation_util/public/services/stub/overlays.ts +++ b/src/plugins/controls/public/services/stub/overlays.ts @@ -12,10 +12,10 @@ import { OverlayModalConfirmOptions, OverlayRef, } from '../../../../../core/public'; -import { PluginServiceFactory } from '../create'; -import { PresentationOverlaysService } from '../overlays'; +import { PluginServiceFactory } from '../../../../presentation_util/public'; +import { ControlsOverlaysService } from '../overlays'; -type OverlaysServiceFactory = PluginServiceFactory; +type OverlaysServiceFactory = PluginServiceFactory; class StubRef implements OverlayRef { public readonly onClose: Promise = Promise.resolve(); diff --git a/src/plugins/presentation_util/public/components/controls/types.ts b/src/plugins/controls/public/types.ts similarity index 60% rename from src/plugins/presentation_util/public/components/controls/types.ts rename to src/plugins/controls/public/types.ts index 9d530fefe7373..70438baec756c 100644 --- a/src/plugins/presentation_util/public/components/controls/types.ts +++ b/src/plugins/controls/public/types.ts @@ -7,9 +7,18 @@ */ import { Filter } from '@kbn/es-query'; -import { DataView } from '../../../../data_views/public'; -import { ControlInput } from '../../../common/controls/types'; -import { EmbeddableFactory, EmbeddableOutput, IEmbeddable } from '../../../../embeddable/public'; + +import { + EmbeddableFactory, + EmbeddableOutput, + EmbeddableSetup, + EmbeddableStart, + IEmbeddable, +} from '../../embeddable/public'; +import { ControlInput } from '../common/types'; +import { DataPublicPluginStart } from '../../data/public'; +import { ControlsService } from './services/controls'; +import { DataView, DataViewsPublicPluginStart } from '../../data_views/public'; export interface CommonControlOutput { filters?: Filter[]; @@ -43,6 +52,25 @@ export interface ControlEditorProps { } /** - * Re-export control types from common + * Plugin types */ -export * from '../../../common/controls/types'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ControlsPluginSetup {} + +export interface ControlsPluginStart { + controlsService: ControlsService; + ContextProvider: React.FC; +} + +export interface ControlsPluginSetupDeps { + embeddable: EmbeddableSetup; +} +export interface ControlsPluginStartDeps { + data: DataPublicPluginStart; + embeddable: EmbeddableStart; + dataViews: DataViewsPublicPluginStart; +} + +// re-export from common +export type { ControlWidth, ControlInput, ControlStyle } from '../common/types'; diff --git a/src/plugins/presentation_util/server/controls/control_group/control_group_container_factory.ts b/src/plugins/controls/server/control_group/control_group_container_factory.ts similarity index 71% rename from src/plugins/presentation_util/server/controls/control_group/control_group_container_factory.ts rename to src/plugins/controls/server/control_group/control_group_container_factory.ts index 17dcbbd249435..39e1a9fbb12c9 100644 --- a/src/plugins/presentation_util/server/controls/control_group/control_group_container_factory.ts +++ b/src/plugins/controls/server/control_group/control_group_container_factory.ts @@ -6,13 +6,13 @@ * Side Public License, v 1. */ -import { EmbeddablePersistableStateService } from 'src/plugins/embeddable/common'; -import { EmbeddableRegistryDefinition } from '../../../../embeddable/server'; -import { CONTROL_GROUP_TYPE } from '../../../common/controls'; +import { EmbeddablePersistableStateService } from '../../../embeddable/common'; +import { EmbeddableRegistryDefinition } from '../../../embeddable/server'; +import { CONTROL_GROUP_TYPE } from '../../common'; import { createControlGroupExtract, createControlGroupInject, -} from '../../../common/controls/control_group/control_group_persistable_state'; +} from '../../common/control_group/control_group_persistable_state'; export const controlGroupContainerPersistableStateServiceFactory = ( persistableStateService: EmbeddablePersistableStateService diff --git a/src/plugins/presentation_util/server/controls/control_types/options_list/options_list_embeddable_factory.ts b/src/plugins/controls/server/control_types/options_list/options_list_embeddable_factory.ts similarity index 72% rename from src/plugins/presentation_util/server/controls/control_types/options_list/options_list_embeddable_factory.ts rename to src/plugins/controls/server/control_types/options_list/options_list_embeddable_factory.ts index b9d69ea489274..846e3cfe9342c 100644 --- a/src/plugins/presentation_util/server/controls/control_types/options_list/options_list_embeddable_factory.ts +++ b/src/plugins/controls/server/control_types/options_list/options_list_embeddable_factory.ts @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -import { EmbeddableRegistryDefinition } from '../../../../../embeddable/server'; -import { OPTIONS_LIST_CONTROL } from '../../../../common/controls'; +import { EmbeddableRegistryDefinition } from '../../../../embeddable/server'; +import { OPTIONS_LIST_CONTROL } from '../../../common'; import { createOptionsListExtract, createOptionsListInject, -} from '../../../../common/controls/control_types/options_list/options_list_persistable_state'; +} from '../../../common/control_types/options_list/options_list_persistable_state'; export const optionsListPersistableStateServiceFactory = (): EmbeddableRegistryDefinition => { return { diff --git a/src/plugins/presentation_util/public/components/controls/index.ts b/src/plugins/controls/server/index.ts similarity index 79% rename from src/plugins/presentation_util/public/components/controls/index.ts rename to src/plugins/controls/server/index.ts index c110bc348498d..5928186715210 100644 --- a/src/plugins/presentation_util/public/components/controls/index.ts +++ b/src/plugins/controls/server/index.ts @@ -6,6 +6,6 @@ * Side Public License, v 1. */ -export * from './control_group'; -export * from './control_types'; -export * from './types'; +import { ControlsPlugin } from './plugin'; + +export const plugin = () => new ControlsPlugin(); diff --git a/src/plugins/controls/server/plugin.ts b/src/plugins/controls/server/plugin.ts new file mode 100644 index 0000000000000..fa7b7970c7e64 --- /dev/null +++ b/src/plugins/controls/server/plugin.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CoreSetup, Plugin } from 'kibana/server'; +import { EmbeddableSetup } from '../../embeddable/server'; +import { controlGroupContainerPersistableStateServiceFactory } from './control_group/control_group_container_factory'; +import { optionsListPersistableStateServiceFactory } from './control_types/options_list/options_list_embeddable_factory'; + +interface SetupDeps { + embeddable: EmbeddableSetup; +} + +export class ControlsPlugin implements Plugin { + public setup(core: CoreSetup, plugins: SetupDeps) { + plugins.embeddable.registerEmbeddableFactory(optionsListPersistableStateServiceFactory()); + + plugins.embeddable.registerEmbeddableFactory( + controlGroupContainerPersistableStateServiceFactory(plugins.embeddable) + ); + return {}; + } + + public start() { + return {}; + } + + public stop() {} +} diff --git a/src/plugins/controls/storybook/decorator.tsx b/src/plugins/controls/storybook/decorator.tsx new file mode 100644 index 0000000000000..603bddf320627 --- /dev/null +++ b/src/plugins/controls/storybook/decorator.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; + +import { DecoratorFn } from '@storybook/react'; +import { I18nProvider } from '@kbn/i18n-react'; +import { pluginServices } from '../public/services'; +import { ControlsServices } from '../public/services'; +import { providers } from '../public/services/storybook'; +import { PluginServiceRegistry } from '../../presentation_util/public'; +import { KibanaContextProvider as KibanaReactProvider } from '../../kibana_react/public'; + +const settings = new Map(); +settings.set('darkMode', true); + +const services = { + http: { + basePath: { + get: () => '', + prepend: () => '', + remove: () => '', + serverBasePath: '', + }, + }, + uiSettings: settings, +}; + +export const servicesContextDecorator: DecoratorFn = (story: Function, storybook) => { + const registry = new PluginServiceRegistry(providers); + pluginServices.setRegistry(registry.start(storybook.args)); + const ContextProvider = pluginServices.getContextProvider(); + + return ( + + + {story()} + + + ); +}; diff --git a/src/plugins/controls/storybook/main.ts b/src/plugins/controls/storybook/main.ts new file mode 100644 index 0000000000000..13f55f8be2df8 --- /dev/null +++ b/src/plugins/controls/storybook/main.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { defaultConfigWebFinal } from '@kbn/storybook'; + +// We have to do this because the kbn/storybook preset overrides the manager entries, +// so we can't customize the theme. +module.exports = { + ...defaultConfigWebFinal, + addons: ['@storybook/addon-a11y', '@storybook/addon-essentials'], +}; diff --git a/src/plugins/controls/storybook/manager.ts b/src/plugins/controls/storybook/manager.ts new file mode 100644 index 0000000000000..1b8c9aa89e687 --- /dev/null +++ b/src/plugins/controls/storybook/manager.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { addons } from '@storybook/addons'; +import { create } from '@storybook/theming'; +import { PANEL_ID } from '@storybook/addon-actions'; + +// @ts-expect-error There's probably a better way to do this. +import { registerThemeSwitcherAddon } from '@kbn/storybook/target_node/lib/register_theme_switcher_addon'; + +addons.setConfig({ + theme: create({ + base: 'light', + brandTitle: 'Kibana Controls Storybook', + brandUrl: 'https://github.com/elastic/kibana/tree/main/src/plugins/controls', + }), + showPanel: true.valueOf, + selectedPanel: PANEL_ID, +}); + +registerThemeSwitcherAddon(); diff --git a/src/plugins/controls/storybook/preview.tsx b/src/plugins/controls/storybook/preview.tsx new file mode 100644 index 0000000000000..e71f4e08b2027 --- /dev/null +++ b/src/plugins/controls/storybook/preview.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { addDecorator } from '@storybook/react'; +import { Title, Subtitle, Description, Primary, Stories } from '@storybook/addon-docs/blocks'; + +import { servicesContextDecorator } from './decorator'; + +addDecorator(servicesContextDecorator); + +export const parameters = { + docs: { + page: () => ( + <> + + <Subtitle /> + <Description /> + <Primary /> + <Stories /> + </> + ), + }, +}; diff --git a/src/plugins/controls/tsconfig.json b/src/plugins/controls/tsconfig.json new file mode 100644 index 0000000000000..ed0c2e63011d0 --- /dev/null +++ b/src/plugins/controls/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "extraPublicDirs": ["common"], + "include": [ + "common/**/*", + "public/**/*", + "public/**/*.json", + "server/**/*", + "storybook/**/*", + "../../../typings/**/*" + ], + "references": [ + { "path": "../../core/tsconfig.json" }, + { "path": "../saved_objects/tsconfig.json" }, + { "path": "../kibana_react/tsconfig.json" }, + { "path": "../embeddable/tsconfig.json" }, + { "path": "../presentation_util/tsconfig.json" }, + { "path": "../kibana_react/tsconfig.json" }, + { "path": "../data/tsconfig.json" } + ] +} diff --git a/src/plugins/dashboard/common/embeddable/dashboard_container_persistable_state.ts b/src/plugins/dashboard/common/embeddable/dashboard_container_persistable_state.ts index bc8f56fc8c4dc..c0768331d20c5 100644 --- a/src/plugins/dashboard/common/embeddable/dashboard_container_persistable_state.ts +++ b/src/plugins/dashboard/common/embeddable/dashboard_container_persistable_state.ts @@ -17,7 +17,7 @@ import { DashboardContainerStateWithType, DashboardPanelState, } from '../types'; -import { CONTROL_GROUP_TYPE } from '../../../presentation_util/common/lib'; +import { CONTROL_GROUP_TYPE } from '../../../controls/common'; const getPanelStatePrefix = (state: DashboardPanelState) => `${state.explicitInput.id}:`; diff --git a/src/plugins/dashboard/common/saved_dashboard_references.ts b/src/plugins/dashboard/common/saved_dashboard_references.ts index bc7358b49ceb4..346190e4fef91 100644 --- a/src/plugins/dashboard/common/saved_dashboard_references.ts +++ b/src/plugins/dashboard/common/saved_dashboard_references.ts @@ -19,7 +19,7 @@ import { convertSavedDashboardPanelToPanelState, } from './embeddable/embeddable_saved_object_converters'; import { SavedDashboardPanel } from './types'; -import { CONTROL_GROUP_TYPE } from '../../presentation_util/common/lib'; +import { CONTROL_GROUP_TYPE } from '../../controls/common'; export interface ExtractDeps { embeddablePersistableStateService: EmbeddablePersistableStateService; diff --git a/src/plugins/dashboard/common/types.ts b/src/plugins/dashboard/common/types.ts index bfe53514969d7..29e3d48d7f0d5 100644 --- a/src/plugins/dashboard/common/types.ts +++ b/src/plugins/dashboard/common/types.ts @@ -22,7 +22,7 @@ import { } from './bwc/types'; import { GridData } from './embeddable/types'; -import { ControlGroupInput } from '../../presentation_util/common/controls/control_group/types'; +import { ControlGroupInput } from '../../controls/common'; export type PanelId = string; export type SavedObjectId = string; diff --git a/src/plugins/dashboard/kibana.json b/src/plugins/dashboard/kibana.json index 2be6e9b269e71..683a1a551f81d 100644 --- a/src/plugins/dashboard/kibana.json +++ b/src/plugins/dashboard/kibana.json @@ -9,6 +9,7 @@ "requiredPlugins": [ "data", "embeddable", + "controls", "inspector", "navigation", "savedObjects", diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx index 36261fbe130a3..d9733d1a35586 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx @@ -47,7 +47,7 @@ import { combineDashboardFiltersWithControlGroupFilters, syncDashboardControlGroup, } from '../lib/dashboard_control_group'; -import { ControlGroupContainer } from '../../../../presentation_util/public'; +import { ControlGroupContainer } from '../../../../controls/public'; export interface DashboardContainerServices { ExitFullScreenButton: React.ComponentType<any>; diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx index f7cf329d0ae35..7be36a954d2f1 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx @@ -28,7 +28,7 @@ import { ControlGroupInput, ControlGroupOutput, CONTROL_GROUP_TYPE, -} from '../../../../presentation_util/public'; +} from '../../../../controls/public'; import { getDefaultDashboardControlGroupInput } from '../../dashboard_constants'; export type DashboardContainerFactory = EmbeddableFactory< diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx b/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx index 1e19e495585fe..a862c084de400 100644 --- a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx +++ b/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx @@ -13,7 +13,7 @@ import { DashboardContainer, DashboardReactContextValue } from '../dashboard_con import { DashboardGrid } from '../grid'; import { context } from '../../../services/kibana_react'; import { DashboardEmptyScreen } from '../empty_screen/dashboard_empty_screen'; -import { ControlGroupContainer } from '../../../../../presentation_util/public'; +import { ControlGroupContainer } from '../../../../../controls/public'; export interface DashboardViewportProps { container: DashboardContainer; diff --git a/src/plugins/dashboard/public/application/lib/convert_dashboard_state.ts b/src/plugins/dashboard/public/application/lib/convert_dashboard_state.ts index 8d55af5808da6..89ad65f58278f 100644 --- a/src/plugins/dashboard/public/application/lib/convert_dashboard_state.ts +++ b/src/plugins/dashboard/public/application/lib/convert_dashboard_state.ts @@ -21,7 +21,7 @@ import { } from '../../types'; import { convertSavedPanelsToPanelMap } from './convert_dashboard_panels'; import { deserializeControlGroupFromDashboardSavedObject } from './dashboard_control_group'; -import { ControlGroupInput } from '../../../../presentation_util/public'; +import { ControlGroupInput } from '../../../../controls/public'; interface SavedObjectToDashboardStateProps { version: string; diff --git a/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts b/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts index aaf6c5f0af4fc..90d5a67c3da47 100644 --- a/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts +++ b/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts @@ -15,7 +15,7 @@ import { DashboardContainer } from '..'; import { DashboardState } from '../../types'; import { getDefaultDashboardControlGroupInput } from '../../dashboard_constants'; import { DashboardContainerInput, DashboardSavedObject } from '../..'; -import { ControlGroupContainer, ControlGroupInput } from '../../../../presentation_util/public'; +import { ControlGroupContainer, ControlGroupInput } from '../../../../controls/public'; // only part of the control group input should be stored in dashboard state. The rest is passed down from the dashboard. export interface DashboardControlGroupInput { diff --git a/src/plugins/dashboard/public/dashboard_constants.ts b/src/plugins/dashboard/public/dashboard_constants.ts index 6f9a30e3a7041..9063b279c25f2 100644 --- a/src/plugins/dashboard/public/dashboard_constants.ts +++ b/src/plugins/dashboard/public/dashboard_constants.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import type { ControlStyle } from '../../presentation_util/public'; +import type { ControlStyle } from '../../controls/public'; export const DASHBOARD_STATE_STORAGE_KEY = '_a'; diff --git a/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts index d8e8b70fc1340..52ecb9549d54d 100644 --- a/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts +++ b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts @@ -18,7 +18,7 @@ import { extractReferences, injectReferences } from '../../common/saved_dashboar import { SavedObjectAttributes, SavedObjectReference } from '../../../../core/types'; import { DashboardOptions } from '../types'; -import { ControlStyle } from '../../../presentation_util/public'; +import { ControlStyle } from '../../../controls/public'; export interface DashboardSavedObject extends SavedObject { id?: string; diff --git a/src/plugins/dashboard/tsconfig.json b/src/plugins/dashboard/tsconfig.json index 78a1958a43156..680d06780543a 100644 --- a/src/plugins/dashboard/tsconfig.json +++ b/src/plugins/dashboard/tsconfig.json @@ -6,23 +6,18 @@ "declaration": true, "declarationMap": true }, - "include": [ - "*.ts", - ".storybook/**/*", - "common/**/*", - "public/**/*", - "server/**/*", - ], + "include": ["*.ts", ".storybook/**/*", "common/**/*", "public/**/*", "server/**/*"], "references": [ { "path": "../../core/tsconfig.json" }, { "path": "../inspector/tsconfig.json" }, { "path": "../kibana_react/tsconfig.json" }, { "path": "../kibana_utils/tsconfig.json" }, { "path": "../share/tsconfig.json" }, + { "path": "../controls/tsconfig.json" }, { "path": "../presentation_util/tsconfig.json" }, { "path": "../url_forwarding/tsconfig.json" }, { "path": "../usage_collection/tsconfig.json" }, - { "path": "../data/tsconfig.json"}, + { "path": "../data/tsconfig.json" }, { "path": "../embeddable/tsconfig.json" }, { "path": "../home/tsconfig.json" }, { "path": "../navigation/tsconfig.json" }, @@ -32,6 +27,6 @@ { "path": "../charts/tsconfig.json" }, { "path": "../discover/tsconfig.json" }, { "path": "../visualizations/tsconfig.json" }, - { "path": "../../../x-pack/plugins/spaces/tsconfig.json" }, + { "path": "../../../x-pack/plugins/spaces/tsconfig.json" } ] } diff --git a/src/plugins/expression_image/public/expression_renderers/image_renderer.tsx b/src/plugins/expression_image/public/expression_renderers/image_renderer.tsx index a38649f13fb32..161fcef8be0fc 100644 --- a/src/plugins/expression_image/public/expression_renderers/image_renderer.tsx +++ b/src/plugins/expression_image/public/expression_renderers/image_renderer.tsx @@ -13,7 +13,7 @@ import { Observable } from 'rxjs'; import { CoreTheme } from 'kibana/public'; import { CoreSetup } from '../../../../core/public'; import { KibanaThemeProvider } from '../../../kibana_react/public'; -import { getElasticLogo, isValidUrl, defaultTheme$ } from '../../../presentation_util/public'; +import { getElasticLogo, defaultTheme$, isValidUrl } from '../../../presentation_util/public'; import { ImageRendererConfig } from '../../common/types'; const strings = { diff --git a/src/plugins/presentation_util/README.mdx b/src/plugins/presentation_util/README.mdx index 575e8002e6eb8..2cbb03232b9dd 100755 --- a/src/plugins/presentation_util/README.mdx +++ b/src/plugins/presentation_util/README.mdx @@ -209,3 +209,98 @@ export function MyComponent() { } ``` </DocAccordion> + +## Redux Embeddables +The Redux Embeddables system allows embeddable authors to interact with their embeddables in a standardized way using Redux toolkit. This wrapper abstracts away store and slice creation, embeddable input sync, and context creation. To use this system, a developer can wrap their components in the ReduxEmbeddableWrapper, supplying only an object of reducers. + +### Reducers +The reducer object expected by the ReduxEmbeddableWrapper is the same type as the reducers expected by [Redux Toolkit's CreateSlice](https://redux-toolkit.js.org/api/createslice). + +<DocAccordion buttonContent="Reducers Example" initialIsOpen> +```ts +// my_embeddable_reducers.ts +import { MyEmbeddableInput } from './my_embeddable'; + +export const myEmbeddableReducers = { + setSpecialBoolean: ( + state: WritableDraft<MyEmbeddableInput>, + action: PayloadAction<MyEmbeddableInput['specialBoolean']> + ) => { + state.specialBoolean = action.payload; + } +} + +``` +</DocAccordion> + +### Lazy Component and Types +Because the ReduxEmbeddableWrapper is a lazy component, it also must be unwrapped with the `withSuspense` component from Presentation Util. When you await this component, you must also pass in the type information so that the redux store and actions are properly typed. + + <DocAccordion buttonContent="Awaiting LazyReduxEmbeddableWrapper" initialIsOpen> + ```ts + // my_embeddable.tsx + + import { + withSuspense, + LazyReduxEmbeddableWrapper, + ReduxEmbeddableWrapperPropsWithChildren, + } from '../../../../presentation_util/public'; + + export interface MyEmbeddableInput { + specialBoolean: boolean + } + + const MyEmbeddableReduxWrapper = withSuspense< + ReduxEmbeddableWrapperPropsWithChildren<MyEmbeddableInput> + >(LazyReduxEmbeddableWrapper); + + ``` +</DocAccordion> + +The ReduxEmbeddableWrapper should be used inside of embeddable classes, and should wrap all components under the embeddable in the render function. + +<DocAccordion buttonContent="Wrapping Embeddable Render" initialIsOpen> + ```ts + // my_embeddable.tsx + + public render(dom: HTMLElement) { + if (this.domNode) ReactDOM.unmountComponentAtNode(this.domNode); + this.domNode = dom; + ReactDOM.render( + <MyEmbeddableReduxWrapper embeddable={this} reducers={myEmbeddableReducers}> + <MyEmbeddableComponent /> + </MyEmbeddableReduxWrapper>, + dom + ); + } + ``` +</DocAccordion> + +### Accessing Actions and State + +From components under the embeddable, actions, containerActions, and the current state of the redux store are accessed via the ReduxEmbeddableContext. This context requires the input type and the type of the reducers, and will return the appropriately typed actions, a hook for dispatching actions, a selector to get the current redux state, and a suite of `containerActions` if the embeddable is a Container. + +<DocAccordion buttonContent="Accessing Redux Embeddable Context" initialIsOpen> + ```ts + // my_embeddable_component.tsx + import { useReduxEmbeddableContext } from '../../../../presentation_util/public'; + + const { + useEmbeddableSelector, + useEmbeddableDispatch, + actions: { setSpecialBoolean }, + } = useReduxEmbeddableContext< + MyEmbeddableInput, + typeof myEmbeddableReducers + >(); + + const dispatch = useEmbeddableDispatch(); + + // current state + const { specialBoolean } = useEmbeddableSelector((state) => state); + + // change specialBoolean after 5 seconds + setTimeout(() => dispatch(setSpecialBoolean(false)), 5000); + ``` +</DocAccordion> + diff --git a/src/plugins/presentation_util/common/lib/index.ts b/src/plugins/presentation_util/common/lib/index.ts index 030780c130fa5..3fe90009ad8df 100644 --- a/src/plugins/presentation_util/common/lib/index.ts +++ b/src/plugins/presentation_util/common/lib/index.ts @@ -8,4 +8,3 @@ export * from './utils'; export * from './test_helpers'; -export * from '../controls'; diff --git a/src/plugins/presentation_util/kibana.json b/src/plugins/presentation_util/kibana.json index 32460a8455152..6c8d38a5f8a1e 100644 --- a/src/plugins/presentation_util/kibana.json +++ b/src/plugins/presentation_util/kibana.json @@ -9,16 +9,7 @@ "kibanaVersion": "kibana", "server": true, "ui": true, - "extraPublicDirs": [ - "common/lib" - ], - "requiredPlugins": [ - "savedObjects", - "data", - "dataViews", - "embeddable", - "kibanaReact", - "expressions" - ], + "extraPublicDirs": ["common/lib"], + "requiredPlugins": ["savedObjects", "kibanaReact", "embeddable", "expressions", "dataViews"], "optionalPlugins": [] } diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/fixtures/flights.ts b/src/plugins/presentation_util/public/__stories__/fixtures/flights.ts similarity index 93% rename from src/plugins/presentation_util/public/components/controls/__stories__/fixtures/flights.ts rename to src/plugins/presentation_util/public/__stories__/fixtures/flights.ts index 921b7f3999faa..0ec82b1e1994b 100644 --- a/src/plugins/presentation_util/public/components/controls/__stories__/fixtures/flights.ts +++ b/src/plugins/presentation_util/public/__stories__/fixtures/flights.ts @@ -7,12 +7,8 @@ */ import { map, uniq } from 'lodash'; -import { flights } from '../fixtures/flights_data'; -import { - DataView, - DataViewField, - IIndexPatternFieldList, -} from '../../../../../../data_views/common'; +import { flights } from './flights_data'; +import { DataView, DataViewField, IIndexPatternFieldList } from '../../../../data_views/public'; export type Flight = typeof flights[number]; export type FlightField = keyof Flight; diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/fixtures/flights_data.ts b/src/plugins/presentation_util/public/__stories__/fixtures/flights_data.ts similarity index 100% rename from src/plugins/presentation_util/public/components/controls/__stories__/fixtures/flights_data.ts rename to src/plugins/presentation_util/public/__stories__/fixtures/flights_data.ts diff --git a/src/plugins/presentation_util/public/__stories__/index.tsx b/src/plugins/presentation_util/public/__stories__/index.tsx index a5633c4a2dd1f..94904abf43b98 100644 --- a/src/plugins/presentation_util/public/__stories__/index.tsx +++ b/src/plugins/presentation_util/public/__stories__/index.tsx @@ -8,3 +8,5 @@ export * from './render'; export * from './wait_for'; +export * from './fixtures/flights'; +export * from './fixtures/flights_data'; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts b/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts deleted file mode 100644 index 111b247d7417e..0000000000000 --- a/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts +++ /dev/null @@ -1,206 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { i18n } from '@kbn/i18n'; - -export const ControlGroupStrings = { - getEmbeddableTitle: () => - i18n.translate('presentationUtil.controls.controlGroup.title', { - defaultMessage: 'Control group', - }), - emptyState: { - getCallToAction: () => - i18n.translate('presentationUtil.inputControls.controlGroup.emptyState.callToAction', { - defaultMessage: 'Controls let you filter and interact with your dashboard data', - }), - getAddControlButtonTitle: () => - i18n.translate( - 'presentationUtil.inputControls.controlGroup.emptyState.addControlButtonTitle', - { - defaultMessage: 'Add control', - } - ), - getTwoLineLoadingTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.emptyState.twoLineLoadingTitle', { - defaultMessage: '...', - }), - }, - manageControl: { - getFlyoutCreateTitle: () => - i18n.translate( - 'presentationUtil.inputControls.controlGroup.manageControl.createFlyoutTitle', - { - defaultMessage: 'Create control', - } - ), - getFlyoutEditTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.editFlyoutTitle', { - defaultMessage: 'Edit control', - }), - getTitleInputTitle: () => - i18n.translate('presentationUtil.controls.controlGroup.manageControl.titleInputTitle', { - defaultMessage: 'Title', - }), - getWidthInputTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.widthInputTitle', { - defaultMessage: 'Control size', - }), - getSaveChangesTitle: () => - i18n.translate('presentationUtil.controls.controlGroup.manageControl.saveChangesTitle', { - defaultMessage: 'Save and close', - }), - getCancelTitle: () => - i18n.translate('presentationUtil.controls.controlGroup.manageControl.cancelTitle', { - defaultMessage: 'Cancel', - }), - }, - management: { - getAddControlTitle: () => - i18n.translate('presentationUtil.controls.controlGroup.management.addControl', { - defaultMessage: 'Add control', - }), - getManageButtonTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.buttonTitle', { - defaultMessage: 'Configure controls', - }), - getFlyoutTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.flyoutTitle', { - defaultMessage: 'Configure controls', - }), - getDefaultWidthTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.defaultWidthTitle', { - defaultMessage: 'Default size', - }), - getLayoutTitle: () => - i18n.translate('presentationUtil.controls.controlGroup.management.layoutTitle', { - defaultMessage: 'Layout', - }), - getDeleteButtonTitle: () => - i18n.translate('presentationUtil.controls.controlGroup.management.delete', { - defaultMessage: 'Delete control', - }), - getSetAllWidthsToDefaultTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.setAllWidths', { - defaultMessage: 'Set all sizes to default', - }), - getDeleteAllButtonTitle: () => - i18n.translate('presentationUtil.controls.controlGroup.management.deleteAll', { - defaultMessage: 'Delete all', - }), - controlWidth: { - getWidthSwitchLegend: () => - i18n.translate( - 'presentationUtil.controls.controlGroup.management.layout.controlWidthLegend', - { - defaultMessage: 'Change control size', - } - ), - getAutoWidthTitle: () => - i18n.translate('presentationUtil.controls.controlGroup.management.layout.auto', { - defaultMessage: 'Auto', - }), - getSmallWidthTitle: () => - i18n.translate('presentationUtil.controls.controlGroup.management.layout.small', { - defaultMessage: 'Small', - }), - getMediumWidthTitle: () => - i18n.translate('presentationUtil.controls.controlGroup.management.layout.medium', { - defaultMessage: 'Medium', - }), - getLargeWidthTitle: () => - i18n.translate('presentationUtil.controls.controlGroup.management.layout.large', { - defaultMessage: 'Large', - }), - }, - controlStyle: { - getDesignSwitchLegend: () => - i18n.translate( - 'presentationUtil.controls.controlGroup.management.layout.designSwitchLegend', - { - defaultMessage: 'Switch control designs', - } - ), - getSingleLineTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.singleLine', { - defaultMessage: 'Single line', - }), - getTwoLineTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.twoLine', { - defaultMessage: 'Double line', - }), - }, - deleteControls: { - getDeleteAllTitle: () => - i18n.translate('presentationUtil.controls.controlGroup.management.delete.deleteAllTitle', { - defaultMessage: 'Delete all controls?', - }), - getDeleteTitle: () => - i18n.translate('presentationUtil.controls.controlGroup.management.delete.deleteTitle', { - defaultMessage: 'Delete control?', - }), - getSubtitle: () => - i18n.translate('presentationUtil.controls.controlGroup.management.delete.sub', { - defaultMessage: 'Controls are not recoverable once removed.', - }), - getConfirm: () => - i18n.translate('presentationUtil.controls.controlGroup.management.delete.confirm', { - defaultMessage: 'Delete', - }), - getCancel: () => - i18n.translate('presentationUtil.controls.controlGroup.management.delete.cancel', { - defaultMessage: 'Cancel', - }), - }, - discardChanges: { - getTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.title', { - defaultMessage: 'Discard changes?', - }), - getSubtitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.sub', { - defaultMessage: `Changes that you've made to this control will be discarded, are you sure you want to continue?`, - }), - getConfirm: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.confirm', { - defaultMessage: 'Discard changes', - }), - getCancel: () => - i18n.translate('presentationUtil.controls.controlGroup.management.discard.cancel', { - defaultMessage: 'Cancel', - }), - }, - discardNewControl: { - getTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.title', { - defaultMessage: 'Discard new control', - }), - getSubtitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.sub', { - defaultMessage: `Changes that you've made to this control will be discarded, are you sure you want to continue?`, - }), - getConfirm: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.confirm', { - defaultMessage: 'Discard control', - }), - getCancel: () => - i18n.translate('presentationUtil.controls.controlGroup.management.deleteNew.cancel', { - defaultMessage: 'Cancel', - }), - }, - }, - floatingActions: { - getEditButtonTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.floatingActions.editTitle', { - defaultMessage: 'Edit control', - }), - getRemoveButtonTitle: () => - i18n.translate('presentationUtil.controls.controlGroup.floatingActions.removeTitle', { - defaultMessage: 'Remove control', - }), - }, -}; diff --git a/src/plugins/presentation_util/public/components/data_view_picker/data_view_picker.stories.tsx b/src/plugins/presentation_util/public/components/data_view_picker/data_view_picker.stories.tsx index b8b0c46e7823d..f8c1539ecda28 100644 --- a/src/plugins/presentation_util/public/components/data_view_picker/data_view_picker.stories.tsx +++ b/src/plugins/presentation_util/public/components/data_view_picker/data_view_picker.stories.tsx @@ -12,7 +12,7 @@ import useMount from 'react-use/lib/useMount'; import { DataViewPicker } from './data_view_picker'; import { DataView, DataViewListItem } from '../../../../data_views/common'; import { injectStorybookDataView } from '../../services/storybook/data_views'; -import { storybookFlightsDataView } from '../controls/__stories__/fixtures/flights'; +import { storybookFlightsDataView } from '../../mocks'; import { pluginServices, registry, StorybookParams } from '../../services/storybook'; export default { diff --git a/src/plugins/presentation_util/public/components/data_view_picker/data_view_picker.tsx b/src/plugins/presentation_util/public/components/data_view_picker/data_view_picker.tsx index 2911ae7a1e687..2391f945d478a 100644 --- a/src/plugins/presentation_util/public/components/data_view_picker/data_view_picker.tsx +++ b/src/plugins/presentation_util/public/components/data_view_picker/data_view_picker.tsx @@ -114,3 +114,7 @@ export function DataViewPicker({ </> ); } + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default DataViewPicker; diff --git a/src/plugins/presentation_util/public/components/field_picker/field_picker.stories.tsx b/src/plugins/presentation_util/public/components/field_picker/field_picker.stories.tsx index 023d2be949a73..f2462f3a25bb4 100644 --- a/src/plugins/presentation_util/public/components/field_picker/field_picker.stories.tsx +++ b/src/plugins/presentation_util/public/components/field_picker/field_picker.stories.tsx @@ -10,7 +10,7 @@ import React from 'react'; import { FieldPicker } from './field_picker'; import { DataViewField } from '../../../../data_views/common'; -import { storybookFlightsDataView } from '../controls/__stories__/fixtures/flights'; +import { storybookFlightsDataView } from '../../mocks'; export default { component: FieldPicker, diff --git a/src/plugins/presentation_util/public/components/field_picker/field_picker.tsx b/src/plugins/presentation_util/public/components/field_picker/field_picker.tsx index 54efe87a7f432..f9fb6f985b629 100644 --- a/src/plugins/presentation_util/public/components/field_picker/field_picker.tsx +++ b/src/plugins/presentation_util/public/components/field_picker/field_picker.tsx @@ -142,3 +142,7 @@ export const FieldPicker = ({ </EuiFlexGroup> ); }; + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default FieldPicker; diff --git a/src/plugins/presentation_util/public/components/index.tsx b/src/plugins/presentation_util/public/components/index.tsx index b64cf9e97be9d..5a254877399ed 100644 --- a/src/plugins/presentation_util/public/components/index.tsx +++ b/src/plugins/presentation_util/public/components/index.tsx @@ -8,6 +8,7 @@ import React, { Suspense, ComponentType, ReactElement, Ref } from 'react'; import { EuiLoadingSpinner, EuiErrorBoundary } from '@elastic/eui'; +import { ReduxEmbeddableWrapperType } from './redux_embeddables/redux_embeddable_wrapper'; /** * A HOC which supplies React.Suspense with a fallback component, and a `EuiErrorBoundary` to contain errors. @@ -38,6 +39,14 @@ export const LazySavedObjectSaveModalDashboard = React.lazy( () => import('./saved_object_save_modal_dashboard') ); +export const LazyReduxEmbeddableWrapper = React.lazy( + () => import('./redux_embeddables/redux_embeddable_wrapper') +) as ReduxEmbeddableWrapperType; // Lazy component needs to be casted due to generic type props + +export const LazyDataViewPicker = React.lazy(() => import('./data_view_picker/data_view_picker')); + +export const LazyFieldPicker = React.lazy(() => import('./field_picker/field_picker')); + /** * A lazily-loaded ExpressionInput component. */ diff --git a/src/plugins/presentation_util/common/controls/index.ts b/src/plugins/presentation_util/public/components/redux_embeddables/index.ts similarity index 59% rename from src/plugins/presentation_util/common/controls/index.ts rename to src/plugins/presentation_util/public/components/redux_embeddables/index.ts index b01a242bdfa5f..55fb913635e81 100644 --- a/src/plugins/presentation_util/common/controls/index.ts +++ b/src/plugins/presentation_util/public/components/redux_embeddables/index.ts @@ -6,5 +6,12 @@ * Side Public License, v 1. */ -export * from './control_group/types'; -export * from './control_types/options_list/types'; +export { + ReduxEmbeddableContext, + useReduxContainerContext, + useReduxEmbeddableContext, +} from './redux_embeddable_context'; +export type { + ReduxContainerContextServices, + ReduxEmbeddableWrapperPropsWithChildren, +} from './types'; diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_context.ts b/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_context.ts index 159230e4de024..40fdab429ae55 100644 --- a/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_context.ts +++ b/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_context.ts @@ -7,12 +7,12 @@ */ import { createContext, useContext } from 'react'; -import { +import type { GenericEmbeddableReducers, ReduxContainerContextServices, ReduxEmbeddableContextServices, } from './types'; -import { ContainerInput, EmbeddableInput } from '../../../../embeddable/public'; +import type { ContainerInput, EmbeddableInput } from '../../../../embeddable/public'; /** * When creating the context, a generic EmbeddableInput as placeholder is used. This will later be cast to diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx b/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx index 9e7b53fb21c3b..a23dcf944812b 100644 --- a/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx +++ b/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx @@ -15,9 +15,10 @@ import { Filter } from '@kbn/es-query'; import { isEqual } from 'lodash'; import { + ReduxEmbeddableWrapperProps, ReduxContainerContextServices, ReduxEmbeddableContextServices, - ReduxEmbeddableWrapperProps, + ReduxEmbeddableWrapperPropsWithChildren, } from './types'; import { IContainer, @@ -78,7 +79,7 @@ export const getExplicitInput = <InputType extends EmbeddableInput = EmbeddableI * or ReduxContainerContext to interface with the state of the embeddable. */ export const ReduxEmbeddableWrapper = <InputType extends EmbeddableInput = EmbeddableInput>( - props: PropsWithChildren<ReduxEmbeddableWrapperProps<InputType>> + props: ReduxEmbeddableWrapperPropsWithChildren<InputType> ) => { const { embeddable, reducers, diffInput } = useMemo( () => ({ ...getDefaultProps<InputType>(), ...props }), @@ -98,6 +99,13 @@ export const ReduxEmbeddableWrapper = <InputType extends EmbeddableInput = Embed return; }, [embeddable]); + const ReduxEmbeddableStoreProvider = useMemo( + () => + ({ children }: PropsWithChildren<{}>) => + <Provider store={getManagedEmbeddablesStore()}>{children}</Provider>, + [] + ); + const reduxEmbeddableContext: ReduxEmbeddableContextServices | ReduxContainerContextServices = useMemo(() => { const key = `${embeddable.type}_${embeddable.id}`; @@ -145,19 +153,20 @@ export const ReduxEmbeddableWrapper = <InputType extends EmbeddableInput = Embed return { useEmbeddableDispatch: () => useDispatch<typeof store.dispatch>(), useEmbeddableSelector, + ReduxEmbeddableStoreProvider, actions: slice.actions as ReduxEmbeddableContextServices['actions'], containerActions, }; - }, [reducers, embeddable, containerActions]); + }, [reducers, embeddable, containerActions, ReduxEmbeddableStoreProvider]); return ( - <Provider store={getManagedEmbeddablesStore()}> + <ReduxEmbeddableStoreProvider> <ReduxEmbeddableContext.Provider value={reduxEmbeddableContext}> <ReduxEmbeddableSync diffInput={diffInput} embeddable={embeddable}> {props.children} </ReduxEmbeddableSync> </ReduxEmbeddableContext.Provider> - </Provider> + </ReduxEmbeddableStoreProvider> ); }; @@ -225,3 +234,9 @@ const ReduxEmbeddableSync = <InputType extends EmbeddableInput = EmbeddableInput return <>{children}</>; }; + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default ReduxEmbeddableWrapper; + +export type ReduxEmbeddableWrapperType = typeof ReduxEmbeddableWrapper; diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/types.ts b/src/plugins/presentation_util/public/components/redux_embeddables/types.ts index 118b5d340528e..4fcc01ed51c48 100644 --- a/src/plugins/presentation_util/public/components/redux_embeddables/types.ts +++ b/src/plugins/presentation_util/public/components/redux_embeddables/types.ts @@ -13,6 +13,7 @@ import { Dispatch, PayloadAction, } from '@reduxjs/toolkit'; +import { PropsWithChildren } from 'react'; import { TypedUseSelectorHook } from 'react-redux'; import { EmbeddableInput, @@ -35,6 +36,10 @@ export interface ReduxEmbeddableWrapperProps<InputType extends EmbeddableInput = diffInput?: (a: InputType, b: InputType) => Partial<InputType>; } +export type ReduxEmbeddableWrapperPropsWithChildren< + InputType extends EmbeddableInput = EmbeddableInput +> = PropsWithChildren<ReduxEmbeddableWrapperProps<InputType>>; + /** * This context allows components underneath the redux embeddable wrapper to get access to the actions, selector, dispatch, and containerActions. */ @@ -47,6 +52,7 @@ export interface ReduxEmbeddableContextServices< Parameters<ReducerType[Property]>[1]['payload'] >; } & { updateEmbeddableReduxState: ActionCreatorWithPayload<Partial<InputType>> }; + ReduxEmbeddableStoreProvider: React.FC<PropsWithChildren<{}>>; useEmbeddableSelector: TypedUseSelectorHook<InputType>; useEmbeddableDispatch: () => Dispatch<AnyAction>; } diff --git a/src/plugins/presentation_util/public/index.ts b/src/plugins/presentation_util/public/index.ts index 6d83770499e78..7148b9fb6c7dd 100644 --- a/src/plugins/presentation_util/public/index.ts +++ b/src/plugins/presentation_util/public/index.ts @@ -41,6 +41,9 @@ export { LazyDashboardPicker, LazySavedObjectSaveModalDashboard, withSuspense, + LazyDataViewPicker, + LazyFieldPicker, + LazyReduxEmbeddableWrapper, } from './components'; export * from './components/types'; @@ -57,7 +60,13 @@ export { SolutionToolbarPopover, } from './components/solution_toolbar'; -export * from './components/controls'; +export { + ReduxEmbeddableContext, + useReduxContainerContext, + useReduxEmbeddableContext, + type ReduxContainerContextServices, + type ReduxEmbeddableWrapperPropsWithChildren, +} from './components/redux_embeddables'; /** * Register a set of Expression Functions with the Presentation Utility ExpressionInput. This allows diff --git a/src/plugins/presentation_util/public/mocks.ts b/src/plugins/presentation_util/public/mocks.ts index ec1c44d02c497..b569cb7436668 100644 --- a/src/plugins/presentation_util/public/mocks.ts +++ b/src/plugins/presentation_util/public/mocks.ts @@ -13,14 +13,11 @@ import { registry } from './services/kibana'; import { registerExpressionsLanguage } from '.'; const createStartContract = (coreStart: CoreStart): PresentationUtilPluginStart => { - pluginServices.setRegistry( - registry.start({ coreStart, startPlugins: { dataViews: {}, data: {} } as any }) - ); + pluginServices.setRegistry(registry.start({ coreStart, startPlugins: { dataViews: {} } as any })); const startContract: PresentationUtilPluginStart = { ContextProvider: pluginServices.getContextProvider(), labsService: pluginServices.getServices().labs, - controlsService: pluginServices.getServices().controls, registerExpressionsLanguage, }; return startContract; @@ -29,3 +26,5 @@ const createStartContract = (coreStart: CoreStart): PresentationUtilPluginStart export const presentationUtilPluginMock = { createStartContract, }; + +export * from './__stories__/fixtures/flights'; diff --git a/src/plugins/presentation_util/public/plugin.ts b/src/plugins/presentation_util/public/plugin.ts index 92802d0bc9934..9cd9027a53e76 100644 --- a/src/plugins/presentation_util/public/plugin.ts +++ b/src/plugins/presentation_util/public/plugin.ts @@ -12,16 +12,9 @@ import { registry } from './services/kibana'; import { PresentationUtilPluginSetupDeps, PresentationUtilPluginStartDeps, - ControlGroupContainerFactory, PresentationUtilPluginSetup, PresentationUtilPluginStart, - IEditableControlFactory, - ControlEditorProps, - ControlInput, - ControlEmbeddable, } from './types'; -import { OptionsListEmbeddableFactory } from './components/controls/control_types/options_list'; -import { CONTROL_GROUP_TYPE, OPTIONS_LIST_CONTROL } from '.'; import { registerExpressionsLanguage } from '.'; @@ -34,39 +27,10 @@ export class PresentationUtilPlugin PresentationUtilPluginStartDeps > { - private inlineEditors: { - [key: string]: { - controlEditorComponent?: (props: ControlEditorProps) => JSX.Element; - presaveTransformFunction?: ( - newInput: Partial<ControlInput>, - embeddable?: ControlEmbeddable - ) => Partial<ControlInput>; - }; - } = {}; - public setup( _coreSetup: CoreSetup<PresentationUtilPluginStartDeps, PresentationUtilPluginStart>, _setupPlugins: PresentationUtilPluginSetupDeps ): PresentationUtilPluginSetup { - _coreSetup.getStartServices().then(([coreStart, deps]) => { - // register control group embeddable factory - embeddable.registerEmbeddableFactory( - CONTROL_GROUP_TYPE, - new ControlGroupContainerFactory(deps.embeddable) - ); - }); - - const { embeddable } = _setupPlugins; - - // create control type embeddable factories. - const optionsListFactory = new OptionsListEmbeddableFactory(); - const editableOptionsListFactory = optionsListFactory as IEditableControlFactory; - this.inlineEditors[OPTIONS_LIST_CONTROL] = { - controlEditorComponent: editableOptionsListFactory.controlEditorComponent, - presaveTransformFunction: editableOptionsListFactory.presaveTransformFunction, - }; - embeddable.registerEmbeddableFactory(OPTIONS_LIST_CONTROL, optionsListFactory); - return {}; } @@ -75,25 +39,9 @@ export class PresentationUtilPlugin startPlugins: PresentationUtilPluginStartDeps ): PresentationUtilPluginStart { pluginServices.setRegistry(registry.start({ coreStart, startPlugins })); - const { controls: controlsService } = pluginServices.getServices(); - const { embeddable } = startPlugins; - - // register control types with controls service. - const optionsListFactory = embeddable.getEmbeddableFactory(OPTIONS_LIST_CONTROL); - // Temporarily pass along inline editors - inline editing should be made a first-class feature of embeddables - const editableOptionsListFactory = optionsListFactory as IEditableControlFactory; - const { - controlEditorComponent: optionsListControlEditor, - presaveTransformFunction: optionsListPresaveTransform, - } = this.inlineEditors[OPTIONS_LIST_CONTROL]; - editableOptionsListFactory.controlEditorComponent = optionsListControlEditor; - editableOptionsListFactory.presaveTransformFunction = optionsListPresaveTransform; - - if (optionsListFactory) controlsService.registerControlType(optionsListFactory); return { ContextProvider: pluginServices.getContextProvider(), - controlsService, labsService: pluginServices.getServices().labs, registerExpressionsLanguage, }; diff --git a/src/plugins/presentation_util/public/services/index.ts b/src/plugins/presentation_util/public/services/index.ts index d6112b86066e1..da840d7d24b2a 100644 --- a/src/plugins/presentation_util/public/services/index.ts +++ b/src/plugins/presentation_util/public/services/index.ts @@ -12,10 +12,7 @@ import { PresentationCapabilitiesService } from './capabilities'; import { PresentationDashboardsService } from './dashboards'; import { PresentationLabsService } from './labs'; import { registry as stubRegistry } from './stub'; -import { PresentationOverlaysService } from './overlays'; -import { PresentationControlsService } from './controls'; import { PresentationDataViewsService } from './data_views'; -import { PresentationDataService } from './data'; import { registerExpressionsLanguage } from '..'; export type { PresentationCapabilitiesService } from './capabilities'; @@ -25,10 +22,7 @@ export type { PresentationLabsService } from './labs'; export interface PresentationUtilServices { dashboards: PresentationDashboardsService; dataViews: PresentationDataViewsService; - data: PresentationDataService; capabilities: PresentationCapabilitiesService; - overlays: PresentationOverlaysService; - controls: PresentationControlsService; labs: PresentationLabsService; } @@ -39,7 +33,6 @@ export const getStubPluginServices = (): PresentationUtilPluginStart => { return { ContextProvider: pluginServices.getContextProvider(), labsService: pluginServices.getServices().labs, - controlsService: pluginServices.getServices().controls, registerExpressionsLanguage, }; }; diff --git a/src/plugins/presentation_util/public/services/kibana/index.ts b/src/plugins/presentation_util/public/services/kibana/index.ts index 3820442555c26..e412ca5ab1b48 100644 --- a/src/plugins/presentation_util/public/services/kibana/index.ts +++ b/src/plugins/presentation_util/public/services/kibana/index.ts @@ -18,9 +18,6 @@ import { PresentationUtilServices } from '..'; import { capabilitiesServiceFactory } from './capabilities'; import { dataViewsServiceFactory } from './data_views'; import { dashboardsServiceFactory } from './dashboards'; -import { controlsServiceFactory } from './controls'; -import { overlaysServiceFactory } from './overlays'; -import { dataServiceFactory } from './data'; import { labsServiceFactory } from './labs'; export const providers: PluginServiceProviders< @@ -30,10 +27,7 @@ export const providers: PluginServiceProviders< capabilities: new PluginServiceProvider(capabilitiesServiceFactory), labs: new PluginServiceProvider(labsServiceFactory), dataViews: new PluginServiceProvider(dataViewsServiceFactory), - data: new PluginServiceProvider(dataServiceFactory), dashboards: new PluginServiceProvider(dashboardsServiceFactory), - overlays: new PluginServiceProvider(overlaysServiceFactory), - controls: new PluginServiceProvider(controlsServiceFactory), }; export const registry = new PluginServiceRegistry< diff --git a/src/plugins/presentation_util/public/services/storybook/index.ts b/src/plugins/presentation_util/public/services/storybook/index.ts index a2d729f6d730a..18333bd5522ca 100644 --- a/src/plugins/presentation_util/public/services/storybook/index.ts +++ b/src/plugins/presentation_util/public/services/storybook/index.ts @@ -16,10 +16,7 @@ import { dashboardsServiceFactory } from '../stub/dashboards'; import { labsServiceFactory } from './labs'; import { capabilitiesServiceFactory } from './capabilities'; import { PresentationUtilServices } from '..'; -import { overlaysServiceFactory } from './overlays'; -import { controlsServiceFactory } from './controls'; import { dataViewsServiceFactory } from './data_views'; -import { dataServiceFactory } from './data'; export type { PluginServiceProviders } from '../create'; export { PluginServiceProvider, PluginServiceRegistry } from '../create'; @@ -36,9 +33,6 @@ export const providers: PluginServiceProviders<PresentationUtilServices, Storybo capabilities: new PluginServiceProvider(capabilitiesServiceFactory), dashboards: new PluginServiceProvider(dashboardsServiceFactory), dataViews: new PluginServiceProvider(dataViewsServiceFactory), - data: new PluginServiceProvider(dataServiceFactory), - overlays: new PluginServiceProvider(overlaysServiceFactory), - controls: new PluginServiceProvider(controlsServiceFactory), labs: new PluginServiceProvider(labsServiceFactory), }; diff --git a/src/plugins/presentation_util/public/services/stub/index.ts b/src/plugins/presentation_util/public/services/stub/index.ts index 2e312ff682927..34a13d030e535 100644 --- a/src/plugins/presentation_util/public/services/stub/index.ts +++ b/src/plugins/presentation_util/public/services/stub/index.ts @@ -11,21 +11,15 @@ import { dashboardsServiceFactory } from './dashboards'; import { labsServiceFactory } from './labs'; import { PluginServiceProviders, PluginServiceProvider, PluginServiceRegistry } from '../create'; import { PresentationUtilServices } from '..'; -import { overlaysServiceFactory } from './overlays'; -import { controlsServiceFactory } from './controls'; export { dashboardsServiceFactory } from './dashboards'; export { capabilitiesServiceFactory } from './capabilities'; -import { dataServiceFactory } from '../storybook/data'; import { dataViewsServiceFactory } from '../storybook/data_views'; export const providers: PluginServiceProviders<PresentationUtilServices> = { dashboards: new PluginServiceProvider(dashboardsServiceFactory), capabilities: new PluginServiceProvider(capabilitiesServiceFactory), - overlays: new PluginServiceProvider(overlaysServiceFactory), - controls: new PluginServiceProvider(controlsServiceFactory), labs: new PluginServiceProvider(labsServiceFactory), - data: new PluginServiceProvider(dataServiceFactory), dataViews: new PluginServiceProvider(dataViewsServiceFactory), }; diff --git a/src/plugins/presentation_util/public/types.ts b/src/plugins/presentation_util/public/types.ts index 3717cf2505dd8..a918ee3adaf35 100644 --- a/src/plugins/presentation_util/public/types.ts +++ b/src/plugins/presentation_util/public/types.ts @@ -6,12 +6,9 @@ * Side Public License, v 1. */ -import { DataPublicPluginStart } from '../../data/public'; +import { registerExpressionsLanguage } from '.'; import { PresentationLabsService } from './services/labs'; -import { PresentationControlsService } from './services/controls'; import { DataViewsPublicPluginStart } from '../../data_views/public'; -import { EmbeddableSetup, EmbeddableStart } from '../../embeddable/public'; -import { registerExpressionsLanguage } from '.'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface PresentationUtilPluginSetup {} @@ -19,17 +16,11 @@ export interface PresentationUtilPluginSetup {} export interface PresentationUtilPluginStart { ContextProvider: React.FC; labsService: PresentationLabsService; - controlsService: PresentationControlsService; registerExpressionsLanguage: typeof registerExpressionsLanguage; } +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface PresentationUtilPluginSetupDeps {} -export interface PresentationUtilPluginSetupDeps { - embeddable: EmbeddableSetup; -} export interface PresentationUtilPluginStartDeps { - data: DataPublicPluginStart; - embeddable: EmbeddableStart; dataViews: DataViewsPublicPluginStart; } - -export * from './components/controls'; diff --git a/src/plugins/presentation_util/server/plugin.ts b/src/plugins/presentation_util/server/plugin.ts index 2c52fa1f6c2d8..eb55373920625 100644 --- a/src/plugins/presentation_util/server/plugin.ts +++ b/src/plugins/presentation_util/server/plugin.ts @@ -7,24 +7,11 @@ */ import { CoreSetup, Plugin } from 'kibana/server'; -import { EmbeddableSetup } from '../../embeddable/server'; -import { controlGroupContainerPersistableStateServiceFactory } from './controls/control_group/control_group_container_factory'; -import { optionsListPersistableStateServiceFactory } from './controls/control_types/options_list/options_list_embeddable_factory'; import { getUISettings } from './ui_settings'; -interface SetupDeps { - embeddable: EmbeddableSetup; -} - -export class PresentationUtilPlugin implements Plugin<object, object, SetupDeps> { - public setup(core: CoreSetup, plugins: SetupDeps) { +export class PresentationUtilPlugin implements Plugin<object, object> { + public setup(core: CoreSetup) { core.uiSettings.register(getUISettings()); - - plugins.embeddable.registerEmbeddableFactory(optionsListPersistableStateServiceFactory()); - - plugins.embeddable.registerEmbeddableFactory( - controlGroupContainerPersistableStateServiceFactory(plugins.embeddable) - ); return {}; } diff --git a/src/plugins/presentation_util/tsconfig.json b/src/plugins/presentation_util/tsconfig.json index caabd0b18af71..38f2cf3c14a12 100644 --- a/src/plugins/presentation_util/tsconfig.json +++ b/src/plugins/presentation_util/tsconfig.json @@ -6,9 +6,7 @@ "declaration": true, "declarationMap": true }, - "extraPublicDirs": [ - "common" - ], + "extraPublicDirs": ["common"], "include": [ "common/**/*", "public/**/*", @@ -20,9 +18,8 @@ "references": [ { "path": "../../core/tsconfig.json" }, { "path": "../saved_objects/tsconfig.json" }, - { "path": "../kibana_react/tsconfig.json" }, { "path": "../embeddable/tsconfig.json" }, - { "path": "../kibana_react/tsconfig.json"}, + { "path": "../kibana_react/tsconfig.json" }, { "path": "../data/tsconfig.json" } ] } diff --git a/test/functional/page_objects/dashboard_page_controls.ts b/test/functional/page_objects/dashboard_page_controls.ts index 2603608eebee9..1adc60b3596b6 100644 --- a/test/functional/page_objects/dashboard_page_controls.ts +++ b/test/functional/page_objects/dashboard_page_controls.ts @@ -7,8 +7,7 @@ */ import { WebElementWrapper } from 'test/functional/services/lib/web_element_wrapper'; -import { OPTIONS_LIST_CONTROL } from '../../../src/plugins/presentation_util/common/controls/'; -import { ControlWidth } from '../../../src/plugins/presentation_util/public/components/controls'; +import { OPTIONS_LIST_CONTROL, ControlWidth } from '../../../src/plugins/controls/common'; import { FtrService } from '../ftr_provider_context'; diff --git a/x-pack/plugins/canvas/public/functions/pie.test.js b/x-pack/plugins/canvas/public/functions/pie.test.js index ef180181701c9..6f1b66018fb49 100644 --- a/x-pack/plugins/canvas/public/functions/pie.test.js +++ b/x-pack/plugins/canvas/public/functions/pie.test.js @@ -6,7 +6,7 @@ */ import { testPie } from '../../canvas_plugin_src/functions/common/__fixtures__/test_pointseries'; -import { functionWrapper, fontStyle } from '../../../../../src/plugins/presentation_util/public'; +import { fontStyle, functionWrapper } from '../../../../../src/plugins/presentation_util/public'; import { grayscalePalette, seriesStyle, diff --git a/x-pack/plugins/canvas/public/functions/plot.test.js b/x-pack/plugins/canvas/public/functions/plot.test.js index b354c4c02b2f6..1f74698ccaf4e 100644 --- a/x-pack/plugins/canvas/public/functions/plot.test.js +++ b/x-pack/plugins/canvas/public/functions/plot.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper, fontStyle } from '../../../../../src/plugins/presentation_util/public'; +import { fontStyle, functionWrapper } from '../../../../../src/plugins/presentation_util/public'; import { testPlot } from '../../canvas_plugin_src/functions/common/__fixtures__/test_pointseries'; import { grayscalePalette, From 9507d796f99493e14f708705f1c61662295b2e10 Mon Sep 17 00:00:00 2001 From: Davis Plumlee <56367316+dplumlee@users.noreply.github.com> Date: Thu, 6 Jan 2022 15:16:14 -0500 Subject: [PATCH 14/25] [Security Solution][Exceptions] Fixes exceptions modal prepopulate bug (#121720) --- .../public/common/components/exceptions/helpers.test.tsx | 8 ++++++++ .../public/common/components/exceptions/helpers.tsx | 7 +++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx index 8303428894737..8f1b31a7e004c 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx @@ -748,6 +748,7 @@ describe('Exception helpers', () => { event: { code: 'some event code', }, + 'event.code': 'some event code', }); expect(defaultItems[0].entries).toEqual([ @@ -850,6 +851,7 @@ describe('Exception helpers', () => { event: { code: 'ransomware', }, + 'event.code': 'ransomware', }); expect(defaultItems[0].entries).toEqual([ @@ -963,6 +965,7 @@ describe('Exception helpers', () => { event: { code: 'memory_signature', }, + 'event.code': 'memory_signature', }); expect(defaultItems[0].entries).toEqual([ @@ -1014,6 +1017,7 @@ describe('Exception helpers', () => { event: { code: 'memory_signature', }, + 'event.code': 'memory_signature', }); // should not contain name or executable @@ -1074,6 +1078,7 @@ describe('Exception helpers', () => { }, }, }, + 'event.code': 'shellcode_thread', }); expect(defaultItems[0].entries).toEqual([ @@ -1135,6 +1140,7 @@ describe('Exception helpers', () => { event: { code: 'shellcode_thread', }, + 'event.code': 'shellcode_thread', Target: { process: { thread: { @@ -1203,6 +1209,7 @@ describe('Exception helpers', () => { event: { code: 'behavior', }, + 'event.code': 'behavior', file: { path: 'fake-file-path', name: 'fake-file-name', @@ -1390,6 +1397,7 @@ describe('Exception helpers', () => { event: { code: 'behavior', }, + 'event.code': 'behavior', file: { // path: 'fake-file-path', intentionally left commented name: 'fake-file-name', diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx index b6ae9ca9b0132..e8568e317dd2b 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -784,10 +784,9 @@ export const entryHasNonEcsType = ( export const defaultEndpointExceptionItems = ( listId: string, ruleName: string, - alertEcsData: Flattened<Ecs> + alertEcsData: Flattened<Ecs> & { 'event.code'?: string } ): ExceptionsBuilderExceptionItem[] => { - const { event: alertEvent } = alertEcsData; - const eventCode = alertEvent?.code ?? ''; + const eventCode = alertEcsData['event.code'] ?? alertEcsData.event?.code; switch (eventCode) { case 'behavior': @@ -833,7 +832,7 @@ export const defaultEndpointExceptionItems = ( getPrepopulatedEndpointException({ listId, ruleName, - eventCode, + eventCode: eventCode ?? '', codeSignature, alertEcsData, }) From 34d7ca652862fd7a73fd2438606f948caa0dc2e8 Mon Sep 17 00:00:00 2001 From: Ashokaditya <1849116+ashokaditya@users.noreply.github.com> Date: Thu, 6 Jan 2022 21:26:27 +0100 Subject: [PATCH 15/25] [Security Solution][Endpoint] Artifacts event filter card on integration policy edit view (#121879) * fix typo refs elastic/security-team/issues/2031 * Add artifact event filters card to policy edit view on endpoint integration fixes elastic/security-team/issues/2031 * add tests fixes elastic/security-team/issues/2031 * fix typo refs elastic/kibana/pull/111708 * use `eventFiltersListQueryHttpMock` instead review suggestion * add a test for verifying error toast review suggestion * fix tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/management/common/routing.ts | 2 +- .../pages/event_filters/store/reducer.ts | 4 +- .../components/exception_items_summary.tsx | 4 +- ...et_integration_event_filters_card.test.tsx | 99 +++++++++++ .../fleet_integration_event_filters_card.tsx | 155 ++++++++++++++++++ .../endpoint_policy_edit_extension.tsx | 3 + 6 files changed, 262 insertions(+), 5 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_event_filters_card.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_event_filters_card.tsx diff --git a/x-pack/plugins/security_solution/public/management/common/routing.ts b/x-pack/plugins/security_solution/public/management/common/routing.ts index 127ad401be7fb..e776cb3ccaa31 100644 --- a/x-pack/plugins/security_solution/public/management/common/routing.ts +++ b/x-pack/plugins/security_solution/public/management/common/routing.ts @@ -351,7 +351,7 @@ export const getPolicyDetailsArtifactsListPath = ( )}`; }; -export const extractEventFiltetrsPageLocation = ( +export const extractEventFiltersPageLocation = ( query: querystring.ParsedUrlQuery ): EventFiltersPageLocation => { const showParamValue = extractFirstParamValue(query, 'show') as EventFiltersPageLocation['show']; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.ts index 3d58f6c9652ce..95b0078f80f8b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.ts @@ -13,7 +13,7 @@ import { AppAction } from '../../../../common/store/actions'; import { AppLocation, Immutable } from '../../../../../common/endpoint/types'; import { UserChangedUrl } from '../../../../common/store/routing/action'; import { MANAGEMENT_ROUTING_EVENT_FILTERS_PATH } from '../../../common/constants'; -import { extractEventFiltetrsPageLocation } from '../../../common/routing'; +import { extractEventFiltersPageLocation } from '../../../common/routing'; import { isLoadedResourceState, isUninitialisedResourceState, @@ -156,7 +156,7 @@ const eventFiltersUpdateSuccess: CaseReducer<EventFiltersUpdateSuccess> = (state const userChangedUrl: CaseReducer<UserChangedUrl> = (state, action) => { if (isEventFiltersPageLocation(action.payload)) { - const location = extractEventFiltetrsPageLocation(parse(action.payload.search.slice(1))); + const location = extractEventFiltersPageLocation(parse(action.payload.search.slice(1))); return { ...state, location, diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/exception_items_summary.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/exception_items_summary.tsx index 59de05415f86b..45cd14c390e38 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/exception_items_summary.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/exception_items_summary.tsx @@ -46,8 +46,8 @@ export const StyledEuiFlexGridGroup = styled(EuiFlexGroup)` const StyledEuiFlexGroup = styled(EuiFlexGroup)<{ isSmall: boolean; }>` - font-size: ${({ isSmall, theme }) => (isSmall ? theme.eui.euiFontSizeXS : 'innherit')}; - font-weight: ${({ isSmall }) => (isSmall ? '1px' : 'innherit')}; + font-size: ${({ isSmall, theme }) => (isSmall ? theme.eui.euiFontSizeXS : 'inherit')}; + font-weight: ${({ isSmall }) => (isSmall ? '1px' : 'inherit')}; `; const CSS_BOLD: Readonly<React.CSSProperties> = { fontWeight: 'bold' }; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_event_filters_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_event_filters_card.test.tsx new file mode 100644 index 0000000000000..badf7a1a7fd4d --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_event_filters_card.test.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { waitFor, act } from '@testing-library/react'; +import * as reactTestingLibrary from '@testing-library/react'; +import { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../../../common/mock/endpoint'; + +import { eventFiltersListQueryHttpMock } from '../../../../../event_filters/test_utils'; +import { FleetIntegrationEventFiltersCard } from './fleet_integration_event_filters_card'; +import { EndpointDocGenerator } from '../../../../../../../../common/endpoint/generate_data'; +import { getPolicyEventFiltersPath } from '../../../../../../common/routing'; +import { PolicyData } from '../../../../../../../../common/endpoint/types'; +import { getFoundExceptionListItemSchemaMock } from '../../../../../../../../../lists/common/schemas/response/found_exception_list_item_schema.mock'; + +const endpointGenerator = new EndpointDocGenerator('seed'); + +describe('Fleet integration policy endpoint security event filters card', () => { + let render: () => Promise<ReturnType<AppContextTestRender['render']>>; + let renderResult: ReturnType<AppContextTestRender['render']>; + let history: AppContextTestRender['history']; + let mockedContext: AppContextTestRender; + let mockedApi: ReturnType<typeof eventFiltersListQueryHttpMock>; + let policy: PolicyData; + + beforeEach(() => { + policy = endpointGenerator.generatePolicyPackagePolicy(); + mockedContext = createAppRootMockRenderer(); + mockedApi = eventFiltersListQueryHttpMock(mockedContext.coreStart.http); + ({ history } = mockedContext); + render = async () => { + await act(async () => { + renderResult = mockedContext.render( + <FleetIntegrationEventFiltersCard policyId={policy.id} /> + ); + await waitFor(() => expect(mockedApi.responseProvider.eventFiltersList).toHaveBeenCalled()); + }); + return renderResult; + }; + + history.push(getPolicyEventFiltersPath(policy.id)); + }); + + afterEach(() => reactTestingLibrary.cleanup()); + + it('should call the API and render the card correctly', async () => { + mockedApi.responseProvider.eventFiltersList.mockReturnValue( + getFoundExceptionListItemSchemaMock(3) + ); + + await render(); + expect(renderResult.getByTestId('eventFilters-fleet-integration-card')).toHaveTextContent( + 'Event filters3' + ); + }); + + it('should show the card even when no event filters associated with the policy', async () => { + mockedApi.responseProvider.eventFiltersList.mockReturnValue( + getFoundExceptionListItemSchemaMock(0) + ); + + await render(); + expect(renderResult.getByTestId('eventFilters-fleet-integration-card')).toBeTruthy(); + }); + + it('should have the correct manage event filters link', async () => { + mockedApi.responseProvider.eventFiltersList.mockReturnValue( + getFoundExceptionListItemSchemaMock(1) + ); + + await render(); + expect(renderResult.getByTestId('eventFilters-link-to-exceptions')).toHaveAttribute( + 'href', + `/app/security/administration/policy/${policy.id}/eventFilters` + ); + }); + + it('should show an error toast when API request fails', async () => { + const error = new Error('Uh oh! API error!'); + mockedApi.responseProvider.eventFiltersList.mockImplementation(() => { + throw error; + }); + + await render(); + await waitFor(() => { + expect(mockedContext.coreStart.notifications.toasts.addDanger).toHaveBeenCalledTimes(1); + expect(mockedContext.coreStart.notifications.toasts.addDanger).toHaveBeenCalledWith( + `There was an error trying to fetch event filters stats: "${error}"` + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_event_filters_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_event_filters_card.tsx new file mode 100644 index 0000000000000..eecdff54741e3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_event_filters_card.tsx @@ -0,0 +1,155 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React, { memo, useEffect, useMemo, useRef, useState } from 'react'; +import { INTEGRATIONS_PLUGIN_ID } from '../../../../../../../../../fleet/common'; +import { pagePathGetters } from '../../../../../../../../../fleet/public'; +import { + GetExceptionSummaryResponse, + PolicyDetailsRouteState, +} from '../../../../../../../../common/endpoint/types'; +import { useAppUrl, useHttp, useToasts } from '../../../../../../../common/lib/kibana'; +import { getPolicyEventFiltersPath } from '../../../../../../common/routing'; +import { parsePoliciesToKQL } from '../../../../../../common/utils'; +import { ExceptionItemsSummary } from './exception_items_summary'; +import { LinkWithIcon } from './link_with_icon'; +import { StyledEuiFlexItem } from './styled_components'; +import { EventFiltersHttpService } from '../../../../../event_filters/service'; + +export const FleetIntegrationEventFiltersCard = memo<{ + policyId: string; +}>(({ policyId }) => { + const toasts = useToasts(); + const http = useHttp(); + const [stats, setStats] = useState<GetExceptionSummaryResponse | undefined>(); + const isMounted = useRef<boolean>(); + const { getAppUrl } = useAppUrl(); + + const eventFiltersApi = useMemo(() => new EventFiltersHttpService(http), [http]); + const policyEventFiltersPath = getPolicyEventFiltersPath(policyId); + + const policyEventFiltersRouteState = useMemo<PolicyDetailsRouteState>(() => { + const fleetPackageIntegrationCustomUrlPath = `#${ + pagePathGetters.integration_policy_edit({ packagePolicyId: policyId })[1] + }`; + + return { + backLink: { + label: i18n.translate( + 'xpack.securitySolution.endpoint.fleetCustomExtension.artifacts.backButtonLabel', + { + defaultMessage: `Back to Fleet integration policy`, + } + ), + navigateTo: [ + INTEGRATIONS_PLUGIN_ID, + { + path: fleetPackageIntegrationCustomUrlPath, + }, + ], + href: getAppUrl({ + appId: INTEGRATIONS_PLUGIN_ID, + path: fleetPackageIntegrationCustomUrlPath, + }), + }, + }; + }, [getAppUrl, policyId]); + + const linkToEventFilters = useMemo( + () => ( + <LinkWithIcon + href={getAppUrl({ + path: policyEventFiltersPath, + })} + appPath={policyEventFiltersPath} + appState={policyEventFiltersRouteState} + data-test-subj="eventFilters-link-to-exceptions" + size="m" + > + <FormattedMessage + id="xpack.securitySolution.endpoint.fleetCustomExtension.eventFiltersManageLabel" + defaultMessage="Manage event filters" + /> + </LinkWithIcon> + ), + [getAppUrl, policyEventFiltersPath, policyEventFiltersRouteState] + ); + + useEffect(() => { + isMounted.current = true; + const fetchStats = async () => { + try { + const summary = await eventFiltersApi.getList({ + perPage: 1, + page: 1, + filter: parsePoliciesToKQL([policyId, 'all']), + }); + if (isMounted.current) { + setStats({ + total: summary.total, + windows: 0, + linux: 0, + macos: 0, + }); + } + } catch (error) { + if (isMounted.current) { + toasts.addDanger( + i18n.translate( + 'xpack.securitySolution.endpoint.fleetCustomExtension.eventFiltersSummary.error', + { + defaultMessage: 'There was an error trying to fetch event filters stats: "{error}"', + values: { error }, + } + ) + ); + } + } + }; + fetchStats(); + return () => { + isMounted.current = false; + }; + }, [eventFiltersApi, policyId, toasts]); + + return ( + <EuiPanel + hasShadow={false} + paddingSize="l" + hasBorder + data-test-subj="eventFilters-fleet-integration-card" + > + <EuiFlexGroup + alignItems="baseline" + justifyContent="flexStart" + gutterSize="s" + direction="row" + responsive={false} + > + <EuiFlexItem grow={false}> + <EuiText> + <h5> + <FormattedMessage + id="xpack.securitySolution.endpoint.eventFilters.fleetIntegration.title" + defaultMessage="Event filters" + /> + </h5> + </EuiText> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <ExceptionItemsSummary stats={stats} isSmall={true} /> + </EuiFlexItem> + <StyledEuiFlexItem grow={1}>{linkToEventFilters}</StyledEuiFlexItem> + </EuiFlexGroup> + </EuiPanel> + ); +}); + +FleetIntegrationEventFiltersCard.displayName = 'FleetIntegrationEventFiltersCard'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_policy_edit_extension.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_policy_edit_extension.tsx index 4737c237dc29a..e4f2e6e70c02b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_policy_edit_extension.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_policy_edit_extension.tsx @@ -31,6 +31,7 @@ import { import { FleetTrustedAppsCard } from './endpoint_package_custom_extension/components/fleet_trusted_apps_card'; import { LinkWithIcon } from './endpoint_package_custom_extension/components/link_with_icon'; import { FleetIntegrationHostIsolationExceptionsCard } from './endpoint_package_custom_extension/components/fleet_integration_host_isolation_exceptions_card'; +import { FleetIntegrationEventFiltersCard } from './endpoint_package_custom_extension/components/fleet_integration_event_filters_card'; /** * Exports Endpoint-specific package policy instructions * for use in the Ingest app create / edit package policy @@ -181,6 +182,8 @@ const WrappedPolicyDetailsForm = memo<{ customLink={policyTrustedAppsLink} /> <EuiSpacer size="s" /> + <FleetIntegrationEventFiltersCard policyId={policyId} /> + <EuiSpacer size="s" /> <FleetIntegrationHostIsolationExceptionsCard policyId={policyId} /> </div> <EuiSpacer size="l" /> From 83621d6100efb3fba124cc6cb923c56b678d2956 Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall <clint.hall@elastic.co> Date: Thu, 6 Jan 2022 16:28:20 -0500 Subject: [PATCH 16/25] [shared-ux] Create Shared UX Plugin (#122373) * [shared-ux] Create Shared UX Plugin * Rename nav-shared-ux.docnav.json to nav_shared_ux.docnav.json * Fix a few issues * fix limits.yml --- .github/CODEOWNERS | 5 ++ .i18nrc.json | 63 ++++++++++--------- docs/developer/plugin-list.asciidoc | 4 ++ packages/kbn-optimizer/limits.yml | 1 + src/dev/storybook/aliases.ts | 19 +++--- src/plugins/shared_ux/.i18nrc.json | 9 +++ src/plugins/shared_ux/.storybook/main.ts | 11 ++++ src/plugins/shared_ux/.storybook/manager.ts | 21 +++++++ src/plugins/shared_ux/README.md | 29 +++++++++ src/plugins/shared_ux/common/index.ts | 10 +++ src/plugins/shared_ux/docs/about.mdx | 36 +++++++++++ src/plugins/shared_ux/kibana.json | 14 +++++ .../shared_ux/nav_shared_ux.docnav.json | 17 +++++ src/plugins/shared_ux/public/index.ts | 15 +++++ src/plugins/shared_ux/public/plugin.ts | 22 +++++++ src/plugins/shared_ux/public/types.ts | 19 ++++++ src/plugins/shared_ux/server/index.ts | 16 +++++ src/plugins/shared_ux/server/plugin.ts | 37 +++++++++++ src/plugins/shared_ux/server/types.ts | 13 ++++ src/plugins/shared_ux/tsconfig.json | 21 +++++++ 20 files changed, 342 insertions(+), 40 deletions(-) create mode 100755 src/plugins/shared_ux/.i18nrc.json create mode 100644 src/plugins/shared_ux/.storybook/main.ts create mode 100644 src/plugins/shared_ux/.storybook/manager.ts create mode 100755 src/plugins/shared_ux/README.md create mode 100755 src/plugins/shared_ux/common/index.ts create mode 100644 src/plugins/shared_ux/docs/about.mdx create mode 100755 src/plugins/shared_ux/kibana.json create mode 100644 src/plugins/shared_ux/nav_shared_ux.docnav.json create mode 100755 src/plugins/shared_ux/public/index.ts create mode 100755 src/plugins/shared_ux/public/plugin.ts create mode 100755 src/plugins/shared_ux/public/types.ts create mode 100755 src/plugins/shared_ux/server/index.ts create mode 100755 src/plugins/shared_ux/server/plugin.ts create mode 100755 src/plugins/shared_ux/server/types.ts create mode 100644 src/plugins/shared_ux/tsconfig.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 37b0763c5e96e..0858731b4eaf1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -477,3 +477,8 @@ x-pack/test/security_solution_cypress @elastic/security-engineering-productivity # EUI design /src/plugins/kibana_react/public/page_template/ @elastic/eui-design @elastic/kibana-app-services + +# Application Experience + +## Shared UX +/src/plugins/shared_ux @elastic/shared-ux diff --git a/.i18nrc.json b/.i18nrc.json index c348fc2c2b60c..461d66ba47df0 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -1,83 +1,84 @@ { "paths": { + "advancedSettings": "src/plugins/advanced_settings", "alerts": "packages/kbn-alerts/src", + "apmOss": "src/plugins/apm_oss", "autocomplete": "packages/kbn-securitysolution-autocomplete/src", - "kbnConfig": "packages/kbn-config/src", + "bfetch": "src/plugins/bfetch", + "charts": "src/plugins/charts", "console": "src/plugins/console", "core": "src/core", "customIntegrations": "src/plugins/custom_integrations", - "discover": "src/plugins/discover", - "bfetch": "src/plugins/bfetch", "dashboard": "src/plugins/dashboard", "controls": "src/plugins/controls", "data": "src/plugins/data", "dataViews": "src/plugins/data_views", + "devTools": "src/plugins/dev_tools", + "discover": "src/plugins/discover", "embeddableApi": "src/plugins/embeddable", "embeddableExamples": "examples/embeddable_examples", - "fieldFormats": "src/plugins/field_formats", - "uiActionsExamples": "examples/ui_action_examples", - "share": "src/plugins/share", - "home": "src/plugins/home", - "flot": "packages/kbn-ui-shared-deps-src/src/flot_charts", - "charts": "src/plugins/charts", + "esQuery": "packages/kbn-es-query/src", "esUi": "src/plugins/es_ui_shared", - "devTools": "src/plugins/dev_tools", - "expressions": "src/plugins/expressions", "expressionError": "src/plugins/expression_error", + "expressionGauge": "src/plugins/chart_expressions/expression_gauge", + "expressionHeatmap": "src/plugins/chart_expressions/expression_heatmap", "expressionImage": "src/plugins/expression_image", "expressionMetric": "src/plugins/expression_metric", + "expressionMetricVis": "src/plugins/chart_expressions/expression_metric", + "expressionPie": "src/plugins/chart_expressions/expression_pie", "expressionRepeatImage": "src/plugins/expression_repeat_image", "expressionRevealImage": "src/plugins/expression_reveal_image", + "expressions": "src/plugins/expressions", "expressionShape": "src/plugins/expression_shape", - "expressionHeatmap": "src/plugins/chart_expressions/expression_heatmap", - "expressionGauge": "src/plugins/chart_expressions/expression_gauge", "expressionTagcloud": "src/plugins/chart_expressions/expression_tagcloud", - "expressionMetricVis": "src/plugins/chart_expressions/expression_metric", - "expressionPie": "src/plugins/chart_expressions/expression_pie", + "fieldFormats": "src/plugins/field_formats", + "flot": "packages/kbn-ui-shared-deps-src/src/flot_charts", + "home": "src/plugins/home", + "indexPatternEditor": "src/plugins/data_view_editor", + "indexPatternFieldEditor": "src/plugins/data_view_field_editor", + "indexPatternManagement": "src/plugins/data_view_management", "inputControl": "src/plugins/input_control_vis", "inspector": "src/plugins/inspector", "inspectorViews": "src/legacy/core_plugins/inspector_views", + "interactiveSetup": "src/plugins/interactive_setup", "interpreter": "src/legacy/core_plugins/interpreter", "kbn": "src/legacy/core_plugins/kibana", + "kbnConfig": "packages/kbn-config/src", "kbnDocViews": "src/legacy/core_plugins/kbn_doc_views", + "kibana_react": "src/legacy/core_plugins/kibana_react", + "kibana_utils": "src/plugins/kibana_utils", + "kibana-react": "src/plugins/kibana_react", + "kibanaOverview": "src/plugins/kibana_overview", "lists": "packages/kbn-securitysolution-list-utils/src", "management": ["src/legacy/core_plugins/management", "src/plugins/management"], "monaco": "packages/kbn-monaco/src", - "esQuery": "packages/kbn-es-query/src", - "presentationUtil": "src/plugins/presentation_util", - "indexPatternEditor": "src/plugins/data_view_editor", - "indexPatternFieldEditor": "src/plugins/data_view_field_editor", - "indexPatternManagement": "src/plugins/data_view_management", - "interactiveSetup": "src/plugins/interactive_setup", - "advancedSettings": "src/plugins/advanced_settings", - "kibanaOverview": "src/plugins/kibana_overview", - "kibana_react": "src/legacy/core_plugins/kibana_react", - "kibana-react": "src/plugins/kibana_react", - "kibana_utils": "src/plugins/kibana_utils", "navigation": "src/plugins/navigation", "newsfeed": "src/plugins/newsfeed", + "presentationUtil": "src/plugins/presentation_util", "savedObjects": "src/plugins/saved_objects", "savedObjectsManagement": "src/plugins/saved_objects_management", "server": "src/legacy/server", + "share": "src/plugins/share", + "sharedUX": "src/plugins/shared_ux", "statusPage": "src/legacy/core_plugins/status_page", "telemetry": ["src/plugins/telemetry", "src/plugins/telemetry_management_section"], "timelion": ["src/plugins/vis_types/timelion"], "uiActions": "src/plugins/ui_actions", + "uiActionsExamples": "examples/ui_action_examples", + "usageCollection": "src/plugins/usage_collection", "visDefaultEditor": "src/plugins/vis_default_editor", + "visTypeHeatmap": "src/plugins/vis_types/heatmap", "visTypeMarkdown": "src/plugins/vis_type_markdown", "visTypeMetric": "src/plugins/vis_types/metric", + "visTypePie": "src/plugins/vis_types/pie", "visTypeTable": "src/plugins/vis_types/table", "visTypeTagCloud": "src/plugins/vis_types/tagcloud", "visTypeTimeseries": "src/plugins/vis_types/timeseries", "visTypeVega": "src/plugins/vis_types/vega", "visTypeVislib": "src/plugins/vis_types/vislib", "visTypeXy": "src/plugins/vis_types/xy", - "visTypePie": "src/plugins/vis_types/pie", - "visTypeHeatmap": "src/plugins/vis_types/heatmap", "visualizations": "src/plugins/visualizations", - "visualize": "src/plugins/visualize", - "apmOss": "src/plugins/apm_oss", - "usageCollection": "src/plugins/usage_collection" + "visualize": "src/plugins/visualize" }, "translations": [] } diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index f94b68fe9ab36..f95721a5007bb 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -240,6 +240,10 @@ oss plugins. generating deep links to other apps, and creating short URLs. +|{kib-repo}blob/{branch}/src/plugins/shared_ux/README.md[sharedUX] +|Our mission is to make consistency in our user experience a product. + + |{kib-repo}blob/{branch}/src/plugins/telemetry/README.md[telemetry] |Telemetry allows Kibana features to have usage tracked in the wild. The general term "telemetry" refers to multiple things: diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 8865258b36d1b..7c3a23b7535a3 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -121,3 +121,4 @@ pageLoadAssetSize: expressionGauge: 25000 controls: 34788 expressionPie: 26338 + sharedUX: 16225 diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 5043312d0b25c..54a48685275e6 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -10,29 +10,30 @@ export const storybookAliases = { apm: 'x-pack/plugins/apm/.storybook', canvas: 'x-pack/plugins/canvas/storybook', - codeeditor: 'src/plugins/kibana_react/public/code_editor/.storybook', ci_composite: '.ci/.storybook', + codeeditor: 'src/plugins/kibana_react/public/code_editor/.storybook', + controls: 'src/plugins/controls/storybook', custom_integrations: 'src/plugins/custom_integrations/storybook', - url_template_editor: 'src/plugins/kibana_react/public/url_template_editor/.storybook', - dashboard: 'src/plugins/dashboard/.storybook', dashboard_enhanced: 'x-pack/plugins/dashboard_enhanced/.storybook', + dashboard: 'src/plugins/dashboard/.storybook', data_enhanced: 'x-pack/plugins/data_enhanced/.storybook', embeddable: 'src/plugins/embeddable/.storybook', expression_error: 'src/plugins/expression_error/.storybook', expression_image: 'src/plugins/expression_image/.storybook', + expression_metric_vis: 'src/plugins/chart_expressions/expression_metric/.storybook', expression_metric: 'src/plugins/expression_metric/.storybook', + expression_pie: 'src/plugins/chart_expressions/expression_pie/.storybook', expression_repeat_image: 'src/plugins/expression_repeat_image/.storybook', expression_reveal_image: 'src/plugins/expression_reveal_image/.storybook', expression_shape: 'src/plugins/expression_shape/.storybook', expression_tagcloud: 'src/plugins/chart_expressions/expression_tagcloud/.storybook', - expression_metric_vis: 'src/plugins/chart_expressions/expression_metric/.storybook', - expression_pie: 'src/plugins/chart_expressions/expression_pie/.storybook', fleet: 'x-pack/plugins/fleet/.storybook', infra: 'x-pack/plugins/infra/.storybook', - security_solution: 'x-pack/plugins/security_solution/.storybook', - ui_actions_enhanced: 'x-pack/plugins/ui_actions_enhanced/.storybook', + lists: 'x-pack/plugins/lists/.storybook', observability: 'x-pack/plugins/observability/.storybook', presentation: 'src/plugins/presentation_util/storybook', - controls: 'src/plugins/controls/storybook', - lists: 'x-pack/plugins/lists/.storybook', + security_solution: 'x-pack/plugins/security_solution/.storybook', + shared_ux: 'src/plugins/shared_ux/.storybook', + ui_actions_enhanced: 'x-pack/plugins/ui_actions_enhanced/.storybook', + url_template_editor: 'src/plugins/kibana_react/public/url_template_editor/.storybook', }; diff --git a/src/plugins/shared_ux/.i18nrc.json b/src/plugins/shared_ux/.i18nrc.json new file mode 100755 index 0000000000000..cd7ac5b866df2 --- /dev/null +++ b/src/plugins/shared_ux/.i18nrc.json @@ -0,0 +1,9 @@ +{ + "prefix": "sharedUX", + "paths": { + "sharedUX": "." + }, + "translations": [ + "translations/ja-JP.json" + ] +} diff --git a/src/plugins/shared_ux/.storybook/main.ts b/src/plugins/shared_ux/.storybook/main.ts new file mode 100644 index 0000000000000..1261fe5a06f69 --- /dev/null +++ b/src/plugins/shared_ux/.storybook/main.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { defaultConfig } from '@kbn/storybook'; + +module.exports = defaultConfig; diff --git a/src/plugins/shared_ux/.storybook/manager.ts b/src/plugins/shared_ux/.storybook/manager.ts new file mode 100644 index 0000000000000..64284fb46f36f --- /dev/null +++ b/src/plugins/shared_ux/.storybook/manager.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { addons } from '@storybook/addons'; +import { create } from '@storybook/theming'; +import { PANEL_ID } from '@storybook/addon-actions'; + +addons.setConfig({ + theme: create({ + base: 'light', + brandTitle: 'Shared UX Storybook', + brandUrl: 'https://github.com/elastic/kibana/tree/main/src/plugins/shared_ux', + }), + showPanel: true.valueOf, + selectedPanel: PANEL_ID, +}); diff --git a/src/plugins/shared_ux/README.md b/src/plugins/shared_ux/README.md new file mode 100755 index 0000000000000..748414c356e9b --- /dev/null +++ b/src/plugins/shared_ux/README.md @@ -0,0 +1,29 @@ +# sharedUX + +Our mission is to make consistency in our user experience a product. + +## Areas of Focus + +### UX Infrastructure + +- Create and maintain a discoverable home for shared UX code. +- Encourage contribution and usage. + +### UX Patterns + +- Work with design specialists to understand and communicate new and existing designs. +- Foster adoption of design principles by codifying them in shared resources. + +### UX Solutions + +- Drive common projects. +- Build and support common plugins. +- Help teams to create consistent user experiences. + +## The sharedUX Plugin + +This plugin contains common code that is shared among other plugins. + +## Contribution + +Contributions are welcome and encouraged! \ No newline at end of file diff --git a/src/plugins/shared_ux/common/index.ts b/src/plugins/shared_ux/common/index.ts new file mode 100755 index 0000000000000..25e4f74fde516 --- /dev/null +++ b/src/plugins/shared_ux/common/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const PLUGIN_ID = 'sharedUX'; +export const PLUGIN_NAME = 'sharedUX'; diff --git a/src/plugins/shared_ux/docs/about.mdx b/src/plugins/shared_ux/docs/about.mdx new file mode 100644 index 0000000000000..a31a9b99195f2 --- /dev/null +++ b/src/plugins/shared_ux/docs/about.mdx @@ -0,0 +1,36 @@ +--- +id: sharedUX/About +slug: /shared-ux/about +title: About Shared UX +summary: . +date: 2021-01-05 +tags: ['shared-ux'] +--- + +Our mission is to make consistency in our user experience a product. + +## Areas of Focus + +### UX Infrastructure + +- Create and maintain a discoverable home for shared UX code. +- Encourage contribution and usage. + +### UX Patterns + +- Work with design specialists to understand and communicate new and existing designs. +- Foster adoption of design principles by codifying them in shared resources. + +### UX Solutions + +- Drive common projects. +- Build and support common plugins. +- Help teams to create consistent user experiences. + +## The sharedUX Plugin + +This plugin contains common code that is shared among other plugins. + +## Contribution + +Contributions are welcome and encouraged! diff --git a/src/plugins/shared_ux/kibana.json b/src/plugins/shared_ux/kibana.json new file mode 100755 index 0000000000000..44aeeb9cf80fc --- /dev/null +++ b/src/plugins/shared_ux/kibana.json @@ -0,0 +1,14 @@ +{ + "id": "sharedUX", + "version": "1.0.0", + "kibanaVersion": "kibana", + "owner": { + "name": "Shared UX", + "githubTeam": "shared-ux" + }, + "description": "A plugin providing components and services for shared user experiences in Kibana.", + "server": true, + "ui": true, + "requiredPlugins": [], + "optionalPlugins": [] +} diff --git a/src/plugins/shared_ux/nav_shared_ux.docnav.json b/src/plugins/shared_ux/nav_shared_ux.docnav.json new file mode 100644 index 0000000000000..8f6ad010a49cb --- /dev/null +++ b/src/plugins/shared_ux/nav_shared_ux.docnav.json @@ -0,0 +1,17 @@ +{ + "mission": "Shared UX", + "id": "sharedUX", + "landingPageId": "sharedUX/About", + "icon": "globe", + "description": "Developer documentation for Shared UX.", + "items": [ + { + "label": "About", + "items": [ + { + "id": "sharedUX/About" + } + ] + } + ] +} diff --git a/src/plugins/shared_ux/public/index.ts b/src/plugins/shared_ux/public/index.ts new file mode 100755 index 0000000000000..f68c6d148011e --- /dev/null +++ b/src/plugins/shared_ux/public/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SharedUXPlugin } from './plugin'; + +export function plugin() { + return new SharedUXPlugin(); +} + +export type { SharedUXPluginSetup, SharedUXPluginStart } from './types'; diff --git a/src/plugins/shared_ux/public/plugin.ts b/src/plugins/shared_ux/public/plugin.ts new file mode 100755 index 0000000000000..eb60c1a23e6ad --- /dev/null +++ b/src/plugins/shared_ux/public/plugin.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CoreSetup, CoreStart, Plugin } from '../../../core/public'; +import { SharedUXPluginSetup, SharedUXPluginStart } from './types'; + +export class SharedUXPlugin implements Plugin<SharedUXPluginSetup, SharedUXPluginStart> { + public setup(_core: CoreSetup): SharedUXPluginSetup { + return {}; + } + + public start(_core: CoreStart): SharedUXPluginStart { + return {}; + } + + public stop() {} +} diff --git a/src/plugins/shared_ux/public/types.ts b/src/plugins/shared_ux/public/types.ts new file mode 100755 index 0000000000000..c27cba3a866ca --- /dev/null +++ b/src/plugins/shared_ux/public/types.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SharedUXPluginSetup {} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SharedUXPluginStart {} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SharedUXPluginSetupDeps {} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SharedUXPluginStartDeps {} diff --git a/src/plugins/shared_ux/server/index.ts b/src/plugins/shared_ux/server/index.ts new file mode 100755 index 0000000000000..4658588c54c90 --- /dev/null +++ b/src/plugins/shared_ux/server/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PluginInitializerContext } from '../../../core/server'; +import { SharedUXPlugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new SharedUXPlugin(initializerContext); +} + +export type { SharedUXPluginSetup, SharedUXPluginStart } from './types'; diff --git a/src/plugins/shared_ux/server/plugin.ts b/src/plugins/shared_ux/server/plugin.ts new file mode 100755 index 0000000000000..85724bf7699b0 --- /dev/null +++ b/src/plugins/shared_ux/server/plugin.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + Logger, +} from '../../../core/server'; + +import { SharedUXPluginSetup, SharedUXPluginStart } from './types'; + +export class SharedUXPlugin implements Plugin<SharedUXPluginSetup, SharedUXPluginStart> { + private readonly logger: Logger; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + } + + public setup(_core: CoreSetup) { + this.logger.debug('sharedUX: Setup'); + return {}; + } + + public start(_core: CoreStart) { + this.logger.debug('sharedUX: Started'); + return {}; + } + + public stop() {} +} diff --git a/src/plugins/shared_ux/server/types.ts b/src/plugins/shared_ux/server/types.ts new file mode 100755 index 0000000000000..dca8b131fe5de --- /dev/null +++ b/src/plugins/shared_ux/server/types.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SharedUXPluginSetup {} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SharedUXPluginStart {} diff --git a/src/plugins/shared_ux/tsconfig.json b/src/plugins/shared_ux/tsconfig.json new file mode 100644 index 0000000000000..6717616c8f2f3 --- /dev/null +++ b/src/plugins/shared_ux/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*", + ".storybook/**/*", + "../../../typings/**/*" + ], + "references": [ + { + "path": "../../core/tsconfig.json" + }, + ] +} From fa5d7de6840fb9f9e43a109885ccd116131cfa71 Mon Sep 17 00:00:00 2001 From: Steph Milovic <stephanie.milovic@elastic.co> Date: Thu, 6 Jan 2022 17:14:06 -0700 Subject: [PATCH 17/25] [Security Solution] Create Field READ access of data view management bug fix (#122463) --- .../create_field_button/index.test.tsx | 37 +++++++++++++++---- .../components/create_field_button/index.tsx | 7 +++- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/security_solution/public/timelines/components/create_field_button/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/create_field_button/index.test.tsx index 59dcf350e9aba..6f3f3e8b87bc8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/create_field_button/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/create_field_button/index.test.tsx @@ -18,23 +18,26 @@ import { useKibana } from '../../../common/lib/kibana'; import type { DataView } from '../../../../../../../src/plugins/data/common'; import { TimelineId } from '../../../../common/types'; -const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>; - let mockIndexPatternFieldEditor: Start; jest.mock('../../../common/lib/kibana'); +const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>; const runAllPromises = () => new Promise(setImmediate); describe('CreateFieldButton', () => { beforeEach(() => { mockIndexPatternFieldEditor = indexPatternFieldEditorPluginMock.createStartContract(); + mockIndexPatternFieldEditor.userPermissions.editIndexPattern = () => true; useKibanaMock().services.dataViewFieldEditor = mockIndexPatternFieldEditor; useKibanaMock().services.data.dataViews.get = () => new Promise(() => undefined); - }); - - it('displays the button when user has permissions', () => { - mockIndexPatternFieldEditor.userPermissions.editIndexPattern = () => true; + useKibanaMock().services.application.capabilities = { + ...useKibanaMock().services.application.capabilities, + indexPatterns: { save: true }, + }; + }); + // refactor below tests once resolved: https://github.com/elastic/kibana/issues/122462 + it('displays the button when user has read permissions and write permissions', () => { render( <CreateFieldButton selectedDataViewId={'dataViewId'} @@ -49,7 +52,7 @@ describe('CreateFieldButton', () => { expect(screen.getByRole('button')).toBeInTheDocument(); }); - it("doesn't display the button when user doesn't have permissions", () => { + it("doesn't display the button when user doesn't have read permissions", () => { mockIndexPatternFieldEditor.userPermissions.editIndexPattern = () => false; render( <CreateFieldButton @@ -65,8 +68,26 @@ describe('CreateFieldButton', () => { expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); + it("doesn't display the button when user doesn't have write permissions", () => { + useKibanaMock().services.application.capabilities = { + ...useKibanaMock().services.application.capabilities, + indexPatterns: { save: false }, + }; + render( + <CreateFieldButton + selectedDataViewId={'dataViewId'} + onClick={() => undefined} + timelineId={TimelineId.detectionsPage} + />, + { + wrapper: TestProviders, + } + ); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + it("calls 'onClick' param when the button is clicked", async () => { - mockIndexPatternFieldEditor.userPermissions.editIndexPattern = () => true; useKibanaMock().services.data.dataViews.get = () => Promise.resolve({} as DataView); const onClickParam = jest.fn(); diff --git a/x-pack/plugins/security_solution/public/timelines/components/create_field_button/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/create_field_button/index.tsx index 7f9712ad1f850..e6c422ce809a4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/create_field_button/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/create_field_button/index.tsx @@ -41,6 +41,7 @@ export const CreateFieldButton = React.memo<CreateFieldButtonProps>( const { dataViewFieldEditor, data: { dataViews }, + application: { capabilities }, } = useKibana().services; useEffect(() => { @@ -83,7 +84,11 @@ export const CreateFieldButton = React.memo<CreateFieldButtonProps>( timelineId, ]); - if (!dataViewFieldEditor?.userPermissions.editIndexPattern()) { + if ( + !dataViewFieldEditor?.userPermissions.editIndexPattern() || + // remove below check once resolved: https://github.com/elastic/kibana/issues/122462 + !capabilities.indexPatterns.save + ) { return null; } From 5316d08c31136bed21ce52c15116211b3e58238c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= <soren.louv@elastic.co> Date: Fri, 7 Jan 2022 01:44:24 +0100 Subject: [PATCH 18/25] Update Github Action for backport (#122479) --- .backportrc.json | 3 ++- .github/workflows/backport.yml | 1 - package.json | 2 +- yarn.lock | 8 ++++---- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.backportrc.json b/.backportrc.json index b0311fbbeccf1..14cb04da188d4 100644 --- a/.backportrc.json +++ b/.backportrc.json @@ -41,5 +41,6 @@ "^v(\\d+).(\\d+).\\d+$": "$1.$2" }, "autoMerge": true, - "autoMergeMethod": "squash" + "autoMergeMethod": "squash", + "backportBinary": "node scripts/backport" } diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index bcb59caa40887..5ba208a45766e 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -41,4 +41,3 @@ jobs: commit_email: 42973632+kibanamachine@users.noreply.github.com auto_merge: 'true' auto_merge_method: 'squash' - manual_backport_command_template: 'node scripts/backport --pr %pullNumber%' diff --git a/package.json b/package.json index f31baf75e07af..e6be8c8f1f068 100644 --- a/package.json +++ b/package.json @@ -711,7 +711,7 @@ "babel-plugin-require-context-hook": "^1.0.0", "babel-plugin-styled-components": "^2.0.2", "babel-plugin-transform-react-remove-prop-types": "^0.4.24", - "backport": "^6.1.1", + "backport": "^6.1.3", "callsites": "^3.1.0", "chai": "3.5.0", "chance": "1.0.18", diff --git a/yarn.lock b/yarn.lock index d564d3bce5282..e19fac5e0a395 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8656,10 +8656,10 @@ bach@^1.0.0: async-settle "^1.0.0" now-and-later "^2.0.0" -backport@^6.1.1: - version "6.1.1" - resolved "https://registry.yarnpkg.com/backport/-/backport-6.1.1.tgz#bf511fe2f26b8b85c25165a5bc6f390517bc39e5" - integrity sha512-J0U6bWckG655Wf9/u5Q7rl/urJ4JIWrQD6hXKdq03lDX/RfqPKqhWvWgPhOGAKssb5Y0A2g4AonWQhn0MOwdYQ== +backport@^6.1.3: + version "6.1.3" + resolved "https://registry.yarnpkg.com/backport/-/backport-6.1.3.tgz#48a0a8b8eadf422c475f816199390ef06fad16e0" + integrity sha512-LMSXgUOFI9G/Eu4hZDaC7uQwmpedGSxihxVpVcQYwxfdKgMAsYLRwf2R0uQZaWWzTepbpyN9SXvTR5FnacVSFA== dependencies: "@octokit/rest" "^18.12.0" axios "^0.24.0" From ef2610a8f9cca58f09b005717ebe2a026331fe5b Mon Sep 17 00:00:00 2001 From: Jan Monschke <jan.monschke@elastic.co> Date: Fri, 7 Jan 2022 14:52:37 +0100 Subject: [PATCH 19/25] [Security Solution][Investigations] Fix favorite filter behaviour in timeline search (#122265) * fix: apply correct search options for favorited timelines * test: add timeline overview page tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../integration/timelines/overview.tsx | 88 +++++++++++++++++++ .../cypress/objects/timeline.ts | 12 ++- .../cypress/screens/timelines.ts | 8 ++ .../public/timelines/pages/timelines_page.tsx | 2 +- .../saved_object/timelines/index.test.ts | 5 +- .../timeline/saved_object/timelines/index.ts | 46 +++++++--- 6 files changed, 143 insertions(+), 18 deletions(-) create mode 100644 x-pack/plugins/security_solution/cypress/integration/timelines/overview.tsx diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/overview.tsx b/x-pack/plugins/security_solution/cypress/integration/timelines/overview.tsx new file mode 100644 index 0000000000000..f961f94d5f867 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/overview.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + TIMELINES_OVERVIEW_TABLE, + TIMELINES_OVERVIEW_ONLY_FAVORITES, + TIMELINES_OVERVIEW_SEARCH, +} from '../../screens/timelines'; + +import { + getTimeline, + getFavoritedTimeline, + sharedTimelineTitleFragment, +} from '../../objects/timeline'; + +import { cleanKibana } from '../../tasks/common'; + +import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; +import { createTimeline, favoriteTimeline } from '../../tasks/api_calls/timelines'; + +import { TIMELINES_URL } from '../../urls/navigation'; + +describe('timeline overview search', () => { + before(() => { + cleanKibana(); + + createTimeline(getFavoritedTimeline()) + .then((response) => response.body.data.persistTimeline.timeline.savedObjectId) + .then((timelineId) => favoriteTimeline({ timelineId, timelineType: 'default' })); + createTimeline(getTimeline()); + + loginAndWaitForPageWithoutDateRange(TIMELINES_URL); + }); + + beforeEach(() => { + cy.get(TIMELINES_OVERVIEW_SEARCH).clear(); + }); + + it('should show all timelines when no search term was entered', () => { + cy.get(TIMELINES_OVERVIEW_TABLE).contains(getTimeline().title); + cy.get(TIMELINES_OVERVIEW_TABLE).contains(getFavoritedTimeline().title); + }); + + it('should show the correct favorite count without search', () => { + cy.get(TIMELINES_OVERVIEW_ONLY_FAVORITES).contains(1); + }); + + it('should show the correct timelines when the favorite filter is activated', () => { + cy.get(TIMELINES_OVERVIEW_ONLY_FAVORITES).click(); // enable the filter + + cy.get(TIMELINES_OVERVIEW_TABLE).contains(getTimeline().title).should('not.exist'); + cy.get(TIMELINES_OVERVIEW_TABLE).contains(getFavoritedTimeline().title); + cy.get(TIMELINES_OVERVIEW_ONLY_FAVORITES).contains(1); + + cy.get(TIMELINES_OVERVIEW_ONLY_FAVORITES).click(); // disable the filter + }); + + it('should find the correct timeline and have the correct favorite count when searching by timeline title', () => { + cy.get(TIMELINES_OVERVIEW_SEARCH).type(`"${getTimeline().title}"{enter}`); + + cy.get(TIMELINES_OVERVIEW_TABLE).contains(getFavoritedTimeline().title).should('not.exist'); + cy.get(TIMELINES_OVERVIEW_TABLE).contains(getTimeline().title); + cy.get(TIMELINES_OVERVIEW_ONLY_FAVORITES).contains(0); + }); + + it('should find the correct timelines when searching for favorited timelines', () => { + cy.get(TIMELINES_OVERVIEW_ONLY_FAVORITES).click(); // enable the filter + cy.get(TIMELINES_OVERVIEW_SEARCH).type(`"${getFavoritedTimeline().title}"{enter}`); + + cy.get(TIMELINES_OVERVIEW_TABLE).contains(getTimeline().title).should('not.exist'); + cy.get(TIMELINES_OVERVIEW_TABLE).contains(getFavoritedTimeline().title); + cy.get(TIMELINES_OVERVIEW_ONLY_FAVORITES).contains(1); + + cy.get(TIMELINES_OVERVIEW_ONLY_FAVORITES).click(); // disable the filter + }); + + it('should find the correct timelines when both favorited and non-favorited timelines match', () => { + cy.get(TIMELINES_OVERVIEW_SEARCH).type(`"${sharedTimelineTitleFragment}"{enter}`); + + cy.get(TIMELINES_OVERVIEW_TABLE).contains(getTimeline().title); + cy.get(TIMELINES_OVERVIEW_TABLE).contains(getFavoritedTimeline().title); + cy.get(TIMELINES_OVERVIEW_ONLY_FAVORITES).contains(1); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/objects/timeline.ts b/x-pack/plugins/security_solution/cypress/objects/timeline.ts index 27d901f5a74ac..028de768ae204 100644 --- a/x-pack/plugins/security_solution/cypress/objects/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/objects/timeline.ts @@ -34,14 +34,24 @@ export const getFilter = (): TimelineFilter => ({ value: 'exists', }); +export const sharedTimelineTitleFragment = 'Timeline'; + export const getTimeline = (): CompleteTimeline => ({ - title: 'Security Timeline', + title: `Security ${sharedTimelineTitleFragment}`, description: 'This is the best timeline', query: 'host.name: *', notes: 'Yes, the best timeline', filter: getFilter(), }); +export const getFavoritedTimeline = (): CompleteTimeline => ({ + title: `Darkest ${sharedTimelineTitleFragment}`, + description: 'This is the darkest timeline', + query: 'host.name: *', + notes: 'Yes, the darkest timeline, you heard me right', + filter: getFilter(), +}); + export const getIndicatorMatchTimelineTemplate = (): CompleteTimeline => ({ ...getTimeline(), title: 'Generic Threat Match Timeline', diff --git a/x-pack/plugins/security_solution/cypress/screens/timelines.ts b/x-pack/plugins/security_solution/cypress/screens/timelines.ts index 21febda41d062..c9f06fa88b52f 100644 --- a/x-pack/plugins/security_solution/cypress/screens/timelines.ts +++ b/x-pack/plugins/security_solution/cypress/screens/timelines.ts @@ -45,3 +45,11 @@ export const TIMELINES_TABLE = '[data-test-subj="timelines-table"]'; export const TIMELINES_USERNAME = '[data-test-subj="username"]'; export const REFRESH_BUTTON = '[data-test-subj="refreshButton-linkIcon"]'; + +export const TIMELINES_OVERVIEW = '[data-test-subj="timelines-container"]'; + +export const TIMELINES_OVERVIEW_ONLY_FAVORITES = `${TIMELINES_OVERVIEW} [data-test-subj="only-favorites-toggle"]`; + +export const TIMELINES_OVERVIEW_SEARCH = `${TIMELINES_OVERVIEW} [data-test-subj="search-bar"]`; + +export const TIMELINES_OVERVIEW_TABLE = `${TIMELINES_OVERVIEW} [data-test-subj="timelines-table"]`; diff --git a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx index b91c4ac59e303..6151316cc303d 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx +++ b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx @@ -79,7 +79,7 @@ export const TimelinesPageComponent: React.FC = () => { </EuiFlexGroup> </HeaderPage> - <TimelinesContainer> + <TimelinesContainer data-test-subj="timelines-container"> <StatefulOpenTimeline defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} isModal={false} diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.test.ts index 8af3cf7cfb1cf..00355422687b2 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.test.ts @@ -222,11 +222,10 @@ describe('saved_object', () => { test('should send correct options for counts of favorite timeline', async () => { expect(mockFindSavedObject.mock.calls[5][0]).toEqual({ filter: - 'not siem-ui-timeline.attributes.status: draft and not siem-ui-timeline.attributes.status: immutable', + 'not siem-ui-timeline.attributes.status: draft and not siem-ui-timeline.attributes.status: immutable and siem-ui-timeline.attributes.favorite.keySearch: dXNlcm5hbWU=', page: 1, perPage: 1, - search: ' dXNlcm5hbWU=', - searchFields: ['title', 'description', 'favorite.keySearch'], + searchFields: ['title', 'description'], type: 'siem-ui-timeline', }); }); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.ts index cc28e0c9eb853..d9bc1ccfa2fe4 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.ts @@ -161,9 +161,26 @@ const getTimelineTypeFilter = ( : `not siem-ui-timeline.attributes.status: ${TimelineStatus.immutable}`; const filters = [typeFilter, draftFilter, immutableFilter]; - return filters.filter((f) => f != null).join(' and '); + return combineFilters(filters); }; +const getTimelineFavoriteFilter = ({ + onlyUserFavorite, + request, +}: { + onlyUserFavorite: boolean | null; + request: FrameworkRequest; +}) => { + if (!onlyUserFavorite) { + return null; + } + const username = request.user?.username ?? UNAUTHENTICATED_USER; + return `siem-ui-timeline.attributes.favorite.keySearch: ${convertStringToBase64(username)}`; +}; + +const combineFilters = (filters: Array<string | null>) => + filters.filter((f) => f != null).join(' and '); + export const getExistingPrepackagedTimelines = async ( request: FrameworkRequest, countsOnly?: boolean, @@ -197,15 +214,19 @@ export const getAllTimeline = async ( status: TimelineStatusLiteralWithNull, timelineType: TimelineTypeLiteralWithNull ): Promise<AllTimelinesResponse> => { + const searchTerm = search != null ? search : undefined; + const searchFields = ['title', 'description']; + const filter = combineFilters([ + getTimelineTypeFilter(timelineType ?? null, status ?? null), + getTimelineFavoriteFilter({ onlyUserFavorite, request }), + ]); const options: SavedObjectsFindOptions = { type: timelineSavedObjectType, perPage: pageInfo.pageSize, page: pageInfo.pageIndex, - search: search != null ? search : undefined, - searchFields: onlyUserFavorite - ? ['title', 'description', 'favorite.keySearch'] - : ['title', 'description'], - filter: getTimelineTypeFilter(timelineType ?? null, status ?? null), + filter, + search: searchTerm, + searchFields, sortField: sort != null ? sort.sortField : undefined, sortOrder: sort != null ? sort.sortOrder : undefined, }; @@ -233,10 +254,14 @@ export const getAllTimeline = async ( const favoriteTimelineOptions = { type: timelineSavedObjectType, - searchFields: ['title', 'description', 'favorite.keySearch'], + search: searchTerm, + searchFields, perPage: 1, page: 1, - filter: getTimelineTypeFilter(timelineType ?? null, TimelineStatus.active), + filter: combineFilters([ + getTimelineTypeFilter(timelineType ?? null, TimelineStatus.active), + getTimelineFavoriteFilter({ onlyUserFavorite: true, request }), + ]), }; const result = await Promise.all([ @@ -623,11 +648,6 @@ const getSavedTimeline = async (request: FrameworkRequest, timelineId: string) = const getAllSavedTimeline = async (request: FrameworkRequest, options: SavedObjectsFindOptions) => { const userName = request.user?.username ?? UNAUTHENTICATED_USER; const savedObjectsClient = request.context.core.savedObjects.client; - if (options.searchFields != null && options.searchFields.includes('favorite.keySearch')) { - options.search = `${options.search != null ? options.search : ''} ${ - userName != null ? convertStringToBase64(userName) : null - }`; - } const savedObjects = await savedObjectsClient.find<TimelineWithoutExternalRefs>(options); From 119a2d780df761c7b5c9eb821be742f962308aaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20Kopyci=C5=84ski?= <patryk.kopycinski@elastic.co> Date: Fri, 7 Jan 2022 17:24:49 +0100 Subject: [PATCH 20/25] [Osquery] Add `KibanaThemeProvider` to the app (#122051) --- x-pack/plugins/osquery/public/application.tsx | 48 ++++++++++--------- .../osquery_action/index.tsx | 17 ++++--- .../plugins/osquery/public/shared_imports.ts | 1 + 3 files changed, 36 insertions(+), 30 deletions(-) diff --git a/x-pack/plugins/osquery/public/application.tsx b/x-pack/plugins/osquery/public/application.tsx index 20d5132f5c452..17d47f757a2a6 100644 --- a/x-pack/plugins/osquery/public/application.tsx +++ b/x-pack/plugins/osquery/public/application.tsx @@ -15,7 +15,7 @@ import { ThemeProvider } from 'styled-components'; import { QueryClientProvider } from 'react-query'; import { ReactQueryDevtools } from 'react-query/devtools'; -import { useUiSetting$ } from '../../../../src/plugins/kibana_react/public'; +import { useUiSetting$, KibanaThemeProvider } from './shared_imports'; import { Storage } from '../../../../src/plugins/kibana_utils/public'; import { AppMountParameters, CoreStart } from '../../../../src/core/public'; import { AppPluginStartDependencies } from './types'; @@ -44,32 +44,34 @@ const OsqueryAppContext = () => { export const renderApp = ( core: CoreStart, services: AppPluginStartDependencies, - { element, history }: AppMountParameters, + { element, history, theme$ }: AppMountParameters, storage: Storage, kibanaVersion: string ) => { ReactDOM.render( - <KibanaContextProvider - // eslint-disable-next-line react-perf/jsx-no-new-object-as-prop - services={{ - appName: PLUGIN_NAME, - kibanaVersion, - ...core, - ...services, - storage, - }} - > - <EuiErrorBoundary> - <Router history={history}> - <I18nProvider> - <QueryClientProvider client={queryClient}> - <OsqueryAppContext /> - <ReactQueryDevtools initialIsOpen={false} /> - </QueryClientProvider> - </I18nProvider> - </Router> - </EuiErrorBoundary> - </KibanaContextProvider>, + <KibanaThemeProvider theme$={theme$}> + <KibanaContextProvider + // eslint-disable-next-line react-perf/jsx-no-new-object-as-prop + services={{ + appName: PLUGIN_NAME, + kibanaVersion, + ...core, + ...services, + storage, + }} + > + <EuiErrorBoundary> + <Router history={history}> + <I18nProvider> + <QueryClientProvider client={queryClient}> + <OsqueryAppContext /> + <ReactQueryDevtools initialIsOpen={false} /> + </QueryClientProvider> + </I18nProvider> + </Router> + </EuiErrorBoundary> + </KibanaContextProvider> + </KibanaThemeProvider>, element ); diff --git a/x-pack/plugins/osquery/public/shared_components/osquery_action/index.tsx b/x-pack/plugins/osquery/public/shared_components/osquery_action/index.tsx index 8fc289b7ef36b..7bc54b44de775 100644 --- a/x-pack/plugins/osquery/public/shared_components/osquery_action/index.tsx +++ b/x-pack/plugins/osquery/public/shared_components/osquery_action/index.tsx @@ -17,6 +17,7 @@ import { KibanaContextProvider, useKibana } from '../../common/lib/kibana'; import { LiveQuery } from '../../live_queries'; import { queryClient } from '../../query_client'; import { OsqueryIcon } from '../../components/osquery_icon'; +import { KibanaThemeProvider } from '../../shared_imports'; interface OsqueryActionProps { metadata?: { @@ -134,13 +135,15 @@ export const OsqueryAction = React.memo(OsqueryActionComponent); // @ts-expect-error update types const OsqueryActionWrapperComponent = ({ services, ...props }) => ( - <KibanaContextProvider services={services}> - <EuiErrorBoundary> - <QueryClientProvider client={queryClient}> - <OsqueryAction {...props} /> - </QueryClientProvider> - </EuiErrorBoundary> - </KibanaContextProvider> + <KibanaThemeProvider theme$={services.theme.theme$}> + <KibanaContextProvider services={services}> + <EuiErrorBoundary> + <QueryClientProvider client={queryClient}> + <OsqueryAction {...props} /> + </QueryClientProvider> + </EuiErrorBoundary> + </KibanaContextProvider> + </KibanaThemeProvider> ); const OsqueryActionWrapper = React.memo(OsqueryActionWrapperComponent); diff --git a/x-pack/plugins/osquery/public/shared_imports.ts b/x-pack/plugins/osquery/public/shared_imports.ts index c0f9d35ba51a8..c8f163b5bccfc 100644 --- a/x-pack/plugins/osquery/public/shared_imports.ts +++ b/x-pack/plugins/osquery/public/shared_imports.ts @@ -43,3 +43,4 @@ export type { ERROR_CODE } from '../../../../src/plugins/es_ui_shared/static/for export { EuiCodeEditor } from '../../../../src/plugins/es_ui_shared/public'; export type { EuiCodeEditorProps } from '../../../../src/plugins/es_ui_shared/public'; +export { useUiSetting$, KibanaThemeProvider } from '../../../../src/plugins/kibana_react/public'; From 526367c83169482575bd0303f4d085a27c52889a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 7 Jan 2022 14:04:01 -0500 Subject: [PATCH 21/25] Update node-forge to v1.1.0 (#122475) Co-authored-by: Renovate Bot <bot@renovateapp.com> --- package.json | 5 +++-- yarn.lock | 16 ++++++++-------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index e6be8c8f1f068..0118e28918310 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "**/istanbul-lib-coverage": "^3.2.0", "**/json-schema": "^0.4.0", "**/minimist": "^1.2.5", + "**/node-forge": "^1.1.0", "**/pdfkit/crypto-js": "4.0.0", "**/react-syntax-highlighter": "^15.3.1", "**/react-syntax-highlighter/**/highlight.js": "^10.4.1", @@ -299,7 +300,7 @@ "mustache": "^2.3.2", "nock": "12.0.3", "node-fetch": "^2.6.1", - "node-forge": "^0.10.0", + "node-forge": "^1.1.0", "nodemailer": "^6.6.2", "normalize-path": "^3.0.0", "object-hash": "^1.3.1", @@ -623,7 +624,7 @@ "@types/nock": "^10.0.3", "@types/node": "16.10.2", "@types/node-fetch": "^2.5.7", - "@types/node-forge": "^0.10.10", + "@types/node-forge": "^1.0.0", "@types/nodemailer": "^6.4.0", "@types/normalize-path": "^3.0.0", "@types/object-hash": "^1.3.0", diff --git a/yarn.lock b/yarn.lock index e19fac5e0a395..e3adc81008ffc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6280,10 +6280,10 @@ "@types/node" "*" form-data "^3.0.0" -"@types/node-forge@^0.10.10": - version "0.10.10" - resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-0.10.10.tgz#07ffccf0f7f3ebb97de67446555912803be50e7b" - integrity sha512-iixn5bedlE9fm/5mN7fPpXraXlxCVrnNWHZekys8c5fknridLVWGnNRqlaWpenwaijIuB3bNI0lEOm+JD6hZUA== +"@types/node-forge@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-1.0.0.tgz#0b4e9507209485945115a4db4879f39632230593" + integrity sha512-h0bgwPKq5u99T9Gor4qtV1lCZ41xNkai0pie1n/a2mh2/4+jENWOlo7AJ4YKxTZAnSZ8FRurUpdIN7ohaPPuHA== dependencies: "@types/node" "*" @@ -20509,10 +20509,10 @@ node-fetch@2.6.1, node-fetch@^1.0.1, node-fetch@^2.3.0, node-fetch@^2.6.1: resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== -node-forge@^0.10.0: - version "0.10.0" - resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3" - integrity sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA== +node-forge@^0.10.0, node-forge@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.1.0.tgz#53e61b039eea78b442a4e13f9439dbd61b5cd3a8" + integrity sha512-HeZMFB41cirRysIhIFFgORmR51/qhkjRTXXIH9QiwS3AjF9L9Kre9XvOnyE7NMubOSHDuN0GsrFpnqhlJcNWTA== node-gyp-build@^4.2.3: version "4.2.3" From 25f4d0cc0bb77e10c113f30ffbcbd38b1bf0bfac Mon Sep 17 00:00:00 2001 From: Joe Reuter <johannes.reuter@elastic.co> Date: Mon, 10 Jan 2022 10:35:32 +0100 Subject: [PATCH 22/25] [Lens] Wait for vis before asserting (#122333) --- x-pack/test/functional/page_objects/lens_page.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index f55b05bc65a05..4e562067637c5 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -474,6 +474,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont async useFixAction() { await testSubjects.click('errorFixAction'); + await this.waitForVisualization(); }, async isTopLevelAggregation() { From 786f41631a47938748754d70df527671c08f5068 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Zahid <abdul.zahid@deliveryhero.com> Date: Mon, 10 Jan 2022 10:49:00 +0100 Subject: [PATCH 23/25] [Uptime][Monitor Management UI] Add Enabled column in monitor management monitors list table. (#121682) (elastic/uptime/issues/415) * Add enabled column in monitor management monitors list table. --- .../monitor_list/actions.tsx | 2 +- .../monitor_list/monitor_enabled.test.tsx | 79 ++++++++++++ .../monitor_list/monitor_enabled.tsx | 113 ++++++++++++++++++ .../monitor_list/monitor_list.tsx | 28 +++-- .../monitor_management/monitor_management.tsx | 2 +- .../public/state/api/monitor_management.ts | 4 +- 6 files changed, 215 insertions(+), 13 deletions(-) create mode 100644 x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_enabled.test.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_enabled.tsx diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.tsx index cf3606270f0bd..b0567b1723d9e 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.tsx @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; import { EuiButtonIcon, EuiFlexItem, EuiFlexGroup, EuiLoadingSpinner } from '@elastic/eui'; import { UptimeSettingsContext } from '../../../contexts'; import { useFetcher, FETCH_STATUS } from '../../../../../observability/public'; -import { deleteMonitor } from '../../../state/api/monitor_management'; +import { deleteMonitor } from '../../../state/api'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; interface Props { diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_enabled.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_enabled.test.tsx new file mode 100644 index 0000000000000..5130a8bfb5132 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_enabled.test.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ConfigKey, DataStream, SyntheticsMonitor } from '../../../../common/runtime_types'; +import { render } from '../../../lib/helper/rtl_helpers'; +import { FETCH_STATUS } from '../../../../../observability/public'; +import { spyOnUseFetcher } from '../../../lib/helper/spy_use_fetcher'; +import { MonitorEnabled } from './monitor_enabled'; + +describe('<MonitorEnabled />', () => { + const setRefresh = jest.fn(); + const testMonitor = { + [ConfigKey.MONITOR_TYPE]: DataStream.HTTP, + [ConfigKey.ENABLED]: true, + } as unknown as SyntheticsMonitor; + + const assertMonitorEnabled = (button: HTMLButtonElement) => + expect(button).toHaveAttribute('aria-checked', 'true'); + const assertMonitorDisabled = (button: HTMLButtonElement) => + expect(button).toHaveAttribute('aria-checked', 'false'); + + let useFetcher: jest.SpyInstance; + + beforeEach(() => { + useFetcher?.mockClear(); + useFetcher = spyOnUseFetcher({}); + }); + + it('correctly renders "enabled" state', () => { + render(<MonitorEnabled id="test-id" monitor={testMonitor} setRefresh={setRefresh} />); + + const switchButton = screen.getByRole('switch') as HTMLButtonElement; + assertMonitorEnabled(switchButton); + }); + + it('correctly renders "disabled" state', () => { + render( + <MonitorEnabled + id="test-id" + monitor={{ ...testMonitor, [ConfigKey.ENABLED]: false }} + setRefresh={setRefresh} + /> + ); + + const switchButton = screen.getByRole('switch') as HTMLButtonElement; + assertMonitorDisabled(switchButton); + }); + + it('toggles on click', () => { + render(<MonitorEnabled id="test-id" monitor={testMonitor} setRefresh={setRefresh} />); + + const switchButton = screen.getByRole('switch') as HTMLButtonElement; + userEvent.click(switchButton); + assertMonitorDisabled(switchButton); + userEvent.click(switchButton); + assertMonitorEnabled(switchButton); + }); + + it('is disabled while request is in progress', () => { + useFetcher.mockReturnValue({ + data: {}, + status: FETCH_STATUS.LOADING, + refetch: () => {}, + }); + + render(<MonitorEnabled id="test-id" monitor={testMonitor} setRefresh={setRefresh} />); + const switchButton = screen.getByRole('switch') as HTMLButtonElement; + userEvent.click(switchButton); + + expect(switchButton).toHaveAttribute('disabled'); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_enabled.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_enabled.tsx new file mode 100644 index 0000000000000..4b3aba5d6a3ab --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_enabled.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiSwitch, EuiProgress, EuiSwitchEvent } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useEffect, useState } from 'react'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { FETCH_STATUS, useFetcher } from '../../../../../observability/public'; +import { ConfigKey, SyntheticsMonitor } from '../../../../common/runtime_types'; +import { setMonitor } from '../../../state/api'; + +interface Props { + id: string; + monitor: SyntheticsMonitor; + setRefresh: React.Dispatch<React.SetStateAction<boolean>>; +} + +export const MonitorEnabled = ({ id, monitor, setRefresh }: Props) => { + const [isEnabled, setIsEnabled] = useState<boolean | null>(null); + + const { notifications } = useKibana(); + + const { status } = useFetcher(() => { + if (isEnabled !== null) { + return setMonitor({ id, monitor: { ...monitor, [ConfigKey.ENABLED]: isEnabled } }); + } + }, [isEnabled]); + + useEffect(() => { + if (status === FETCH_STATUS.FAILURE) { + notifications.toasts.danger({ + title: ( + <p data-test-subj="uptimeMonitorEnabledUpdateFailure"> + {getMonitorEnabledUpdateFailureMessage(monitor[ConfigKey.NAME])} + </p> + ), + toastLifeTimeMs: 3000, + }); + setIsEnabled(null); + } else if (status === FETCH_STATUS.SUCCESS) { + notifications.toasts.success({ + title: ( + <p data-test-subj="uptimeMonitorEnabledUpdateSuccess"> + {isEnabled + ? getMonitorEnabledSuccessLabel(monitor[ConfigKey.NAME]) + : getMonitorDisabledSuccessLabel(monitor[ConfigKey.NAME])} + </p> + ), + toastLifeTimeMs: 3000, + }); + setRefresh(true); + } + }, [status]); // eslint-disable-line react-hooks/exhaustive-deps + + const enabled = isEnabled ?? monitor[ConfigKey.ENABLED]; + const isLoading = status === FETCH_STATUS.LOADING; + + const handleEnabledChange = (event: EuiSwitchEvent) => { + const checked = event.target.checked; + setIsEnabled(checked); + }; + + return ( + <div css={{ position: 'relative' }} aria-busy={isLoading}> + <EuiSwitch + checked={enabled} + disabled={isLoading} + showLabel={false} + label={enabled ? DISABLE_MONITOR_LABEL : ENABLE_MONITOR_LABEL} + title={enabled ? DISABLE_MONITOR_LABEL : ENABLE_MONITOR_LABEL} + data-test-subj="syntheticsIsMonitorEnabled" + onChange={handleEnabledChange} + /> + {isLoading ? ( + <EuiProgress + css={{ position: 'absolute', left: 0, bottom: -4, width: '100%', height: 2 }} + size="xs" + color="primary" + /> + ) : null} + </div> + ); +}; + +const ENABLE_MONITOR_LABEL = i18n.translate('xpack.uptime.monitorManagement.enableMonitorLabel', { + defaultMessage: 'Enable monitor', +}); + +const DISABLE_MONITOR_LABEL = i18n.translate('xpack.uptime.monitorManagement.disableMonitorLabel', { + defaultMessage: 'Disable monitor', +}); + +const getMonitorEnabledSuccessLabel = (name: string) => + i18n.translate('xpack.uptime.monitorManagement.monitorEnabledSuccessMessage', { + defaultMessage: 'Monitor {name} enabled successfully.', + values: { name }, + }); + +const getMonitorDisabledSuccessLabel = (name: string) => + i18n.translate('xpack.uptime.monitorManagement.monitorDisabledSuccessMessage', { + defaultMessage: 'Monitor {name} disabled successfully.', + values: { name }, + }); + +const getMonitorEnabledUpdateFailureMessage = (name: string) => + i18n.translate('xpack.uptime.monitorManagement.monitorEnabledUpdateFailureMessage', { + defaultMessage: 'Unable to update monitor {name}.', + values: { name }, + }); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_list.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_list.tsx index 813511b31761a..75c94c2d07d1e 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_list.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_list.tsx @@ -7,12 +7,14 @@ import React, { useContext, useMemo, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiBasicTable, EuiPanel, EuiSpacer, EuiLink } from '@elastic/eui'; +import { SyntheticsMonitorSavedObject } from '../../../../common/types'; import { MonitorManagementList as MonitorManagementListState } from '../../../state/reducers/monitor_management'; -import { MonitorFields } from '../../../../common/runtime_types'; +import { MonitorFields, SyntheticsMonitor } from '../../../../common/runtime_types'; import { UptimeSettingsContext } from '../../../contexts'; import { Actions } from './actions'; import { MonitorLocations } from './monitor_locations'; import { MonitorTags } from './tags'; +import { MonitorEnabled } from './monitor_enabled'; import * as labels from '../../overview/monitor_list/translations'; interface Props { @@ -32,7 +34,8 @@ export const MonitorManagementList = ({ setPageSize, setPageIndex, }: Props) => { - const { monitors, total, perPage, page: pageIndex } = list as MonitorManagementListState['list']; + const { total, perPage, page: pageIndex } = list as MonitorManagementListState['list']; + const monitors = list.monitors as SyntheticsMonitorSavedObject[]; const { basePath } = useContext(UptimeSettingsContext); const pagination = useMemo( @@ -84,7 +87,7 @@ export const MonitorManagementList = ({ name: i18n.translate('xpack.uptime.monitorManagement.monitorList.monitorType', { defaultMessage: 'Monitor type', }), - render: ({ type }: Partial<MonitorFields>) => type, + render: ({ type }: SyntheticsMonitor) => type, }, { align: 'left' as const, @@ -92,7 +95,7 @@ export const MonitorManagementList = ({ name: i18n.translate('xpack.uptime.monitorManagement.monitorList.tags', { defaultMessage: 'Tags', }), - render: ({ tags }: Partial<MonitorFields>) => (tags ? <MonitorTags tags={tags} /> : null), + render: ({ tags }: SyntheticsMonitor) => (tags ? <MonitorTags tags={tags} /> : null), }, { align: 'left' as const, @@ -100,7 +103,7 @@ export const MonitorManagementList = ({ name: i18n.translate('xpack.uptime.monitorManagement.monitorList.locations', { defaultMessage: 'Locations', }), - render: ({ locations }: Partial<MonitorFields>) => + render: ({ locations }: SyntheticsMonitor) => locations ? <MonitorLocations locations={locations} /> : null, }, { @@ -109,8 +112,7 @@ export const MonitorManagementList = ({ name: i18n.translate('xpack.uptime.monitorManagement.monitorList.schedule', { defaultMessage: 'Schedule', }), - render: ({ schedule }: Partial<MonitorFields>) => - `@every ${schedule?.number}${schedule?.unit}`, + render: ({ schedule }: SyntheticsMonitor) => `@every ${schedule?.number}${schedule?.unit}`, }, { align: 'left' as const, @@ -118,9 +120,19 @@ export const MonitorManagementList = ({ name: i18n.translate('xpack.uptime.monitorManagement.monitorList.URL', { defaultMessage: 'URL', }), - render: (attributes: Partial<MonitorFields>) => attributes.urls || attributes.hosts, + render: (attributes: MonitorFields) => attributes.urls || attributes.hosts, truncateText: true, }, + { + align: 'left' as const, + field: 'attributes', + name: i18n.translate('xpack.uptime.monitorManagement.monitorList.enabled', { + defaultMessage: 'Enabled', + }), + render: (attributes: SyntheticsMonitor, record: SyntheticsMonitorSavedObject) => ( + <MonitorEnabled id={record.id} monitor={attributes} setRefresh={setRefresh} /> + ), + }, { align: 'left' as const, field: 'id', diff --git a/x-pack/plugins/uptime/public/pages/monitor_management/monitor_management.tsx b/x-pack/plugins/uptime/public/pages/monitor_management/monitor_management.tsx index a272583a2e7b7..0619f4d4bed1c 100644 --- a/x-pack/plugins/uptime/public/pages/monitor_management/monitor_management.tsx +++ b/x-pack/plugins/uptime/public/pages/monitor_management/monitor_management.tsx @@ -24,7 +24,7 @@ export const MonitorManagementPage: React.FC = () => { useEffect(() => { if (refresh) { dispatch(getMonitors({ page: pageIndex, perPage: pageSize })); - setRefresh(false); + setRefresh(false); // TODO: avoid extra re-rendering when `refresh` turn to false (pass down the handler instead) } }, [dispatch, refresh, pageIndex, pageSize]); diff --git a/x-pack/plugins/uptime/public/state/api/monitor_management.ts b/x-pack/plugins/uptime/public/state/api/monitor_management.ts index 33c04c060588d..5f18869257386 100644 --- a/x-pack/plugins/uptime/public/state/api/monitor_management.ts +++ b/x-pack/plugins/uptime/public/state/api/monitor_management.ts @@ -17,14 +17,13 @@ import { import { SyntheticsMonitorSavedObject } from '../../../common/types'; import { apiService } from './utils'; -// TODO: Type the return type from runtime types export const setMonitor = async ({ monitor, id, }: { monitor: SyntheticsMonitor; id?: string; -}): Promise<void> => { +}): Promise<SyntheticsMonitorSavedObject> => { if (id) { return await apiService.put(`${API_URLS.SYNTHETICS_MONITORS}/${id}`, monitor); } else { @@ -32,7 +31,6 @@ export const setMonitor = async ({ } }; -// TODO, change to monitor runtime type export const getMonitor = async ({ id }: { id: string }): Promise<SyntheticsMonitorSavedObject> => { return await apiService.get(`${API_URLS.SYNTHETICS_MONITORS}/${id}`); }; From e938f4cb12d6bd8b4ecb8edd9d95b34ec34bdb34 Mon Sep 17 00:00:00 2001 From: Matthias Wilhelm <matthias.wilhelm@elastic.co> Date: Mon, 10 Jan 2022 11:16:43 +0100 Subject: [PATCH 24/25] [Discover] Unskip flaky histogram time range test (#122324) --- test/functional/apps/discover/_discover.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/test/functional/apps/discover/_discover.ts b/test/functional/apps/discover/_discover.ts index 7becb217c877a..03a8dbcffa2d9 100644 --- a/test/functional/apps/discover/_discover.ts +++ b/test/functional/apps/discover/_discover.ts @@ -38,8 +38,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { after(async () => { await kibanaServer.savedObjects.clean({ types: ['search', 'index-pattern'] }); }); - // FLAKY: https://github.com/elastic/kibana/issues/86602 - describe.skip('query', function () { + describe('query', function () { const queryName1 = 'Query # 1'; it('should show correct time range string by timepicker', async function () { @@ -121,8 +120,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); return actualCount === expectedCount; }); - const prevRowData = await PageObjects.discover.getDocTableField(1); - log.debug(`The first timestamp value in doc table before brushing: ${prevRowData}`); + let prevRowData = ''; + // to make sure the table is already rendered + await retry.try(async () => { + prevRowData = await PageObjects.discover.getDocTableField(1); + log.debug(`The first timestamp value in doc table before brushing: ${prevRowData}`); + }); + await PageObjects.discover.brushHistogram(); await PageObjects.discover.waitUntilSearchingHasFinished(); await retry.waitFor('chart rendering complete after being brushed', async () => { From 3cca7f06305e2a322f1b92adabed2c7338b88631 Mon Sep 17 00:00:00 2001 From: Esteban Beltran <academo@users.noreply.github.com> Date: Mon, 10 Jan 2022 11:18:04 +0100 Subject: [PATCH 25/25] [Security Solution] Remove feature flag trustedAppsByPolicyEnabled (#122107) --- .../common/experimental_features.ts | 1 - .../endpoint_policy_edit_extension.tsx | 111 ++++++++---------- .../pages/policy/view/policy_details.tsx | 17 +-- .../list/policy_trusted_apps_list.test.tsx | 1 - .../components/create_trusted_app_flyout.tsx | 22 +--- .../components/create_trusted_app_form.tsx | 9 +- .../view/trusted_apps_page.test.tsx | 25 ---- .../trusted_apps/view/trusted_apps_page.tsx | 6 +- .../security_solution/server/config.ts | 2 +- .../test/security_solution_endpoint/config.ts | 2 - 10 files changed, 60 insertions(+), 136 deletions(-) diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 67488ba1612bf..4f43e9b61faf9 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -16,7 +16,6 @@ export const allowedExperimentalValues = Object.freeze({ ruleRegistryEnabled: true, tGridEnabled: true, tGridEventRenderedViewEnabled: true, - trustedAppsByPolicyEnabled: true, excludePoliciesInFilterEnabled: false, uebaEnabled: false, disableIsolationUIPendingStatuses: false, diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_policy_edit_extension.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_policy_edit_extension.tsx index e4f2e6e70c02b..4f49ff91b5a8d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_policy_edit_extension.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_policy_edit_extension.tsx @@ -15,7 +15,6 @@ import { NewPackagePolicy, pagePathGetters, } from '../../../../../../../fleet/public'; -import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; import { INTEGRATIONS_PLUGIN_ID } from '../../../../../../../fleet/common'; import { useAppUrl } from '../../../../../common/lib/kibana/hooks'; import { PolicyDetailsRouteState } from '../../../../../../common/endpoint/types'; @@ -58,10 +57,6 @@ const WrappedPolicyDetailsForm = memo<{ const endpointDetailsLoadingError = usePolicyDetailsSelector(apiError); const { getAppUrl } = useAppUrl(); const [, setLastUpdatedPolicy] = useState(updatedPolicy); - // TODO: Remove this and related code when removing FF - const isTrustedAppsByPolicyEnabled = useIsExperimentalFeatureEnabled( - 'trustedAppsByPolicyEnabled' - ); // When the form is initially displayed, trigger the Redux middleware which is based on // the location information stored via the `userChangedUrl` action. @@ -164,63 +159,59 @@ const WrappedPolicyDetailsForm = memo<{ return ( <div data-test-subj="endpointIntegrationPolicyForm"> - {isTrustedAppsByPolicyEnabled ? ( - <> - <div> - <EuiText> - <h5> - <FormattedMessage - id="xpack.securitySolution.endpoint.policyDetails.artifacts.title" - defaultMessage="Artifacts" - /> - </h5> - </EuiText> - <EuiSpacer size="s" /> - <FleetTrustedAppsCard - policyId={policyId} - cardSize="m" - customLink={policyTrustedAppsLink} - /> - <EuiSpacer size="s" /> - <FleetIntegrationEventFiltersCard policyId={policyId} /> - <EuiSpacer size="s" /> - <FleetIntegrationHostIsolationExceptionsCard policyId={policyId} /> - </div> - <EuiSpacer size="l" /> - <div> - <EuiText> - <h5> + <> + <div> + <EuiText> + <h5> + <FormattedMessage + id="xpack.securitySolution.endpoint.policyDetails.artifacts.title" + defaultMessage="Artifacts" + /> + </h5> + </EuiText> + <EuiSpacer size="s" /> + <FleetTrustedAppsCard + policyId={policyId} + cardSize="m" + customLink={policyTrustedAppsLink} + /> + <EuiSpacer size="s" /> + <FleetIntegrationEventFiltersCard policyId={policyId} /> + <EuiSpacer size="s" /> + <FleetIntegrationHostIsolationExceptionsCard policyId={policyId} /> + </div> + <EuiSpacer size="l" /> + <div> + <EuiText> + <h5> + <FormattedMessage + id="xpack.securitySolution.endpoint.policyDetails.settings.title" + defaultMessage="Policy settings" + /> + </h5> + </EuiText> + <EuiSpacer size="s" /> + {endpointDetailsLoadingError ? ( + <EuiCallOut + title={ <FormattedMessage - id="xpack.securitySolution.endpoint.policyDetails.settings.title" - defaultMessage="Policy settings" + id="xpack.securitySolution.endpoint.policyDetails.loadError" + defaultMessage="Failed to load endpoint policy settings" /> - </h5> - </EuiText> - <EuiSpacer size="s" /> - {endpointDetailsLoadingError ? ( - <EuiCallOut - title={ - <FormattedMessage - id="xpack.securitySolution.endpoint.policyDetails.loadError" - defaultMessage="Failed to load endpoint policy settings" - /> - } - iconType="alert" - color="warning" - data-test-subj="endpiontPolicySettingsLoadingError" - > - {endpointDetailsLoadingError.message} - </EuiCallOut> - ) : !endpointPolicyDetails ? ( - <EuiLoadingSpinner size="l" className="essentialAnimation" /> - ) : ( - <PolicyDetailsForm /> - )} - </div> - </> - ) : ( - <PolicyDetailsForm /> - )} + } + iconType="alert" + color="warning" + data-test-subj="endpiontPolicySettingsLoadingError" + > + {endpointDetailsLoadingError.message} + </EuiCallOut> + ) : !endpointPolicyDetails ? ( + <EuiLoadingSpinner size="l" className="essentialAnimation" /> + ) : ( + <PolicyDetailsForm /> + )} + </div> + </> </div> ); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx index b9dd3419062df..df660bedd3ab9 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx @@ -12,10 +12,8 @@ import { EuiCallOut, EuiLoadingSpinner, EuiPageTemplate } from '@elastic/eui'; import { usePolicyDetailsSelector } from './policy_hooks'; import { policyDetails, agentStatusSummary, apiError } from '../store/policy_details/selectors'; import { AgentsSummary } from './agents_summary'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { PolicyTabs } from './tabs'; import { AdministrationListPage } from '../../../components/administration_list_page'; -import { PolicyFormLayout } from './policy_forms/components'; import { BackToExternalAppButton, BackToExternalAppButtonProps, @@ -26,10 +24,6 @@ import { useAppUrl } from '../../../../common/lib/kibana'; import { APP_UI_ID } from '../../../../../common/constants'; export const PolicyDetails = React.memo(() => { - // TODO: Remove this and related code when removing FF - const isTrustedAppsByPolicyEnabled = useIsExperimentalFeatureEnabled( - 'trustedAppsByPolicyEnabled' - ); const { state: routeState = {} } = useLocation<PolicyDetailsRouteState>(); const { getAppUrl } = useAppUrl(); @@ -106,13 +100,8 @@ export const PolicyDetails = React.memo(() => { ); } - // TODO: Remove this and related code when removing FF - if (isTrustedAppsByPolicyEnabled) { - return <PolicyTabs />; - } - - return <PolicyFormLayout />; - }, [isTrustedAppsByPolicyEnabled, policyApiError, policyItem]); + return <PolicyTabs />; + }, [policyApiError, policyItem]); return ( <AdministrationListPage @@ -122,7 +111,7 @@ export const PolicyDetails = React.memo(() => { headerBackComponent={backToEndpointList} actions={policyApiError ? undefined : headerRightContent} restrictWidth={true} - hasBottomBorder={!isTrustedAppsByPolicyEnabled} // TODO: Remove this and related code when removing FF + hasBottomBorder={false} > {pageBody} </AdministrationListPage> diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx index 32568ec2b48ee..67b435f4873a1 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx @@ -86,7 +86,6 @@ describe('when rendering the PolicyTrustedAppsList', () => { }); mockedApis = policyDetailsPageAllApiHttpMocks(appTestContext.coreStart.http); - appTestContext.setExperimentalFlag({ trustedAppsByPolicyEnabled: true }); waitForAction = appTestContext.middlewareSpy.waitForAction; componentRenderProps = {}; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_flyout.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_flyout.tsx index d7404961fd71d..f76ac89474e7b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_flyout.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_flyout.tsx @@ -51,7 +51,6 @@ import { useTestIdGenerator } from '../../../../components/hooks/use_test_id_gen import { useLicense } from '../../../../../common/hooks/use_license'; import { isGlobalEffectScope } from '../../state/type_guards'; import { NewTrustedApp } from '../../../../../../common/endpoint/types'; -import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; export type CreateTrustedAppFlyoutProps = Omit<EuiFlyoutProps, 'hideCloseButton'>; export const CreateTrustedAppFlyout = memo<CreateTrustedAppFlyoutProps>( @@ -116,10 +115,6 @@ export const CreateTrustedAppFlyout = memo<CreateTrustedAppFlyoutProps>( [dispatch, formValues] ); - const isTrustedAppsByPolicyEnabled = useIsExperimentalFeatureEnabled( - 'trustedAppsByPolicyEnabled' - ); - const [wasByPolicy, setWasByPolicy] = useState(!isGlobalEffectScope(formValues.effectScope)); // set initial state of `wasByPolicy` that checks if the initial state of the exception was by policy or not useEffect(() => { @@ -133,21 +128,8 @@ export const CreateTrustedAppFlyout = memo<CreateTrustedAppFlyoutProps>( }, [formValues]); const showExpiredLicenseBanner = useMemo(() => { - return ( - isTrustedAppsByPolicyEnabled && - !isPlatinumPlus && - isEditMode && - wasByPolicy && - (!isGlobal || isFormDirty) - ); - }, [ - isTrustedAppsByPolicyEnabled, - isPlatinumPlus, - isEditMode, - isGlobal, - isFormDirty, - wasByPolicy, - ]); + return !isPlatinumPlus && isEditMode && wasByPolicy && (!isGlobal || isFormDirty); + }, [isPlatinumPlus, isEditMode, isGlobal, isFormDirty, wasByPolicy]); // If there was a failure trying to retrieve the Trusted App for edit item, // then redirect back to the list ++ show toast message. diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx index 5f9b51d52e485..d9b1cc6624042 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx @@ -33,7 +33,6 @@ import { hasSimpleExecutableName, } from '../../../../../../common/endpoint/service/trusted_apps/validations'; -import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; import { isGlobalEffectScope, isMacosLinuxTrustedAppCondition, @@ -238,10 +237,6 @@ export const CreateTrustedAppForm = memo<CreateTrustedAppFormProps>( const dataTestSubj = formProps['data-test-subj']; - const isTrustedAppsByPolicyEnabled = useIsExperimentalFeatureEnabled( - 'trustedAppsByPolicyEnabled' - ); - const isPlatinumPlus = useLicense().isPlatinumPlus(); const isGlobal = useMemo(() => { @@ -519,7 +514,7 @@ export const CreateTrustedAppForm = memo<CreateTrustedAppFormProps>( value={trustedApp.description} onChange={handleDomChangeEvents} fullWidth - compressed={isTrustedAppsByPolicyEnabled} + compressed maxLength={256} data-test-subj={getTestId('descriptionField')} /> @@ -577,7 +572,7 @@ export const CreateTrustedAppForm = memo<CreateTrustedAppFormProps>( data-test-subj={getTestId('conditionsBuilder')} /> </EuiFormRow> - {isTrustedAppsByPolicyEnabled && showAssignmentSection ? ( + {showAssignmentSection ? ( <> <EuiHorizontalRule /> <EuiFormRow fullWidth={fullWidth} data-test-subj={getTestId('policySelection')}> diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx index 01330eb0e246c..ab0bbaa875a39 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx @@ -20,16 +20,11 @@ import { HttpFetchOptions, HttpFetchOptionsWithPath } from 'kibana/public'; import { isFailedResourceState, isLoadedResourceState } from '../state'; import { forceHTMLElementOffsetWidth } from '../../../components/effected_policy_select/test_utils'; import { toUpdateTrustedApp } from '../../../../../common/endpoint/service/trusted_apps/to_update_trusted_app'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { licenseService } from '../../../../common/hooks/use_license'; import { FoundExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import { EXCEPTION_LIST_ITEM_URL } from '@kbn/securitysolution-list-constants'; import { trustedAppsAllHttpMocks } from '../../mocks'; -// TODO: remove this mock when feature flag is removed -jest.mock('../../../../common/hooks/use_experimental_features'); -const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock; - jest.mock('../../../../common/hooks/use_license', () => { const licenseServiceInstance = { isPlatinumPlus: jest.fn(), @@ -173,7 +168,6 @@ describe('When on the Trusted Apps Page', () => { describe('the license is downgraded to gold or below and the user is editing a per policy TA', () => { beforeEach(async () => { (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); - useIsExperimentalFeatureEnabledMock.mockReturnValue(true); const originalFakeTrustedAppProvider = getFakeTrustedApp.getMockImplementation(); getFakeTrustedApp.mockImplementation(() => { @@ -198,7 +192,6 @@ describe('When on the Trusted Apps Page', () => { describe('the license is downgraded to gold or below and the user is adding a new TA', () => { beforeEach(async () => { (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); - useIsExperimentalFeatureEnabledMock.mockReturnValue(true); const originalFakeTrustedAppProvider = getFakeTrustedApp.getMockImplementation(); getFakeTrustedApp.mockImplementation(() => { @@ -467,7 +460,6 @@ describe('When on the Trusted Apps Page', () => { }); it('should have list of policies populated', async () => { - useIsExperimentalFeatureEnabledMock.mockReturnValue(true); const resetEnv = forceHTMLElementOffsetWidth(); const renderResult = await renderAndClickAddButton(); act(() => { @@ -700,23 +692,6 @@ describe('When on the Trusted Apps Page', () => { expect(flyoutAddButton.disabled).toBe(true); }); }); - - describe('and there is a feature flag for agents policy', () => { - it('should hide agents policy if feature flag is disabled', async () => { - useIsExperimentalFeatureEnabledMock.mockReturnValue(false); - const renderResult = await renderAndClickAddButton(); - expect( - renderResult.queryByTestId('addTrustedAppFlyout-createForm-policySelection') - ).toBeNull(); - }); - it('should display agents policy if feature flag is enabled', async () => { - useIsExperimentalFeatureEnabledMock.mockReturnValue(true); - const renderResult = await renderAndClickAddButton(); - expect( - renderResult.queryByTestId('addTrustedAppFlyout-createForm-policySelection') - ).toBeTruthy(); - }); - }); }); describe('and there are no trusted apps', () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx index 63cb52785ea96..71076d84c13b1 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx @@ -33,13 +33,9 @@ import { EmptyState } from './components/empty_state'; import { SearchExceptions } from '../../../components/search_exceptions'; import { BackToExternalAppButton } from '../../../components/back_to_external_app_button'; import { ListPageRouteState } from '../../../../../common/endpoint/types'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { ManagementPageLoader } from '../../../components/management_page_loader'; export const TrustedAppsPage = memo(() => { - const isTrustedAppsByPolicyEnabled = useIsExperimentalFeatureEnabled( - 'trustedAppsByPolicyEnabled' - ); const dispatch = useDispatch<Dispatch<AppAction>>(); const { state: routeState } = useLocation<ListPageRouteState | undefined>(); const location = useTrustedAppsSelector(getCurrentLocation); @@ -120,7 +116,7 @@ export const TrustedAppsPage = memo(() => { defaultValue={location.filter} onSearch={handleOnSearch} placeholder={SEARCH_TRUSTED_APP_PLACEHOLDER} - hasPolicyFilter={isTrustedAppsByPolicyEnabled} + hasPolicyFilter={true} policyList={policyList} defaultExcludedPolicies={location.excluded_policies} defaultIncludedPolicies={location.included_policies} diff --git a/x-pack/plugins/security_solution/server/config.ts b/x-pack/plugins/security_solution/server/config.ts index ba1c547a1bbce..b76edf3b50800 100644 --- a/x-pack/plugins/security_solution/server/config.ts +++ b/x-pack/plugins/security_solution/server/config.ts @@ -90,7 +90,7 @@ export const configSchema = schema.object({ * @example * xpack.securitySolution.enableExperimental: * - someCrazyFeature - * - trustedAppsByPolicyEnabled + * - someEvenCrazierFeature */ enableExperimental: schema.arrayOf(schema.string(), { defaultValue: () => [], diff --git a/x-pack/test/security_solution_endpoint/config.ts b/x-pack/test/security_solution_endpoint/config.ts index 2bfb231887ac2..b00df7732ea4f 100644 --- a/x-pack/test/security_solution_endpoint/config.ts +++ b/x-pack/test/security_solution_endpoint/config.ts @@ -44,8 +44,6 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { // always install Endpoint package by default when Fleet sets up `--xpack.fleet.packages.0.name=endpoint`, `--xpack.fleet.packages.0.version=latest`, - // TODO: Remove feature flags once we're good to go - '--xpack.securitySolution.enableExperimental=["trustedAppsByPolicyEnabled"]', ], }, layout: {