= ({ breadcrumbs }) => {
const { query } = useParams() as { query: string };
- const { totalQueriesForQuery, queriesPerDayForQuery, startDate } = useValues(AnalyticsLogic);
+ const { totalQueriesForQuery, queriesPerDayForQuery, startDate, topClicksForQuery } = useValues(
+ AnalyticsLogic
+ );
return (
@@ -63,7 +66,18 @@ export const QueryDetail: React.FC = ({ breadcrumbs }) => {
/>
- TODO: Query detail page
+
+
+
);
};
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.test.tsx
index f25b044e8a56fc..efd2de9223c980 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.test.tsx
@@ -4,15 +4,19 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { setMockValues } from '../../../../__mocks__';
+
import React from 'react';
import { shallow } from 'enzyme';
+import { RecentQueriesTable } from '../components';
import { RecentQueries } from './';
describe('RecentQueries', () => {
it('renders', () => {
+ setMockValues({ recentQueries: [] });
const wrapper = shallow();
- expect(wrapper.isEmptyRender()).toBe(false); // TODO
+ expect(wrapper.find(RecentQueriesTable)).toHaveLength(1);
});
});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.tsx
index 3510a2a0e82210..708863ba0e5c89 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.tsx
@@ -5,14 +5,20 @@
*/
import React from 'react';
+import { useValues } from 'kea';
import { RECENT_QUERIES } from '../constants';
import { AnalyticsLayout } from '../analytics_layout';
+import { AnalyticsSearch, RecentQueriesTable } from '../components';
+import { AnalyticsLogic } from '../';
export const RecentQueries: React.FC = () => {
+ const { recentQueries } = useValues(AnalyticsLogic);
+
return (
- TODO: Recent queries
+
+
);
};
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.test.tsx
index 9747609aaf0664..754a349c2fe944 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.test.tsx
@@ -4,15 +4,19 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { setMockValues } from '../../../../__mocks__';
+
import React from 'react';
import { shallow } from 'enzyme';
+import { AnalyticsTable } from '../components';
import { TopQueries } from './';
describe('TopQueries', () => {
it('renders', () => {
+ setMockValues({ topQueries: [] });
const wrapper = shallow();
- expect(wrapper.isEmptyRender()).toBe(false); // TODO
+ expect(wrapper.find(AnalyticsTable)).toHaveLength(1);
});
});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.tsx
index 3f2867871765ca..0814ba16e39dca 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.tsx
@@ -5,14 +5,20 @@
*/
import React from 'react';
+import { useValues } from 'kea';
import { TOP_QUERIES } from '../constants';
import { AnalyticsLayout } from '../analytics_layout';
+import { AnalyticsSearch, AnalyticsTable } from '../components';
+import { AnalyticsLogic } from '../';
export const TopQueries: React.FC = () => {
+ const { topQueries } = useValues(AnalyticsLogic);
+
return (
- TODO: Top queries
+
+
);
};
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.test.tsx
index bc55753acf1524..f1eb3a2f69a98e 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.test.tsx
@@ -4,15 +4,19 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { setMockValues } from '../../../../__mocks__';
+
import React from 'react';
import { shallow } from 'enzyme';
+import { AnalyticsTable } from '../components';
import { TopQueriesNoClicks } from './';
describe('TopQueriesNoClicks', () => {
it('renders', () => {
+ setMockValues({ topQueriesNoClicks: [] });
const wrapper = shallow();
- expect(wrapper.isEmptyRender()).toBe(false); // TODO
+ expect(wrapper.find(AnalyticsTable)).toHaveLength(1);
});
});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.tsx
index dc14c4a83bff30..283a790b615719 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.tsx
@@ -5,14 +5,20 @@
*/
import React from 'react';
+import { useValues } from 'kea';
import { TOP_QUERIES_NO_CLICKS } from '../constants';
import { AnalyticsLayout } from '../analytics_layout';
+import { AnalyticsSearch, AnalyticsTable } from '../components';
+import { AnalyticsLogic } from '../';
export const TopQueriesNoClicks: React.FC = () => {
+ const { topQueriesNoClicks } = useValues(AnalyticsLogic);
+
return (
- TODO: Top queries with no clicks
+
+
);
};
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.test.tsx
index 72c718f3747141..8e404e34b5f3e4 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.test.tsx
@@ -4,15 +4,19 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { setMockValues } from '../../../../__mocks__';
+
import React from 'react';
import { shallow } from 'enzyme';
+import { AnalyticsTable } from '../components';
import { TopQueriesNoResults } from './';
describe('TopQueriesNoResults', () => {
it('renders', () => {
+ setMockValues({ topQueriesNoResults: [] });
const wrapper = shallow();
- expect(wrapper.isEmptyRender()).toBe(false); // TODO
+ expect(wrapper.find(AnalyticsTable)).toHaveLength(1);
});
});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.tsx
index da8595b43859f3..8a54d529b2dd00 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.tsx
@@ -5,14 +5,20 @@
*/
import React from 'react';
+import { useValues } from 'kea';
import { TOP_QUERIES_NO_RESULTS } from '../constants';
import { AnalyticsLayout } from '../analytics_layout';
+import { AnalyticsSearch, AnalyticsTable } from '../components';
+import { AnalyticsLogic } from '../';
export const TopQueriesNoResults: React.FC = () => {
+ const { topQueriesNoResults } = useValues(AnalyticsLogic);
+
return (
- TODO: Top queries with no results
+
+
);
};
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.test.tsx
index 74e31e77974ee1..714da0d8e45dd0 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.test.tsx
@@ -4,15 +4,19 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { setMockValues } from '../../../../__mocks__';
+
import React from 'react';
import { shallow } from 'enzyme';
+import { AnalyticsTable } from '../components';
import { TopQueriesWithClicks } from './';
describe('TopQueriesWithClicks', () => {
it('renders', () => {
+ setMockValues({ topQueriesWithClicks: [] });
const wrapper = shallow();
- expect(wrapper.isEmptyRender()).toBe(false); // TODO
+ expect(wrapper.find(AnalyticsTable)).toHaveLength(1);
});
});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.tsx
index dc6e837be61d8f..73ad9e2e973d82 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.tsx
@@ -5,14 +5,20 @@
*/
import React from 'react';
+import { useValues } from 'kea';
import { TOP_QUERIES_WITH_CLICKS } from '../constants';
import { AnalyticsLayout } from '../analytics_layout';
+import { AnalyticsSearch, AnalyticsTable } from '../components';
+import { AnalyticsLogic } from '../';
export const TopQueriesWithClicks: React.FC = () => {
+ const { topQueriesWithClicks } = useValues(AnalyticsLogic);
+
return (
- TODO: Top queries with clicks
+
+
);
};
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts
index f5f534807fabfc..2ce7eed2368402 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts
@@ -21,6 +21,7 @@ interface AppValues extends WorkplaceSearchInitialData {
interface AppActions {
initializeAppData(props: InitialAppData): InitialAppData;
setContext(isOrganization: boolean): boolean;
+ setSourceRestriction(canCreatePersonalSources: boolean): boolean;
}
const emptyOrg = {} as Organization;
@@ -34,6 +35,7 @@ export const AppLogic = kea>({
isFederatedAuth,
}),
setContext: (isOrganization) => isOrganization,
+ setSourceRestriction: (canCreatePersonalSources: boolean) => canCreatePersonalSources,
},
reducers: {
hasInitialized: [
@@ -64,6 +66,10 @@ export const AppLogic = kea>({
emptyAccount,
{
initializeAppData: (_, { workplaceSearch }) => workplaceSearch?.account || emptyAccount,
+ setSourceRestriction: (state, canCreatePersonalSources) => ({
+ ...state,
+ canCreatePersonalSources,
+ }),
},
],
},
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx
index 8a83e9aad5fd9b..7357e84f27a417 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx
@@ -45,9 +45,7 @@ export const WorkplaceSearchNav: React.FC = ({
{NAV.ROLE_MAPPINGS}
-
- {NAV.SECURITY}
-
+ {NAV.SECURITY}
{NAV.SETTINGS}
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts
index e72e28aa47d9b8..17fbbf517f3473 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts
@@ -289,6 +289,87 @@ export const DOCUMENTATION_LINK_TITLE = i18n.translate(
}
);
+export const PRIVATE_SOURCES_DESCRIPTION = i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.security.privateSources.description',
+ {
+ defaultMessage:
+ 'Private sources are connected by users in your organization to create a personalized search experience.',
+ }
+);
+
+export const PRIVATE_SOURCES_TOGGLE_DESCRIPTION = i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.security.privateSourcesToggle.description',
+ {
+ defaultMessage: 'Enable private sources for your organization',
+ }
+);
+
+export const REMOTE_SOURCES_TOGGLE_TEXT = i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.security.remoteSourcesToggle.text',
+ {
+ defaultMessage: 'Enable remote private sources',
+ }
+);
+
+export const REMOTE_SOURCES_TABLE_DESCRIPTION = i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.security.remoteSourcesTable.description',
+ {
+ defaultMessage:
+ 'Remote sources synchronize and store a limited amount of data on disk, with a low impact on storage resources.',
+ }
+);
+
+export const REMOTE_SOURCES_EMPTY_TABLE_TITLE = i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.security.remoteSourcesEmptyTable.title',
+ {
+ defaultMessage: 'No remote private sources configured yet',
+ }
+);
+
+export const STANDARD_SOURCES_TOGGLE_TEXT = i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.security.standardSourcesToggle.text',
+ {
+ defaultMessage: 'Enable standard private sources',
+ }
+);
+
+export const STANDARD_SOURCES_TABLE_DESCRIPTION = i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.security.standardSourcesTable.description',
+ {
+ defaultMessage:
+ 'Standard sources synchronize and store all searchable data on disk, with a directly correlated impact on storage resources.',
+ }
+);
+
+export const STANDARD_SOURCES_EMPTY_TABLE_TITLE = i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.security.standardSourcesEmptyTable.title',
+ {
+ defaultMessage: 'No standard private sources configured yet',
+ }
+);
+
+export const SECURITY_UNSAVED_CHANGES_MESSAGE = i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.security.unsavedChanges.message',
+ {
+ defaultMessage:
+ 'Your private sources settings have not been saved. Are you sure you want to leave?',
+ }
+);
+
+export const PRIVATE_SOURCES_UPDATE_CONFIRMATION_TEXT = i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.security.privateSourcesUpdateConfirmation.text',
+ {
+ defaultMessage: 'Updates to private source configuration will take effect immediately.',
+ }
+);
+
+export const SOURCE_RESTRICTIONS_SUCCESS_MESSAGE = i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.security.sourceRestrictionsSuccess.message',
+ {
+ defaultMessage: 'Successfully updated source restrictions.',
+ }
+);
+
export const PUBLIC_KEY_LABEL = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.publicKey.label',
{
@@ -382,6 +463,20 @@ export const SAVE_CHANGES_BUTTON = i18n.translate(
}
);
+export const SAVE_SETTINGS_BUTTON = i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.saveSettings.button',
+ {
+ defaultMessage: 'Save settings',
+ }
+);
+
+export const KEEP_EDITING_BUTTON = i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.keepEditing.button',
+ {
+ defaultMessage: 'Keep editing',
+ }
+);
+
export const NAME_LABEL = i18n.translate('xpack.enterpriseSearch.workplaceSearch.name.label', {
defaultMessage: 'Name',
});
@@ -493,6 +588,10 @@ export const UPDATE_BUTTON = i18n.translate(
}
);
+export const RESET_BUTTON = i18n.translate('xpack.enterpriseSearch.workplaceSearch.reset.button', {
+ defaultMessage: 'Reset',
+});
+
export const CONFIGURE_BUTTON = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.configure.button',
{
@@ -522,6 +621,10 @@ export const PRIVATE_PLATINUM_LICENSE_CALLOUT = i18n.translate(
}
);
+export const SOURCE = i18n.translate('xpack.enterpriseSearch.workplaceSearch.source.text', {
+ defaultMessage: 'Source',
+});
+
export const PRIVATE_SOURCE = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.privateSource.text',
{
@@ -529,6 +632,20 @@ export const PRIVATE_SOURCE = i18n.translate(
}
);
+export const PRIVATE_SOURCES = i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.privateSources.text',
+ {
+ defaultMessage: 'Private Sources',
+ }
+);
+
+export const CONFIRM_CHANGES_TEXT = i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.confirmChanges.text',
+ {
+ defaultMessage: 'Confirm changes',
+ }
+);
+
export const CONNECTORS_HEADER_TITLE = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.connectors.header.title',
{
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx
index d10de7a7701711..ec1b8cfcba958f 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx
@@ -22,6 +22,7 @@ import {
SOURCES_PATH,
PERSONAL_SOURCES_PATH,
ORG_SETTINGS_PATH,
+ SECURITY_PATH,
} from './routes';
import { SetupGuide } from './views/setup_guide';
@@ -29,6 +30,7 @@ import { ErrorState } from './views/error_state';
import { NotFound } from '../shared/not_found';
import { Overview } from './views/overview';
import { GroupsRouter } from './views/groups';
+import { Security } from './views/security';
import { SourcesRouter } from './views/content_sources';
import { SettingsRouter } from './views/settings';
@@ -102,6 +104,11 @@ export const WorkplaceSearchConfigured: React.FC = (props) => {
+
+ } restrictWidth readOnlyMode={readOnlyMode}>
+
+
+
} />}
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.test.tsx
new file mode 100644
index 00000000000000..4db5c60d5800d4
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.test.tsx
@@ -0,0 +1,54 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { setMockValues } from '../../../../__mocks__';
+
+import React from 'react';
+import { shallow } from 'enzyme';
+import { EuiSwitch } from '@elastic/eui';
+
+import { PrivateSourcesTable } from './private_sources_table';
+
+describe('PrivateSourcesTable', () => {
+ beforeEach(() => {
+ setMockValues({ hasPlatinumLicense: true, isEnabled: true });
+ });
+
+ const props = {
+ sourceSection: { isEnabled: true, contentSources: [] },
+ updateSource: jest.fn(),
+ updateEnabled: jest.fn(),
+ };
+
+ it('renders', () => {
+ const wrapper = shallow();
+
+ expect(wrapper.find(EuiSwitch)).toHaveLength(1);
+ });
+
+ it('handles switches clicks', () => {
+ const wrapper = shallow(
+
+ );
+
+ const sectionSwitch = wrapper.find(EuiSwitch).first();
+ const sourceSwitch = wrapper.find(EuiSwitch).last();
+
+ const event = { target: { value: true } };
+ sectionSwitch.prop('onChange')(event as any);
+ sourceSwitch.prop('onChange')(event as any);
+
+ expect(props.updateEnabled).toHaveBeenCalled();
+ expect(props.updateSource).toHaveBeenCalled();
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.tsx
new file mode 100644
index 00000000000000..c767dfaba86f94
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.tsx
@@ -0,0 +1,182 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+
+import classNames from 'classnames';
+import { useValues } from 'kea';
+
+import {
+ EuiPanel,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiSwitch,
+ EuiText,
+ EuiTable,
+ EuiTableBody,
+ EuiTableHeader,
+ EuiTableHeaderCell,
+ EuiTableRow,
+ EuiTableRowCell,
+ EuiSpacer,
+} from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { i18n } from '@kbn/i18n';
+
+import { LicensingLogic } from '../../../../shared/licensing';
+import { SecurityLogic, PrivateSourceSection } from '../security_logic';
+import {
+ REMOTE_SOURCES_TOGGLE_TEXT,
+ REMOTE_SOURCES_TABLE_DESCRIPTION,
+ REMOTE_SOURCES_EMPTY_TABLE_TITLE,
+ STANDARD_SOURCES_TOGGLE_TEXT,
+ STANDARD_SOURCES_TABLE_DESCRIPTION,
+ STANDARD_SOURCES_EMPTY_TABLE_TITLE,
+ SOURCE,
+} from '../../../constants';
+
+interface PrivateSourcesTableProps {
+ sourceType: 'remote' | 'standard';
+ sourceSection: PrivateSourceSection;
+ updateSource(sourceId: string, isEnabled: boolean): void;
+ updateEnabled(isEnabled: boolean): void;
+}
+
+const REMOTE_SOURCES_EMPTY_TABLE_DESCRIPTION = (
+
+ {i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.security.remoteSourcesEmptyTable.enabledStrong',
+ { defaultMessage: 'enabled by default' }
+ )}
+
+ ),
+ }}
+ />
+);
+
+const STANDARD_SOURCES_EMPTY_TABLE_DESCRIPTION = (
+
+ {i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.security.standardSourcesEmptyTable.notEnabledStrong',
+ { defaultMessage: 'not enabled by default' }
+ )}
+
+ ),
+ }}
+ />
+);
+
+export const PrivateSourcesTable: React.FC = ({
+ sourceType,
+ sourceSection: { isEnabled: sectionEnabled, contentSources },
+ updateSource,
+ updateEnabled,
+}) => {
+ const { hasPlatinumLicense } = useValues(LicensingLogic);
+ const { isEnabled } = useValues(SecurityLogic);
+
+ const isRemote = sourceType === 'remote';
+ const hasSources = contentSources.length > 0;
+ const panelDisabled = !isEnabled || !hasPlatinumLicense;
+ const sectionDisabled = !sectionEnabled;
+
+ const panelClass = classNames('euiPanel--outline euiPanel--noShadow', {
+ 'euiPanel--disabled': panelDisabled,
+ });
+
+ const tableClass = classNames({ 'euiTable--disabled': sectionDisabled });
+
+ const emptyState = (
+ <>
+
+
+
+
+ {isRemote ? REMOTE_SOURCES_EMPTY_TABLE_TITLE : STANDARD_SOURCES_EMPTY_TABLE_TITLE}
+
+
+
+ {isRemote
+ ? REMOTE_SOURCES_EMPTY_TABLE_DESCRIPTION
+ : STANDARD_SOURCES_EMPTY_TABLE_DESCRIPTION}
+
+
+ >
+ );
+
+ const sectionHeading = (
+
+
+
+ updateEnabled(e.target.checked)}
+ disabled={!isEnabled || !hasPlatinumLicense}
+ showLabel={false}
+ label={`${sourceType} Sources Toggle`}
+ data-test-subj={`${sourceType}EnabledToggle`}
+ compressed
+ />
+
+
+
+ {isRemote ? REMOTE_SOURCES_TOGGLE_TEXT : STANDARD_SOURCES_TOGGLE_TEXT}
+
+
+ {isRemote ? REMOTE_SOURCES_TABLE_DESCRIPTION : STANDARD_SOURCES_TABLE_DESCRIPTION}
+
+ {!hasSources && emptyState}
+
+
+ );
+
+ const sourcesTable = (
+ <>
+
+
+
+ {SOURCE}
+
+
+
+ {contentSources.map((source, i) => (
+
+ {source.name}
+
+ updateSource(source.id, e.target.checked)}
+ showLabel={false}
+ label={`${source.name} Toggle`}
+ data-test-subj={`${sourceType}SourceToggle`}
+ compressed
+ />
+
+
+ ))}
+
+
+ >
+ );
+
+ return (
+
+ {sectionHeading}
+ {hasSources && sourcesTable}
+
+ );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/index.ts
new file mode 100644
index 00000000000000..a2db1bbc15a152
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { Security } from './security';
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.test.tsx
new file mode 100644
index 00000000000000..bca0d5edc32d60
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.test.tsx
@@ -0,0 +1,112 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { setMockValues, setMockActions } from '../../../__mocks__';
+import { unmountHandler } from '../../../__mocks__/shallow_useeffect.mock';
+
+import React from 'react';
+import { shallow } from 'enzyme';
+import { EuiSwitch, EuiConfirmModal } from '@elastic/eui';
+import { Loading } from '../../../shared/loading';
+
+import { ViewContentHeader } from '../../components/shared/view_content_header';
+import { Security } from './security';
+
+describe('Security', () => {
+ const initializeSourceRestrictions = jest.fn();
+ const updatePrivateSourcesEnabled = jest.fn();
+ const updateRemoteEnabled = jest.fn();
+ const updateRemoteSource = jest.fn();
+ const updateStandardEnabled = jest.fn();
+ const updateStandardSource = jest.fn();
+ const saveSourceRestrictions = jest.fn();
+ const resetState = jest.fn();
+
+ const mockValues = {
+ isEnabled: true,
+ remote: { isEnabled: true, contentSources: [] },
+ standard: { isEnabled: true, contentSources: [] },
+ dataLoading: false,
+ unsavedChanges: false,
+ hasPlatinumLicense: true,
+ };
+
+ beforeEach(() => {
+ setMockValues(mockValues);
+ setMockActions({
+ initializeSourceRestrictions,
+ updatePrivateSourcesEnabled,
+ updateRemoteEnabled,
+ updateRemoteSource,
+ updateStandardEnabled,
+ updateStandardSource,
+ saveSourceRestrictions,
+ resetState,
+ });
+ });
+
+ it('renders on Basic license', () => {
+ setMockValues({ ...mockValues, hasPlatinumLicense: false });
+ const wrapper = shallow();
+
+ expect(wrapper.find(ViewContentHeader)).toHaveLength(1);
+ expect(wrapper.find(EuiSwitch).prop('disabled')).toEqual(true);
+ });
+
+ it('renders on Platinum license', () => {
+ const wrapper = shallow();
+
+ expect(wrapper.find(ViewContentHeader)).toHaveLength(1);
+ expect(wrapper.find(EuiSwitch).prop('disabled')).toEqual(false);
+ });
+
+ it('returns Loading when loading', () => {
+ setMockValues({ ...mockValues, dataLoading: true });
+ const wrapper = shallow();
+
+ expect(wrapper.find(Loading)).toHaveLength(1);
+ });
+
+ it('handles window.onbeforeunload change', () => {
+ setMockValues({ ...mockValues, unsavedChanges: true });
+ shallow();
+
+ expect(window.onbeforeunload!({} as any)).toEqual(
+ 'Your private sources settings have not been saved. Are you sure you want to leave?'
+ );
+ });
+
+ it('handles window.onbeforeunload unmount', () => {
+ setMockValues({ ...mockValues, unsavedChanges: true });
+ shallow();
+
+ unmountHandler();
+
+ expect(window.onbeforeunload).toEqual(null);
+ });
+
+ it('handles switch click', () => {
+ const wrapper = shallow();
+
+ const privateSourcesSwitch = wrapper.find(EuiSwitch);
+ const event = { target: { checked: true } };
+ privateSourcesSwitch.prop('onChange')(event as any);
+
+ expect(updatePrivateSourcesEnabled).toHaveBeenCalled();
+ });
+
+ it('handles confirmModal submission', () => {
+ setMockValues({ ...mockValues, unsavedChanges: true });
+ const wrapper = shallow();
+
+ const header = wrapper.find(ViewContentHeader).dive();
+ header.find('[data-test-subj="SaveSettingsButton"]').prop('onClick')!({} as any);
+ const modal = wrapper.find(EuiConfirmModal);
+ modal.prop('onConfirm')!({} as any);
+
+ expect(saveSourceRestrictions).toHaveBeenCalled();
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.tsx
new file mode 100644
index 00000000000000..41df1a1acc5156
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.tsx
@@ -0,0 +1,196 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { useEffect, useState } from 'react';
+
+import classNames from 'classnames';
+import { useActions, useValues } from 'kea';
+
+import {
+ EuiButton,
+ EuiButtonEmpty,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiSwitch,
+ EuiText,
+ EuiSpacer,
+ EuiPanel,
+ EuiConfirmModal,
+ EuiOverlayMask,
+} from '@elastic/eui';
+
+import { LicensingLogic } from '../../../shared/licensing';
+import { FlashMessages } from '../../../shared/flash_messages';
+import { LicenseCallout } from '../../components/shared/license_callout';
+import { Loading } from '../../../shared/loading';
+import { ViewContentHeader } from '../../components/shared/view_content_header';
+import { SecurityLogic } from './security_logic';
+
+import { PrivateSourcesTable } from './components/private_sources_table';
+
+import {
+ SECURITY_UNSAVED_CHANGES_MESSAGE,
+ RESET_BUTTON,
+ SAVE_SETTINGS_BUTTON,
+ SAVE_CHANGES_BUTTON,
+ KEEP_EDITING_BUTTON,
+ PRIVATE_SOURCES,
+ PRIVATE_SOURCES_DESCRIPTION,
+ PRIVATE_SOURCES_TOGGLE_DESCRIPTION,
+ PRIVATE_PLATINUM_LICENSE_CALLOUT,
+ CONFIRM_CHANGES_TEXT,
+ PRIVATE_SOURCES_UPDATE_CONFIRMATION_TEXT,
+} from '../../constants';
+
+export const Security: React.FC = () => {
+ const [confirmModalVisible, setConfirmModalVisibility] = useState(false);
+
+ const hideConfirmModal = () => setConfirmModalVisibility(false);
+ const showConfirmModal = () => setConfirmModalVisibility(true);
+
+ const { hasPlatinumLicense } = useValues(LicensingLogic);
+
+ const {
+ initializeSourceRestrictions,
+ updatePrivateSourcesEnabled,
+ updateRemoteEnabled,
+ updateRemoteSource,
+ updateStandardEnabled,
+ updateStandardSource,
+ saveSourceRestrictions,
+ resetState,
+ } = useActions(SecurityLogic);
+
+ const { isEnabled, remote, standard, dataLoading, unsavedChanges } = useValues(SecurityLogic);
+
+ useEffect(() => {
+ initializeSourceRestrictions();
+ }, []);
+
+ useEffect(() => {
+ window.onbeforeunload = unsavedChanges ? () => SECURITY_UNSAVED_CHANGES_MESSAGE : null;
+ return () => {
+ window.onbeforeunload = null;
+ };
+ }, [unsavedChanges]);
+
+ if (dataLoading) return ;
+
+ const panelClass = classNames('euiPanel--noShadow', {
+ 'euiPanel--disabled': !hasPlatinumLicense,
+ });
+
+ const savePrivateSources = () => {
+ saveSourceRestrictions();
+ hideConfirmModal();
+ };
+
+ const headerActions = (
+
+
+
+ {RESET_BUTTON}
+
+
+
+
+ {SAVE_SETTINGS_BUTTON}
+
+
+
+ );
+
+ const header = (
+ <>
+
+
+ >
+ );
+
+ const allSourcesToggle = (
+
+
+
+ updatePrivateSourcesEnabled(e.target.checked)}
+ disabled={!hasPlatinumLicense}
+ showLabel={false}
+ label="Private Sources Toggle"
+ data-test-subj="PrivateSourcesToggle"
+ />
+
+
+
+ {PRIVATE_SOURCES_TOGGLE_DESCRIPTION}
+
+
+
+
+ );
+
+ const platinumLicenseCallout = (
+ <>
+
+
+ >
+ );
+
+ const sourceTables = (
+ <>
+
+
+
+
+ >
+ );
+
+ const confirmModal = (
+
+
+ {PRIVATE_SOURCES_UPDATE_CONFIRMATION_TEXT}
+
+
+ );
+
+ return (
+ <>
+
+ {header}
+ {allSourcesToggle}
+ {!hasPlatinumLicense && platinumLicenseCallout}
+ {sourceTables}
+ {confirmModalVisible && confirmModal}
+ >
+ );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security_logic.test.ts
new file mode 100644
index 00000000000000..abb1308081f0ca
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security_logic.test.ts
@@ -0,0 +1,169 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { LogicMounter } from '../../../__mocks__/kea.mock';
+import { mockHttpValues, mockFlashMessageHelpers } from '../../../__mocks__';
+import { SecurityLogic } from './security_logic';
+import { nextTick } from '@kbn/test/jest';
+
+describe('SecurityLogic', () => {
+ const { http } = mockHttpValues;
+ const { flashAPIErrors } = mockFlashMessageHelpers;
+ const { mount } = new LogicMounter(SecurityLogic);
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mount();
+ });
+
+ const defaultValues = {
+ dataLoading: true,
+ cachedServerState: {},
+ isEnabled: false,
+ remote: {},
+ standard: {},
+ unsavedChanges: true,
+ };
+
+ const serverProps = {
+ isEnabled: true,
+ remote: {
+ isEnabled: true,
+ contentSources: [{ id: 'gmail', name: 'Gmail', isEnabled: true }],
+ },
+ standard: {
+ isEnabled: true,
+ contentSources: [{ id: 'one_drive', name: 'OneDrive', isEnabled: true }],
+ },
+ };
+
+ it('has expected default values', () => {
+ expect(SecurityLogic.values).toEqual(defaultValues);
+ });
+
+ describe('actions', () => {
+ it('setServerProps', () => {
+ SecurityLogic.actions.setServerProps(serverProps);
+
+ expect(SecurityLogic.values.isEnabled).toEqual(true);
+ });
+
+ it('setSourceRestrictionsUpdated', () => {
+ SecurityLogic.actions.setSourceRestrictionsUpdated(serverProps);
+
+ expect(SecurityLogic.values.isEnabled).toEqual(true);
+ });
+
+ it('updatePrivateSourcesEnabled', () => {
+ SecurityLogic.actions.updatePrivateSourcesEnabled(false);
+
+ expect(SecurityLogic.values.isEnabled).toEqual(false);
+ });
+
+ it('updateRemoteEnabled', () => {
+ SecurityLogic.actions.updateRemoteEnabled(false);
+
+ expect(SecurityLogic.values.remote.isEnabled).toEqual(false);
+ });
+
+ it('updateStandardEnabled', () => {
+ SecurityLogic.actions.updateStandardEnabled(false);
+
+ expect(SecurityLogic.values.standard.isEnabled).toEqual(false);
+ });
+
+ it('updateRemoteSource', () => {
+ SecurityLogic.actions.setServerProps(serverProps);
+ SecurityLogic.actions.updateRemoteSource('gmail', false);
+
+ expect(SecurityLogic.values.remote.contentSources[0].isEnabled).toEqual(false);
+ });
+
+ it('updateStandardSource', () => {
+ SecurityLogic.actions.setServerProps(serverProps);
+ SecurityLogic.actions.updateStandardSource('one_drive', false);
+
+ expect(SecurityLogic.values.standard.contentSources[0].isEnabled).toEqual(false);
+ });
+ });
+
+ describe('selectors', () => {
+ describe('unsavedChanges', () => {
+ it('returns true while loading', () => {
+ expect(SecurityLogic.values.unsavedChanges).toEqual(true);
+ });
+
+ it('returns false after loading', () => {
+ SecurityLogic.actions.setServerProps(serverProps);
+
+ expect(SecurityLogic.values.unsavedChanges).toEqual(false);
+ });
+ });
+ });
+
+ describe('listeners', () => {
+ describe('initializeSourceRestrictions', () => {
+ it('calls API and sets values', async () => {
+ const setServerPropsSpy = jest.spyOn(SecurityLogic.actions, 'setServerProps');
+ http.get.mockReturnValue(Promise.resolve(serverProps));
+ SecurityLogic.actions.initializeSourceRestrictions();
+
+ expect(http.get).toHaveBeenCalledWith(
+ '/api/workplace_search/org/security/source_restrictions'
+ );
+ await nextTick();
+ expect(setServerPropsSpy).toHaveBeenCalledWith(serverProps);
+ });
+
+ it('handles error', async () => {
+ http.get.mockReturnValue(Promise.reject('this is an error'));
+
+ SecurityLogic.actions.initializeSourceRestrictions();
+ try {
+ await nextTick();
+ } catch {
+ expect(flashAPIErrors).toHaveBeenCalledWith('this is an error');
+ }
+ });
+ });
+
+ describe('saveSourceRestrictions', () => {
+ it('calls API and sets values', async () => {
+ http.patch.mockReturnValue(Promise.resolve(serverProps));
+ SecurityLogic.actions.setSourceRestrictionsUpdated(serverProps);
+ SecurityLogic.actions.saveSourceRestrictions();
+
+ expect(http.patch).toHaveBeenCalledWith(
+ '/api/workplace_search/org/security/source_restrictions',
+ {
+ body: JSON.stringify(serverProps),
+ }
+ );
+ });
+
+ it('handles error', async () => {
+ http.patch.mockReturnValue(Promise.reject('this is an error'));
+
+ SecurityLogic.actions.saveSourceRestrictions();
+ try {
+ await nextTick();
+ } catch {
+ expect(flashAPIErrors).toHaveBeenCalledWith('this is an error');
+ }
+ });
+ });
+
+ describe('resetState', () => {
+ it('calls API and sets values', async () => {
+ SecurityLogic.actions.setServerProps(serverProps);
+ SecurityLogic.actions.updatePrivateSourcesEnabled(false);
+ SecurityLogic.actions.resetState();
+
+ expect(SecurityLogic.values.isEnabled).toEqual(true);
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security_logic.ts
new file mode 100644
index 00000000000000..df843b330d411f
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security_logic.ts
@@ -0,0 +1,181 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { cloneDeep } from 'lodash';
+import { isEqual } from 'lodash';
+
+import { kea, MakeLogicType } from 'kea';
+
+import {
+ clearFlashMessages,
+ setSuccessMessage,
+ flashAPIErrors,
+} from '../../../shared/flash_messages';
+import { HttpLogic } from '../../../shared/http';
+import { AppLogic } from '../../app_logic';
+
+import { SOURCE_RESTRICTIONS_SUCCESS_MESSAGE } from '../../constants';
+
+export interface PrivateSource {
+ id: string;
+ name: string;
+ isEnabled: boolean;
+}
+
+export interface PrivateSourceSection {
+ isEnabled: boolean;
+ contentSources: PrivateSource[];
+}
+
+export interface SecurityServerProps {
+ isEnabled: boolean;
+ remote: PrivateSourceSection;
+ standard: PrivateSourceSection;
+}
+
+interface SecurityValues extends SecurityServerProps {
+ dataLoading: boolean;
+ unsavedChanges: boolean;
+ cachedServerState: SecurityServerProps;
+}
+
+interface SecurityActions {
+ setServerProps(serverProps: SecurityServerProps): SecurityServerProps;
+ setSourceRestrictionsUpdated(serverProps: SecurityServerProps): SecurityServerProps;
+ initializeSourceRestrictions(): void;
+ saveSourceRestrictions(): void;
+ updatePrivateSourcesEnabled(isEnabled: boolean): { isEnabled: boolean };
+ updateRemoteEnabled(isEnabled: boolean): { isEnabled: boolean };
+ updateRemoteSource(
+ sourceId: string,
+ isEnabled: boolean
+ ): { sourceId: string; isEnabled: boolean };
+ updateStandardEnabled(isEnabled: boolean): { isEnabled: boolean };
+ updateStandardSource(
+ sourceId: string,
+ isEnabled: boolean
+ ): { sourceId: string; isEnabled: boolean };
+ resetState(): void;
+}
+
+const route = '/api/workplace_search/org/security/source_restrictions';
+
+export const SecurityLogic = kea>({
+ path: ['enterprise_search', 'workplace_search', 'security_logic'],
+ actions: {
+ setServerProps: (serverProps: SecurityServerProps) => serverProps,
+ setSourceRestrictionsUpdated: (serverProps: SecurityServerProps) => serverProps,
+ initializeSourceRestrictions: () => true,
+ saveSourceRestrictions: () => null,
+ updatePrivateSourcesEnabled: (isEnabled: boolean) => ({ isEnabled }),
+ updateRemoteEnabled: (isEnabled: boolean) => ({ isEnabled }),
+ updateRemoteSource: (sourceId: string, isEnabled: boolean) => ({ sourceId, isEnabled }),
+ updateStandardEnabled: (isEnabled: boolean) => ({ isEnabled }),
+ updateStandardSource: (sourceId: string, isEnabled: boolean) => ({ sourceId, isEnabled }),
+ resetState: () => null,
+ },
+ reducers: {
+ dataLoading: [
+ true,
+ {
+ setServerProps: () => false,
+ },
+ ],
+ cachedServerState: [
+ {} as SecurityServerProps,
+ {
+ setServerProps: (_, serverProps) => cloneDeep(serverProps),
+ setSourceRestrictionsUpdated: (_, serverProps) => cloneDeep(serverProps),
+ },
+ ],
+ isEnabled: [
+ false,
+ {
+ setServerProps: (_, { isEnabled }) => isEnabled,
+ setSourceRestrictionsUpdated: (_, { isEnabled }) => isEnabled,
+ updatePrivateSourcesEnabled: (_, { isEnabled }) => isEnabled,
+ },
+ ],
+ remote: [
+ {} as PrivateSourceSection,
+ {
+ setServerProps: (_, { remote }) => remote,
+ setSourceRestrictionsUpdated: (_, { remote }) => remote,
+ updateRemoteEnabled: (state, { isEnabled }) => ({ ...state, isEnabled }),
+ updateRemoteSource: (state, { sourceId, isEnabled }) =>
+ updateSourceEnabled(state, sourceId, isEnabled),
+ },
+ ],
+ standard: [
+ {} as PrivateSourceSection,
+ {
+ setServerProps: (_, { standard }) => standard,
+ setSourceRestrictionsUpdated: (_, { standard }) => standard,
+ updateStandardEnabled: (state, { isEnabled }) => ({ ...state, isEnabled }),
+ updateStandardSource: (state, { sourceId, isEnabled }) =>
+ updateSourceEnabled(state, sourceId, isEnabled),
+ },
+ ],
+ },
+ selectors: ({ selectors }) => ({
+ unsavedChanges: [
+ () => [
+ selectors.cachedServerState,
+ selectors.isEnabled,
+ selectors.remote,
+ selectors.standard,
+ ],
+ (cached, isEnabled, remote, standard) =>
+ cached.isEnabled !== isEnabled ||
+ !isEqual(cached.remote, remote) ||
+ !isEqual(cached.standard, standard),
+ ],
+ }),
+ listeners: ({ actions, values }) => ({
+ initializeSourceRestrictions: async () => {
+ const { http } = HttpLogic.values;
+
+ try {
+ const response = await http.get(route);
+ actions.setServerProps(response);
+ } catch (e) {
+ flashAPIErrors(e);
+ }
+ },
+ saveSourceRestrictions: async () => {
+ const { isEnabled, remote, standard } = values;
+ const serverData = { isEnabled, remote, standard };
+ const body = JSON.stringify(serverData);
+ const { http } = HttpLogic.values;
+
+ try {
+ const response = await http.patch(route, { body });
+ actions.setSourceRestrictionsUpdated(response);
+ setSuccessMessage(SOURCE_RESTRICTIONS_SUCCESS_MESSAGE);
+ AppLogic.actions.setSourceRestriction(isEnabled);
+ } catch (e) {
+ flashAPIErrors(e);
+ }
+ },
+ resetState: () => {
+ actions.setServerProps(cloneDeep(values.cachedServerState));
+ clearFlashMessages();
+ },
+ }),
+});
+
+const updateSourceEnabled = (
+ section: PrivateSourceSection,
+ id: string,
+ isEnabled: boolean
+): PrivateSourceSection => {
+ const updatedSection = { ...section };
+ const sources = updatedSection.contentSources;
+ const sourceIndex = sources.findIndex((source) => source.id === id);
+ updatedSection.contentSources[sourceIndex] = { ...sources[sourceIndex], isEnabled };
+
+ return updatedSection;
+};
diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/index.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/index.ts
index 99445108b315af..f2792be8e65359 100644
--- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/index.ts
+++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/index.ts
@@ -10,10 +10,12 @@ import { registerOverviewRoute } from './overview';
import { registerGroupsRoutes } from './groups';
import { registerSourcesRoutes } from './sources';
import { registerSettingsRoutes } from './settings';
+import { registerSecurityRoutes } from './security';
export const registerWorkplaceSearchRoutes = (dependencies: RouteDependencies) => {
registerOverviewRoute(dependencies);
registerGroupsRoutes(dependencies);
registerSourcesRoutes(dependencies);
registerSettingsRoutes(dependencies);
+ registerSecurityRoutes(dependencies);
};
diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/security.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/security.test.ts
new file mode 100644
index 00000000000000..12f84278e9ead7
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/security.test.ts
@@ -0,0 +1,108 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__';
+
+import { registerSecurityRoute, registerSecuritySourceRestrictionsRoute } from './security';
+
+describe('security routes', () => {
+ describe('GET /api/workplace_search/org/security', () => {
+ let mockRouter: MockRouter;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockRouter = new MockRouter({
+ method: 'get',
+ path: '/api/workplace_search/org/security',
+ });
+
+ registerSecurityRoute({
+ ...mockDependencies,
+ router: mockRouter.router,
+ });
+ });
+
+ it('creates a request handler', () => {
+ mockRouter.callRoute({});
+
+ expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
+ path: '/ws/org/security',
+ });
+ });
+ });
+
+ describe('GET /api/workplace_search/org/security/source_restrictions', () => {
+ let mockRouter: MockRouter;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockRouter = new MockRouter({
+ method: 'get',
+ path: '/api/workplace_search/org/security/source_restrictions',
+ payload: 'body',
+ });
+
+ registerSecuritySourceRestrictionsRoute({
+ ...mockDependencies,
+ router: mockRouter.router,
+ });
+ });
+
+ it('creates a request handler', () => {
+ mockRouter.callRoute({});
+
+ expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
+ path: '/ws/org/security/source_restrictions',
+ });
+ });
+ });
+
+ describe('PATCH /api/workplace_search/org/security/source_restrictions', () => {
+ let mockRouter: MockRouter;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockRouter = new MockRouter({
+ method: 'patch',
+ path: '/api/workplace_search/org/security/source_restrictions',
+ payload: 'body',
+ });
+
+ registerSecuritySourceRestrictionsRoute({
+ ...mockDependencies,
+ router: mockRouter.router,
+ });
+ });
+
+ it('creates a request handler', () => {
+ expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
+ path: '/ws/org/security/source_restrictions',
+ });
+ });
+
+ describe('validates', () => {
+ it('correctly', () => {
+ const request = {
+ body: {
+ isEnabled: true,
+ remote: {
+ isEnabled: true,
+ contentSources: [{ id: 'gmail', name: 'Gmail', isEnabled: true }],
+ },
+ standard: {
+ isEnabled: false,
+ contentSources: [{ id: 'dropbox', name: 'Dropbox', isEnabled: false }],
+ },
+ },
+ };
+ mockRouter.shouldValidate(request);
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/security.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/security.ts
new file mode 100644
index 00000000000000..0aa218dfc28839
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/security.ts
@@ -0,0 +1,78 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { schema } from '@kbn/config-schema';
+
+import { RouteDependencies } from '../../plugin';
+
+export function registerSecurityRoute({
+ router,
+ enterpriseSearchRequestHandler,
+}: RouteDependencies) {
+ router.get(
+ {
+ path: '/api/workplace_search/org/security',
+ validate: false,
+ },
+ enterpriseSearchRequestHandler.createRequest({
+ path: '/ws/org/security',
+ })
+ );
+}
+
+export function registerSecuritySourceRestrictionsRoute({
+ router,
+ enterpriseSearchRequestHandler,
+}: RouteDependencies) {
+ router.get(
+ {
+ path: '/api/workplace_search/org/security/source_restrictions',
+ validate: false,
+ },
+ enterpriseSearchRequestHandler.createRequest({
+ path: '/ws/org/security/source_restrictions',
+ })
+ );
+
+ router.patch(
+ {
+ path: '/api/workplace_search/org/security/source_restrictions',
+ validate: {
+ body: schema.object({
+ isEnabled: schema.boolean(),
+ remote: schema.object({
+ isEnabled: schema.boolean(),
+ contentSources: schema.arrayOf(
+ schema.object({
+ isEnabled: schema.boolean(),
+ id: schema.string(),
+ name: schema.string(),
+ })
+ ),
+ }),
+ standard: schema.object({
+ isEnabled: schema.boolean(),
+ contentSources: schema.arrayOf(
+ schema.object({
+ isEnabled: schema.boolean(),
+ id: schema.string(),
+ name: schema.string(),
+ })
+ ),
+ }),
+ }),
+ },
+ },
+ enterpriseSearchRequestHandler.createRequest({
+ path: '/ws/org/security/source_restrictions',
+ })
+ );
+}
+
+export const registerSecurityRoutes = (dependencies: RouteDependencies) => {
+ registerSecurityRoute(dependencies);
+ registerSecuritySourceRestrictionsRoute(dependencies);
+};
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx
index a35a314bec985c..0be9e00b70f935 100644
--- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx
+++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx
@@ -4,8 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import React, { FC, Fragment, useEffect, useRef } from 'react';
-
+import React, { FC, Fragment, useEffect, useMemo, useRef } from 'react';
+import { debounce } from 'lodash';
import {
EuiCallOut,
EuiCodeEditor,
@@ -22,6 +22,9 @@ import { XJsonMode } from '../../../../../../../shared_imports';
const xJsonMode = new XJsonMode();
+import { useNotifications } from '../../../../../contexts/kibana';
+import { ml } from '../../../../../services/ml_api_service';
+import { extractErrorMessage } from '../../../../../../../common/util/errors';
import { CreateAnalyticsFormProps } from '../../../analytics_management/hooks/use_create_analytics_form';
import { CreateStep } from '../create_step';
import { ANALYTICS_STEPS } from '../../page';
@@ -42,11 +45,33 @@ export const CreateAnalyticsAdvancedEditor: FC = (prop
} = state.form;
const forceInput = useRef(null);
+ const { toasts } = useNotifications();
const onChange = (str: string) => {
setAdvancedEditorRawString(str);
};
+ const debouncedJobIdCheck = useMemo(
+ () =>
+ debounce(async () => {
+ try {
+ const { results } = await ml.dataFrameAnalytics.jobsExists([jobId], true);
+ setFormState({ jobIdExists: results[jobId] });
+ } catch (e) {
+ toasts.addDanger(
+ i18n.translate(
+ 'xpack.ml.dataframe.analytics.create.advancedEditor.errorCheckingJobIdExists',
+ {
+ defaultMessage: 'The following error occurred checking if job id exists: {error}',
+ values: { error: extractErrorMessage(e) },
+ }
+ )
+ );
+ }
+ }, 400),
+ [jobId]
+ );
+
// Temp effect to close the context menu popover on Clone button click
useEffect(() => {
if (forceInput.current === null) {
@@ -57,6 +82,18 @@ export const CreateAnalyticsAdvancedEditor: FC = (prop
forceInput.current.dispatchEvent(evt);
}, []);
+ useEffect(() => {
+ if (jobIdValid === true) {
+ debouncedJobIdCheck();
+ } else if (typeof jobId === 'string' && jobId.trim() === '' && jobIdExists === true) {
+ setFormState({ jobIdExists: false });
+ }
+
+ return () => {
+ debouncedJobIdCheck.cancel();
+ };
+ }, [jobId]);
+
return (
= ({
}
}, 400);
+ const debouncedJobIdCheck = useMemo(
+ () =>
+ debounce(async () => {
+ try {
+ const { results } = await ml.dataFrameAnalytics.jobsExists([jobId], true);
+ setFormState({ jobIdExists: results[jobId] });
+ } catch (e) {
+ notifications.toasts.addDanger(
+ i18n.translate('xpack.ml.dataframe.analytics.create.errorCheckingJobIdExists', {
+ defaultMessage: 'The following error occurred checking if job id exists: {error}',
+ values: { error: extractErrorMessage(e) },
+ })
+ );
+ }
+ }, 400),
+ [jobId]
+ );
+
+ useEffect(() => {
+ if (jobIdValid === true) {
+ debouncedJobIdCheck();
+ } else if (typeof jobId === 'string' && jobId.trim() === '' && jobIdExists === true) {
+ setFormState({ jobIdExists: false });
+ }
+
+ return () => {
+ debouncedJobIdCheck.cancel();
+ };
+ }, [jobId]);
+
useEffect(() => {
if (destinationIndexNameValid === true) {
debouncedIndexCheck();
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts
index a277ae6e6a66e6..998460d75f6f07 100644
--- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts
+++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts
@@ -499,7 +499,6 @@ export function reducer(state: State, action: Action): State {
}
if (action.payload.jobId !== undefined) {
- newFormState.jobIdExists = state.jobIds.some((id) => newFormState.jobId === id);
newFormState.jobIdEmpty = newFormState.jobId === '';
newFormState.jobIdValid = isJobIdValid(newFormState.jobId);
newFormState.jobIdInvalidMaxLength = !!maxLengthValidator(JOB_ID_MAX_LENGTH)(
@@ -542,12 +541,6 @@ export function reducer(state: State, action: Action): State {
case ACTION.SET_JOB_CONFIG:
return validateAdvancedEditor({ ...state, jobConfig: action.payload });
- case ACTION.SET_JOB_IDS: {
- const newState = { ...state, jobIds: action.jobIds };
- newState.form.jobIdExists = newState.jobIds.some((id) => newState.form.jobId === id);
- return newState;
- }
-
case ACTION.SWITCH_TO_ADVANCED_EDITOR:
const jobConfig = getJobConfigFromFormState(state.form);
const shouldDisableSwitchToForm = isAdvancedConfig(jobConfig);
@@ -562,7 +555,7 @@ export function reducer(state: State, action: Action): State {
});
case ACTION.SWITCH_TO_FORM:
- const { jobConfig: config, jobIds } = state;
+ const { jobConfig: config } = state;
const { jobId } = state.form;
// @ts-ignore
const formState = getFormStateFromJobConfig(config, false);
@@ -571,7 +564,6 @@ export function reducer(state: State, action: Action): State {
formState.jobId = jobId;
}
- formState.jobIdExists = jobIds.some((id) => formState.jobId === id);
formState.jobIdEmpty = jobId === '';
formState.jobIdValid = isJobIdValid(jobId);
formState.jobIdInvalidMaxLength = !!maxLengthValidator(JOB_ID_MAX_LENGTH)(jobId);
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts
index 0b88f52e555c0b..f5bfd3075f26bd 100644
--- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts
+++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts
@@ -14,11 +14,7 @@ import { ml } from '../../../../../services/ml_api_service';
import { useMlContext } from '../../../../../contexts/ml';
import { DuplicateIndexPatternError } from '../../../../../../../../../../src/plugins/data/public';
-import {
- useRefreshAnalyticsList,
- DataFrameAnalyticsId,
- DataFrameAnalyticsConfig,
-} from '../../../../common';
+import { useRefreshAnalyticsList, DataFrameAnalyticsConfig } from '../../../../common';
import { extractCloningConfig, isAdvancedConfig } from '../../components/action_clone';
import { ActionDispatchers, ACTION } from './actions';
@@ -80,9 +76,6 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => {
dispatch({ type: ACTION.SET_IS_JOB_STARTED, isJobStarted });
};
- const setJobIds = (jobIds: DataFrameAnalyticsId[]) =>
- dispatch({ type: ACTION.SET_JOB_IDS, jobIds });
-
const resetRequestMessages = () => dispatch({ type: ACTION.RESET_REQUEST_MESSAGES });
const resetForm = () => dispatch({ type: ACTION.RESET_FORM });
@@ -180,25 +173,6 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => {
};
const prepareFormValidation = async () => {
- // re-fetch existing analytics job IDs and indices for form validation
- try {
- setJobIds(
- (await ml.dataFrameAnalytics.getDataFrameAnalytics()).data_frame_analytics.map(
- (job: DataFrameAnalyticsConfig) => job.id
- )
- );
- } catch (e) {
- addRequestMessage({
- error: extractErrorMessage(e),
- message: i18n.translate(
- 'xpack.ml.dataframe.analytics.create.errorGettingDataFrameAnalyticsList',
- {
- defaultMessage: 'An error occurred getting the existing data frame analytics job IDs:',
- }
- ),
- });
- }
-
try {
// Set the existing index pattern titles.
const indexPatternsMap: SourceIndexMap = {};
diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts
index 298dcad4ce488d..7b246e557d7a57 100644
--- a/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts
+++ b/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts
@@ -45,6 +45,11 @@ interface DeleteDataFrameAnalyticsWithIndexResponse {
destIndexDeleted: DeleteDataFrameAnalyticsWithIndexStatus;
destIndexPatternDeleted: DeleteDataFrameAnalyticsWithIndexStatus;
}
+interface JobsExistsResponse {
+ results: {
+ [jobId: string]: boolean;
+ };
+}
export const dataFrameAnalytics = {
getDataFrameAnalytics(analyticsId?: string) {
@@ -98,6 +103,14 @@ export const dataFrameAnalytics = {
query: { treatAsRoot, type },
});
},
+ jobsExists(analyticsIds: string[], allSpaces: boolean = false) {
+ const body = JSON.stringify({ analyticsIds, allSpaces });
+ return http({
+ path: `${basePath()}/data_frame/analytics/jobs_exist`,
+ method: 'POST',
+ body,
+ });
+ },
evaluateDataFrameAnalytics(evaluateConfig: any) {
const body = JSON.stringify(evaluateConfig);
return http({
diff --git a/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts b/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts
index b154978d041f45..7706aa9d650c7c 100644
--- a/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts
+++ b/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts
@@ -13,7 +13,7 @@ import { ReportDocument } from '../../lib/store';
import { TaskRunResult } from '../../lib/tasks';
import { ExportTypeDefinition } from '../../types';
-interface ErrorFromPayload {
+export interface ErrorFromPayload {
message: string;
}
diff --git a/x-pack/plugins/reporting/tsconfig.json b/x-pack/plugins/reporting/tsconfig.json
new file mode 100644
index 00000000000000..88e8d343f4700f
--- /dev/null
+++ b/x-pack/plugins/reporting/tsconfig.json
@@ -0,0 +1,31 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "composite": true,
+ "outDir": "./target/types",
+ "emitDeclarationOnly": true,
+ "declaration": true,
+ "declarationMap": true
+ },
+ "include": [
+ "common/**/*",
+ "public/**/*",
+ "server/**/*",
+ "../../../typings/**/*"
+ ],
+ "references": [
+ { "path": "../../../src/core/tsconfig.json" },
+ { "path": "../../../src/plugins/data/tsconfig.json"},
+ { "path": "../../../src/plugins/discover/tsconfig.json" },
+ { "path": "../../../src/plugins/embeddable/tsconfig.json" },
+ { "path": "../../../src/plugins/kibana_react/tsconfig.json" },
+ { "path": "../../../src/plugins/management/tsconfig.json" },
+ { "path": "../../../src/plugins/share/tsconfig.json" },
+ { "path": "../../../src/plugins/ui_actions/tsconfig.json" },
+ { "path": "../../../src/plugins/usage_collection/tsconfig.json" },
+ { "path": "../features/tsconfig.json" },
+ { "path": "../licensing/tsconfig.json" },
+ { "path": "../security/tsconfig.json" },
+ { "path": "../spaces/tsconfig.json" },
+ ]
+}
diff --git a/x-pack/plugins/security_solution/cypress/integration/data_sources/sourcerer.spec.ts b/x-pack/plugins/security_solution/cypress/integration/data_sources/sourcerer.spec.ts
index 8b5871a6a67db0..857582aac76381 100644
--- a/x-pack/plugins/security_solution/cypress/integration/data_sources/sourcerer.spec.ts
+++ b/x-pack/plugins/security_solution/cypress/integration/data_sources/sourcerer.spec.ts
@@ -28,7 +28,9 @@ import { populateTimeline } from '../../tasks/timeline';
import { SERVER_SIDE_EVENT_COUNT } from '../../screens/timeline';
import { cleanKibana } from '../../tasks/common';
-describe('Sourcerer', () => {
+// Skipped at the moment as this has flake due to click handler issues. This has been raised with team members
+// and the code is being re-worked and then these tests will be unskipped
+describe.skip('Sourcerer', () => {
before(() => {
cleanKibana();
});
diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts
index 37123dedfd6613..2c9dc14aa05b23 100644
--- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts
+++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts
@@ -5,7 +5,7 @@
*/
import { formatMitreAttackDescription } from '../../helpers/rules';
-import { newThreatIndicatorRule } from '../../objects/rule';
+import { indexPatterns, newThreatIndicatorRule } from '../../objects/rule';
import {
ALERT_RULE_METHOD,
@@ -70,7 +70,24 @@ import {
createAndActivateRule,
fillAboutRuleAndContinue,
fillDefineIndicatorMatchRuleAndContinue,
+ fillIndexAndIndicatorIndexPattern,
+ fillIndicatorMatchRow,
fillScheduleRuleAndContinue,
+ getCustomIndicatorQueryInput,
+ getCustomQueryInput,
+ getCustomQueryInvalidationText,
+ getDefineContinueButton,
+ getIndexPatternClearButton,
+ getIndexPatternInvalidationText,
+ getIndicatorAndButton,
+ getIndicatorAtLeastOneInvalidationText,
+ getIndicatorDeleteButton,
+ getIndicatorIndex,
+ getIndicatorIndexComboField,
+ getIndicatorIndicatorIndex,
+ getIndicatorInvalidationText,
+ getIndicatorMappingComboField,
+ getIndicatorOrButton,
selectIndicatorMatchType,
waitForAlertsToPopulate,
waitForTheRuleToBeExecuted,
@@ -92,14 +109,6 @@ describe('Detection rules, Indicator Match', () => {
cleanKibana();
esArchiverLoad('threat_indicator');
esArchiverLoad('threat_data');
- });
-
- afterEach(() => {
- esArchiverUnload('threat_indicator');
- esArchiverUnload('threat_data');
- });
-
- it('Creates and activates a new Indicator Match rule', () => {
loginAndWaitForPageWithoutDateRange(DETECTIONS_URL);
waitForAlertsPanelToBeLoaded();
waitForAlertsIndexToBeCreated();
@@ -107,89 +116,330 @@ describe('Detection rules, Indicator Match', () => {
waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded();
goToCreateNewRule();
selectIndicatorMatchType();
- fillDefineIndicatorMatchRuleAndContinue(newThreatIndicatorRule);
- fillAboutRuleAndContinue(newThreatIndicatorRule);
- fillScheduleRuleAndContinue(newThreatIndicatorRule);
- createAndActivateRule();
+ });
+
+ afterEach(() => {
+ esArchiverUnload('threat_indicator');
+ esArchiverUnload('threat_data');
+ });
- cy.get(CUSTOM_RULES_BTN).should('have.text', 'Custom rules (1)');
+ describe('Creating new indicator match rules', () => {
+ describe('Index patterns', () => {
+ it('Contains a predefined index pattern', () => {
+ getIndicatorIndex().should('have.text', indexPatterns.join(''));
+ });
- changeToThreeHundredRowsPerPage();
- waitForRulesToBeLoaded();
+ it('Does NOT show invalidation text on initial page load if indicator index pattern is filled out', () => {
+ getIndicatorIndicatorIndex().type(`${newThreatIndicatorRule.indicatorIndexPattern}{enter}`);
+ getDefineContinueButton().click();
+ getIndexPatternInvalidationText().should('not.exist');
+ });
- cy.get(RULES_TABLE).then(($table) => {
- cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRules);
+ it('Shows invalidation text when you try to continue without filling it out', () => {
+ getIndexPatternClearButton().click();
+ getIndicatorIndicatorIndex().type(`${newThreatIndicatorRule.indicatorIndexPattern}{enter}`);
+ getDefineContinueButton().click();
+ getIndexPatternInvalidationText().should('exist');
+ });
});
- filterByCustomRules();
+ describe('Indicator index patterns', () => {
+ it('Contains empty index pattern', () => {
+ getIndicatorIndicatorIndex().should('have.text', '');
+ });
+
+ it('Does NOT show invalidation text on initial page load', () => {
+ getIndexPatternInvalidationText().should('not.exist');
+ });
- cy.get(RULES_TABLE).then(($table) => {
- cy.wrap($table.find(RULES_ROW).length).should('eql', 1);
+ it('Shows invalidation text if you try to continue without filling it out', () => {
+ getDefineContinueButton().click();
+ getIndexPatternInvalidationText().should('exist');
+ });
});
- cy.get(RULE_NAME).should('have.text', newThreatIndicatorRule.name);
- cy.get(RISK_SCORE).should('have.text', newThreatIndicatorRule.riskScore);
- cy.get(SEVERITY).should('have.text', newThreatIndicatorRule.severity);
- cy.get(RULE_SWITCH).should('have.attr', 'aria-checked', 'true');
-
- goToRuleDetails();
-
- cy.get(RULE_NAME_HEADER).should('have.text', `${newThreatIndicatorRule.name}`);
- cy.get(ABOUT_RULE_DESCRIPTION).should('have.text', newThreatIndicatorRule.description);
- cy.get(ABOUT_DETAILS).within(() => {
- getDetails(SEVERITY_DETAILS).should('have.text', newThreatIndicatorRule.severity);
- getDetails(RISK_SCORE_DETAILS).should('have.text', newThreatIndicatorRule.riskScore);
- getDetails(REFERENCE_URLS_DETAILS).should((details) => {
- expect(removeExternalLinkText(details.text())).equal(expectedUrls);
- });
- getDetails(FALSE_POSITIVES_DETAILS).should('have.text', expectedFalsePositives);
- getDetails(MITRE_ATTACK_DETAILS).should((mitre) => {
- expect(removeExternalLinkText(mitre.text())).equal(expectedMitre);
- });
- getDetails(TAGS_DETAILS).should('have.text', expectedTags);
+
+ describe('custom query input', () => {
+ it('Has a default set of *:*', () => {
+ getCustomQueryInput().should('have.text', '*:*');
+ });
+
+ it('Shows invalidation text if text is removed', () => {
+ getCustomQueryInput().type('{selectall}{del}');
+ getCustomQueryInvalidationText().should('exist');
+ });
});
- cy.get(INVESTIGATION_NOTES_TOGGLE).click({ force: true });
- cy.get(ABOUT_INVESTIGATION_NOTES).should('have.text', INVESTIGATION_NOTES_MARKDOWN);
-
- cy.get(DEFINITION_DETAILS).within(() => {
- getDetails(INDEX_PATTERNS_DETAILS).should(
- 'have.text',
- newThreatIndicatorRule.index!.join('')
- );
- getDetails(CUSTOM_QUERY_DETAILS).should('have.text', '*:*');
- getDetails(RULE_TYPE_DETAILS).should('have.text', 'Indicator Match');
- getDetails(TIMELINE_TEMPLATE_DETAILS).should('have.text', 'None');
- getDetails(INDICATOR_INDEX_PATTERNS).should(
- 'have.text',
- newThreatIndicatorRule.indicatorIndexPattern.join('')
- );
- getDetails(INDICATOR_MAPPING).should(
- 'have.text',
- `${newThreatIndicatorRule.indicatorMapping} MATCHES ${newThreatIndicatorRule.indicatorIndexField}`
- );
- getDetails(INDICATOR_INDEX_QUERY).should('have.text', '*:*');
+
+ describe('custom indicator query input', () => {
+ it('Has a default set of *:*', () => {
+ getCustomIndicatorQueryInput().should('have.text', '*:*');
+ });
+
+ it('Shows invalidation text if text is removed', () => {
+ getCustomIndicatorQueryInput().type('{selectall}{del}');
+ getCustomQueryInvalidationText().should('exist');
+ });
});
- cy.get(SCHEDULE_DETAILS).within(() => {
- getDetails(RUNS_EVERY_DETAILS).should(
- 'have.text',
- `${newThreatIndicatorRule.runsEvery.interval}${newThreatIndicatorRule.runsEvery.type}`
- );
- getDetails(ADDITIONAL_LOOK_BACK_DETAILS).should(
- 'have.text',
- `${newThreatIndicatorRule.lookBack.interval}${newThreatIndicatorRule.lookBack.type}`
- );
+ describe('Indicator mapping', () => {
+ beforeEach(() => {
+ fillIndexAndIndicatorIndexPattern(
+ newThreatIndicatorRule.index,
+ newThreatIndicatorRule.indicatorIndexPattern
+ );
+ });
+
+ it('Does NOT show invalidation text on initial page load', () => {
+ getIndicatorInvalidationText().should('not.exist');
+ });
+
+ it('Shows invalidation text when you try to press continue without filling anything out', () => {
+ getDefineContinueButton().click();
+ getIndicatorAtLeastOneInvalidationText().should('exist');
+ });
+
+ it('Shows invalidation text when the "AND" button is pressed and both the mappings are blank', () => {
+ getIndicatorAndButton().click();
+ getIndicatorInvalidationText().should('exist');
+ });
+
+ it('Shows invalidation text when the "OR" button is pressed and both the mappings are blank', () => {
+ getIndicatorOrButton().click();
+ getIndicatorInvalidationText().should('exist');
+ });
+
+ it('Does NOT show invalidation text when there is a valid "index field" and a valid "indicator index field"', () => {
+ fillIndicatorMatchRow({
+ indexField: newThreatIndicatorRule.indicatorMapping,
+ indicatorIndexField: newThreatIndicatorRule.indicatorIndexField,
+ });
+ getDefineContinueButton().click();
+ getIndicatorInvalidationText().should('not.exist');
+ });
+
+ it('Shows invalidation text when there is an invalid "index field" and a valid "indicator index field"', () => {
+ fillIndicatorMatchRow({
+ indexField: 'non-existent-value',
+ indicatorIndexField: newThreatIndicatorRule.indicatorIndexField,
+ validColumns: 'indicatorField',
+ });
+ getDefineContinueButton().click();
+ getIndicatorInvalidationText().should('exist');
+ });
+
+ it('Shows invalidation text when there is a valid "index field" and an invalid "indicator index field"', () => {
+ fillIndicatorMatchRow({
+ indexField: newThreatIndicatorRule.indicatorMapping,
+ indicatorIndexField: 'non-existent-value',
+ validColumns: 'indexField',
+ });
+ getDefineContinueButton().click();
+ getIndicatorInvalidationText().should('exist');
+ });
+
+ it('Deletes the first row when you have two rows. Both rows valid rows of "index fields" and valid "indicator index fields". The second row should become the first row', () => {
+ fillIndicatorMatchRow({
+ indexField: newThreatIndicatorRule.indicatorMapping,
+ indicatorIndexField: newThreatIndicatorRule.indicatorIndexField,
+ });
+ getIndicatorAndButton().click();
+ fillIndicatorMatchRow({
+ rowNumber: 2,
+ indexField: 'agent.name',
+ indicatorIndexField: newThreatIndicatorRule.indicatorIndexField,
+ validColumns: 'indicatorField',
+ });
+ getIndicatorDeleteButton().click();
+ getIndicatorIndexComboField().should('have.text', 'agent.name');
+ getIndicatorMappingComboField().should(
+ 'have.text',
+ newThreatIndicatorRule.indicatorIndexField
+ );
+ getIndicatorIndexComboField(2).should('not.exist');
+ getIndicatorMappingComboField(2).should('not.exist');
+ });
+
+ it('Deletes the first row when you have two rows. Both rows have valid "index fields" and invalid "indicator index fields". The second row should become the first row', () => {
+ fillIndicatorMatchRow({
+ indexField: newThreatIndicatorRule.indicatorMapping,
+ indicatorIndexField: 'non-existent-value',
+ validColumns: 'indexField',
+ });
+ getIndicatorAndButton().click();
+ fillIndicatorMatchRow({
+ rowNumber: 2,
+ indexField: newThreatIndicatorRule.indicatorMapping,
+ indicatorIndexField: 'second-non-existent-value',
+ validColumns: 'indexField',
+ });
+ getIndicatorDeleteButton().click();
+ getIndicatorMappingComboField().should('have.text', 'second-non-existent-value');
+ getIndicatorIndexComboField(2).should('not.exist');
+ getIndicatorMappingComboField(2).should('not.exist');
+ });
+
+ it('Deletes the first row when you have two rows. Both rows have valid "indicator index fields" and invalid "index fields". The second row should become the first row', () => {
+ fillIndicatorMatchRow({
+ indexField: 'non-existent-value',
+ indicatorIndexField: newThreatIndicatorRule.indicatorIndexField,
+ validColumns: 'indicatorField',
+ });
+ getIndicatorAndButton().click();
+ fillIndicatorMatchRow({
+ rowNumber: 2,
+ indexField: 'second-non-existent-value',
+ indicatorIndexField: newThreatIndicatorRule.indicatorIndexField,
+ validColumns: 'indicatorField',
+ });
+ getIndicatorDeleteButton().click();
+ getIndicatorIndexComboField().should('have.text', 'second-non-existent-value');
+ getIndicatorIndexComboField(2).should('not.exist');
+ getIndicatorMappingComboField(2).should('not.exist');
+ });
+
+ it('Deletes the first row of data but not the UI elements and the text defaults back to the placeholder of Search', () => {
+ fillIndicatorMatchRow({
+ indexField: newThreatIndicatorRule.indicatorMapping,
+ indicatorIndexField: newThreatIndicatorRule.indicatorIndexField,
+ });
+ getIndicatorDeleteButton().click();
+ getIndicatorIndexComboField().should('text', 'Search');
+ getIndicatorMappingComboField().should('text', 'Search');
+ getIndicatorIndexComboField(2).should('not.exist');
+ getIndicatorMappingComboField(2).should('not.exist');
+ });
+
+ it('Deletes the second row when you have three rows. The first row is valid data, the second row is invalid data, and the third row is valid data. Third row should shift up correctly', () => {
+ fillIndicatorMatchRow({
+ indexField: newThreatIndicatorRule.indicatorMapping,
+ indicatorIndexField: newThreatIndicatorRule.indicatorIndexField,
+ });
+ getIndicatorAndButton().click();
+ fillIndicatorMatchRow({
+ rowNumber: 2,
+ indexField: 'non-existent-value',
+ indicatorIndexField: 'non-existent-value',
+ validColumns: 'none',
+ });
+ getIndicatorAndButton().click();
+ fillIndicatorMatchRow({
+ rowNumber: 3,
+ indexField: newThreatIndicatorRule.indicatorMapping,
+ indicatorIndexField: newThreatIndicatorRule.indicatorIndexField,
+ });
+ getIndicatorDeleteButton(2).click();
+ getIndicatorIndexComboField(1).should('text', newThreatIndicatorRule.indicatorMapping);
+ getIndicatorMappingComboField(1).should('text', newThreatIndicatorRule.indicatorIndexField);
+ getIndicatorIndexComboField(2).should('text', newThreatIndicatorRule.indicatorMapping);
+ getIndicatorMappingComboField(2).should('text', newThreatIndicatorRule.indicatorIndexField);
+ getIndicatorIndexComboField(3).should('not.exist');
+ getIndicatorMappingComboField(3).should('not.exist');
+ });
+
+ it('Can add two OR rows and delete the second row. The first row has invalid data and the second row has valid data. The first row is deleted and the second row shifts up correctly.', () => {
+ fillIndicatorMatchRow({
+ indexField: 'non-existent-value-one',
+ indicatorIndexField: 'non-existent-value-two',
+ validColumns: 'none',
+ });
+ getIndicatorOrButton().click();
+ fillIndicatorMatchRow({
+ rowNumber: 2,
+ indexField: newThreatIndicatorRule.indicatorMapping,
+ indicatorIndexField: newThreatIndicatorRule.indicatorIndexField,
+ });
+ getIndicatorDeleteButton().click();
+ getIndicatorIndexComboField().should('text', newThreatIndicatorRule.indicatorMapping);
+ getIndicatorMappingComboField().should('text', newThreatIndicatorRule.indicatorIndexField);
+ getIndicatorIndexComboField(2).should('not.exist');
+ getIndicatorMappingComboField(2).should('not.exist');
+ });
});
- waitForTheRuleToBeExecuted();
- waitForAlertsToPopulate();
-
- cy.get(NUMBER_OF_ALERTS).should('have.text', expectedNumberOfAlerts);
- cy.get(ALERT_RULE_NAME).first().should('have.text', newThreatIndicatorRule.name);
- cy.get(ALERT_RULE_VERSION).first().should('have.text', '1');
- cy.get(ALERT_RULE_METHOD).first().should('have.text', 'threat_match');
- cy.get(ALERT_RULE_SEVERITY)
- .first()
- .should('have.text', newThreatIndicatorRule.severity.toLowerCase());
- cy.get(ALERT_RULE_RISK_SCORE).first().should('have.text', newThreatIndicatorRule.riskScore);
+ it('Creates and activates a new Indicator Match rule', () => {
+ fillDefineIndicatorMatchRuleAndContinue(newThreatIndicatorRule);
+ fillAboutRuleAndContinue(newThreatIndicatorRule);
+ fillScheduleRuleAndContinue(newThreatIndicatorRule);
+ createAndActivateRule();
+
+ cy.get(CUSTOM_RULES_BTN).should('have.text', 'Custom rules (1)');
+
+ changeToThreeHundredRowsPerPage();
+ waitForRulesToBeLoaded();
+
+ cy.get(RULES_TABLE).then(($table) => {
+ cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRules);
+ });
+
+ filterByCustomRules();
+
+ cy.get(RULES_TABLE).then(($table) => {
+ cy.wrap($table.find(RULES_ROW).length).should('eql', 1);
+ });
+ cy.get(RULE_NAME).should('have.text', newThreatIndicatorRule.name);
+ cy.get(RISK_SCORE).should('have.text', newThreatIndicatorRule.riskScore);
+ cy.get(SEVERITY).should('have.text', newThreatIndicatorRule.severity);
+ cy.get(RULE_SWITCH).should('have.attr', 'aria-checked', 'true');
+
+ goToRuleDetails();
+
+ cy.get(RULE_NAME_HEADER).should('have.text', `${newThreatIndicatorRule.name}`);
+ cy.get(ABOUT_RULE_DESCRIPTION).should('have.text', newThreatIndicatorRule.description);
+ cy.get(ABOUT_DETAILS).within(() => {
+ getDetails(SEVERITY_DETAILS).should('have.text', newThreatIndicatorRule.severity);
+ getDetails(RISK_SCORE_DETAILS).should('have.text', newThreatIndicatorRule.riskScore);
+ getDetails(REFERENCE_URLS_DETAILS).should((details) => {
+ expect(removeExternalLinkText(details.text())).equal(expectedUrls);
+ });
+ getDetails(FALSE_POSITIVES_DETAILS).should('have.text', expectedFalsePositives);
+ getDetails(MITRE_ATTACK_DETAILS).should((mitre) => {
+ expect(removeExternalLinkText(mitre.text())).equal(expectedMitre);
+ });
+ getDetails(TAGS_DETAILS).should('have.text', expectedTags);
+ });
+ cy.get(INVESTIGATION_NOTES_TOGGLE).click({ force: true });
+ cy.get(ABOUT_INVESTIGATION_NOTES).should('have.text', INVESTIGATION_NOTES_MARKDOWN);
+
+ cy.get(DEFINITION_DETAILS).within(() => {
+ getDetails(INDEX_PATTERNS_DETAILS).should(
+ 'have.text',
+ newThreatIndicatorRule.index!.join('')
+ );
+ getDetails(CUSTOM_QUERY_DETAILS).should('have.text', '*:*');
+ getDetails(RULE_TYPE_DETAILS).should('have.text', 'Indicator Match');
+ getDetails(TIMELINE_TEMPLATE_DETAILS).should('have.text', 'None');
+ getDetails(INDICATOR_INDEX_PATTERNS).should(
+ 'have.text',
+ newThreatIndicatorRule.indicatorIndexPattern.join('')
+ );
+ getDetails(INDICATOR_MAPPING).should(
+ 'have.text',
+ `${newThreatIndicatorRule.indicatorMapping} MATCHES ${newThreatIndicatorRule.indicatorIndexField}`
+ );
+ getDetails(INDICATOR_INDEX_QUERY).should('have.text', '*:*');
+ });
+
+ cy.get(SCHEDULE_DETAILS).within(() => {
+ getDetails(RUNS_EVERY_DETAILS).should(
+ 'have.text',
+ `${newThreatIndicatorRule.runsEvery.interval}${newThreatIndicatorRule.runsEvery.type}`
+ );
+ getDetails(ADDITIONAL_LOOK_BACK_DETAILS).should(
+ 'have.text',
+ `${newThreatIndicatorRule.lookBack.interval}${newThreatIndicatorRule.lookBack.type}`
+ );
+ });
+
+ waitForTheRuleToBeExecuted();
+ waitForAlertsToPopulate();
+
+ cy.get(NUMBER_OF_ALERTS).should('have.text', expectedNumberOfAlerts);
+ cy.get(ALERT_RULE_NAME).first().should('have.text', newThreatIndicatorRule.name);
+ cy.get(ALERT_RULE_VERSION).first().should('have.text', '1');
+ cy.get(ALERT_RULE_METHOD).first().should('have.text', 'threat_match');
+ cy.get(ALERT_RULE_SEVERITY)
+ .first()
+ .should('have.text', newThreatIndicatorRule.severity.toLowerCase());
+ cy.get(ALERT_RULE_RISK_SCORE).first().should('have.text', newThreatIndicatorRule.riskScore);
+ });
});
});
diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts
index 2bfd2fbf0054c2..ac70a1cae148e7 100644
--- a/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts
+++ b/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts
@@ -47,7 +47,8 @@ import { openTimeline } from '../../tasks/timelines';
import { OVERVIEW_URL } from '../../urls/navigation';
-describe('Timelines', () => {
+// Skipped at the moment as there looks to be in-deterministic bugs with the open timeline dialog.
+describe.skip('Timelines', () => {
beforeEach(() => {
cleanKibana();
});
@@ -89,7 +90,7 @@ describe('Timelines', () => {
cy.get(FAVORITE_TIMELINE).should('exist');
cy.get(TIMELINE_TITLE).should('have.text', timeline.title);
- cy.get(TIMELINE_DESCRIPTION).should('have.text', timeline.description);
+ cy.get(TIMELINE_DESCRIPTION).should('have.text', timeline.description); // This is the flake part where it sometimes does not show/load the timelines correctly
cy.get(TIMELINE_QUERY).should('have.text', `${timeline.query} `);
cy.get(TIMELINE_FILTER(timeline.filter)).should('exist');
cy.get(PIN_EVENT)
diff --git a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts
index 66681e77b7eb9e..2a59dd33399c57 100644
--- a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts
+++ b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts
@@ -38,6 +38,22 @@ export const CUSTOM_QUERY_INPUT = '[data-test-subj="queryInput"]';
export const THREAT_MATCH_QUERY_INPUT =
'[data-test-subj="detectionEngineStepDefineThreatRuleQueryBar"] [data-test-subj="queryInput"]';
+export const THREAT_MATCH_AND_BUTTON = '[data-test-subj="andButton"]';
+
+export const THREAT_ITEM_ENTRY_DELETE_BUTTON = '[data-test-subj="itemEntryDeleteButton"]';
+
+export const THREAT_MATCH_OR_BUTTON = '[data-test-subj="orButton"]';
+
+export const THREAT_COMBO_BOX_INPUT = '[data-test-subj="fieldAutocompleteComboBox"]';
+
+export const INVALID_MATCH_CONTENT = 'All matches require both a field and threat index field.';
+
+export const AT_LEAST_ONE_VALID_MATCH = 'At least one indicator match is required.';
+
+export const AT_LEAST_ONE_INDEX_PATTERN = 'A minimum of one index pattern is required.';
+
+export const CUSTOM_QUERY_REQUIRED = 'A custom query is required.';
+
export const DEFINE_CONTINUE_BUTTON = '[data-test-subj="define-continue"]';
export const DEFINE_EDIT_BUTTON = '[data-test-subj="edit-define-rule"]';
diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts
index 7836960b1a6941..5143dc27e7d7ad 100644
--- a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts
+++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts
@@ -63,13 +63,20 @@ import {
EQL_QUERY_PREVIEW_HISTOGRAM,
EQL_QUERY_VALIDATION_SPINNER,
COMBO_BOX_CLEAR_BTN,
- COMBO_BOX_RESULT,
MITRE_ATTACK_TACTIC_DROPDOWN,
MITRE_ATTACK_TECHNIQUE_DROPDOWN,
MITRE_ATTACK_SUBTECHNIQUE_DROPDOWN,
MITRE_ATTACK_ADD_TACTIC_BUTTON,
MITRE_ATTACK_ADD_SUBTECHNIQUE_BUTTON,
MITRE_ATTACK_ADD_TECHNIQUE_BUTTON,
+ THREAT_COMBO_BOX_INPUT,
+ THREAT_ITEM_ENTRY_DELETE_BUTTON,
+ THREAT_MATCH_AND_BUTTON,
+ INVALID_MATCH_CONTENT,
+ THREAT_MATCH_OR_BUTTON,
+ AT_LEAST_ONE_VALID_MATCH,
+ AT_LEAST_ONE_INDEX_PATTERN,
+ CUSTOM_QUERY_REQUIRED,
} from '../screens/create_new_rule';
import { TOAST_ERROR } from '../screens/shared';
import { SERVER_SIDE_EVENT_COUNT } from '../screens/timeline';
@@ -144,7 +151,7 @@ export const fillAboutRuleAndContinue = (
rule: CustomRule | MachineLearningRule | ThresholdRule | ThreatIndicatorRule
) => {
fillAboutRule(rule);
- cy.get(ABOUT_CONTINUE_BTN).should('exist').click({ force: true });
+ getAboutContinueButton().should('exist').click({ force: true });
};
export const fillAboutRuleWithOverrideAndContinue = (rule: OverrideRule) => {
@@ -222,7 +229,7 @@ export const fillAboutRuleWithOverrideAndContinue = (rule: OverrideRule) => {
cy.get(COMBO_BOX_INPUT).type(`${rule.timestampOverride}{enter}`);
});
- cy.get(ABOUT_CONTINUE_BTN).should('exist').click({ force: true });
+ getAboutContinueButton().should('exist').click({ force: true });
};
export const fillDefineCustomRuleWithImportedQueryAndContinue = (
@@ -282,19 +289,132 @@ export const fillDefineEqlRuleAndContinue = (rule: CustomRule) => {
cy.get(EQL_QUERY_INPUT).should('not.exist');
};
+/**
+ * Fills in the indicator match rows for tests by giving it an optional rowNumber,
+ * a indexField, a indicatorIndexField, and an optional validRows which indicates
+ * which row is valid or not.
+ *
+ * There are special tricks below with Eui combo box:
+ * cy.get(`button[title="${indexField}"]`)
+ * .should('be.visible')
+ * .then(([e]) => e.click());
+ *
+ * To first ensure the button is there before clicking on the button. There are
+ * race conditions where if the Eui drop down button from the combo box is not
+ * visible then the click handler is not there either, and when we click on it
+ * that will cause the item to _not_ be selected. Using a {enter} with the combo
+ * box also does not select things from EuiCombo boxes either, so I have to click
+ * the actual contents of the EuiCombo box to select things.
+ */
+export const fillIndicatorMatchRow = ({
+ rowNumber,
+ indexField,
+ indicatorIndexField,
+ validColumns,
+}: {
+ rowNumber?: number; // default is 1
+ indexField: string;
+ indicatorIndexField: string;
+ validColumns?: 'indexField' | 'indicatorField' | 'both' | 'none'; // default is both are valid entries
+}) => {
+ const computedRowNumber = rowNumber == null ? 1 : rowNumber;
+ const computedValueRows = validColumns == null ? 'both' : validColumns;
+ const OFFSET = 2;
+ cy.get(COMBO_BOX_INPUT)
+ .eq(computedRowNumber * OFFSET + 1)
+ .type(indexField);
+ if (computedValueRows === 'indexField' || computedValueRows === 'both') {
+ cy.get(`button[title="${indexField}"]`)
+ .should('be.visible')
+ .then(([e]) => e.click());
+ }
+ cy.get(COMBO_BOX_INPUT)
+ .eq(computedRowNumber * OFFSET + 2)
+ .type(indicatorIndexField);
+
+ if (computedValueRows === 'indicatorField' || computedValueRows === 'both') {
+ cy.get(`button[title="${indicatorIndexField}"]`)
+ .should('be.visible')
+ .then(([e]) => e.click());
+ }
+};
+
+/**
+ * Fills in both the index pattern and the indicator match index pattern.
+ * @param indexPattern The index pattern.
+ * @param indicatorIndex The indicator index pattern.
+ */
+export const fillIndexAndIndicatorIndexPattern = (
+ indexPattern?: string[],
+ indicatorIndex?: string[]
+) => {
+ getIndexPatternClearButton().click();
+ getIndicatorIndex().type(`${indexPattern}{enter}`);
+ getIndicatorIndicatorIndex().type(`${indicatorIndex}{enter}`);
+};
+
+/** Returns the indicator index drop down field. Pass in row number, default is 1 */
+export const getIndicatorIndexComboField = (row = 1) =>
+ cy.get(THREAT_COMBO_BOX_INPUT).eq(row * 2 - 2);
+
+/** Returns the indicator mapping drop down field. Pass in row number, default is 1 */
+export const getIndicatorMappingComboField = (row = 1) =>
+ cy.get(THREAT_COMBO_BOX_INPUT).eq(row * 2 - 1);
+
+/** Returns the indicator matches DELETE button for the mapping. Pass in row number, default is 1 */
+export const getIndicatorDeleteButton = (row = 1) =>
+ cy.get(THREAT_ITEM_ENTRY_DELETE_BUTTON).eq(row - 1);
+
+/** Returns the indicator matches AND button for the mapping */
+export const getIndicatorAndButton = () => cy.get(THREAT_MATCH_AND_BUTTON);
+
+/** Returns the indicator matches OR button for the mapping */
+export const getIndicatorOrButton = () => cy.get(THREAT_MATCH_OR_BUTTON);
+
+/** Returns the invalid match content. */
+export const getIndicatorInvalidationText = () => cy.contains(INVALID_MATCH_CONTENT);
+
+/** Returns that at least one valid match is required content */
+export const getIndicatorAtLeastOneInvalidationText = () => cy.contains(AT_LEAST_ONE_VALID_MATCH);
+
+/** Returns that at least one index pattern is required content */
+export const getIndexPatternInvalidationText = () => cy.contains(AT_LEAST_ONE_INDEX_PATTERN);
+
+/** Returns the continue button on the step of about */
+export const getAboutContinueButton = () => cy.get(ABOUT_CONTINUE_BTN);
+
+/** Returns the continue button on the step of define */
+export const getDefineContinueButton = () => cy.get(DEFINE_CONTINUE_BUTTON);
+
+/** Returns the indicator index pattern */
+export const getIndicatorIndex = () => cy.get(COMBO_BOX_INPUT).eq(0);
+
+/** Returns the indicator's indicator index */
+export const getIndicatorIndicatorIndex = () => cy.get(COMBO_BOX_INPUT).eq(2);
+
+/** Returns the index pattern's clear button */
+export const getIndexPatternClearButton = () => cy.get(COMBO_BOX_CLEAR_BTN);
+
+/** Returns the custom query input */
+export const getCustomQueryInput = () => cy.get(CUSTOM_QUERY_INPUT).eq(0);
+
+/** Returns the custom query input */
+export const getCustomIndicatorQueryInput = () => cy.get(CUSTOM_QUERY_INPUT).eq(1);
+
+/** Returns custom query required content */
+export const getCustomQueryInvalidationText = () => cy.contains(CUSTOM_QUERY_REQUIRED);
+
+/**
+ * Fills in the define indicator match rules and then presses the continue button
+ * @param rule The rule to use to fill in everything
+ */
export const fillDefineIndicatorMatchRuleAndContinue = (rule: ThreatIndicatorRule) => {
- const INDEX_PATTERNS = 0;
- const INDICATOR_INDEX_PATTERN = 2;
- const INDICATOR_MAPPING = 3;
- const INDICATOR_INDEX_FIELD = 4;
-
- cy.get(COMBO_BOX_CLEAR_BTN).click();
- cy.get(COMBO_BOX_INPUT).eq(INDEX_PATTERNS).type(`${rule.index}{enter}`);
- cy.get(COMBO_BOX_INPUT).eq(INDICATOR_INDEX_PATTERN).type(`${rule.indicatorIndexPattern}{enter}`);
- cy.get(COMBO_BOX_INPUT).eq(INDICATOR_MAPPING).type(`${rule.indicatorMapping}{enter}`);
- cy.get(COMBO_BOX_RESULT).first().click();
- cy.get(COMBO_BOX_INPUT).eq(INDICATOR_INDEX_FIELD).type(`${rule.indicatorIndexField}{enter}`);
- cy.get(DEFINE_CONTINUE_BUTTON).should('exist').click({ force: true });
+ fillIndexAndIndicatorIndexPattern(rule.index, rule.indicatorIndexPattern);
+ fillIndicatorMatchRow({
+ indexField: rule.indicatorMapping,
+ indicatorIndexField: rule.indicatorIndexField,
+ });
+ getDefineContinueButton().should('exist').click({ force: true });
cy.get(CUSTOM_QUERY_INPUT).should('not.exist');
};
@@ -304,7 +424,7 @@ export const fillDefineMachineLearningRuleAndContinue = (rule: MachineLearningRu
cy.get(ANOMALY_THRESHOLD_INPUT).type(`{selectall}${machineLearningRule.anomalyScoreThreshold}`, {
force: true,
});
- cy.get(DEFINE_CONTINUE_BUTTON).should('exist').click({ force: true });
+ getDefineContinueButton().should('exist').click({ force: true });
cy.get(MACHINE_LEARNING_DROPDOWN).should('not.exist');
};
diff --git a/x-pack/plugins/security_solution/cypress/tasks/timelines.ts b/x-pack/plugins/security_solution/cypress/tasks/timelines.ts
index a04ecb1f9ccaa4..c2b5790b1ae123 100644
--- a/x-pack/plugins/security_solution/cypress/tasks/timelines.ts
+++ b/x-pack/plugins/security_solution/cypress/tasks/timelines.ts
@@ -19,7 +19,10 @@ export const exportTimeline = (timelineId: string) => {
};
export const openTimeline = (id: string) => {
- cy.get(TIMELINE(id), { timeout: 500 }).click();
+ // This temporary wait here is to reduce flakeyness until we integrate cypress-pipe. Then please let us use cypress pipe.
+ // Ref: https://www.cypress.io/blog/2019/01/22/when-can-the-test-click/
+ // Ref: https://github.com/NicholasBoll/cypress-pipe#readme
+ cy.get(TIMELINE(id)).should('be.visible').wait(1500).click();
};
export const waitForTimelinesPanelToBeLoaded = () => {
diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.test.tsx
index 36033c358766df..ce6ca7ebc22ddf 100644
--- a/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.test.tsx
@@ -22,6 +22,7 @@ describe('EntryItem', () => {
const wrapper = mount(
{
const wrapper = mount(
{
expect(mockOnChange).toHaveBeenCalledWith(
{
+ id: '123',
field: 'machine.os',
type: 'mapping',
value: 'ip',
@@ -97,6 +100,7 @@ describe('EntryItem', () => {
const wrapper = mount(
{
onChange: (a: EuiComboBoxOptionOption[]) => void;
}).onChange([{ label: 'is not' }]);
- expect(mockOnChange).toHaveBeenCalledWith({ field: 'ip', type: 'mapping', value: '' }, 0);
+ expect(mockOnChange).toHaveBeenCalledWith(
+ { id: '123', field: 'ip', type: 'mapping', value: '' },
+ 0
+ );
});
});
diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.tsx
index c99e63ff4eda08..51b724bff2e5d0 100644
--- a/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.tsx
@@ -75,7 +75,11 @@ export const EntryItem: React.FC = ({
);
} else {
- return comboBox;
+ return (
+
+ {comboBox}
+
+ );
}
}, [handleFieldChange, indexPattern, entry, showLabel]);
@@ -101,7 +105,11 @@ export const EntryItem: React.FC = ({
);
} else {
- return comboBox;
+ return (
+
+ {comboBox}
+
+ );
}
}, [handleThreatFieldChange, threatIndexPatterns, entry, showLabel]);
diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.test.tsx
index b4f97808b54c41..b3a74c76977152 100644
--- a/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.test.tsx
@@ -21,6 +21,10 @@ import {
} from './helpers';
import { ThreatMapEntry } from '../../../../common/detection_engine/schemas/types';
+jest.mock('uuid', () => ({
+ v4: jest.fn().mockReturnValue('123'),
+}));
+
const getMockIndexPattern = (): IndexPattern =>
({
id: '1234',
@@ -29,6 +33,7 @@ const getMockIndexPattern = (): IndexPattern =>
} as IndexPattern);
const getMockEntry = (): FormattedEntry => ({
+ id: '123',
field: getField('ip'),
value: getField('ip'),
type: 'mapping',
@@ -42,6 +47,7 @@ describe('Helpers', () => {
afterEach(() => {
moment.tz.setDefault('Browser');
+ jest.clearAllMocks();
});
describe('#getFormattedEntry', () => {
@@ -70,6 +76,7 @@ describe('Helpers', () => {
const output = getFormattedEntry(payloadIndexPattern, payloadIndexPattern, payloadItem, 0);
const expected: FormattedEntry = {
entryIndex: 0,
+ id: '123',
field: {
name: 'machine.os.raw.text',
type: 'string',
@@ -94,6 +101,7 @@ describe('Helpers', () => {
const output = getFormattedEntries(payloadIndexPattern, payloadIndexPattern, payloadItems);
const expected: FormattedEntry[] = [
{
+ id: '123',
entryIndex: 0,
field: undefined,
value: undefined,
@@ -109,6 +117,7 @@ describe('Helpers', () => {
const output = getFormattedEntries(payloadIndexPattern, payloadIndexPattern, payloadItems);
const expected: FormattedEntry[] = [
{
+ id: '123',
entryIndex: 0,
field: {
name: 'machine.os',
@@ -134,6 +143,7 @@ describe('Helpers', () => {
const output = getFormattedEntries(payloadIndexPattern, threatIndexPattern, payloadItems);
const expected: FormattedEntry[] = [
{
+ id: '123',
entryIndex: 0,
field: {
name: 'machine.os',
@@ -170,6 +180,7 @@ describe('Helpers', () => {
const output = getFormattedEntries(payloadIndexPattern, payloadIndexPattern, payloadItems);
const expected: FormattedEntry[] = [
{
+ id: '123',
field: {
name: 'machine.os',
type: 'string',
@@ -194,6 +205,7 @@ describe('Helpers', () => {
entryIndex: 0,
},
{
+ id: '123',
field: {
name: 'ip',
type: 'ip',
@@ -249,9 +261,10 @@ describe('Helpers', () => {
const payloadItem = getMockEntry();
const payloadIFieldType = getField('ip');
const output = getEntryOnFieldChange(payloadItem, payloadIFieldType);
- const expected: { updatedEntry: Entry; index: number } = {
+ const expected: { updatedEntry: Entry & { id: string }; index: number } = {
index: 0,
updatedEntry: {
+ id: '123',
field: 'ip',
type: 'mapping',
value: 'ip',
diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx
index 349dae76301d49..90a996c06e4924 100644
--- a/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx
@@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import uuid from 'uuid';
import {
ThreatMap,
threatMap,
@@ -12,6 +13,7 @@ import {
import { IndexPattern, IFieldType } from '../../../../../../../src/plugins/data/common';
import { Entry, FormattedEntry, ThreatMapEntries, EmptyEntry } from './types';
+import { addIdToItem } from '../../utils/add_remove_id_to_item';
/**
* Formats the entry into one that is easily usable for the UI.
@@ -24,7 +26,8 @@ export const getFormattedEntry = (
indexPattern: IndexPattern,
threatIndexPatterns: IndexPattern,
item: Entry,
- itemIndex: number
+ itemIndex: number,
+ uuidGen: () => string = uuid.v4
): FormattedEntry => {
const { fields } = indexPattern;
const { fields: threatFields } = threatIndexPatterns;
@@ -34,7 +37,9 @@ export const getFormattedEntry = (
const [threatFoundField] = threatFields.filter(
({ name }) => threatField != null && threatField === name
);
+ const maybeId: typeof item & { id?: string } = item;
return {
+ id: maybeId.id ?? uuidGen(),
field: foundField,
type: 'mapping',
value: threatFoundField,
@@ -90,10 +95,11 @@ export const getEntryOnFieldChange = (
const { entryIndex } = item;
return {
updatedEntry: {
+ id: item.id,
field: newField != null ? newField.name : '',
type: 'mapping',
value: item.value != null ? item.value.name : '',
- },
+ } as Entry, // Cast to Entry since id is only used as a react key prop and can be ignored elsewhere
index: entryIndex,
};
};
@@ -112,30 +118,33 @@ export const getEntryOnThreatFieldChange = (
const { entryIndex } = item;
return {
updatedEntry: {
+ id: item.id,
field: item.field != null ? item.field.name : '',
type: 'mapping',
value: newField != null ? newField.name : '',
- },
+ } as Entry, // Cast to Entry since id is only used as a react key prop and can be ignored elsewhere
index: entryIndex,
};
};
-export const getDefaultEmptyEntry = (): EmptyEntry => ({
- field: '',
- type: 'mapping',
- value: '',
-});
+export const getDefaultEmptyEntry = (): EmptyEntry => {
+ return addIdToItem({
+ field: '',
+ type: 'mapping',
+ value: '',
+ });
+};
export const getNewItem = (): ThreatMap => {
- return {
+ return addIdToItem({
entries: [
- {
+ addIdToItem({
field: '',
type: 'mapping',
value: '',
- },
+ }),
],
- };
+ });
};
export const filterItems = (items: ThreatMapEntries[]): ThreatMapping => {
diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/index.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/index.tsx
index d3936e10bd877a..8aa4af21b03ccb 100644
--- a/x-pack/plugins/security_solution/public/common/components/threat_match/index.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/threat_match/index.tsx
@@ -158,43 +158,45 @@ export const ThreatMatchComponent = ({
}, []);
return (
- {entries.map((entryListItem, index) => (
-
-
- {index !== 0 &&
- (andLogicIncluded ? (
-
-
-
-
-
-
-
-
-
-
- ) : (
-
-
-
- ))}
-
-
-
-
-
- ))}
+ {entries.map((entryListItem, index) => {
+ const key = (entryListItem as typeof entryListItem & { id?: string }).id ?? `${index}`;
+ return (
+
+
+ {index !== 0 &&
+ (andLogicIncluded ? (
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
+ ))}
+
+
+
+
+
+ );
+ })}
diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/list_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/list_item.test.tsx
index 90492bc46e2b0b..66af24025656e6 100644
--- a/x-pack/plugins/security_solution/public/common/components/threat_match/list_item.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/threat_match/list_item.test.tsx
@@ -68,7 +68,6 @@ describe('ListItemComponent', () => {
({ eui: euiLightVars, darkMode: false })}>
{
({ eui: euiLightVars, darkMode: false })}>
{
({ eui: euiLightVars, darkMode: false })}>
{
({ eui: euiLightVars, darkMode: false })}>
{
const wrapper = mount(
{
const wrapper = mount(
{
const wrapper = mount(
{
const wrapper = mount(
{
const wrapper = mount(
(
({
listItem,
- listId,
listItemIndex,
indexPattern,
threatIndexPatterns,
@@ -88,7 +86,7 @@ export const ListItemComponent = React.memo(
{entries.map((item, index) => (
-
+
({
+ v4: jest.fn().mockReturnValue('123'),
+}));
+
const initialState: State = {
andLogicIncluded: false,
entries: [],
@@ -22,6 +26,10 @@ const getEntry = (): ThreatMapEntry => ({
});
describe('reducer', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
describe('#setEntries', () => {
test('should return "andLogicIncluded" ', () => {
const update = reducer()(initialState, {
diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/types.ts b/x-pack/plugins/security_solution/public/common/components/threat_match/types.ts
index 0cbd885db2d546..f3af5faaed25c0 100644
--- a/x-pack/plugins/security_solution/public/common/components/threat_match/types.ts
+++ b/x-pack/plugins/security_solution/public/common/components/threat_match/types.ts
@@ -7,6 +7,7 @@ import { ThreatMap, ThreatMapEntry } from '../../../../common/detection_engine/s
import { IFieldType } from '../../../../../../../src/plugins/data/common';
export interface FormattedEntry {
+ id: string;
field: IFieldType | undefined;
type: 'mapping';
value: IFieldType | undefined;
diff --git a/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.test.ts b/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.test.ts
new file mode 100644
index 00000000000000..fa067a53f25731
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.test.ts
@@ -0,0 +1,76 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { addIdToItem, removeIdFromItem } from './add_remove_id_to_item';
+
+jest.mock('uuid', () => ({
+ v4: jest.fn().mockReturnValue('123'),
+}));
+
+describe('add_remove_id_to_item', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('addIdToItem', () => {
+ test('it adds an id to an empty item', () => {
+ expect(addIdToItem({})).toEqual({ id: '123' });
+ });
+
+ test('it adds a complex object', () => {
+ expect(
+ addIdToItem({
+ field: '',
+ type: 'mapping',
+ value: '',
+ })
+ ).toEqual({
+ id: '123',
+ field: '',
+ type: 'mapping',
+ value: '',
+ });
+ });
+
+ test('it adds an id to an existing item', () => {
+ expect(addIdToItem({ test: '456' })).toEqual({ id: '123', test: '456' });
+ });
+
+ test('it does not change the id if it already exists', () => {
+ expect(addIdToItem({ id: '456' })).toEqual({ id: '456' });
+ });
+
+ test('it returns the same reference if it has an id already', () => {
+ const obj = { id: '456' };
+ expect(addIdToItem(obj)).toBe(obj);
+ });
+
+ test('it returns a new reference if it adds an id to an item', () => {
+ const obj = { test: '456' };
+ expect(addIdToItem(obj)).not.toBe(obj);
+ });
+ });
+
+ describe('removeIdFromItem', () => {
+ test('it removes an id from an item', () => {
+ expect(removeIdFromItem({ id: '456' })).toEqual({});
+ });
+
+ test('it returns a new reference if it removes an id from an item', () => {
+ const obj = { id: '123', test: '456' };
+ expect(removeIdFromItem(obj)).not.toBe(obj);
+ });
+
+ test('it does not effect an item without an id', () => {
+ expect(removeIdFromItem({ test: '456' })).toEqual({ test: '456' });
+ });
+
+ test('it returns the same reference if it does not have an id already', () => {
+ const obj = { test: '456' };
+ expect(removeIdFromItem(obj)).toBe(obj);
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.ts b/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.ts
new file mode 100644
index 00000000000000..a74cf8680fa485
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.ts
@@ -0,0 +1,49 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import uuid from 'uuid';
+
+/**
+ * This is useful for when you have arrays without an ID and need to add one for
+ * ReactJS keys. I break the types slightly by introducing an id to an arbitrary item
+ * but then cast it back to the regular type T.
+ * Usage of this could be considered tech debt as I am adding an ID when the backend
+ * could be doing the same thing but it depends on how you want to model your data and
+ * if you view modeling your data with id's to please ReactJS a good or bad thing.
+ * @param item The item to add an id to.
+ */
+type NotArray = T extends unknown[] ? never : T;
+export const addIdToItem = (item: NotArray): T => {
+ const maybeId: typeof item & { id?: string } = item;
+ if (maybeId.id != null) {
+ return item;
+ } else {
+ return { ...item, id: uuid.v4() };
+ }
+};
+
+/**
+ * This is to reverse the id you added to your arrays for ReactJS keys.
+ * @param item The item to remove the id from.
+ */
+export const removeIdFromItem = (
+ item: NotArray
+):
+ | T
+ | Pick<
+ T & {
+ id?: string | undefined;
+ },
+ Exclude
+ > => {
+ const maybeId: typeof item & { id?: string } = item;
+ if (maybeId.id != null) {
+ const { id, ...noId } = maybeId;
+ return noId;
+ } else {
+ return item;
+ }
+};
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_privilege_user.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_privilege_user.tsx
index b72dd3b2f84dd7..191c3955caa9ba 100644
--- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_privilege_user.tsx
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_privilege_user.tsx
@@ -50,7 +50,7 @@ export const usePrivilegeUser = (): ReturnPrivilegeUser => {
const abortCtrl = new AbortController();
setLoading(true);
- async function fetchData() {
+ const fetchData = async () => {
try {
const privilege = await getUserPrivilege({
signal: abortCtrl.signal,
@@ -89,15 +89,14 @@ export const usePrivilegeUser = (): ReturnPrivilegeUser => {
if (isSubscribed) {
setLoading(false);
}
- }
+ };
fetchData();
return () => {
isSubscribed = false;
abortCtrl.abort();
};
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
+ }, [dispatchToaster]);
return { loading, ...privilegeUser };
};
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx
index 3bef1d8edd048e..9022e3a32163c4 100644
--- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx
@@ -46,7 +46,7 @@ export const useQueryAlerts = (
let isSubscribed = true;
const abortCtrl = new AbortController();
- async function fetchData() {
+ const fetchData = async () => {
try {
setLoading(true);
const alertResponse = await fetchQueryAlerts({
@@ -77,7 +77,7 @@ export const useQueryAlerts = (
if (isSubscribed) {
setLoading(false);
}
- }
+ };
fetchData();
return () => {
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.tsx
index 5ebdb38b8dd5c5..bfdc1d1ceee215 100644
--- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.tsx
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.tsx
@@ -106,8 +106,7 @@ export const useSignalIndex = (): ReturnSignalIndex => {
isSubscribed = false;
abortCtrl.abort();
};
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
+ }, [dispatchToaster]);
return { loading, ...signalIndex };
};
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/transforms.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/transforms.ts
new file mode 100644
index 00000000000000..7821bb23a7ca38
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/transforms.ts
@@ -0,0 +1,98 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { flow } from 'fp-ts/lib/function';
+import { addIdToItem, removeIdFromItem } from '../../../../common/utils/add_remove_id_to_item';
+import {
+ CreateRulesSchema,
+ UpdateRulesSchema,
+} from '../../../../../common/detection_engine/schemas/request';
+import { Rule } from './types';
+
+// These are a collection of transforms that are UI specific and useful for UI concerns
+// that are inserted between the API and the actual user interface. In some ways these
+// might be viewed as technical debt or to compensate for the differences and preferences
+// of how ReactJS might prefer data vs. how we want to model data. Each function should have
+// a description giving context around the transform.
+
+/**
+ * Transforms the output of rules to compensate for technical debt or UI concerns such as
+ * ReactJS preferences for having ids within arrays if the data is not modeled that way.
+ *
+ * If you add a new transform of the output called "myNewTransform" do it
+ * in the form of:
+ * flow(removeIdFromThreatMatchArray, myNewTransform)(rule)
+ *
+ * @param rule The rule to transform the output of
+ * @returns The rule transformed from the output
+ */
+export const transformOutput = (
+ rule: CreateRulesSchema | UpdateRulesSchema
+): CreateRulesSchema | UpdateRulesSchema => flow(removeIdFromThreatMatchArray)(rule);
+
+/**
+ * Transforms the output of rules to compensate for technical debt or UI concerns such as
+ * ReactJS preferences for having ids within arrays if the data is not modeled that way.
+ *
+ * If you add a new transform of the input called "myNewTransform" do it
+ * in the form of:
+ * flow(addIdToThreatMatchArray, myNewTransform)(rule)
+ *
+ * @param rule The rule to transform the output of
+ * @returns The rule transformed from the output
+ */
+export const transformInput = (rule: Rule): Rule => flow(addIdToThreatMatchArray)(rule);
+
+/**
+ * This adds an id to the incoming threat match arrays as ReactJS prefers to have
+ * an id added to them for use as a stable id. Later if we decide to change the data
+ * model to have id's within the array then this code should be removed. If not, then
+ * this code should stay as an adapter for ReactJS.
+ *
+ * This does break the type system slightly as we are lying a bit to the type system as we return
+ * the same rule as we have previously but are augmenting the arrays with an id which TypeScript
+ * doesn't mind us doing here. However, downstream you will notice that you have an id when the type
+ * does not indicate it. In that case just cast this temporarily if you're using the id. If you're not,
+ * you can ignore the id and just use the normal TypeScript with ReactJS.
+ *
+ * @param rule The rule to add an id to the threat matches.
+ * @returns rule The rule but with id added to the threat array and entries
+ */
+export const addIdToThreatMatchArray = (rule: Rule): Rule => {
+ if (rule.type === 'threat_match' && rule.threat_mapping != null) {
+ const threatMapWithId = rule.threat_mapping.map((mapping) => {
+ const newEntries = mapping.entries.map((entry) => addIdToItem(entry));
+ return addIdToItem({ entries: newEntries });
+ });
+ return { ...rule, threat_mapping: threatMapWithId };
+ } else {
+ return rule;
+ }
+};
+
+/**
+ * This removes an id from the threat match arrays as ReactJS prefers to have
+ * an id added to them for use as a stable id. Later if we decide to change the data
+ * model to have id's within the array then this code should be removed. If not, then
+ * this code should stay as an adapter for ReactJS.
+ *
+ * @param rule The rule to remove an id from the threat matches.
+ * @returns rule The rule but with id removed from the threat array and entries
+ */
+export const removeIdFromThreatMatchArray = (
+ rule: CreateRulesSchema | UpdateRulesSchema
+): CreateRulesSchema | UpdateRulesSchema => {
+ if (rule.type === 'threat_match' && rule.threat_mapping != null) {
+ const threatMapWithoutId = rule.threat_mapping.map((mapping) => {
+ const newEntries = mapping.entries.map((entry) => removeIdFromItem(entry));
+ const newMapping = removeIdFromItem(mapping);
+ return { ...newMapping, entries: newEntries };
+ });
+ return { ...rule, threat_mapping: threatMapWithoutId };
+ } else {
+ return rule;
+ }
+};
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_create_rule.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_create_rule.tsx
index 2bbd27994fc771..fe8e0fd8ceb970 100644
--- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_create_rule.tsx
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_create_rule.tsx
@@ -11,6 +11,7 @@ import { CreateRulesSchema } from '../../../../../common/detection_engine/schema
import { createRule } from './api';
import * as i18n from './translations';
+import { transformOutput } from './transforms';
interface CreateRuleReturn {
isLoading: boolean;
@@ -29,11 +30,11 @@ export const useCreateRule = (): ReturnCreateRule => {
let isSubscribed = true;
const abortCtrl = new AbortController();
setIsSaved(false);
- async function saveRule() {
+ const saveRule = async () => {
if (rule != null) {
try {
setIsLoading(true);
- await createRule({ rule, signal: abortCtrl.signal });
+ await createRule({ rule: transformOutput(rule), signal: abortCtrl.signal });
if (isSubscribed) {
setIsSaved(true);
}
@@ -46,15 +47,14 @@ export const useCreateRule = (): ReturnCreateRule => {
setIsLoading(false);
}
}
- }
+ };
saveRule();
return () => {
isSubscribed = false;
abortCtrl.abort();
};
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [rule]);
+ }, [rule, dispatchToaster]);
return [{ isLoading, isSaved }, setRule];
};
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.tsx
index d83d4e0caa9771..bdbe13af401517 100644
--- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.tsx
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.tsx
@@ -262,8 +262,14 @@ export const usePrePackagedRules = ({
isSubscribed = false;
abortCtrl.abort();
};
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [canUserCRUD, hasIndexWrite, isAuthenticated, hasEncryptionKey, isSignalIndexExists]);
+ }, [
+ canUserCRUD,
+ hasIndexWrite,
+ isAuthenticated,
+ hasEncryptionKey,
+ isSignalIndexExists,
+ dispatchToaster,
+ ]);
const prePackagedRuleStatus = useMemo(
() =>
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.tsx
index 706c2645a4dddc..3b84558d344e7f 100644
--- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.tsx
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.tsx
@@ -8,6 +8,7 @@ import { useEffect, useState } from 'react';
import { errorToToaster, useStateToaster } from '../../../../common/components/toasters';
import { fetchRuleById } from './api';
+import { transformInput } from './transforms';
import * as i18n from './translations';
import { Rule } from './types';
@@ -28,13 +29,15 @@ export const useRule = (id: string | undefined): ReturnRule => {
let isSubscribed = true;
const abortCtrl = new AbortController();
- async function fetchData(idToFetch: string) {
+ const fetchData = async (idToFetch: string) => {
try {
setLoading(true);
- const ruleResponse = await fetchRuleById({
- id: idToFetch,
- signal: abortCtrl.signal,
- });
+ const ruleResponse = transformInput(
+ await fetchRuleById({
+ id: idToFetch,
+ signal: abortCtrl.signal,
+ })
+ );
if (isSubscribed) {
setRule(ruleResponse);
}
@@ -47,7 +50,7 @@ export const useRule = (id: string | undefined): ReturnRule => {
if (isSubscribed) {
setLoading(false);
}
- }
+ };
if (id != null) {
fetchData(id);
}
@@ -55,8 +58,7 @@ export const useRule = (id: string | undefined): ReturnRule => {
isSubscribed = false;
abortCtrl.abort();
};
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [id]);
+ }, [id, dispatchToaster]);
return [loading, rule];
};
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_async.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_async.tsx
index fbca46097dcd9c..48bfe71b4722bf 100644
--- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_async.tsx
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_async.tsx
@@ -6,12 +6,14 @@
import { useEffect, useCallback } from 'react';
+import { flow } from 'fp-ts/lib/function';
import { useAsync, withOptionalSignal } from '../../../../shared_imports';
import { useHttp } from '../../../../common/lib/kibana';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import { pureFetchRuleById } from './api';
import { Rule } from './types';
import * as i18n from './translations';
+import { transformInput } from './transforms';
export interface UseRuleAsync {
error: unknown;
@@ -20,11 +22,15 @@ export interface UseRuleAsync {
rule: Rule | null;
}
-const _fetchRule = withOptionalSignal(pureFetchRuleById);
-const _useRuleAsync = () => useAsync(_fetchRule);
+const _fetchRule = flow(withOptionalSignal(pureFetchRuleById), async (rule: Promise) =>
+ transformInput(await rule)
+);
+
+/** This does not use "_useRuleAsyncInternal" as that would deactivate the useHooks linter rule, so instead it has the word "Internal" post-pended */
+const useRuleAsyncInternal = () => useAsync(_fetchRule);
export const useRuleAsync = (ruleId: string): UseRuleAsync => {
- const { start, loading, result, error } = _useRuleAsync();
+ const { start, loading, result, error } = useRuleAsyncInternal();
const http = useHttp();
const { addError } = useAppToasts();
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_status.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_status.tsx
index ddf50e9edae518..2bec8f9a2d0a24 100644
--- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_status.tsx
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_status.tsx
@@ -64,8 +64,7 @@ export const useRuleStatus = (id: string | undefined | null): ReturnRuleStatus =
isSubscribed = false;
abortCtrl.abort();
};
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [id]);
+ }, [id, dispatchToaster]);
return [loading, ruleStatus, fetchRuleStatus.current];
};
@@ -122,8 +121,7 @@ export const useRulesStatuses = (rules: Rules): ReturnRulesStatuses => {
isSubscribed = false;
abortCtrl.abort();
};
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [rules]);
+ }, [rules, dispatchToaster]);
return { loading, rulesStatuses };
};
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_tags.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_tags.tsx
index 038f974e1394ec..bab419813e1aa0 100644
--- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_tags.tsx
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_tags.tsx
@@ -26,7 +26,7 @@ export const useTags = (): ReturnTags => {
let isSubscribed = true;
const abortCtrl = new AbortController();
- async function fetchData() {
+ const fetchData = async () => {
setLoading(true);
try {
const fetchTagsResult = await fetchTags({
@@ -44,7 +44,7 @@ export const useTags = (): ReturnTags => {
if (isSubscribed) {
setLoading(false);
}
- }
+ };
fetchData();
reFetchTags.current = fetchData;
@@ -53,8 +53,7 @@ export const useTags = (): ReturnTags => {
isSubscribed = false;
abortCtrl.abort();
};
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
+ }, [dispatchToaster]);
return [loading, tags, reFetchTags.current];
};
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_update_rule.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_update_rule.tsx
index a437974e93ba30..729336b697e4d3 100644
--- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_update_rule.tsx
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_update_rule.tsx
@@ -9,6 +9,8 @@ import { useEffect, useState, Dispatch } from 'react';
import { errorToToaster, useStateToaster } from '../../../../common/components/toasters';
import { UpdateRulesSchema } from '../../../../../common/detection_engine/schemas/request';
+import { transformOutput } from './transforms';
+
import { updateRule } from './api';
import * as i18n from './translations';
@@ -29,11 +31,11 @@ export const useUpdateRule = (): ReturnUpdateRule => {
let isSubscribed = true;
const abortCtrl = new AbortController();
setIsSaved(false);
- async function saveRule() {
+ const saveRule = async () => {
if (rule != null) {
try {
setIsLoading(true);
- await updateRule({ rule, signal: abortCtrl.signal });
+ await updateRule({ rule: transformOutput(rule), signal: abortCtrl.signal });
if (isSubscribed) {
setIsSaved(true);
}
@@ -46,15 +48,14 @@ export const useUpdateRule = (): ReturnUpdateRule => {
setIsLoading(false);
}
}
- }
+ };
saveRule();
return () => {
isSubscribed = false;
abortCtrl.abort();
};
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [rule]);
+ }, [rule, dispatchToaster]);
return [{ isLoading, isSaved }, setRule];
};
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx
index 0d585b44638153..86f24594fc57ed 100644
--- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx
@@ -356,19 +356,20 @@ export const getMonitoringColumns = (
truncateText: true,
width: '14%',
},
- {
- field: 'current_status.last_look_back_date',
- name: i18n.COLUMN_LAST_LOOKBACK_DATE,
- render: (value: RuleStatus['current_status']['last_look_back_date']) => {
- return value == null ? (
- getEmptyTagValue()
- ) : (
-
- );
- },
- truncateText: true,
- width: '16%',
- },
+ // hiding this field until after 7.11 release
+ // {
+ // field: 'current_status.last_look_back_date',
+ // name: i18n.COLUMN_LAST_LOOKBACK_DATE,
+ // render: (value: RuleStatus['current_status']['last_look_back_date']) => {
+ // return value == null ? (
+ // getEmptyTagValue()
+ // ) : (
+ //
+ // );
+ // },
+ // truncateText: true,
+ // width: '16%',
+ // },
{
field: 'current_status.status_date',
name: i18n.COLUMN_LAST_COMPLETE_RUN,
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts
index 2d993c7be08b01..f7066cd42e4c17 100644
--- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts
@@ -353,7 +353,7 @@ export const COLUMN_QUERY_TIMES = i18n.translate(
export const COLUMN_GAP = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.columns.gap',
{
- defaultMessage: 'Gap (if any)',
+ defaultMessage: 'Last Gap (if any)',
}
);
diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json
index 28ef79beb72cfd..d0634d6cd87a26 100644
--- a/x-pack/plugins/translations/translations/ja-JP.json
+++ b/x-pack/plugins/translations/translations/ja-JP.json
@@ -12595,7 +12595,6 @@
"xpack.ml.dataframe.analytics.create.duplicateIndexPatternErrorMessageError": "インデックスパターン{indexPatternName}はすでに作成されています。",
"xpack.ml.dataframe.analytics.create.errorCheckingIndexExists": "既存のインデックス名の取得中に次のエラーが発生しました:{error}",
"xpack.ml.dataframe.analytics.create.errorCreatingDataFrameAnalyticsJob": "データフレーム分析ジョブの作成中にエラーが発生しました。",
- "xpack.ml.dataframe.analytics.create.errorGettingDataFrameAnalyticsList": "既存のデータフレーム分析ジョブIDの取得中にエラーが発生しました。",
"xpack.ml.dataframe.analytics.create.errorGettingIndexPatternTitles": "既存のインデックスパターンのタイトルの取得中にエラーが発生しました。",
"xpack.ml.dataframe.analytics.create.errorStartingDataFrameAnalyticsJob": "データフレーム分析ジョブの開始中にエラーが発生しました。",
"xpack.ml.dataframe.analytics.create.etaInputAriaLabel": "縮小が重みに適用されました",
diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json
index 052a00b1aefa48..4ca6d11aa8940f 100644
--- a/x-pack/plugins/translations/translations/zh-CN.json
+++ b/x-pack/plugins/translations/translations/zh-CN.json
@@ -12624,7 +12624,6 @@
"xpack.ml.dataframe.analytics.create.duplicateIndexPatternErrorMessageError": "索引模式 {indexPatternName} 已存在。",
"xpack.ml.dataframe.analytics.create.errorCheckingIndexExists": "获取现有索引名称时发生以下错误:{error}",
"xpack.ml.dataframe.analytics.create.errorCreatingDataFrameAnalyticsJob": "创建数据帧分析作业时发生错误:",
- "xpack.ml.dataframe.analytics.create.errorGettingDataFrameAnalyticsList": "获取现有数据帧分析作业 ID 时发生错误:",
"xpack.ml.dataframe.analytics.create.errorGettingIndexPatternTitles": "获取现有索引模式标题时发生错误:",
"xpack.ml.dataframe.analytics.create.errorStartingDataFrameAnalyticsJob": "启动数据帧分析作业时发生错误:",
"xpack.ml.dataframe.analytics.create.etaInputAriaLabel": "缩小量已应用于权重",
diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.test.tsx
index 28b74c5affbdf1..b3d20a6acd3e38 100644
--- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.test.tsx
+++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.test.tsx
@@ -59,10 +59,13 @@ describe('useBarChartsHooks', () => {
const firstChartItems = result.current[0];
const lastChartItems = result.current[4];
- // first chart items last item should be x 199, since we only display 150 items
+ // first chart items last item should be x 149, since we only display 150 items
expect(firstChartItems[firstChartItems.length - 1].x).toBe(CANVAS_MAX_ITEMS - 1);
- // since here are 5 charts, last chart first item should be x 800
+ // first chart will only contain x values from 0 - 149;
+ expect(firstChartItems.find((item) => item.x > 149)).toBe(undefined);
+
+ // since here are 5 charts, last chart first item should be x 600
expect(lastChartItems[0].x).toBe(CANVAS_MAX_ITEMS * 4);
expect(lastChartItems[lastChartItems.length - 1].x).toBe(CANVAS_MAX_ITEMS * 5 - 1);
});
diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.ts
index 3345b30f5239f5..7beb0be28902b8 100644
--- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.ts
+++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.ts
@@ -17,17 +17,16 @@ export const useBarCharts = ({ data = [] }: UseBarHookProps) => {
useEffect(() => {
if (data.length > 0) {
- let chartIndex = 1;
+ let chartIndex = 0;
- const firstCanvasItems = data.filter((item) => item.x <= CANVAS_MAX_ITEMS);
-
- const chartsN: Array = [firstCanvasItems];
+ const chartsN: Array = [];
data.forEach((item) => {
// Subtract 1 to account for x value starting from 0
if (item.x === CANVAS_MAX_ITEMS * chartIndex && !chartsN[item.x / CANVAS_MAX_ITEMS]) {
- chartsN.push([]);
+ chartsN.push([item]);
chartIndex++;
+ return;
}
chartsN[chartIndex - 1].push(item);
});
diff --git a/x-pack/test/api_integration/apis/search/search.ts b/x-pack/test/api_integration/apis/search/search.ts
index 0c08b834a27783..2115976bcced1a 100644
--- a/x-pack/test/api_integration/apis/search/search.ts
+++ b/x-pack/test/api_integration/apis/search/search.ts
@@ -6,6 +6,7 @@
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
+import { verifyErrorResponse } from '../../../../../test/api_integration/apis/search/verify_error';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
@@ -90,6 +91,23 @@ export default function ({ getService }: FtrProviderContext) {
expect(resp2.body.isRunning).to.be(false);
});
+ it('should fail without kbn-xref header', async () => {
+ const resp = await supertest
+ .post(`/internal/search/ese`)
+ .send({
+ params: {
+ body: {
+ query: {
+ match_all: {},
+ },
+ },
+ },
+ })
+ .expect(400);
+
+ verifyErrorResponse(resp.body, 400, 'Request must contain a kbn-xsrf header.');
+ });
+
it('should return 400 when unknown index type is provided', async () => {
const resp = await supertest
.post(`/internal/search/ese`)
@@ -106,7 +124,7 @@ export default function ({ getService }: FtrProviderContext) {
})
.expect(400);
- expect(resp.body.message).to.contain('Unknown indexType');
+ verifyErrorResponse(resp.body, 400, 'Unknown indexType');
});
it('should return 400 if invalid id is provided', async () => {
@@ -124,7 +142,7 @@ export default function ({ getService }: FtrProviderContext) {
})
.expect(400);
- expect(resp.body.message).to.contain('illegal_argument_exception');
+ verifyErrorResponse(resp.body, 400, 'illegal_argument_exception', true);
});
it('should return 404 if unkown id is provided', async () => {
@@ -143,12 +161,11 @@ export default function ({ getService }: FtrProviderContext) {
},
})
.expect(404);
-
- expect(resp.body.message).to.contain('resource_not_found_exception');
+ verifyErrorResponse(resp.body, 404, 'resource_not_found_exception', true);
});
it('should return 400 with a bad body', async () => {
- await supertest
+ const resp = await supertest
.post(`/internal/search/ese`)
.set('kbn-xsrf', 'foo')
.send({
@@ -160,6 +177,8 @@ export default function ({ getService }: FtrProviderContext) {
},
})
.expect(400);
+
+ verifyErrorResponse(resp.body, 400, 'parsing_exception', true);
});
});
@@ -186,8 +205,7 @@ export default function ({ getService }: FtrProviderContext) {
},
})
.expect(400);
-
- expect(resp.body.message).to.contain('illegal_argument_exception');
+ verifyErrorResponse(resp.body, 400, 'illegal_argument_exception', true);
});
it('should return 400 if rollup search is without non-existent index', async () => {
@@ -207,7 +225,7 @@ export default function ({ getService }: FtrProviderContext) {
})
.expect(400);
- expect(resp.body.message).to.contain('illegal_argument_exception');
+ verifyErrorResponse(resp.body, 400, 'illegal_argument_exception', true);
});
it('should rollup search', async () => {
@@ -241,7 +259,7 @@ export default function ({ getService }: FtrProviderContext) {
.set('kbn-xsrf', 'foo')
.send()
.expect(400);
- expect(resp.body.message).to.contain('illegal_argument_exception');
+ verifyErrorResponse(resp.body, 400, 'illegal_argument_exception', true);
});
it('should delete a search', async () => {
diff --git a/x-pack/tsconfig.json b/x-pack/tsconfig.json
index b198c0b5464f18..1e49e272de8031 100644
--- a/x-pack/tsconfig.json
+++ b/x-pack/tsconfig.json
@@ -7,6 +7,7 @@
"plugins/apm/e2e/cypress/**/*",
"plugins/apm/ftr_e2e/**/*",
"plugins/apm/scripts/**/*",
+ "plugins/canvas/**/*",
"plugins/console_extensions/**/*",
"plugins/data_enhanced/**/*",
"plugins/discover_enhanced/**/*",
@@ -23,6 +24,7 @@
"plugins/maps/**/*",
"plugins/maps_file_upload/**/*",
"plugins/maps_legacy_licensing/**/*",
+ "plugins/reporting/**/*",
"plugins/searchprofiler/**/*",
"plugins/security_solution/cypress/**/*",
"plugins/task_manager/**/*",
@@ -51,15 +53,13 @@
},
"references": [
{ "path": "../src/core/tsconfig.json" },
- { "path": "../src/plugins/telemetry_management_section/tsconfig.json" },
- { "path": "../src/plugins/management/tsconfig.json" },
{ "path": "../src/plugins/bfetch/tsconfig.json" },
{ "path": "../src/plugins/charts/tsconfig.json" },
{ "path": "../src/plugins/console/tsconfig.json" },
{ "path": "../src/plugins/dashboard/tsconfig.json" },
- { "path": "../src/plugins/discover/tsconfig.json" },
{ "path": "../src/plugins/data/tsconfig.json" },
{ "path": "../src/plugins/dev_tools/tsconfig.json" },
+ { "path": "../src/plugins/discover/tsconfig.json" },
{ "path": "../src/plugins/embeddable/tsconfig.json" },
{ "path": "../src/plugins/es_ui_shared/tsconfig.json" },
{ "path": "../src/plugins/expressions/tsconfig.json" },
@@ -69,53 +69,55 @@
{ "path": "../src/plugins/kibana_react/tsconfig.json" },
{ "path": "../src/plugins/kibana_usage_collection/tsconfig.json" },
{ "path": "../src/plugins/kibana_utils/tsconfig.json" },
+ { "path": "../src/plugins/management/tsconfig.json" },
{ "path": "../src/plugins/navigation/tsconfig.json" },
{ "path": "../src/plugins/newsfeed/tsconfig.json" },
- { "path": "../src/plugins/saved_objects/tsconfig.json" },
+ { "path": "../src/plugins/presentation_util/tsconfig.json" },
{ "path": "../src/plugins/saved_objects_management/tsconfig.json" },
{ "path": "../src/plugins/saved_objects_tagging_oss/tsconfig.json" },
- { "path": "../src/plugins/presentation_util/tsconfig.json" },
+ { "path": "../src/plugins/saved_objects/tsconfig.json" },
{ "path": "../src/plugins/security_oss/tsconfig.json" },
{ "path": "../src/plugins/share/tsconfig.json" },
- { "path": "../src/plugins/telemetry/tsconfig.json" },
{ "path": "../src/plugins/telemetry_collection_manager/tsconfig.json" },
- { "path": "../src/plugins/url_forwarding/tsconfig.json" },
+ { "path": "../src/plugins/telemetry_management_section/tsconfig.json" },
+ { "path": "../src/plugins/telemetry/tsconfig.json" },
{ "path": "../src/plugins/ui_actions/tsconfig.json" },
{ "path": "../src/plugins/url_forwarding/tsconfig.json" },
{ "path": "../src/plugins/usage_collection/tsconfig.json" },
+ { "path": "./plugins/actions/tsconfig.json"},
+ { "path": "./plugins/alerts/tsconfig.json"},
+ { "path": "./plugins/beats_management/tsconfig.json" },
+ { "path": "./plugins/canvas/tsconfig.json" },
+ { "path": "./plugins/cloud/tsconfig.json" },
{ "path": "./plugins/console_extensions/tsconfig.json" },
{ "path": "./plugins/data_enhanced/tsconfig.json" },
{ "path": "./plugins/discover_enhanced/tsconfig.json" },
- { "path": "./plugins/global_search/tsconfig.json" },
- { "path": "./plugins/global_search_providers/tsconfig.json" },
+ { "path": "./plugins/embeddable_enhanced/tsconfig.json" },
+ { "path": "./plugins/encrypted_saved_objects/tsconfig.json" },
{ "path": "./plugins/enterprise_search/tsconfig.json" },
+ { "path": "./plugins/event_log/tsconfig.json" },
{ "path": "./plugins/features/tsconfig.json" },
+ { "path": "./plugins/global_search_bar/tsconfig.json" },
+ { "path": "./plugins/global_search_providers/tsconfig.json" },
+ { "path": "./plugins/global_search/tsconfig.json" },
{ "path": "./plugins/graph/tsconfig.json" },
- { "path": "./plugins/embeddable_enhanced/tsconfig.json" },
- { "path": "./plugins/event_log/tsconfig.json" },
- { "path": "./plugins/licensing/tsconfig.json" },
{ "path": "./plugins/lens/tsconfig.json" },
- { "path": "./plugins/maps/tsconfig.json" },
+ { "path": "./plugins/license_management/tsconfig.json" },
+ { "path": "./plugins/licensing/tsconfig.json" },
{ "path": "./plugins/maps_file_upload/tsconfig.json" },
{ "path": "./plugins/maps_legacy_licensing/tsconfig.json" },
+ { "path": "./plugins/maps/tsconfig.json" },
+ { "path": "./plugins/painless_lab/tsconfig.json" },
+ { "path": "./plugins/saved_objects_tagging/tsconfig.json" },
{ "path": "./plugins/searchprofiler/tsconfig.json" },
+ { "path": "./plugins/security/tsconfig.json" },
+ { "path": "./plugins/spaces/tsconfig.json" },
+ { "path": "./plugins/stack_alerts/tsconfig.json"},
{ "path": "./plugins/task_manager/tsconfig.json" },
{ "path": "./plugins/telemetry_collection_xpack/tsconfig.json" },
- { "path": "./plugins/ui_actions_enhanced/tsconfig.json" },
{ "path": "./plugins/translations/tsconfig.json" },
- { "path": "./plugins/spaces/tsconfig.json" },
- { "path": "./plugins/security/tsconfig.json" },
- { "path": "./plugins/encrypted_saved_objects/tsconfig.json" },
- { "path": "./plugins/beats_management/tsconfig.json" },
- { "path": "./plugins/cloud/tsconfig.json" },
- { "path": "./plugins/saved_objects_tagging/tsconfig.json" },
- { "path": "./plugins/global_search_bar/tsconfig.json" },
- { "path": "./plugins/actions/tsconfig.json"},
- { "path": "./plugins/alerts/tsconfig.json"},
{ "path": "./plugins/triggers_actions_ui/tsconfig.json"},
- { "path": "./plugins/stack_alerts/tsconfig.json"},
- { "path": "./plugins/license_management/tsconfig.json" },
- { "path": "./plugins/painless_lab/tsconfig.json" },
+ { "path": "./plugins/ui_actions_enhanced/tsconfig.json" },
{ "path": "./plugins/watcher/tsconfig.json" },
{ "path": "./plugins/runtime_fields/tsconfig.json" },
{ "path": "./plugins/index_management/tsconfig.json" },
diff --git a/x-pack/tsconfig.refs.json b/x-pack/tsconfig.refs.json
index 0605b8d3df0778..4700f8efb16114 100644
--- a/x-pack/tsconfig.refs.json
+++ b/x-pack/tsconfig.refs.json
@@ -3,26 +3,37 @@
"references": [
{ "path": "./plugins/actions/tsconfig.json"},
{ "path": "./plugins/alerts/tsconfig.json"},
- { "path": "./plugins/dashboard_enhanced/tsconfig.json" },
- { "path": "./plugins/licensing/tsconfig.json" },
- { "path": "./plugins/lens/tsconfig.json" },
+ { "path": "./plugins/beats_management/tsconfig.json" },
+ { "path": "./plugins/canvas/tsconfig.json" },
+ { "path": "./plugins/cloud/tsconfig.json" },
{ "path": "./plugins/console_extensions/tsconfig.json" },
- { "path": "./plugins/discover_enhanced/tsconfig.json" },
+ { "path": "./plugins/dashboard_enhanced/tsconfig.json" },
{ "path": "./plugins/data_enhanced/tsconfig.json" },
- { "path": "./plugins/global_search/tsconfig.json" },
- { "path": "./plugins/global_search_providers/tsconfig.json" },
+ { "path": "./plugins/discover_enhanced/tsconfig.json" },
+ { "path": "./plugins/embeddable_enhanced/tsconfig.json" },
+ { "path": "./plugins/encrypted_saved_objects/tsconfig.json" },
+ { "path": "./plugins/enterprise_search/tsconfig.json" },
{ "path": "./plugins/event_log/tsconfig.json"},
{ "path": "./plugins/features/tsconfig.json" },
+ { "path": "./plugins/global_search_bar/tsconfig.json" },
+ { "path": "./plugins/global_search_providers/tsconfig.json" },
+ { "path": "./plugins/global_search/tsconfig.json" },
{ "path": "./plugins/graph/tsconfig.json" },
- { "path": "./plugins/embeddable_enhanced/tsconfig.json" },
- { "path": "./plugins/enterprise_search/tsconfig.json" },
- { "path": "./plugins/maps/tsconfig.json" },
+ { "path": "./plugins/lens/tsconfig.json" },
+ { "path": "./plugins/license_management/tsconfig.json" },
+ { "path": "./plugins/licensing/tsconfig.json" },
{ "path": "./plugins/maps_file_upload/tsconfig.json" },
{ "path": "./plugins/maps_legacy_licensing/tsconfig.json" },
+ { "path": "./plugins/maps/tsconfig.json" },
+ { "path": "./plugins/painless_lab/tsconfig.json" },
+ { "path": "./plugins/reporting/tsconfig.json" },
+ { "path": "./plugins/saved_objects_tagging/tsconfig.json" },
{ "path": "./plugins/searchprofiler/tsconfig.json" },
+ { "path": "./plugins/security/tsconfig.json" },
+ { "path": "./plugins/spaces/tsconfig.json" },
+ { "path": "./plugins/stack_alerts/tsconfig.json"},
{ "path": "./plugins/task_manager/tsconfig.json" },
{ "path": "./plugins/telemetry_collection_xpack/tsconfig.json" },
- { "path": "./plugins/ui_actions_enhanced/tsconfig.json" },
{ "path": "./plugins/translations/tsconfig.json" },
{ "path": "./plugins/triggers_actions_ui/tsconfig.json"},
{ "path": "./plugins/spaces/tsconfig.json" },
@@ -35,8 +46,9 @@
{ "path": "./plugins/global_search_bar/tsconfig.json" },
{ "path": "./plugins/license_management/tsconfig.json" },
{ "path": "./plugins/painless_lab/tsconfig.json" },
- { "path": "./plugins/watcher/tsconfig.json" },
{ "path": "./plugins/runtime_fields/tsconfig.json" },
- { "path": "./plugins/index_management/tsconfig.json" }
+ { "path": "./plugins/index_management/tsconfig.json" },
+ { "path": "./plugins/ui_actions_enhanced/tsconfig.json" },
+ { "path": "./plugins/watcher/tsconfig.json" }
]
}