From c52f5edfcc2738fd8f5481d679561dc5e9533f71 Mon Sep 17 00:00:00 2001 From: Davis Plumlee <56367316+dplumlee@users.noreply.github.com> Date: Mon, 21 Jun 2021 18:34:11 -0400 Subject: [PATCH 01/57] [Security Solution][Exceptions] Fixes empty exceptions filter bug (#102583) --- packages/kbn-securitysolution-list-utils/src/helpers/index.ts | 4 ++++ .../public/exceptions/components/builder/helpers.test.ts | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/kbn-securitysolution-list-utils/src/helpers/index.ts b/packages/kbn-securitysolution-list-utils/src/helpers/index.ts index a483da152ac895..d208624b69fc5e 100644 --- a/packages/kbn-securitysolution-list-utils/src/helpers/index.ts +++ b/packages/kbn-securitysolution-list-utils/src/helpers/index.ts @@ -95,6 +95,10 @@ export const filterExceptionItems = ( } }, []); + if (entries.length === 0) { + return acc; + } + const item = { ...exception, entries }; if (exceptionListItemSchema.is(item)) { diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/helpers.test.ts b/x-pack/plugins/lists/public/exceptions/components/builder/helpers.test.ts index ec46038c397e5c..212db40f3168cc 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/helpers.test.ts +++ b/x-pack/plugins/lists/public/exceptions/components/builder/helpers.test.ts @@ -1697,9 +1697,9 @@ describe('Exception builder helpers', () => { namespaceType: 'single', ruleName: 'rule name', }); - const exceptions = filterExceptionItems([{ ...rest, meta }]); + const exceptions = filterExceptionItems([{ ...rest, entries: [getEntryMatchMock()], meta }]); - expect(exceptions).toEqual([{ ...rest, entries: [], meta: undefined }]); + expect(exceptions).toEqual([{ ...rest, entries: [getEntryMatchMock()], meta: undefined }]); }); }); From 3084de6782ce245b3635a7240fbdaa3980d97b8b Mon Sep 17 00:00:00 2001 From: Spencer Date: Mon, 21 Jun 2021 15:36:43 -0700 Subject: [PATCH 02/57] [kbn/test/es] remove unnecessary es user management logic (#102584) Co-authored-by: spalger Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../kbn-test/src/functional_tests/lib/auth.ts | 188 ------------------ .../functional_tests/lib/run_elasticsearch.ts | 23 +-- packages/kbn-test/src/index.ts | 2 - src/core/test_helpers/kbn_server.ts | 25 +-- 4 files changed, 3 insertions(+), 235 deletions(-) delete mode 100644 packages/kbn-test/src/functional_tests/lib/auth.ts diff --git a/packages/kbn-test/src/functional_tests/lib/auth.ts b/packages/kbn-test/src/functional_tests/lib/auth.ts deleted file mode 100644 index abd1e0f9e7d5e9..00000000000000 --- a/packages/kbn-test/src/functional_tests/lib/auth.ts +++ /dev/null @@ -1,188 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import fs from 'fs'; -import util from 'util'; -import { format as formatUrl } from 'url'; -import request from 'request'; -import type { ToolingLog } from '@kbn/dev-utils'; - -export const DEFAULT_SUPERUSER_PASS = 'changeme'; -const readFile = util.promisify(fs.readFile); - -function delay(delayMs: number) { - return new Promise((res) => setTimeout(res, delayMs)); -} - -interface UpdateCredentialsOptions { - port: number; - auth: string; - username: string; - password: string; - retries?: number; - protocol: string; - caCert?: Buffer | string; -} -async function updateCredentials({ - port, - auth, - username, - password, - retries = 10, - protocol, - caCert, -}: UpdateCredentialsOptions): Promise { - const result = await new Promise<{ body: any; httpResponse: request.Response }>( - (resolve, reject) => - request( - { - method: 'PUT', - uri: formatUrl({ - protocol: `${protocol}:`, - auth, - hostname: 'localhost', - port, - pathname: `/_security/user/${username}/_password`, - }), - json: true, - body: { password }, - ca: caCert, - }, - (err, httpResponse, body) => { - if (err) return reject(err); - resolve({ httpResponse, body }); - } - ) - ); - - const { body, httpResponse } = result; - const { statusCode } = httpResponse; - - if (statusCode === 200) { - return; - } - - if (retries > 0) { - await delay(2500); - return await updateCredentials({ - port, - auth, - username, - password, - retries: retries - 1, - protocol, - caCert, - }); - } - - throw new Error(`${statusCode} response, expected 200 -- ${JSON.stringify(body)}`); -} - -interface SetupUsersOptions { - log: ToolingLog; - esPort: number; - updates: Array<{ username: string; password: string; roles?: string[] }>; - protocol?: string; - caPath?: string; -} - -export async function setupUsers({ - log, - esPort, - updates, - protocol = 'http', - caPath, -}: SetupUsersOptions): Promise { - // track the current credentials for the `elastic` user as - // they will likely change as we apply updates - let auth = `elastic:${DEFAULT_SUPERUSER_PASS}`; - const caCert = caPath ? await readFile(caPath) : undefined; - - for (const { username, password, roles } of updates) { - // If working with a built-in user, just change the password - if (['logstash_system', 'elastic', 'kibana'].includes(username)) { - await updateCredentials({ port: esPort, auth, username, password, protocol, caCert }); - log.info('setting %j user password to %j', username, password); - - // If not a builtin user, add them - } else { - await insertUser({ port: esPort, auth, username, password, roles, protocol, caCert }); - log.info('Added %j user with password to %j', username, password); - } - - if (username === 'elastic') { - auth = `elastic:${password}`; - } - } -} - -interface InserUserOptions { - port: number; - auth: string; - username: string; - password: string; - roles?: string[]; - retries?: number; - protocol: string; - caCert?: Buffer | string; -} -async function insertUser({ - port, - auth, - username, - password, - roles = [], - retries = 10, - protocol, - caCert, -}: InserUserOptions): Promise { - const result = await new Promise<{ body: any; httpResponse: request.Response }>( - (resolve, reject) => - request( - { - method: 'POST', - uri: formatUrl({ - protocol: `${protocol}:`, - auth, - hostname: 'localhost', - port, - pathname: `/_security/user/${username}`, - }), - json: true, - body: { password, roles }, - ca: caCert, - }, - (err, httpResponse, body) => { - if (err) return reject(err); - resolve({ httpResponse, body }); - } - ) - ); - - const { body, httpResponse } = result; - const { statusCode } = httpResponse; - if (statusCode === 200) { - return; - } - - if (retries > 0) { - await delay(2500); - return await insertUser({ - port, - auth, - username, - password, - roles, - retries: retries - 1, - protocol, - caCert, - }); - } - - throw new Error(`${statusCode} response, expected 200 -- ${JSON.stringify(body)}`); -} diff --git a/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts b/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts index 7ba9a3c1c4733e..da83d8285a6b5f 100644 --- a/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts +++ b/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts @@ -12,8 +12,6 @@ import { KIBANA_ROOT } from './paths'; import type { Config } from '../../functional_test_runner/'; import { createTestEsCluster } from '../../es'; -import { setupUsers, DEFAULT_SUPERUSER_PASS } from './auth'; - interface RunElasticsearchOptions { log: ToolingLog; esFrom: string; @@ -34,9 +32,7 @@ export async function runElasticsearch({ const cluster = createTestEsCluster({ port: config.get('servers.elasticsearch.port'), - password: isSecurityEnabled - ? DEFAULT_SUPERUSER_PASS - : config.get('servers.elasticsearch.password'), + password: isSecurityEnabled ? 'changeme' : config.get('servers.elasticsearch.password'), license, log, basePath: resolve(KIBANA_ROOT, '.es'), @@ -49,22 +45,5 @@ export async function runElasticsearch({ await cluster.start(); - if (isSecurityEnabled) { - await setupUsers({ - log, - esPort: config.get('servers.elasticsearch.port'), - updates: [config.get('servers.elasticsearch'), config.get('servers.kibana')], - protocol: config.get('servers.elasticsearch').protocol, - caPath: getRelativeCertificateAuthorityPath(config.get('kbnTestServer.serverArgs')), - }); - } - return cluster; } - -function getRelativeCertificateAuthorityPath(esConfig: string[] = []) { - const caConfig = esConfig.find( - (config) => config.indexOf('--elasticsearch.ssl.certificateAuthorities') === 0 - ); - return caConfig ? caConfig.split('=')[1] : undefined; -} diff --git a/packages/kbn-test/src/index.ts b/packages/kbn-test/src/index.ts index dd5343b0118b3f..af100a33ea3a78 100644 --- a/packages/kbn-test/src/index.ts +++ b/packages/kbn-test/src/index.ts @@ -29,8 +29,6 @@ export { esTestConfig, createTestEsCluster } from './es'; export { kbnTestConfig, kibanaServerTestUser, kibanaTestUser, adminTestUser } from './kbn'; -export { setupUsers, DEFAULT_SUPERUSER_PASS } from './functional_tests/lib/auth'; - export { readConfigFile } from './functional_test_runner/lib/config/read_config_file'; export { runFtrCli } from './functional_test_runner/cli'; diff --git a/src/core/test_helpers/kbn_server.ts b/src/core/test_helpers/kbn_server.ts index ba22ecb3b63768..2995ffd08e5c07 100644 --- a/src/core/test_helpers/kbn_server.ts +++ b/src/core/test_helpers/kbn_server.ts @@ -7,15 +7,7 @@ */ import { ToolingLog, REPO_ROOT } from '@kbn/dev-utils'; -import { - createTestEsCluster, - DEFAULT_SUPERUSER_PASS, - esTestConfig, - kbnTestConfig, - kibanaServerTestUser, - kibanaTestUser, - setupUsers, -} from '@kbn/test'; +import { createTestEsCluster, esTestConfig, kibanaServerTestUser, kibanaTestUser } from '@kbn/test'; import { defaultsDeep } from 'lodash'; import { resolve } from 'path'; import { BehaviorSubject } from 'rxjs'; @@ -208,7 +200,6 @@ export function createTestServers({ defaultsDeep({}, settings.es ?? {}, { log, license, - password: license === 'trial' ? DEFAULT_SUPERUSER_PASS : undefined, }) ); @@ -224,19 +215,7 @@ export function createTestServers({ await es.start(); if (['gold', 'trial'].includes(license)) { - await setupUsers({ - log, - esPort: esTestConfig.getUrlParts().port, - updates: [ - ...usersToBeAdded, - // user elastic - esTestConfig.getUrlParts() as { username: string; password: string }, - // user kibana - kbnTestConfig.getUrlParts() as { username: string; password: string }, - ], - }); - - // Override provided configs, we know what the elastic user is now + // Override provided configs kbnSettings.elasticsearch = { hosts: [esTestConfig.getUrl()], username: kibanaServerTestUser.username, From 2e3d527696910b58a36ca94506833782110297cc Mon Sep 17 00:00:00 2001 From: Andrew Kroh Date: Mon, 21 Jun 2021 18:48:19 -0400 Subject: [PATCH 03/57] [Fleet] Update final pipeline based on ECS event.agent_id_status (#102805) This updates the Fleet final pipeline added in #100973 to match the specification of `event.agent_id_status` field as defined in ECS. The field was added to ECS in https://github.com/elastic/ecs/pull/1454. Basically the values of the field were simplified from what was originally proposed and implemented. --- .../ingest_pipeline/final_pipeline.ts | 25 ++++++++++--------- .../apis/epm/final_pipeline.ts | 8 +++--- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts index 4c0484c058abf0..f929a4f139981f 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts @@ -59,25 +59,26 @@ processors: } String verified(def ctx, def params) { - // Agents only use API keys. - if (ctx?._security?.authentication_type == null || ctx._security.authentication_type != 'API_KEY') { - return "no_api_key"; + // No agent.id field to validate. + if (ctx?.agent?.id == null) { + return "missing"; } - // Verify the API key owner before trusting any metadata it contains. - if (!is_user_trusted(ctx, params.trusted_users)) { - return "untrusted_user"; - } - - // API keys created by Fleet include metadata about the agent they were issued to. - if (ctx?._security?.api_key?.metadata?.agent_id == null || ctx?.agent?.id == null) { - return "missing_metadata"; + // Check auth metadata from API key. + if (ctx?._security?.authentication_type == null + // Agents only use API keys. + || ctx._security.authentication_type != 'API_KEY' + // Verify the API key owner before trusting any metadata it contains. + || !is_user_trusted(ctx, params.trusted_users) + // Verify the API key has metadata indicating the assigned agent ID. + || ctx?._security?.api_key?.metadata?.agent_id == null) { + return "auth_metadata_missing"; } // The API key can only be used represent the agent.id it was issued to. if (ctx._security.api_key.metadata.agent_id != ctx.agent.id) { // Potential masquerade attempt. - return "agent_id_mismatch"; + return "mismatch"; } return "verified"; diff --git a/x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts b/x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts index a800546a27a3e4..81f712e095c788 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts @@ -112,14 +112,14 @@ export default function (providerContext: FtrProviderContext) { // @ts-expect-error const event = doc._source.event; - expect(event.agent_id_status).to.be('no_api_key'); + expect(event.agent_id_status).to.be('auth_metadata_missing'); expect(event).to.have.property('ingested'); }); const scenarios = [ { name: 'API key without metadata', - expectedStatus: 'missing_metadata', + expectedStatus: 'auth_metadata_missing', event: { agent: { id: 'agent1' } }, }, { @@ -134,7 +134,7 @@ export default function (providerContext: FtrProviderContext) { }, { name: 'API key with agent id metadata and no agent id in event', - expectedStatus: 'missing_metadata', + expectedStatus: 'missing', apiKey: { metadata: { agent_id: 'agent1', @@ -143,7 +143,7 @@ export default function (providerContext: FtrProviderContext) { }, { name: 'API key with agent id metadata and tampered agent id in event', - expectedStatus: 'agent_id_mismatch', + expectedStatus: 'mismatch', apiKey: { metadata: { agent_id: 'agent2', From 8619bdbc46a493dc26eee4733a6d0d3cfb2759d1 Mon Sep 17 00:00:00 2001 From: spalger Date: Mon, 21 Jun 2021 16:55:26 -0700 Subject: [PATCH 04/57] remove duplicate apm-rum deps from devDeps --- package.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/package.json b/package.json index 29371c9532915b..9e46c9619251bb 100644 --- a/package.json +++ b/package.json @@ -446,8 +446,6 @@ "@bazel/typescript": "^3.5.1", "@cypress/snapshot": "^2.1.7", "@cypress/webpack-preprocessor": "^5.6.0", - "@elastic/apm-rum": "^5.6.1", - "@elastic/apm-rum-react": "^1.2.5", "@elastic/eslint-config-kibana": "link:bazel-bin/packages/elastic-eslint-config-kibana", "@elastic/eslint-plugin-eui": "0.0.2", "@elastic/github-checks-reporter": "0.0.20b3", From 75aafd0ede14e4d0a581df4ec7f138776f353329 Mon Sep 17 00:00:00 2001 From: spalger Date: Mon, 21 Jun 2021 16:57:43 -0700 Subject: [PATCH 05/57] Revert "remove duplicate apm-rum deps from devDeps" This reverts commit 8619bdbc46a493dc26eee4733a6d0d3cfb2759d1. --- package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/package.json b/package.json index 9e46c9619251bb..29371c9532915b 100644 --- a/package.json +++ b/package.json @@ -446,6 +446,8 @@ "@bazel/typescript": "^3.5.1", "@cypress/snapshot": "^2.1.7", "@cypress/webpack-preprocessor": "^5.6.0", + "@elastic/apm-rum": "^5.6.1", + "@elastic/apm-rum-react": "^1.2.5", "@elastic/eslint-config-kibana": "link:bazel-bin/packages/elastic-eslint-config-kibana", "@elastic/eslint-plugin-eui": "0.0.2", "@elastic/github-checks-reporter": "0.0.20b3", From 138bd0df307bd7f10b6b72b79e7151e7347d5472 Mon Sep 17 00:00:00 2001 From: Constance Date: Mon, 21 Jun 2021 17:42:16 -0700 Subject: [PATCH 06/57] [Workplace Search] Convert Sources pages to new page template (+ personal dashboard) (#102592) * Refactor PersonalDashboardLayout to more closely match new page template - Remove references to enterpriseSearchLayout CSS (which will be removed in an upcoming PR) - Prefer to lean more heavily on default EuiPage props/CSS/etc. - Handle conditional sidebar logic in this layout rather than passing it in as a prop - Update props & DRY concerns to more closely match WorkplaceSearchPageTemplate - e.g. isLoading & pageChrome (mostly for document titles) - make FlashMessage and readOnlyMode work OOTB w/o props) * Convert Source subnav to EuiSideNav format + update PrivateSourcesSidebar to use EuiSIdeNav * Update routers - removing wrapping layouts, flash messages, chrome/telemetry * Refactor SourceRouter into shared layout component - Remove license callout, page header, and page chrome/telemetry - NOTE: The early page isLoading behavior (lines 51-) is required to prevent a flash of a completely empty page (instead of preserving the layout/side nav while loading). We cannot let the page fall through to the route because some routes are conditionally rendered based on isCustomSource. - FWIW: App Search has a similar isLoading early return with its Engine sub nav, and also a similar AnalyticsLayout for DRYing out repeated concerns/UI elements within Analytics subroutes. * Convert all single source views to new page template - Mostly removing isLoading tests - NOTE: Schema page could *possibly* use the new isEmptyState/emptyState page template props, but would need some layout reshuffling * Convert Add Source pages to conditional page templates - Opted to give these pages their own conditional layout logic - this could possibly be DRY'd out - There is possibly extra cleanup here on this file that could have been done (e.g. empty state, titles, etc.) in light of the new templates - but I didn't want to spend extra time here and went with creating as few diffs as possible * Convert separate Organization Sources & Private Sources views to new page templates + fix Link to EuiButtonTo on Organization Sources view * Update Account Settings with personal layout + write tests + add related KibanaLogic branch coverage * [UX feedback] Do not render page headers while loading on Overview & Sources pages * [PR feedback] Breadcrumb errors/fallbacks * [Proposal] Update schema errors routing to better work with nav/breadcrumbs - `exact` is required to make the parent schemas/ not gobble schema/{errorId} - added bonus breadcrumb for nicer schema navigation UX - No tests need to update AFAICT * Ignore Typescript error on soon-to-come EUI prop --- .../shared/kibana/kibana_logic.test.ts | 6 + .../components/layout/nav.test.tsx | 3 + .../components/layout/nav.tsx | 3 +- .../personal_dashboard_layout.scss | 24 ++-- .../personal_dashboard_layout.test.tsx | 81 ++++++++++-- .../personal_dashboard_layout.tsx | 65 ++++++---- .../private_sources_sidebar.test.tsx | 53 +++++--- .../private_sources_sidebar.tsx | 14 ++- .../applications/workplace_search/index.tsx | 44 ++----- .../workplace_search/routes.test.tsx | 4 +- .../applications/workplace_search/routes.ts | 2 +- .../account_settings.test.tsx | 57 +++++++++ .../account_settings/account_settings.tsx | 6 +- .../components/add_source/add_source.test.tsx | 27 +++- .../components/add_source/add_source.tsx | 14 ++- .../add_source/add_source_list.test.tsx | 95 ++++++++------ .../components/add_source/add_source_list.tsx | 27 ++-- .../display_settings.test.tsx | 8 -- .../display_settings/display_settings.tsx | 14 ++- .../components/overview.test.tsx | 10 -- .../content_sources/components/overview.tsx | 12 +- .../components/schema/schema.test.tsx | 10 +- .../components/schema/schema.tsx | 13 +- .../schema/schema_change_errors.tsx | 9 +- .../components/source_content.test.tsx | 8 -- .../components/source_content.tsx | 12 +- .../components/source_layout.test.tsx | 84 +++++++++++++ .../components/source_layout.tsx | 84 +++++++++++++ .../components/source_settings.tsx | 8 +- .../components/source_sub_nav.test.tsx | 94 +++++++++++--- .../components/source_sub_nav.tsx | 74 ++++++----- .../organization_sources.test.tsx | 16 +-- .../content_sources/organization_sources.tsx | 71 ++++++----- .../content_sources/private_sources.test.tsx | 8 -- .../views/content_sources/private_sources.tsx | 17 +-- .../content_sources/source_router.test.tsx | 101 ++++++--------- .../views/content_sources/source_router.tsx | 118 +++++------------- .../views/content_sources/sources_router.tsx | 110 +++++++--------- .../views/overview/overview.test.tsx | 7 ++ .../views/overview/overview.tsx | 12 +- .../components/source_config.test.tsx | 7 ++ .../settings/components/source_config.tsx | 2 +- 42 files changed, 882 insertions(+), 552 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/account_settings/account_settings.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.test.ts index 4cc907c3de9e4c..39392d0c5c78e6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.test.ts @@ -33,6 +33,12 @@ describe('KibanaLogic', () => { expect(KibanaLogic.values.config).toEqual({}); }); + it('gracefully handles disabled security', () => { + mountKibanaLogic({ ...mockKibanaValues, security: undefined } as any); + + expect(KibanaLogic.values.security).toEqual({}); + }); + it('gracefully handles non-cloud installs', () => { mountKibanaLogic({ ...mockKibanaValues, cloud: undefined } as any); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx index 3d5d0a8e6f2cfd..04b0880a7351cd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx @@ -9,6 +9,9 @@ jest.mock('../../../shared/layout', () => ({ ...jest.requireActual('../../../shared/layout'), generateNavLink: jest.fn(({ to }) => ({ href: to })), })); +jest.mock('../../views/content_sources/components/source_sub_nav', () => ({ + useSourceSubNav: () => [], +})); jest.mock('../../views/groups/components/group_sub_nav', () => ({ useGroupSubNav: () => [], })); 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 f59679e0ee0484..99225bc36e892b 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 @@ -19,6 +19,7 @@ import { GROUPS_PATH, ORG_SETTINGS_PATH, } from '../../routes'; +import { useSourceSubNav } from '../../views/content_sources/components/source_sub_nav'; import { useGroupSubNav } from '../../views/groups/components/group_sub_nav'; import { useSettingsSubNav } from '../../views/settings/components/settings_sub_nav'; @@ -33,7 +34,7 @@ export const useWorkplaceSearchNav = () => { id: 'sources', name: NAV.SOURCES, ...generateNavLink({ to: SOURCES_PATH }), - items: [], // TODO: Source subnav + items: useSourceSubNav(), }, { id: 'groups', diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.scss index 175f6b9ebca208..3287cb21783cbf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.scss +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.scss @@ -6,18 +6,20 @@ */ .personalDashboardLayout { - $sideBarWidth: $euiSize * 30; - $consoleHeaderHeight: 48px; // NOTE: Keep an eye on this for changes - $pageHeight: calc(100vh - #{$consoleHeaderHeight}); + &__sideBar { + padding: $euiSizeXL $euiSizeXXL $euiSizeXXL; - left: $sideBarWidth; - width: calc(100% - #{$sideBarWidth}); - min-height: $pageHeight; + @include euiBreakpoint('m', 'l') { + min-width: $euiSize * 20; + } + @include euiBreakpoint('xl') { + min-width: $euiSize * 30; + } + } - &__sideBar { - padding: 32px 40px 40px; - width: $sideBarWidth; - margin-left: -$sideBarWidth; - height: $pageHeight; + &__body { + position: relative; + width: 100%; + height: 100%; } } diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.test.tsx index faeaa7323e93f0..6847e91d46f6e6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.test.tsx @@ -5,37 +5,102 @@ * 2.0. */ +import { setMockValues } from '../../../../__mocks__/kea_logic'; +import { mockUseRouteMatch } from '../../../../__mocks__/react_router'; + import React from 'react'; import { shallow } from 'enzyme'; import { EuiCallOut } from '@elastic/eui'; -import { AccountHeader } from '..'; +import { FlashMessages } from '../../../../shared/flash_messages'; +import { SetWorkplaceSearchChrome } from '../../../../shared/kibana_chrome'; +import { Loading } from '../../../../shared/loading'; + +import { AccountHeader, AccountSettingsSidebar, PrivateSourcesSidebar } from '../index'; import { PersonalDashboardLayout } from './personal_dashboard_layout'; describe('PersonalDashboardLayout', () => { const children =

test

; - const sidebar =

test

; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues({ readOnlyMode: false }); + }); it('renders', () => { - const wrapper = shallow( - {children} - ); + const wrapper = shallow({children}); expect(wrapper.find('[data-test-subj="TestChildren"]')).toHaveLength(1); - expect(wrapper.find('[data-test-subj="TestSidebar"]')).toHaveLength(1); + expect(wrapper.find('.personalDashboardLayout')).toHaveLength(1); expect(wrapper.find(AccountHeader)).toHaveLength(1); + expect(wrapper.find(FlashMessages)).toHaveLength(1); }); - it('renders callout when in read-only mode', () => { + describe('renders sidebar content based on the route', () => { + it('renders the private sources sidebar on the private sources path', () => { + (mockUseRouteMatch as jest.Mock).mockImplementation((path: string) => path === '/p/sources'); + const wrapper = shallow({children}); + + expect(wrapper.find(PrivateSourcesSidebar)).toHaveLength(1); + }); + + it('renders the account settings sidebar on the account settings path', () => { + (mockUseRouteMatch as jest.Mock).mockImplementation((path: string) => path === '/p/settings'); + const wrapper = shallow({children}); + + expect(wrapper.find(AccountSettingsSidebar)).toHaveLength(1); + }); + + it('does not render a sidebar if not on a valid personal dashboard path', () => { + (mockUseRouteMatch as jest.Mock).mockImplementation((path: string) => path === '/test'); + const wrapper = shallow({children}); + + expect(wrapper.find(AccountSettingsSidebar)).toHaveLength(0); + expect(wrapper.find(PrivateSourcesSidebar)).toHaveLength(0); + }); + }); + + describe('loading state', () => { + it('renders a loading icon in place of children', () => { + const wrapper = shallow( + {children} + ); + + expect(wrapper.find(Loading)).toHaveLength(1); + expect(wrapper.find('[data-test-subj="TestChildren"]')).toHaveLength(0); + }); + + it('renders children & does not render a loading icon when the page is done loading', () => { + const wrapper = shallow( + {children} + ); + + expect(wrapper.find(Loading)).toHaveLength(0); + expect(wrapper.find('[data-test-subj="TestChildren"]')).toHaveLength(1); + }); + }); + + it('sets WS page chrome (primarily document title)', () => { const wrapper = shallow( - + {children} ); + expect(wrapper.find(SetWorkplaceSearchChrome).prop('trail')).toEqual([ + 'Sources', + 'Add source', + 'Gmail', + ]); + }); + + it('renders callout when in read-only mode', () => { + setMockValues({ readOnlyMode: true }); + const wrapper = shallow({children}); + expect(wrapper.find(EuiCallOut)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.tsx index 1ab9e07dfa14d5..5b68d661ac5df4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.tsx @@ -6,44 +6,67 @@ */ import React from 'react'; +import { useRouteMatch } from 'react-router-dom'; -import { EuiPage, EuiPageSideBar, EuiPageBody, EuiCallOut } from '@elastic/eui'; +import { useValues } from 'kea'; -import { AccountHeader } from '..'; +import { + EuiPage, + EuiPageSideBar, + EuiPageBody, + EuiPageContentBody, + EuiCallOut, + EuiSpacer, +} from '@elastic/eui'; +import { FlashMessages } from '../../../../shared/flash_messages'; +import { HttpLogic } from '../../../../shared/http'; +import { SetWorkplaceSearchChrome } from '../../../../shared/kibana_chrome'; +import { BreadcrumbTrail } from '../../../../shared/kibana_chrome/generate_breadcrumbs'; +import { Loading } from '../../../../shared/loading'; + +import { PERSONAL_SOURCES_PATH, PERSONAL_SETTINGS_PATH } from '../../../routes'; import { PRIVATE_DASHBOARD_READ_ONLY_MODE_WARNING } from '../../../views/content_sources/constants'; +import { AccountHeader, AccountSettingsSidebar, PrivateSourcesSidebar } from '../index'; import './personal_dashboard_layout.scss'; interface LayoutProps { - restrictWidth?: boolean; - readOnlyMode?: boolean; - sidebar: React.ReactNode; + isLoading?: boolean; + pageChrome?: BreadcrumbTrail; } export const PersonalDashboardLayout: React.FC = ({ children, - restrictWidth, - readOnlyMode, - sidebar, + isLoading, + pageChrome, }) => { + const { readOnlyMode } = useValues(HttpLogic); + return ( <> + {pageChrome && } - - - {sidebar} + + + {useRouteMatch(PERSONAL_SOURCES_PATH) && } + {useRouteMatch(PERSONAL_SETTINGS_PATH) && } - - {readOnlyMode && ( - - )} - {children} + + + {readOnlyMode && ( + <> + + + + )} + + {isLoading ? : children} + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.test.tsx index 387724af970f89..9fa4d4dd1b237c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.test.tsx @@ -7,17 +7,22 @@ import { setMockValues } from '../../../../__mocks__/kea_logic'; +jest.mock('../../../views/content_sources/components/source_sub_nav', () => ({ + useSourceSubNav: () => [], +})); + import React from 'react'; import { shallow } from 'enzyme'; +import { EuiSideNav } from '@elastic/eui'; + import { PRIVATE_CAN_CREATE_PAGE_TITLE, PRIVATE_VIEW_ONLY_PAGE_TITLE, PRIVATE_VIEW_ONLY_PAGE_DESCRIPTION, PRIVATE_CAN_CREATE_PAGE_DESCRIPTION, } from '../../../constants'; -import { SourceSubNav } from '../../../views/content_sources/components/source_sub_nav'; import { ViewContentHeader } from '../../shared/view_content_header'; @@ -26,6 +31,7 @@ import { PrivateSourcesSidebar } from './private_sources_sidebar'; describe('PrivateSourcesSidebar', () => { const mockValues = { account: { canCreatePersonalSources: true }, + contentSource: {}, }; beforeEach(() => { @@ -36,25 +42,42 @@ describe('PrivateSourcesSidebar', () => { const wrapper = shallow(); expect(wrapper.find(ViewContentHeader)).toHaveLength(1); - expect(wrapper.find(SourceSubNav)).toHaveLength(1); }); - it('uses correct title and description when private sources are enabled', () => { - const wrapper = shallow(); + describe('header text', () => { + it('uses correct title and description when private sources are enabled', () => { + const wrapper = shallow(); + + expect(wrapper.find(ViewContentHeader).prop('title')).toEqual(PRIVATE_CAN_CREATE_PAGE_TITLE); + expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( + PRIVATE_CAN_CREATE_PAGE_DESCRIPTION + ); + }); - expect(wrapper.find(ViewContentHeader).prop('title')).toEqual(PRIVATE_CAN_CREATE_PAGE_TITLE); - expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( - PRIVATE_CAN_CREATE_PAGE_DESCRIPTION - ); + it('uses correct title and description when private sources are disabled', () => { + setMockValues({ ...mockValues, account: { canCreatePersonalSources: false } }); + const wrapper = shallow(); + + expect(wrapper.find(ViewContentHeader).prop('title')).toEqual(PRIVATE_VIEW_ONLY_PAGE_TITLE); + expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( + PRIVATE_VIEW_ONLY_PAGE_DESCRIPTION + ); + }); }); - it('uses correct title and description when private sources are disabled', () => { - setMockValues({ account: { canCreatePersonalSources: false } }); - const wrapper = shallow(); + describe('sub nav', () => { + it('renders a side nav when viewing a single source', () => { + setMockValues({ ...mockValues, contentSource: { id: '1', name: 'test source' } }); + const wrapper = shallow(); + + expect(wrapper.find(EuiSideNav)).toHaveLength(1); + }); + + it('does not render a side nav if not on a source page', () => { + setMockValues({ ...mockValues, contentSource: {} }); + const wrapper = shallow(); - expect(wrapper.find(ViewContentHeader).prop('title')).toEqual(PRIVATE_VIEW_ONLY_PAGE_TITLE); - expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( - PRIVATE_VIEW_ONLY_PAGE_DESCRIPTION - ); + expect(wrapper.find(EuiSideNav)).toHaveLength(0); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.tsx index 5505ae57b2ad5f..36496b83b31231 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.tsx @@ -9,6 +9,8 @@ import React from 'react'; import { useValues } from 'kea'; +import { EuiSideNav } from '@elastic/eui'; + import { AppLogic } from '../../../app_logic'; import { PRIVATE_CAN_CREATE_PAGE_TITLE, @@ -16,7 +18,8 @@ import { PRIVATE_VIEW_ONLY_PAGE_DESCRIPTION, PRIVATE_CAN_CREATE_PAGE_DESCRIPTION, } from '../../../constants'; -import { SourceSubNav } from '../../../views/content_sources/components/source_sub_nav'; +import { useSourceSubNav } from '../../../views/content_sources/components/source_sub_nav'; +import { SourceLogic } from '../../../views/content_sources/source_logic'; import { ViewContentHeader } from '../../shared/view_content_header'; export const PrivateSourcesSidebar = () => { @@ -31,10 +34,17 @@ export const PrivateSourcesSidebar = () => { ? PRIVATE_CAN_CREATE_PAGE_DESCRIPTION : PRIVATE_VIEW_ONLY_PAGE_DESCRIPTION; + const { + contentSource: { id = '', name = '' }, + } = useValues(SourceLogic); + + const navItems = [{ id, name, items: useSourceSubNav() }]; + return ( <> - + {/* @ts-expect-error: TODO, uncomment this once EUI 34.x lands in Kibana & `mobileBreakpoints` is a valid prop */} + {id && } ); }; 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 f4278d5083143a..8a1e9c02753225 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 @@ -19,11 +19,6 @@ import { NotFound } from '../shared/not_found'; import { AppLogic } from './app_logic'; import { WorkplaceSearchNav, WorkplaceSearchHeaderActions } from './components/layout'; -import { - PersonalDashboardLayout, - PrivateSourcesSidebar, - AccountSettingsSidebar, -} from './components/layout'; import { GROUPS_PATH, SETUP_GUIDE_PATH, @@ -34,11 +29,11 @@ import { ROLE_MAPPINGS_PATH, SECURITY_PATH, PERSONAL_SETTINGS_PATH, + PERSONAL_PATH, } from './routes'; import { AccountSettings } from './views/account_settings'; import { SourcesRouter } from './views/content_sources'; import { SourceAdded } from './views/content_sources/components/source_added'; -import { SourceSubNav } from './views/content_sources/components/source_sub_nav'; import { ErrorState } from './views/error_state'; import { GroupsRouter } from './views/groups'; import { Overview } from './views/overview'; @@ -60,9 +55,6 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { const { pathname } = useLocation(); - // We don't want so show the subnavs on the container root pages. - const showSourcesSubnav = pathname !== SOURCES_PATH && pathname !== PERSONAL_SOURCES_PATH; - /** * Personal dashboard urls begin with /p/ * EX: http://localhost:5601/app/enterprise_search/workplace_search/p/sources @@ -95,32 +87,18 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { - - } - > - - - - - } - > - - + + + + + + + + + - } />} - restrictWidth - readOnlyMode={readOnlyMode} - > - - + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx index a5a3d6b491bb96..b89a1451f7e571 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx @@ -76,13 +76,13 @@ describe('getReindexJobRoute', () => { it('should format org path', () => { expect(getReindexJobRoute(SOURCE_ID, REINDEX_ID, true)).toEqual( - `/sources/${SOURCE_ID}/schema_errors/${REINDEX_ID}` + `/sources/${SOURCE_ID}/schemas/${REINDEX_ID}` ); }); it('should format user path', () => { expect(getReindexJobRoute(SOURCE_ID, REINDEX_ID, false)).toEqual( - `/p/sources/${SOURCE_ID}/schema_errors/${REINDEX_ID}` + `/p/sources/${SOURCE_ID}/schemas/${REINDEX_ID}` ); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts index 1fe8019c4b3646..3c564c1f912ecc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts @@ -88,7 +88,7 @@ export const SOURCE_CONTENT_PATH = `${SOURCES_PATH}/:sourceId/content`; export const SOURCE_SCHEMAS_PATH = `${SOURCES_PATH}/:sourceId/schemas`; export const SOURCE_DISPLAY_SETTINGS_PATH = `${SOURCES_PATH}/:sourceId/display_settings`; export const SOURCE_SETTINGS_PATH = `${SOURCES_PATH}/:sourceId/settings`; -export const REINDEX_JOB_PATH = `${SOURCES_PATH}/:sourceId/schema_errors/:activeReindexJobId`; +export const REINDEX_JOB_PATH = `${SOURCE_SCHEMAS_PATH}/:activeReindexJobId`; export const DISPLAY_SETTINGS_SEARCH_RESULT_PATH = `${SOURCE_DISPLAY_SETTINGS_PATH}/`; export const DISPLAY_SETTINGS_RESULT_DETAIL_PATH = `${SOURCE_DISPLAY_SETTINGS_PATH}/result_detail`; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/account_settings/account_settings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/account_settings/account_settings.test.tsx new file mode 100644 index 00000000000000..5ff80a7683db6a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/account_settings/account_settings.test.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../../__mocks__/shallow_useeffect.mock'; +import { mockKibanaValues } from '../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { AccountSettings } from './'; + +describe('AccountSettings', () => { + const { + security: { + authc: { getCurrentUser }, + uiApi: { + components: { getPersonalInfo, getChangePassword }, + }, + }, + } = mockKibanaValues; + + const mockCurrentUser = (user?: unknown) => + (getCurrentUser as jest.Mock).mockReturnValue(Promise.resolve(user)); + + beforeAll(() => { + mockCurrentUser(); + }); + + it('gets the current user on mount', () => { + shallow(); + + expect(getCurrentUser).toHaveBeenCalled(); + }); + + it('does not render if the current user does not exist', async () => { + mockCurrentUser(null); + const wrapper = await shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('renders the security UI components when the user exists', async () => { + mockCurrentUser({ username: 'mock user' }); + (getPersonalInfo as jest.Mock).mockReturnValue(
); + (getChangePassword as jest.Mock).mockReturnValue(
); + + const wrapper = await shallow(); + + expect(wrapper.childAt(0).dive().find('[data-test-subj="PersonalInfo"]')).toHaveLength(1); + expect(wrapper.childAt(1).dive().find('[data-test-subj="ChangePassword"]')).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/account_settings/account_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/account_settings/account_settings.tsx index e28faaeec8993a..313d3ffa59d48f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/account_settings/account_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/account_settings/account_settings.tsx @@ -11,6 +11,8 @@ import { useValues } from 'kea'; import type { AuthenticatedUser } from '../../../../../../security/public'; import { KibanaLogic } from '../../../shared/kibana/kibana_logic'; +import { PersonalDashboardLayout } from '../../components/layout'; +import { ACCOUNT_SETTINGS_TITLE } from '../../constants'; export const AccountSettings: React.FC = () => { const { security } = useValues(KibanaLogic); @@ -31,9 +33,9 @@ export const AccountSettings: React.FC = () => { } return ( - <> + - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx index 92cbfcf6eeafe4..0501509b3a8ef7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx @@ -17,7 +17,10 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { Loading } from '../../../../../shared/loading'; +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; import { AddSource } from './add_source'; import { AddSourceSteps } from './add_source_logic'; @@ -68,11 +71,27 @@ describe('AddSourceList', () => { expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); }); - it('handles loading state', () => { - setMockValues({ ...mockValues, dataLoading: true }); + describe('layout', () => { + it('renders the default workplace search layout when on an organization view', () => { + setMockValues({ ...mockValues, isOrganization: true }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate); + }); + + it('renders the personal dashboard layout when not in an organization', () => { + setMockValues({ ...mockValues, isOrganization: false }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(PersonalDashboardLayout); + }); + }); + + it('renders a breadcrumb fallback while data is loading', () => { + setMockValues({ ...mockValues, dataLoading: true, sourceConfigData: {} }); const wrapper = shallow(); - expect(wrapper.find(Loading)).toHaveLength(1); + expect(wrapper.prop('pageChrome')).toEqual(['Sources', 'Add Source', '...']); }); it('renders Config Completed step', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx index ee4bcfb9afd341..b0c3ebe64830cb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx @@ -13,9 +13,12 @@ import { i18n } from '@kbn/i18n'; import { setSuccessMessage } from '../../../../../shared/flash_messages'; import { KibanaLogic } from '../../../../../shared/kibana'; -import { Loading } from '../../../../../shared/loading'; import { AppLogic } from '../../../../app_logic'; -import { CUSTOM_SERVICE_TYPE } from '../../../../constants'; +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; +import { NAV, CUSTOM_SERVICE_TYPE } from '../../../../constants'; import { SOURCES_PATH, getSourcesPath } from '../../../../routes'; import { SourceDataItem } from '../../../../types'; import { staticSourceData } from '../../source_data'; @@ -71,8 +74,6 @@ export const AddSource: React.FC = (props) => { return resetSourceState; }, []); - if (dataLoading) return ; - const goToConfigurationIntro = () => setAddSourceStep(AddSourceSteps.ConfigIntroStep); const goToSaveConfig = () => setAddSourceStep(AddSourceSteps.SaveConfigStep); const setConfigCompletedStep = () => setAddSourceStep(AddSourceSteps.ConfigCompletedStep); @@ -99,9 +100,10 @@ export const AddSource: React.FC = (props) => { }; const header = ; + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; return ( - <> + {addSourceCurrentStep === AddSourceSteps.ConfigIntroStep && ( )} @@ -158,6 +160,6 @@ export const AddSource: React.FC = (props) => { {addSourceCurrentStep === AddSourceSteps.ReauthenticateStep && ( )} - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.test.tsx index 6bf71cd73ec354..b30511f0a6d80b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.test.tsx @@ -19,7 +19,11 @@ import { shallow } from 'enzyme'; import { EuiEmptyPrompt, EuiFieldSearch } from '@elastic/eui'; -import { Loading } from '../../../../../shared/loading'; +import { getPageDescription } from '../../../../../test_helpers'; +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; import { AddSourceList } from './add_source_list'; @@ -54,14 +58,21 @@ describe('AddSourceList', () => { expect(wrapper.find(AvailableSourcesList)).toHaveLength(1); }); - it('returns loading when loading', () => { - setMockValues({ - ...mockValues, - dataLoading: true, + describe('layout', () => { + it('renders the default workplace search layout when on an organization view', () => { + setMockValues({ ...mockValues, isOrganization: true }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate); }); - const wrapper = shallow(); - expect(wrapper.find(Loading)).toHaveLength(1); + it('renders the personal dashboard layout and a header when not in an organization', () => { + setMockValues({ ...mockValues, isOrganization: false }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(PersonalDashboardLayout); + expect(wrapper.find(ViewContentHeader)).toHaveLength(1); + }); }); describe('filters sources', () => { @@ -97,49 +108,51 @@ describe('AddSourceList', () => { }); describe('content headings', () => { - it('should render correct organization heading with sources', () => { - const wrapper = shallow(); - - expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( - ADD_SOURCE_ORG_SOURCE_DESCRIPTION - ); - }); + describe('organization view', () => { + it('should render the correct organization heading with sources', () => { + const wrapper = shallow(); - it('should render correct organization heading without sources', () => { - setMockValues({ - ...mockValues, - contentSources: [], + expect(getPageDescription(wrapper)).toEqual(ADD_SOURCE_ORG_SOURCE_DESCRIPTION); }); - const wrapper = shallow(); - expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( - ADD_SOURCE_NEW_SOURCE_DESCRIPTION + ADD_SOURCE_ORG_SOURCE_DESCRIPTION - ); - }); + it('should render the correct organization heading without sources', () => { + setMockValues({ + ...mockValues, + contentSources: [], + }); + const wrapper = shallow(); - it('should render correct account heading with sources', () => { - const wrapper = shallow(); - setMockValues({ - ...mockValues, - isOrganization: false, + expect(getPageDescription(wrapper)).toEqual( + ADD_SOURCE_NEW_SOURCE_DESCRIPTION + ADD_SOURCE_ORG_SOURCE_DESCRIPTION + ); }); - - expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( - ADD_SOURCE_ORG_SOURCE_DESCRIPTION - ); }); - it('should render correct account heading without sources', () => { - setMockValues({ - ...mockValues, - isOrganization: false, - contentSources: [], + describe('personal dashboard view', () => { + it('should render the correct personal heading with sources', () => { + setMockValues({ + ...mockValues, + isOrganization: false, + }); + const wrapper = shallow(); + + expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( + ADD_SOURCE_PRIVATE_SOURCE_DESCRIPTION + ); }); - const wrapper = shallow(); - expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( - ADD_SOURCE_NEW_SOURCE_DESCRIPTION + ADD_SOURCE_PRIVATE_SOURCE_DESCRIPTION - ); + it('should render the correct personal heading without sources', () => { + setMockValues({ + ...mockValues, + isOrganization: false, + contentSources: [], + }); + const wrapper = shallow(); + + expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( + ADD_SOURCE_NEW_SOURCE_DESCRIPTION + ADD_SOURCE_PRIVATE_SOURCE_DESCRIPTION + ); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx index 80d35553bb8bb4..a7a64194cb42f2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx @@ -19,12 +19,15 @@ import { EuiEmptyPrompt, } from '@elastic/eui'; -import { Loading } from '../../../../../shared/loading'; import { AppLogic } from '../../../../app_logic'; import noSharedSourcesIcon from '../../../../assets/share_circle.svg'; +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; import { ContentSection } from '../../../../components/shared/content_section'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; -import { CUSTOM_SERVICE_TYPE } from '../../../../constants'; +import { NAV, CUSTOM_SERVICE_TYPE } from '../../../../constants'; import { SourceDataItem } from '../../../../types'; import { SourcesLogic } from '../../sources_logic'; @@ -58,8 +61,6 @@ export const AddSourceList: React.FC = () => { return resetSourcesState; }, []); - if (dataLoading) return ; - const hasSources = contentSources.length > 0; const showConfiguredSourcesList = configuredSources.find( ({ serviceType }) => serviceType !== CUSTOM_SERVICE_TYPE @@ -97,12 +98,22 @@ export const AddSourceList: React.FC = () => { filterConfiguredSources ) as SourceDataItem[]; + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; + return ( - <> - + + {!isOrganization && ( +
+ +
+ )} {showConfiguredSourcesList || isOrganization ? ( - { )} - +
); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.test.tsx index aa5cec385738d2..e5714bf4bdfbf5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.test.tsx @@ -16,7 +16,6 @@ import { shallow } from 'enzyme'; import { EuiButton, EuiTabbedContent } from '@elastic/eui'; -import { Loading } from '../../../../../shared/loading'; import { UnsavedChangesPrompt } from '../../../../../shared/unsaved_changes_prompt'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; @@ -57,13 +56,6 @@ describe('DisplaySettings', () => { expect(wrapper.find('form')).toHaveLength(1); }); - it('returns loading when loading', () => { - setMockValues({ ...values, dataLoading: true }); - const wrapper = shallow(); - - expect(wrapper.find(Loading)).toHaveLength(1); - }); - describe('tabbed content', () => { const tabs = [ { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx index d923fbe7a1a8e8..ae47e20026b68c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx @@ -20,10 +20,10 @@ import { } from '@elastic/eui'; import { clearFlashMessages } from '../../../../../shared/flash_messages'; -import { Loading } from '../../../../../shared/loading'; import { UnsavedChangesPrompt } from '../../../../../shared/unsaved_changes_prompt'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; -import { SAVE_BUTTON } from '../../../../constants'; +import { NAV, SAVE_BUTTON } from '../../../../constants'; +import { SourceLayout } from '../source_layout'; import { UNSAVED_MESSAGE, @@ -64,8 +64,6 @@ export const DisplaySettings: React.FC = ({ tabId }) => { return clearFlashMessages; }, []); - if (dataLoading) return ; - const tabs = [ { id: 'search_results', @@ -89,7 +87,11 @@ export const DisplaySettings: React.FC = ({ tabId }) => { }; return ( - <> + = ({ tabId }) => { )} {addFieldModalVisible && } - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx index f2cf5f50b813b4..d99eac5de74e5a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx @@ -5,8 +5,6 @@ * 2.0. */ -import '../../../../__mocks__/shallow_useeffect.mock'; - import { setMockValues } from '../../../../__mocks__/kea_logic'; import { fullContentSources } from '../../../__mocks__/content_sources.mock'; @@ -16,7 +14,6 @@ import { shallow } from 'enzyme'; import { EuiEmptyPrompt, EuiPanel, EuiTable } from '@elastic/eui'; -import { Loading } from '../../../../shared/loading'; import { ComponentLoader } from '../../../components/shared/component_loader'; import { Overview } from './overview'; @@ -44,13 +41,6 @@ describe('Overview', () => { expect(documentSummary.find('[data-test-subj="DocumentSummaryRow"]')).toHaveLength(1); }); - it('returns Loading when loading', () => { - setMockValues({ ...mockValues, dataLoading: true }); - const wrapper = shallow(); - - expect(wrapper.find(Loading)).toHaveLength(1); - }); - it('renders ComponentLoader when loading', () => { setMockValues({ ...mockValues, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx index 153df1bc00496a..cc890e0f104ac8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx @@ -29,7 +29,6 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { Loading } from '../../../../shared/loading'; import { EuiPanelTo } from '../../../../shared/react_router_helpers'; import { AppLogic } from '../../../app_logic'; import aclImage from '../../../assets/supports_acl.svg'; @@ -78,8 +77,10 @@ import { } from '../constants'; import { SourceLogic } from '../source_logic'; +import { SourceLayout } from './source_layout'; + export const Overview: React.FC = () => { - const { contentSource, dataLoading } = useValues(SourceLogic); + const { contentSource } = useValues(SourceLogic); const { isOrganization } = useValues(AppLogic); const { @@ -97,8 +98,6 @@ export const Overview: React.FC = () => { isFederatedSource, } = contentSource; - if (dataLoading) return ; - const DocumentSummary = () => { let totalDocuments = 0; const tableContent = summary?.map((item, index) => { @@ -450,8 +449,9 @@ export const Overview: React.FC = () => { ); return ( - <> + + @@ -513,6 +513,6 @@ export const Overview: React.FC = () => { - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.test.tsx index 178c9125ee4370..47859e4e67b170 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.test.tsx @@ -16,7 +16,6 @@ import { shallow } from 'enzyme'; import { EuiEmptyPrompt, EuiFieldSearch } from '@elastic/eui'; -import { Loading } from '../../../../../shared/loading'; import { SchemaAddFieldModal, SchemaErrorsCallout } from '../../../../../shared/schema'; import { Schema } from './schema'; @@ -71,13 +70,6 @@ describe('Schema', () => { expect(wrapper.find(SchemaFieldsTable)).toHaveLength(1); }); - it('returns loading when loading', () => { - setMockValues({ ...mockValues, dataLoading: true }); - const wrapper = shallow(); - - expect(wrapper.find(Loading)).toHaveLength(1); - }); - it('handles empty state', () => { setMockValues({ ...mockValues, activeSchema: {} }); const wrapper = shallow(); @@ -106,7 +98,7 @@ describe('Schema', () => { expect(wrapper.find(SchemaErrorsCallout)).toHaveLength(1); expect(wrapper.find(SchemaErrorsCallout).prop('viewErrorsPath')).toEqual( - '/sources/123/schema_errors/123' + '/sources/123/schemas/123' ); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.tsx index 65ed988f45ff08..a0efebdcb5a48c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.tsx @@ -20,11 +20,12 @@ import { EuiPanel, } from '@elastic/eui'; -import { Loading } from '../../../../../shared/loading'; import { SchemaAddFieldModal, SchemaErrorsCallout } from '../../../../../shared/schema'; import { AppLogic } from '../../../../app_logic'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; +import { NAV } from '../../../../constants'; import { getReindexJobRoute } from '../../../../routes'; +import { SourceLayout } from '../source_layout'; import { SCHEMA_ADD_FIELD_BUTTON, @@ -65,8 +66,6 @@ export const Schema: React.FC = () => { initializeSchema(); }, []); - if (dataLoading) return ; - const hasSchemaFields = Object.keys(activeSchema).length > 0; const { hasErrors, activeReindexJobId } = mostRecentIndexJob; @@ -77,7 +76,11 @@ export const Schema: React.FC = () => { ); return ( - <> + { closeAddFieldModal={closeAddFieldModal} /> )} - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_change_errors.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_change_errors.tsx index e300823aa3ed30..eb07beda733276 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_change_errors.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_change_errors.tsx @@ -12,6 +12,8 @@ import { useActions, useValues } from 'kea'; import { SchemaErrorsAccordion } from '../../../../../shared/schema'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; +import { NAV } from '../../../../constants'; +import { SourceLayout } from '../source_layout'; import { SCHEMA_ERRORS_HEADING } from './constants'; import { SchemaLogic } from './schema_logic'; @@ -30,9 +32,12 @@ export const SchemaChangeErrors: React.FC = () => { }, []); return ( - <> + - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.test.tsx index 4bcc4b16166d18..9304f0f344a1be 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.test.tsx @@ -25,7 +25,6 @@ import { } from '@elastic/eui'; import { DEFAULT_META } from '../../../../shared/constants'; -import { Loading } from '../../../../shared/loading'; import { ComponentLoader } from '../../../components/shared/component_loader'; import { TablePaginationBar } from '../../../components/shared/table_pagination_bar'; @@ -61,13 +60,6 @@ describe('SourceContent', () => { expect(wrapper.find(EuiTable)).toHaveLength(1); }); - it('returns Loading when loading', () => { - setMockValues({ ...mockValues, dataLoading: true }); - const wrapper = shallow(); - - expect(wrapper.find(Loading)).toHaveLength(1); - }); - it('returns ComponentLoader when section loading', () => { setMockValues({ ...mockValues, sectionLoading: true }); const wrapper = shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx index fbafe54df7493c..a0e3c28f20eb0b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx @@ -31,12 +31,11 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { Loading } from '../../../../shared/loading'; import { TruncatedContent } from '../../../../shared/truncate'; import { ComponentLoader } from '../../../components/shared/component_loader'; import { TablePaginationBar } from '../../../components/shared/table_pagination_bar'; import { ViewContentHeader } from '../../../components/shared/view_content_header'; -import { CUSTOM_SERVICE_TYPE } from '../../../constants'; +import { NAV, CUSTOM_SERVICE_TYPE } from '../../../constants'; import { CUSTOM_SOURCE_DOCS_URL } from '../../../routes'; import { SourceContentItem } from '../../../types'; import { @@ -51,6 +50,8 @@ import { } from '../constants'; import { SourceLogic } from '../source_logic'; +import { SourceLayout } from './source_layout'; + const MAX_LENGTH = 28; export const SourceContent: React.FC = () => { @@ -67,7 +68,6 @@ export const SourceContent: React.FC = () => { }, contentItems, contentFilterValue, - dataLoading, sectionLoading, } = useValues(SourceLogic); @@ -75,8 +75,6 @@ export const SourceContent: React.FC = () => { searchContentSourceDocuments(id); }, [contentFilterValue, activePage]); - if (dataLoading) return ; - const showPagination = totalPages > 1; const hasItems = totalItems > 0; const emptyMessage = contentFilterValue @@ -193,7 +191,7 @@ export const SourceContent: React.FC = () => { ); return ( - <> + @@ -219,6 +217,6 @@ export const SourceContent: React.FC = () => { {sectionLoading && } {!sectionLoading && (hasItems ? contentTable : emptyState)} - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.test.tsx new file mode 100644 index 00000000000000..7c7d77ec418e7f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.test.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../../../__mocks__/shallow_useeffect.mock'; + +import { setMockValues } from '../../../../__mocks__/kea_logic'; +import { contentSources } from '../../../__mocks__/content_sources.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiCallOut } from '@elastic/eui'; + +import { WorkplaceSearchPageTemplate, PersonalDashboardLayout } from '../../../components/layout'; + +import { SourceInfoCard } from './source_info_card'; +import { SourceLayout } from './source_layout'; + +describe('SourceLayout', () => { + const contentSource = contentSources[1]; + const mockValues = { + contentSource, + dataLoading: false, + isOrganization: true, + }; + + beforeEach(() => { + setMockValues({ ...mockValues }); + }); + + it('renders', () => { + const wrapper = shallow( + +
+ + ); + + expect(wrapper.find(SourceInfoCard)).toHaveLength(1); + expect(wrapper.find('.testChild')).toHaveLength(1); + }); + + it('renders the default Workplace Search layout when on an organization view', () => { + setMockValues({ ...mockValues, isOrganization: true }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate); + }); + + it('renders a personal dashboard layout when not on an organization view', () => { + setMockValues({ ...mockValues, isOrganization: false }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(PersonalDashboardLayout); + }); + + it('passes any page template props to the underlying page template', () => { + const wrapper = shallow(); + + expect(wrapper.find(WorkplaceSearchPageTemplate).prop('pageViewTelemetry')).toEqual('test'); + }); + + it('handles breadcrumbs while loading', () => { + setMockValues({ + ...mockValues, + contentSource: {}, + dataLoading: true, + }); + const wrapper = shallow(); + + expect(wrapper.prop('pageChrome')).toEqual(['Sources', '...']); + }); + + it('renders a callout when the source is not supported by the current license', () => { + setMockValues({ ...mockValues, contentSource: { supportedByLicense: false } }); + const wrapper = shallow(); + + expect(wrapper.find(EuiCallOut)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.tsx new file mode 100644 index 00000000000000..446e93e0c61f3f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useValues } from 'kea'; +import moment from 'moment'; + +import { EuiButton, EuiCallOut, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; + +import { PageTemplateProps } from '../../../../shared/layout'; +import { AppLogic } from '../../../app_logic'; +import { WorkplaceSearchPageTemplate, PersonalDashboardLayout } from '../../../components/layout'; +import { NAV } from '../../../constants'; +import { ENT_SEARCH_LICENSE_MANAGEMENT } from '../../../routes'; + +import { + SOURCE_DISABLED_CALLOUT_TITLE, + SOURCE_DISABLED_CALLOUT_DESCRIPTION, + SOURCE_DISABLED_CALLOUT_BUTTON, +} from '../constants'; +import { SourceLogic } from '../source_logic'; + +import { SourceInfoCard } from './source_info_card'; + +export const SourceLayout: React.FC = ({ + children, + pageChrome = [], + ...props +}) => { + const { contentSource, dataLoading } = useValues(SourceLogic); + const { isOrganization } = useValues(AppLogic); + + const { + name, + createdAt, + serviceType, + serviceName, + isFederatedSource, + supportedByLicense, + } = contentSource; + + const pageHeader = ( + <> + + + + ); + + const callout = ( + <> + +

{SOURCE_DISABLED_CALLOUT_DESCRIPTION}

+ + {SOURCE_DISABLED_CALLOUT_BUTTON} + +
+ + + ); + + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; + + return ( + + {!supportedByLicense && callout} + {pageHeader} + {children} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx index aa6cbf3cf6574d..667e7fd4dbfb42 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx @@ -26,6 +26,8 @@ import { AppLogic } from '../../../app_logic'; import { ContentSection } from '../../../components/shared/content_section'; import { SourceConfigFields } from '../../../components/shared/source_config_fields'; import { ViewContentHeader } from '../../../components/shared/view_content_header'; +import { NAV } from '../../../constants'; + import { CANCEL_BUTTON, OK_BUTTON, @@ -52,6 +54,8 @@ import { import { staticSourceData } from '../source_data'; import { SourceLogic } from '../source_logic'; +import { SourceLayout } from './source_layout'; + export const SourceSettings: React.FC = () => { const { updateContentSource, removeContentSource } = useActions(SourceLogic); const { getSourceConfigData } = useActions(AddSourceLogic); @@ -128,7 +132,7 @@ export const SourceSettings: React.FC = () => { ); return ( - <> +
@@ -197,6 +201,6 @@ export const SourceSettings: React.FC = () => { {confirmModalVisible && confirmModal} - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.test.tsx index 25c389419d731e..7f07c59587f96c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.test.tsx @@ -7,34 +7,92 @@ import { setMockValues } from '../../../../__mocks__/kea_logic'; -import React from 'react'; +jest.mock('../../../../shared/layout', () => ({ + generateNavLink: jest.fn(({ to }) => ({ href: to })), +})); -import { shallow } from 'enzyme'; +import { useSourceSubNav } from './source_sub_nav'; -import { SideNavLink } from '../../../../shared/layout'; -import { CUSTOM_SERVICE_TYPE } from '../../../constants'; +describe('useSourceSubNav', () => { + it('returns undefined when no content source id present', () => { + setMockValues({ contentSource: {} }); -import { SourceSubNav } from './source_sub_nav'; + expect(useSourceSubNav()).toEqual(undefined); + }); -describe('SourceSubNav', () => { - it('renders empty when no group id present', () => { - setMockValues({ contentSource: {} }); - const wrapper = shallow(); + it('returns EUI nav items', () => { + setMockValues({ isOrganization: true, contentSource: { id: '1' } }); - expect(wrapper.find(SideNavLink)).toHaveLength(0); + expect(useSourceSubNav()).toEqual([ + { + id: 'sourceOverview', + name: 'Overview', + href: '/sources/1', + }, + { + id: 'sourceContent', + name: 'Content', + href: '/sources/1/content', + }, + { + id: 'sourceSettings', + name: 'Settings', + href: '/sources/1/settings', + }, + ]); }); - it('renders nav items', () => { - setMockValues({ contentSource: { id: '1' } }); - const wrapper = shallow(); + it('returns extra nav items for custom sources', () => { + setMockValues({ isOrganization: true, contentSource: { id: '2', serviceType: 'custom' } }); - expect(wrapper.find(SideNavLink)).toHaveLength(3); + expect(useSourceSubNav()).toEqual([ + { + id: 'sourceOverview', + name: 'Overview', + href: '/sources/2', + }, + { + id: 'sourceContent', + name: 'Content', + href: '/sources/2/content', + }, + { + id: 'sourceSchema', + name: 'Schema', + href: '/sources/2/schemas', + }, + { + id: 'sourceDisplaySettings', + name: 'Display Settings', + href: '/sources/2/display_settings', + }, + { + id: 'sourceSettings', + name: 'Settings', + href: '/sources/2/settings', + }, + ]); }); - it('renders custom source nav items', () => { - setMockValues({ contentSource: { id: '1', serviceType: CUSTOM_SERVICE_TYPE } }); - const wrapper = shallow(); + it('returns nav links to personal dashboard when not on an organization page', () => { + setMockValues({ isOrganization: false, contentSource: { id: '3' } }); - expect(wrapper.find(SideNavLink)).toHaveLength(5); + expect(useSourceSubNav()).toEqual([ + { + id: 'sourceOverview', + name: 'Overview', + href: '/p/sources/3', + }, + { + id: 'sourceContent', + name: 'Content', + href: '/p/sources/3/content', + }, + { + id: 'sourceSettings', + name: 'Settings', + href: '/p/sources/3/settings', + }, + ]); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.tsx index 12e1506ec6efda..6b595a06f0404d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.tsx @@ -5,11 +5,11 @@ * 2.0. */ -import React from 'react'; - import { useValues } from 'kea'; -import { SideNavLink } from '../../../../shared/layout'; +import { EuiSideNavItemType } from '@elastic/eui'; + +import { generateNavLink } from '../../../../shared/layout'; import { AppLogic } from '../../../app_logic'; import { NAV, CUSTOM_SERVICE_TYPE } from '../../../constants'; import { @@ -22,40 +22,52 @@ import { } from '../../../routes'; import { SourceLogic } from '../source_logic'; -export const SourceSubNav: React.FC = () => { +export const useSourceSubNav = () => { const { isOrganization } = useValues(AppLogic); const { contentSource: { id, serviceType }, } = useValues(SourceLogic); - if (!id) return null; + if (!id) return undefined; + + const navItems: Array> = [ + { + id: 'sourceOverview', + name: NAV.OVERVIEW, + ...generateNavLink({ to: getContentSourcePath(SOURCE_DETAILS_PATH, id, isOrganization) }), + }, + { + id: 'sourceContent', + name: NAV.CONTENT, + ...generateNavLink({ to: getContentSourcePath(SOURCE_CONTENT_PATH, id, isOrganization) }), + }, + ]; const isCustom = serviceType === CUSTOM_SERVICE_TYPE; + if (isCustom) { + navItems.push({ + id: 'sourceSchema', + name: NAV.SCHEMA, + ...generateNavLink({ + to: getContentSourcePath(SOURCE_SCHEMAS_PATH, id, isOrganization), + shouldShowActiveForSubroutes: true, + }), + }); + navItems.push({ + id: 'sourceDisplaySettings', + name: NAV.DISPLAY_SETTINGS, + ...generateNavLink({ + to: getContentSourcePath(SOURCE_DISPLAY_SETTINGS_PATH, id, isOrganization), + shouldShowActiveForSubroutes: true, + }), + }); + } + + navItems.push({ + id: 'sourceSettings', + name: NAV.SETTINGS, + ...generateNavLink({ to: getContentSourcePath(SOURCE_SETTINGS_PATH, id, isOrganization) }), + }); - return ( -
- - {NAV.OVERVIEW} - - - {NAV.CONTENT} - - {isCustom && ( - <> - - {NAV.SCHEMA} - - - {NAV.DISPLAY_SETTINGS} - - - )} - - {NAV.SETTINGS} - -
- ); + return navItems; }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.test.tsx index 9df91406c4b7b4..2317c84af2432b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.test.tsx @@ -10,14 +10,10 @@ import { setMockValues, setMockActions } from '../../../__mocks__/kea_logic'; import { contentSources } from '../../__mocks__/content_sources.mock'; import React from 'react'; -import { Redirect } from 'react-router-dom'; import { shallow } from 'enzyme'; -import { Loading } from '../../../shared/loading'; import { SourcesTable } from '../../components/shared/sources_table'; -import { ViewContentHeader } from '../../components/shared/view_content_header'; -import { ADD_SOURCE_PATH, getSourcesPath } from '../../routes'; import { OrganizationSources } from './organization_sources'; @@ -42,20 +38,12 @@ describe('OrganizationSources', () => { const wrapper = shallow(); expect(wrapper.find(SourcesTable)).toHaveLength(1); - expect(wrapper.find(ViewContentHeader)).toHaveLength(1); }); - it('returns loading when loading', () => { + it('does not render a page header when data is loading (to prevent a jump after redirect)', () => { setMockValues({ ...mockValues, dataLoading: true }); const wrapper = shallow(); - expect(wrapper.find(Loading)).toHaveLength(1); - }); - - it('returns redirect when no sources', () => { - setMockValues({ ...mockValues, contentSources: [] }); - const wrapper = shallow(); - - expect(wrapper.find(Redirect).prop('to')).toEqual(getSourcesPath(ADD_SOURCE_PATH, true)); + expect(wrapper.prop('pageHeader')).toBeUndefined(); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.tsx index 4559003b4597f0..a4273ae2ae6a2d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.tsx @@ -6,16 +6,15 @@ */ import React, { useEffect } from 'react'; -import { Link, Redirect } from 'react-router-dom'; +import { Redirect } from 'react-router-dom'; import { useActions, useValues } from 'kea'; -import { EuiButton } from '@elastic/eui'; - -import { Loading } from '../../../shared/loading'; +import { EuiButtonTo } from '../../../shared/react_router_helpers'; +import { WorkplaceSearchPageTemplate } from '../../components/layout'; import { ContentSection } from '../../components/shared/content_section'; import { SourcesTable } from '../../components/shared/sources_table'; -import { ViewContentHeader } from '../../components/shared/view_content_header'; +import { NAV } from '../../constants'; import { ADD_SOURCE_PATH, getSourcesPath } from '../../routes'; import { @@ -36,33 +35,41 @@ export const OrganizationSources: React.FC = () => { const { dataLoading, contentSources } = useValues(SourcesLogic); - if (dataLoading) return ; - - if (contentSources.length === 0) return ; - return ( - - - - {ORG_SOURCES_LINK} - - - } - description={ORG_SOURCES_HEADER_DESCRIPTION} - alignItems="flexStart" - /> - - - - - + + {ORG_SOURCES_LINK} + , + ], + } + } + isLoading={dataLoading} + isEmptyState={!contentSources.length} + emptyState={} + > + + + + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.test.tsx index 08f560c984344d..e2b0dfba1fa97e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.test.tsx @@ -15,7 +15,6 @@ import { shallow } from 'enzyme'; import { EuiCallOut, EuiEmptyPrompt } from '@elastic/eui'; -import { Loading } from '../../../shared/loading'; import { ContentSection } from '../../components/shared/content_section'; import { SourcesTable } from '../../components/shared/sources_table'; @@ -43,13 +42,6 @@ describe('PrivateSources', () => { expect(wrapper.find(SourcesView)).toHaveLength(1); }); - it('renders Loading when loading', () => { - setMockValues({ ...mockValues, dataLoading: true }); - const wrapper = shallow(); - - expect(wrapper.find(Loading)).toHaveLength(1); - }); - it('renders only shared sources section when canCreatePersonalSources is false', () => { setMockValues({ ...mockValues }); const wrapper = shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.tsx index 128c65eeb95daa..693c1e8bd5e403 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.tsx @@ -13,12 +13,13 @@ import { EuiCallOut, EuiEmptyPrompt, EuiSpacer, EuiPanel } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { LicensingLogic } from '../../../shared/licensing'; -import { Loading } from '../../../shared/loading'; import { EuiButtonTo } from '../../../shared/react_router_helpers'; import { AppLogic } from '../../app_logic'; import noSharedSourcesIcon from '../../assets/share_circle.svg'; +import { PersonalDashboardLayout } from '../../components/layout'; import { ContentSection } from '../../components/shared/content_section'; import { SourcesTable } from '../../components/shared/sources_table'; +import { NAV } from '../../constants'; import { ADD_SOURCE_PATH, getSourcesPath } from '../../routes'; import { toSentenceSerial } from '../../utils'; @@ -53,8 +54,6 @@ export const PrivateSources: React.FC = () => { account: { canCreatePersonalSources, groups }, } = useValues(AppLogic); - if (dataLoading) return ; - const hasConfiguredConnectors = serviceTypes.some(({ configured }) => configured); const canAddSources = canCreatePersonalSources && hasConfiguredConnectors; const hasPrivateSources = privateContentSources?.length > 0; @@ -144,10 +143,12 @@ export const PrivateSources: React.FC = () => { ); return ( - - {hasPrivateSources && !hasPlatinumLicense && licenseCallout} - {canCreatePersonalSources && privateSourcesSection} - {sharedSourcesSection} - + + + {hasPrivateSources && !hasPlatinumLicense && licenseCallout} + {canCreatePersonalSources && privateSourcesSection} + {sharedSourcesSection} + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.test.tsx index 783fc434fe8e5d..afe0d1f89faea0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.test.tsx @@ -5,21 +5,17 @@ * 2.0. */ -import '../../../__mocks__/shallow_useeffect.mock'; - import { setMockValues, setMockActions } from '../../../__mocks__/kea_logic'; -import { mockLocation, mockUseParams } from '../../../__mocks__/react_router'; +import { mockUseParams } from '../../../__mocks__/react_router'; import { unmountHandler } from '../../../__mocks__/shallow_useeffect.mock'; import { contentSources } from '../../__mocks__/content_sources.mock'; import React from 'react'; -import { Route, Switch } from 'react-router-dom'; +import { Route } from 'react-router-dom'; import { shallow } from 'enzyme'; -import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { Loading } from '../../../shared/loading'; -import { NAV } from '../../constants'; +import { WorkplaceSearchPageTemplate, PersonalDashboardLayout } from '../../components/layout'; import { DisplaySettingsRouter } from './components/display_settings'; import { Overview } from './components/overview'; @@ -37,6 +33,7 @@ describe('SourceRouter', () => { const mockValues = { contentSource, dataLoading: false, + isOrganization: true, }; beforeEach(() => { @@ -50,11 +47,41 @@ describe('SourceRouter', () => { })); }); - it('returns Loading when loading', () => { - setMockValues({ ...mockValues, dataLoading: true }); - const wrapper = shallow(); + describe('mount/unmount events', () => { + it('fetches & initializes source data on mount', () => { + shallow(); - expect(wrapper.find(Loading)).toHaveLength(1); + expect(initializeSource).toHaveBeenCalledWith(contentSource.id); + }); + + it('resets state on unmount', () => { + shallow(); + unmountHandler(); + + expect(resetSourceState).toHaveBeenCalled(); + }); + }); + + describe('loading state when fetching source data', () => { + // NOTE: The early page isLoading returns are required to prevent a flash of a completely empty + // page (instead of preserving the layout/side nav while loading). We also cannot let the code + // fall through to the router because some routes are conditionally rendered based on isCustomSource. + + it('returns an empty loading Workplace Search page on organization views', () => { + setMockValues({ ...mockValues, dataLoading: true, isOrganization: true }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate); + expect(wrapper.prop('isLoading')).toEqual(true); + }); + + it('returns an empty loading personal dashboard page when not on an organization view', () => { + setMockValues({ ...mockValues, dataLoading: true, isOrganization: false }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(PersonalDashboardLayout); + expect(wrapper.prop('isLoading')).toEqual(true); + }); }); it('renders source routes (standard)', () => { @@ -63,7 +90,6 @@ describe('SourceRouter', () => { expect(wrapper.find(Overview)).toHaveLength(1); expect(wrapper.find(SourceSettings)).toHaveLength(1); expect(wrapper.find(SourceContent)).toHaveLength(1); - expect(wrapper.find(Switch)).toHaveLength(1); expect(wrapper.find(Route)).toHaveLength(3); }); @@ -76,55 +102,4 @@ describe('SourceRouter', () => { expect(wrapper.find(SchemaChangeErrors)).toHaveLength(1); expect(wrapper.find(Route)).toHaveLength(6); }); - - it('handles breadcrumbs while loading (standard)', () => { - setMockValues({ - ...mockValues, - contentSource: {}, - }); - - const loadingBreadcrumbs = ['Sources', '...']; - - const wrapper = shallow(); - - const overviewBreadCrumb = wrapper.find(SetPageChrome).at(0); - const contentBreadCrumb = wrapper.find(SetPageChrome).at(1); - const settingsBreadCrumb = wrapper.find(SetPageChrome).at(2); - - expect(overviewBreadCrumb.prop('trail')).toEqual([...loadingBreadcrumbs]); - expect(contentBreadCrumb.prop('trail')).toEqual([...loadingBreadcrumbs, NAV.CONTENT]); - expect(settingsBreadCrumb.prop('trail')).toEqual([...loadingBreadcrumbs, NAV.SETTINGS]); - }); - - it('handles breadcrumbs while loading (custom)', () => { - setMockValues({ - ...mockValues, - contentSource: { serviceType: 'custom' }, - }); - - const loadingBreadcrumbs = ['Sources', '...']; - - const wrapper = shallow(); - - const schemaBreadCrumb = wrapper.find(SetPageChrome).at(2); - const schemaErrorsBreadCrumb = wrapper.find(SetPageChrome).at(3); - const displaySettingsBreadCrumb = wrapper.find(SetPageChrome).at(4); - - expect(schemaBreadCrumb.prop('trail')).toEqual([...loadingBreadcrumbs, NAV.SCHEMA]); - expect(schemaErrorsBreadCrumb.prop('trail')).toEqual([...loadingBreadcrumbs, NAV.SCHEMA]); - expect(displaySettingsBreadCrumb.prop('trail')).toEqual([ - ...loadingBreadcrumbs, - NAV.DISPLAY_SETTINGS, - ]); - }); - - describe('reset state', () => { - it('resets state when leaving source tree', () => { - mockLocation.pathname = '/home'; - shallow(); - unmountHandler(); - - expect(resetSourceState).toHaveBeenCalled(); - }); - }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx index d5d6c8e541e4f2..bf68a60757c0df 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx @@ -10,18 +10,11 @@ import React, { useEffect } from 'react'; import { Route, Switch, useLocation, useParams } from 'react-router-dom'; import { useActions, useValues } from 'kea'; -import moment from 'moment'; -import { EuiButton, EuiCallOut, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; - -import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { Loading } from '../../../shared/loading'; -import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; import { AppLogic } from '../../app_logic'; -import { NAV } from '../../constants'; +import { WorkplaceSearchPageTemplate, PersonalDashboardLayout } from '../../components/layout'; import { CUSTOM_SERVICE_TYPE } from '../../constants'; import { - ENT_SEARCH_LICENSE_MANAGEMENT, REINDEX_JOB_PATH, SOURCE_DETAILS_PATH, SOURCE_CONTENT_PATH, @@ -37,13 +30,7 @@ import { Overview } from './components/overview'; import { Schema } from './components/schema'; import { SchemaChangeErrors } from './components/schema/schema_change_errors'; import { SourceContent } from './components/source_content'; -import { SourceInfoCard } from './components/source_info_card'; import { SourceSettings } from './components/source_settings'; -import { - SOURCE_DISABLED_CALLOUT_TITLE, - SOURCE_DISABLED_CALLOUT_DESCRIPTION, - SOURCE_DISABLED_CALLOUT_BUTTON, -} from './constants'; import { SourceLogic } from './source_logic'; export const SourceRouter: React.FC = () => { @@ -61,84 +48,43 @@ export const SourceRouter: React.FC = () => { return resetSourceState; }, []); - if (dataLoading) return ; + if (dataLoading) { + return isOrganization ? ( + + ) : ( + + ); + } - const { - name, - createdAt, - serviceType, - serviceName, - isFederatedSource, - supportedByLicense, - } = contentSource; + const { serviceType } = contentSource; const isCustomSource = serviceType === CUSTOM_SERVICE_TYPE; - const pageHeader = ( - <> - - - - ); - - const callout = ( - <> - -

{SOURCE_DISABLED_CALLOUT_DESCRIPTION}

- - {SOURCE_DISABLED_CALLOUT_BUTTON} - -
- - - ); - return ( - <> - {!supportedByLicense && callout} - {pageHeader} - - - - - + + + + + + + + {isCustomSource && ( + + - - - - + )} + {isCustomSource && ( + + - {isCustomSource && ( - - - - - - )} - {isCustomSource && ( - - - - - - )} - {isCustomSource && ( - - - - - - )} - - - - + )} + {isCustomSource && ( + + - - + )} + + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx index 84bff65e62cef4..2abdba07b5c881 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx @@ -11,12 +11,8 @@ import { Redirect, Route, Switch, useLocation } from 'react-router-dom'; import { Location } from 'history'; import { useActions, useValues } from 'kea'; -import { FlashMessages } from '../../../shared/flash_messages'; -import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { LicensingLogic } from '../../../shared/licensing'; -import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; import { AppLogic } from '../../app_logic'; -import { NAV } from '../../constants'; import { ADD_SOURCE_PATH, SOURCE_DETAILS_PATH, @@ -52,71 +48,53 @@ export const SourcesRouter: React.FC = () => { }, [pathname]); return ( - <> - - - - - - + + + + + + + + {staticSourceData.map(({ addPath, accountContextOnly }, i) => ( + + {!hasPlatinumLicense && accountContextOnly ? ( + + ) : ( + + )} - - - - + ))} + {staticSourceData.map(({ addPath }, i) => ( + + - {staticSourceData.map(({ addPath, accountContextOnly, name }, i) => ( - - - {!hasPlatinumLicense && accountContextOnly ? ( - - ) : ( - - )} - - ))} - {staticSourceData.map(({ addPath, name }, i) => ( - - - - - ))} - {staticSourceData.map(({ addPath, name }, i) => ( - - - - - ))} - {staticSourceData.map(({ addPath, name, configuration: { needsConfiguration } }, i) => { - if (needsConfiguration) - return ( - - - - - ); - })} - {canCreatePersonalSources ? ( - - - - - - ) : ( - - )} - - - + ))} + {staticSourceData.map(({ addPath }, i) => ( + + - - + ))} + {staticSourceData.map(({ addPath, configuration: { needsConfiguration } }, i) => { + if (needsConfiguration) + return ( + + + + ); + })} + {canCreatePersonalSources ? ( + + - - + ) : ( + + )} + + + + + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.test.tsx index cf23470e8155eb..7bd40d6f04a56f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.test.tsx @@ -25,6 +25,13 @@ describe('Overview', () => { expect(mockActions.initializeOverview).toHaveBeenCalled(); }); + it('does not render a page header when data is loading (to prevent a jump between non/onboarding headers)', () => { + setMockValues({ dataLoading: true }); + const wrapper = shallow(); + + expect(wrapper.prop('pageHeader')).toBeUndefined(); + }); + it('renders onboarding state', () => { setMockValues({ dataLoading: false }); const wrapper = shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx index 0049c5b732d3d0..c51fdb64b8f261 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx @@ -53,17 +53,15 @@ export const Overview: React.FC = () => { const hideOnboarding = hasUsers && hasOrgSources && isOldAccount && orgName !== defaultOrgName; - const headerTitle = dataLoading || hideOnboarding ? HEADER_TITLE : ONBOARDING_HEADER_TITLE; - const headerDescription = - dataLoading || hideOnboarding ? HEADER_DESCRIPTION : ONBOARDING_HEADER_DESCRIPTION; + const headerTitle = hideOnboarding ? HEADER_TITLE : ONBOARDING_HEADER_TITLE; + const headerDescription = hideOnboarding ? HEADER_DESCRIPTION : ONBOARDING_HEADER_DESCRIPTION; return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx index b32e3af0218273..35619d2b2d560d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx @@ -40,6 +40,13 @@ describe('SourceConfig', () => { expect(wrapper.find(EuiConfirmModal)).toHaveLength(1); }); + it('renders a breadcrumb fallback while data is loading', () => { + setMockValues({ dataLoading: true, sourceConfigData: {} }); + const wrapper = shallow(); + + expect(wrapper.prop('pageChrome')).toEqual(['Settings', 'Content source connectors', '...']); + }); + it('handles delete click', () => { const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx index f1dfda78ee13ff..c2a0b60e1eca3d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx @@ -47,7 +47,7 @@ export const SourceConfig: React.FC = ({ sourceIndex }) => { return ( Date: Mon, 21 Jun 2021 21:20:41 -0400 Subject: [PATCH 07/57] [Fleet] Correctly check for degraded status in agent healthbar (#102821) --- .../fleet/common/services/agent_status.ts | 2 +- .../apis/fleet_telemetry.ts | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/fleet/common/services/agent_status.ts b/x-pack/plugins/fleet/common/services/agent_status.ts index df5de6ad981914..b8a59e64477234 100644 --- a/x-pack/plugins/fleet/common/services/agent_status.ts +++ b/x-pack/plugins/fleet/common/services/agent_status.ts @@ -54,7 +54,7 @@ export function buildKueryForOnlineAgents() { } export function buildKueryForErrorAgents() { - return 'last_checkin_status:error or .last_checkin_status:degraded'; + return 'last_checkin_status:error or last_checkin_status:degraded'; } export function buildKueryForOfflineAgents() { diff --git a/x-pack/test/fleet_api_integration/apis/fleet_telemetry.ts b/x-pack/test/fleet_api_integration/apis/fleet_telemetry.ts index 5e4a580473dd1a..36eef019f7bf72 100644 --- a/x-pack/test/fleet_api_integration/apis/fleet_telemetry.ts +++ b/x-pack/test/fleet_api_integration/apis/fleet_telemetry.ts @@ -21,9 +21,12 @@ export default function (providerContext: FtrProviderContext) { let data: any = {}; switch (status) { - case 'unhealthy': + case 'error': data = { last_checkin_status: 'error' }; break; + case 'degraded': + data = { last_checkin_status: 'degraded' }; + break; case 'offline': data = { last_checkin: '2017-06-07T18:59:04.498Z' }; break; @@ -85,12 +88,13 @@ export default function (providerContext: FtrProviderContext) { // Default Fleet Server await generateAgent('healthy', defaultFleetServerPolicy.id); await generateAgent('healthy', defaultFleetServerPolicy.id); - await generateAgent('unhealthy', defaultFleetServerPolicy.id); + await generateAgent('error', defaultFleetServerPolicy.id); // Default policy await generateAgent('healthy', defaultServerPolicy.id); await generateAgent('offline', defaultServerPolicy.id); - await generateAgent('unhealthy', defaultServerPolicy.id); + await generateAgent('error', defaultServerPolicy.id); + await generateAgent('degraded', defaultServerPolicy.id); }); it('should return the correct telemetry values for fleet', async () => { @@ -105,12 +109,12 @@ export default function (providerContext: FtrProviderContext) { .expect(200); expect(apiResponse.stack_stats.kibana.plugins.fleet.agents).eql({ - total_enrolled: 6, + total_enrolled: 7, healthy: 3, - unhealthy: 2, + unhealthy: 3, offline: 1, updating: 0, - total_all_statuses: 6, + total_all_statuses: 7, }); expect(apiResponse.stack_stats.kibana.plugins.fleet.fleet_server).eql({ From 42fc79742f38275d84a224eb04b6a25f93dd78c4 Mon Sep 17 00:00:00 2001 From: Ahmad Bamieh Date: Tue, 22 Jun 2021 06:21:35 +0300 Subject: [PATCH 08/57] [Telemetry] Track event loop delays on the server (#101580) --- .../collectors/event_loop_delays/constants.ts | 37 +++++ .../event_loop_delays.mocks.ts | 49 +++++++ .../event_loop_delays.test.ts | 135 ++++++++++++++++++ .../event_loop_delays/event_loop_delays.ts | 109 ++++++++++++++ .../event_loop_delays_usage_collector.test.ts | 84 +++++++++++ .../event_loop_delays_usage_collector.ts | 53 +++++++ .../collectors/event_loop_delays/index.ts | 11 ++ .../event_loop_delays/rollups/daily.test.ts | 81 +++++++++++ .../event_loop_delays/rollups/daily.ts | 35 +++++ .../event_loop_delays/rollups/index.ts | 9 ++ .../integration_tests/daily_rollups.test.ts | 94 ++++++++++++ .../event_loop_delays/saved_objects.test.ts | 122 ++++++++++++++++ .../event_loop_delays/saved_objects.ts | 72 ++++++++++ .../collectors/event_loop_delays/schema.ts | 111 ++++++++++++++ .../server/collectors/index.ts | 1 + .../server/plugin.test.ts | 5 +- .../kibana_usage_collection/server/plugin.ts | 33 +++-- src/plugins/telemetry/schema/oss_plugins.json | 87 +++++++++++ 18 files changed, 1114 insertions(+), 14 deletions(-) create mode 100644 src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/constants.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.mocks.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.test.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays_usage_collector.test.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays_usage_collector.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/index.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/daily.test.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/daily.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/index.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/integration_tests/daily_rollups.test.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/saved_objects.test.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/saved_objects.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/schema.ts diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/constants.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/constants.ts new file mode 100644 index 00000000000000..1753c87c9d0054 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/constants.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** + * Roll daily indices every 24h + */ +export const ROLL_DAILY_INDICES_INTERVAL = 24 * 60 * 60 * 1000; + +/** + * Start rolling indices after 5 minutes up + */ +export const ROLL_INDICES_START = 5 * 60 * 1000; + +/** + * Reset the event loop delay historgram every 1 hour + */ +export const MONITOR_EVENT_LOOP_DELAYS_INTERVAL = 1 * 60 * 60 * 1000; + +/** + * Reset the event loop delay historgram every 24h + */ +export const MONITOR_EVENT_LOOP_DELAYS_RESET = 24 * 60 * 60 * 1000; + +/** + * Start monitoring the event loop delays after 1 minute + */ +export const MONITOR_EVENT_LOOP_DELAYS_START = 1 * 60 * 1000; + +/** + * Event loop monitoring sampling rate in milliseconds. + */ +export const MONITOR_EVENT_LOOP_DELAYS_RESOLUTION = 10; diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.mocks.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.mocks.ts new file mode 100644 index 00000000000000..6b03d3cc5cbd12 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.mocks.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import moment from 'moment'; +import type { IntervalHistogram } from './event_loop_delays'; + +export const mockMonitorEnable = jest.fn(); +export const mockMonitorPercentile = jest.fn(); +export const mockMonitorReset = jest.fn(); +export const mockMonitorDisable = jest.fn(); +export const monitorEventLoopDelay = jest.fn().mockReturnValue({ + enable: mockMonitorEnable, + percentile: mockMonitorPercentile, + disable: mockMonitorDisable, + reset: mockMonitorReset, +}); + +jest.doMock('perf_hooks', () => ({ + monitorEventLoopDelay, +})); + +function createMockHistogram(overwrites: Partial = {}): IntervalHistogram { + const now = moment(); + + return { + min: 9093120, + max: 53247999, + mean: 11993238.600747818, + exceeds: 0, + stddev: 1168191.9357543814, + fromTimestamp: now.startOf('day').toISOString(), + lastUpdatedAt: now.toISOString(), + percentiles: { + '50': 12607487, + '75': 12615679, + '95': 12648447, + '99': 12713983, + }, + ...overwrites, + }; +} + +export const mocked = { + createHistogram: createMockHistogram, +}; diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.test.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.test.ts new file mode 100644 index 00000000000000..d03236a9756b3b --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.test.ts @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Subject } from 'rxjs'; + +import { + mockMonitorEnable, + mockMonitorPercentile, + monitorEventLoopDelay, + mockMonitorReset, + mockMonitorDisable, +} from './event_loop_delays.mocks'; +import { savedObjectsRepositoryMock } from '../../../../../core/server/mocks'; +import { startTrackingEventLoopDelaysUsage, EventLoopDelaysCollector } from './event_loop_delays'; + +describe('EventLoopDelaysCollector', () => { + jest.useFakeTimers('modern'); + const mockNow = jest.getRealSystemTime(); + jest.setSystemTime(mockNow); + + beforeEach(() => jest.clearAllMocks()); + afterAll(() => jest.useRealTimers()); + + test('#constructor enables monitoring', () => { + new EventLoopDelaysCollector(); + expect(monitorEventLoopDelay).toBeCalledWith({ resolution: 10 }); + expect(mockMonitorEnable).toBeCalledTimes(1); + }); + + test('#collect returns event loop delays histogram', () => { + const eventLoopDelaysCollector = new EventLoopDelaysCollector(); + const histogramData = eventLoopDelaysCollector.collect(); + expect(mockMonitorPercentile).toHaveBeenNthCalledWith(1, 50); + expect(mockMonitorPercentile).toHaveBeenNthCalledWith(2, 75); + expect(mockMonitorPercentile).toHaveBeenNthCalledWith(3, 95); + expect(mockMonitorPercentile).toHaveBeenNthCalledWith(4, 99); + + expect(Object.keys(histogramData)).toMatchInlineSnapshot(` + Array [ + "min", + "max", + "mean", + "exceeds", + "stddev", + "fromTimestamp", + "lastUpdatedAt", + "percentiles", + ] + `); + }); + test('#reset resets histogram data', () => { + const eventLoopDelaysCollector = new EventLoopDelaysCollector(); + eventLoopDelaysCollector.reset(); + expect(mockMonitorReset).toBeCalledTimes(1); + }); + test('#stop disables monitoring event loop delays', () => { + const eventLoopDelaysCollector = new EventLoopDelaysCollector(); + eventLoopDelaysCollector.stop(); + expect(mockMonitorDisable).toBeCalledTimes(1); + }); +}); + +describe('startTrackingEventLoopDelaysUsage', () => { + const mockInternalRepository = savedObjectsRepositoryMock.create(); + const stopMonitoringEventLoop$ = new Subject(); + + beforeAll(() => jest.useFakeTimers('modern')); + beforeEach(() => jest.clearAllMocks()); + afterEach(() => stopMonitoringEventLoop$.next()); + + it('initializes EventLoopDelaysCollector and starts timer', () => { + const collectionStartDelay = 1000; + startTrackingEventLoopDelaysUsage( + mockInternalRepository, + stopMonitoringEventLoop$, + collectionStartDelay + ); + + expect(monitorEventLoopDelay).toBeCalledTimes(1); + expect(mockMonitorPercentile).toBeCalledTimes(0); + jest.advanceTimersByTime(collectionStartDelay); + expect(mockMonitorPercentile).toBeCalled(); + }); + + it('stores event loop delays every collectionInterval duration', () => { + const collectionStartDelay = 100; + const collectionInterval = 1000; + startTrackingEventLoopDelaysUsage( + mockInternalRepository, + stopMonitoringEventLoop$, + collectionStartDelay, + collectionInterval + ); + + expect(mockInternalRepository.create).toBeCalledTimes(0); + jest.advanceTimersByTime(collectionStartDelay); + expect(mockInternalRepository.create).toBeCalledTimes(1); + jest.advanceTimersByTime(collectionInterval); + expect(mockInternalRepository.create).toBeCalledTimes(2); + jest.advanceTimersByTime(collectionInterval); + expect(mockInternalRepository.create).toBeCalledTimes(3); + }); + + it('resets histogram every histogramReset duration', () => { + const collectionStartDelay = 0; + const collectionInterval = 1000; + const histogramReset = 5000; + startTrackingEventLoopDelaysUsage( + mockInternalRepository, + stopMonitoringEventLoop$, + collectionStartDelay, + collectionInterval, + histogramReset + ); + + expect(mockMonitorReset).toBeCalledTimes(0); + jest.advanceTimersByTime(collectionInterval * 5); + expect(mockMonitorReset).toBeCalledTimes(1); + jest.advanceTimersByTime(collectionInterval * 5); + expect(mockMonitorReset).toBeCalledTimes(2); + }); + + it('stops monitoring event loop delays once stopMonitoringEventLoop$.next is called', () => { + startTrackingEventLoopDelaysUsage(mockInternalRepository, stopMonitoringEventLoop$); + + expect(mockMonitorDisable).toBeCalledTimes(0); + stopMonitoringEventLoop$.next(); + expect(mockMonitorDisable).toBeCalledTimes(1); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.ts new file mode 100644 index 00000000000000..655cba580fc5df --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { EventLoopDelayMonitor } from 'perf_hooks'; +import { monitorEventLoopDelay } from 'perf_hooks'; +import { takeUntil, finalize, map } from 'rxjs/operators'; +import { Observable, timer } from 'rxjs'; +import type { ISavedObjectsRepository } from 'kibana/server'; +import { + MONITOR_EVENT_LOOP_DELAYS_START, + MONITOR_EVENT_LOOP_DELAYS_INTERVAL, + MONITOR_EVENT_LOOP_DELAYS_RESET, + MONITOR_EVENT_LOOP_DELAYS_RESOLUTION, +} from './constants'; +import { storeHistogram } from './saved_objects'; + +export interface IntervalHistogram { + fromTimestamp: string; + lastUpdatedAt: string; + min: number; + max: number; + mean: number; + exceeds: number; + stddev: number; + percentiles: { + 50: number; + 75: number; + 95: number; + 99: number; + }; +} + +export class EventLoopDelaysCollector { + private readonly loopMonitor: EventLoopDelayMonitor; + private fromTimestamp: Date; + + constructor() { + const monitor = monitorEventLoopDelay({ + resolution: MONITOR_EVENT_LOOP_DELAYS_RESOLUTION, + }); + monitor.enable(); + this.fromTimestamp = new Date(); + this.loopMonitor = monitor; + } + + public collect(): IntervalHistogram { + const { min, max, mean, exceeds, stddev } = this.loopMonitor; + + return { + min, + max, + mean, + exceeds, + stddev, + fromTimestamp: this.fromTimestamp.toISOString(), + lastUpdatedAt: new Date().toISOString(), + percentiles: { + 50: this.loopMonitor.percentile(50), + 75: this.loopMonitor.percentile(75), + 95: this.loopMonitor.percentile(95), + 99: this.loopMonitor.percentile(99), + }, + }; + } + + public reset() { + this.loopMonitor.reset(); + this.fromTimestamp = new Date(); + } + + public stop() { + this.loopMonitor.disable(); + } +} + +/** + * The monitoring of the event loop starts immediately. + * The first collection of the histogram happens after 1 minute. + * The daily histogram data is updated every 1 hour. + */ +export function startTrackingEventLoopDelaysUsage( + internalRepository: ISavedObjectsRepository, + stopMonitoringEventLoop$: Observable, + collectionStartDelay = MONITOR_EVENT_LOOP_DELAYS_START, + collectionInterval = MONITOR_EVENT_LOOP_DELAYS_INTERVAL, + histogramReset = MONITOR_EVENT_LOOP_DELAYS_RESET +) { + const eventLoopDelaysCollector = new EventLoopDelaysCollector(); + + const resetOnCount = Math.ceil(histogramReset / collectionInterval); + timer(collectionStartDelay, collectionInterval) + .pipe( + map((i) => (i + 1) % resetOnCount === 0), + takeUntil(stopMonitoringEventLoop$), + finalize(() => eventLoopDelaysCollector.stop()) + ) + .subscribe(async (shouldReset) => { + const histogram = eventLoopDelaysCollector.collect(); + if (shouldReset) { + eventLoopDelaysCollector.reset(); + } + await storeHistogram(histogram, internalRepository); + }); +} diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays_usage_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays_usage_collector.test.ts new file mode 100644 index 00000000000000..06c51f6afa3a88 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays_usage_collector.test.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + Collector, + createUsageCollectionSetupMock, + createCollectorFetchContextMock, +} from '../../../../usage_collection/server/mocks'; +import { registerEventLoopDelaysCollector } from './event_loop_delays_usage_collector'; +import { loggingSystemMock, savedObjectsRepositoryMock } from '../../../../../core/server/mocks'; +import type { SavedObjectsFindResponse } from '../../../../../core/server'; + +const logger = loggingSystemMock.createLogger(); + +describe('registerEventLoopDelaysCollector', () => { + let collector: Collector; + const mockRegisterType = jest.fn(); + const mockInternalRepository = savedObjectsRepositoryMock.create(); + const mockGetSavedObjectsClient = () => mockInternalRepository; + + const usageCollectionMock = createUsageCollectionSetupMock(); + usageCollectionMock.makeUsageCollector.mockImplementation((config) => { + collector = new Collector(logger, config); + return createUsageCollectionSetupMock().makeUsageCollector(config); + }); + + const collectorFetchContext = createCollectorFetchContextMock(); + + beforeAll(() => { + registerEventLoopDelaysCollector( + logger, + usageCollectionMock, + mockRegisterType, + mockGetSavedObjectsClient + ); + }); + + it('registers event_loop_delays collector', () => { + expect(collector).not.toBeUndefined(); + expect(collector.type).toBe('event_loop_delays'); + }); + + it('registers savedObjectType "event_loop_delays_daily"', () => { + expect(mockRegisterType).toBeCalledTimes(1); + expect(mockRegisterType).toBeCalledWith( + expect.objectContaining({ + name: 'event_loop_delays_daily', + }) + ); + }); + + it('returns objects from event_loop_delays_daily from fetch function', async () => { + const mockFind = jest.fn().mockResolvedValue(({ + saved_objects: [{ attributes: { test: 1 } }], + } as unknown) as SavedObjectsFindResponse); + mockInternalRepository.find = mockFind; + const fetchResult = await collector.fetch(collectorFetchContext); + + expect(fetchResult).toMatchInlineSnapshot(` + Object { + "daily": Array [ + Object { + "test": 1, + }, + ], + } + `); + expect(mockFind).toBeCalledTimes(1); + expect(mockFind.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "sortField": "updated_at", + "sortOrder": "desc", + "type": "event_loop_delays_daily", + }, + ] + `); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays_usage_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays_usage_collector.ts new file mode 100644 index 00000000000000..774e021d7a549e --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays_usage_collector.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { timer } from 'rxjs'; +import { SavedObjectsServiceSetup, ISavedObjectsRepository, Logger } from 'kibana/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { rollDailyData } from './rollups'; +import { registerSavedObjectTypes, EventLoopDelaysDaily } from './saved_objects'; +import { eventLoopDelaysUsageSchema, EventLoopDelaysUsageReport } from './schema'; +import { SAVED_OBJECTS_DAILY_TYPE } from './saved_objects'; +import { ROLL_DAILY_INDICES_INTERVAL, ROLL_INDICES_START } from './constants'; + +export function registerEventLoopDelaysCollector( + logger: Logger, + usageCollection: UsageCollectionSetup, + registerType: SavedObjectsServiceSetup['registerType'], + getSavedObjectsClient: () => ISavedObjectsRepository | undefined +) { + registerSavedObjectTypes(registerType); + + timer(ROLL_INDICES_START, ROLL_DAILY_INDICES_INTERVAL).subscribe(() => + rollDailyData(logger, getSavedObjectsClient()) + ); + + const collector = usageCollection.makeUsageCollector({ + type: 'event_loop_delays', + isReady: () => typeof getSavedObjectsClient() !== 'undefined', + schema: eventLoopDelaysUsageSchema, + fetch: async () => { + const internalRepository = getSavedObjectsClient(); + if (!internalRepository) { + return { daily: [] }; + } + + const { saved_objects: savedObjects } = await internalRepository.find({ + type: SAVED_OBJECTS_DAILY_TYPE, + sortField: 'updated_at', + sortOrder: 'desc', + }); + + return { + daily: savedObjects.map((savedObject) => savedObject.attributes), + }; + }, + }); + + usageCollection.registerCollector(collector); +} diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/index.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/index.ts new file mode 100644 index 00000000000000..693b173c2759ea --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { startTrackingEventLoopDelaysUsage } from './event_loop_delays'; +export { registerEventLoopDelaysCollector } from './event_loop_delays_usage_collector'; +export { SAVED_OBJECTS_DAILY_TYPE } from './saved_objects'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/daily.test.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/daily.test.ts new file mode 100644 index 00000000000000..cb59d6a44b07e6 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/daily.test.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { rollDailyData } from './daily'; +import { loggingSystemMock, savedObjectsRepositoryMock } from '../../../../../../core/server/mocks'; +import type { SavedObjectsFindResponse } from '../../../../../../core/server'; + +describe('rollDailyData', () => { + const logger = loggingSystemMock.createLogger(); + const mockSavedObjectsClient = savedObjectsRepositoryMock.create(); + + beforeEach(() => jest.clearAllMocks()); + + it('returns false if no savedObjectsClient', async () => { + await rollDailyData(logger, undefined); + expect(mockSavedObjectsClient.find).toBeCalledTimes(0); + }); + + it('calls delete on documents older than 3 days', async () => { + mockSavedObjectsClient.find.mockResolvedValueOnce({ + saved_objects: [{ id: 'test_id_1' }, { id: 'test_id_2' }], + } as SavedObjectsFindResponse); + + await rollDailyData(logger, mockSavedObjectsClient); + + expect(mockSavedObjectsClient.find).toHaveBeenCalledTimes(1); + expect(mockSavedObjectsClient.delete).toBeCalledTimes(2); + expect(mockSavedObjectsClient.delete).toHaveBeenNthCalledWith( + 1, + 'event_loop_delays_daily', + 'test_id_1' + ); + expect(mockSavedObjectsClient.delete).toHaveBeenNthCalledWith( + 2, + 'event_loop_delays_daily', + 'test_id_2' + ); + }); + + it('calls logger.debug on repository find error', async () => { + const mockError = new Error('find error'); + mockSavedObjectsClient.find.mockRejectedValueOnce(mockError); + + await rollDailyData(logger, mockSavedObjectsClient); + expect(logger.debug).toBeCalledTimes(2); + expect(logger.debug).toHaveBeenNthCalledWith( + 1, + 'Failed to rollup transactional to daily entries' + ); + expect(logger.debug).toHaveBeenNthCalledWith(2, mockError); + }); + + it('settles all deletes before logging failures', async () => { + const mockError1 = new Error('delete error 1'); + const mockError2 = new Error('delete error 2'); + mockSavedObjectsClient.find.mockResolvedValueOnce({ + saved_objects: [{ id: 'test_id_1' }, { id: 'test_id_2' }, { id: 'test_id_3' }], + } as SavedObjectsFindResponse); + + mockSavedObjectsClient.delete.mockRejectedValueOnce(mockError1); + mockSavedObjectsClient.delete.mockResolvedValueOnce(true); + mockSavedObjectsClient.delete.mockRejectedValueOnce(mockError2); + + await rollDailyData(logger, mockSavedObjectsClient); + expect(mockSavedObjectsClient.delete).toBeCalledTimes(3); + expect(logger.debug).toBeCalledTimes(2); + expect(logger.debug).toHaveBeenNthCalledWith( + 1, + 'Failed to rollup transactional to daily entries' + ); + expect(logger.debug).toHaveBeenNthCalledWith(2, [ + { reason: mockError1, status: 'rejected' }, + { reason: mockError2, status: 'rejected' }, + ]); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/daily.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/daily.ts new file mode 100644 index 00000000000000..29072335d272b1 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/daily.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Logger } from '@kbn/logging'; +import { ISavedObjectsRepository } from '../../../../../../core/server'; +import { deleteHistogramSavedObjects } from '../saved_objects'; + +/** + * daily rollup function. Deletes histogram saved objects older than 3 days + * @param logger + * @param savedObjectsClient + */ +export async function rollDailyData( + logger: Logger, + savedObjectsClient?: ISavedObjectsRepository +): Promise { + if (!savedObjectsClient) { + return; + } + try { + const settledDeletes = await deleteHistogramSavedObjects(savedObjectsClient); + const failedDeletes = settledDeletes.filter(({ status }) => status !== 'fulfilled'); + if (failedDeletes.length) { + throw failedDeletes; + } + } catch (err) { + logger.debug(`Failed to rollup transactional to daily entries`); + logger.debug(err); + } +} diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/index.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/index.ts new file mode 100644 index 00000000000000..4523069a820e7c --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { rollDailyData } from './daily'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/integration_tests/daily_rollups.test.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/integration_tests/daily_rollups.test.ts new file mode 100644 index 00000000000000..8c227f260da6e6 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/integration_tests/daily_rollups.test.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Logger, ISavedObjectsRepository } from '../../../../../../../core/server'; +import { + createTestServers, + TestElasticsearchUtils, + TestKibanaUtils, + createRootWithCorePlugins, +} from '../../../../../../../core/test_helpers/kbn_server'; +import { rollDailyData } from '../daily'; +import { mocked } from '../../event_loop_delays.mocks'; + +import { + SAVED_OBJECTS_DAILY_TYPE, + serializeSavedObjectId, + EventLoopDelaysDaily, +} from '../../saved_objects'; +import moment from 'moment'; + +const { startES } = createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), +}); + +function createRawObject(date: moment.MomentInput) { + const pid = Math.round(Math.random() * 10000); + return { + type: SAVED_OBJECTS_DAILY_TYPE, + id: serializeSavedObjectId({ pid, date }), + attributes: { + ...mocked.createHistogram({ + fromTimestamp: moment(date).startOf('day').toISOString(), + lastUpdatedAt: moment(date).toISOString(), + }), + processId: pid, + }, + }; +} + +const rawEventLoopDelaysDaily = [ + createRawObject(moment.now()), + createRawObject(moment.now()), + createRawObject(moment().subtract(1, 'days')), + createRawObject(moment().subtract(3, 'days')), +]; + +const outdatedRawEventLoopDelaysDaily = [ + createRawObject(moment().subtract(5, 'days')), + createRawObject(moment().subtract(7, 'days')), +]; + +describe('daily rollups integration test', () => { + let esServer: TestElasticsearchUtils; + let root: TestKibanaUtils['root']; + let internalRepository: ISavedObjectsRepository; + let logger: Logger; + + beforeAll(async () => { + esServer = await startES(); + root = createRootWithCorePlugins(); + + await root.setup(); + const start = await root.start(); + logger = root.logger.get('test dailt rollups'); + internalRepository = start.savedObjects.createInternalRepository([SAVED_OBJECTS_DAILY_TYPE]); + + await internalRepository.bulkCreate( + [...rawEventLoopDelaysDaily, ...outdatedRawEventLoopDelaysDaily], + { refresh: true } + ); + }); + + afterAll(async () => { + await esServer.stop(); + await root.shutdown(); + }); + + it('deletes documents older that 3 days from the saved objects repository', async () => { + await rollDailyData(logger, internalRepository); + const { + total, + saved_objects: savedObjects, + } = await internalRepository.find({ type: SAVED_OBJECTS_DAILY_TYPE }); + expect(total).toBe(rawEventLoopDelaysDaily.length); + expect(savedObjects.map(({ id, type, attributes }) => ({ id, type, attributes }))).toEqual( + rawEventLoopDelaysDaily + ); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/saved_objects.test.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/saved_objects.test.ts new file mode 100644 index 00000000000000..022040615bd457 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/saved_objects.test.ts @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + storeHistogram, + serializeSavedObjectId, + deleteHistogramSavedObjects, +} from './saved_objects'; +import { savedObjectsRepositoryMock } from '../../../../../core/server/mocks'; +import type { SavedObjectsFindResponse } from '../../../../../core/server/'; +import { mocked } from './event_loop_delays.mocks'; + +describe('serializeSavedObjectId', () => { + it('returns serialized id', () => { + const id = serializeSavedObjectId({ date: 1623233091278, pid: 123 }); + expect(id).toBe('123::09062021'); + }); +}); + +describe('storeHistogram', () => { + const mockHistogram = mocked.createHistogram(); + const mockInternalRepository = savedObjectsRepositoryMock.create(); + + jest.useFakeTimers('modern'); + const mockNow = jest.getRealSystemTime(); + jest.setSystemTime(mockNow); + + beforeEach(() => jest.clearAllMocks()); + afterAll(() => jest.useRealTimers()); + + it('stores histogram data in a savedObject', async () => { + await storeHistogram(mockHistogram, mockInternalRepository); + const pid = process.pid; + const id = serializeSavedObjectId({ date: mockNow, pid }); + + expect(mockInternalRepository.create).toBeCalledWith( + 'event_loop_delays_daily', + { ...mockHistogram, processId: pid }, + { id, overwrite: true } + ); + }); +}); + +describe('deleteHistogramSavedObjects', () => { + const mockInternalRepository = savedObjectsRepositoryMock.create(); + + beforeEach(() => { + jest.clearAllMocks(); + mockInternalRepository.find.mockResolvedValue({ + saved_objects: [{ id: 'test_obj_1' }, { id: 'test_obj_1' }], + } as SavedObjectsFindResponse); + }); + + it('builds filter query based on time range passed in days', async () => { + await deleteHistogramSavedObjects(mockInternalRepository); + await deleteHistogramSavedObjects(mockInternalRepository, 20); + expect(mockInternalRepository.find.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "filter": "event_loop_delays_daily.attributes.lastUpdatedAt < \\"now-3d/d\\"", + "type": "event_loop_delays_daily", + }, + ], + Array [ + Object { + "filter": "event_loop_delays_daily.attributes.lastUpdatedAt < \\"now-20d/d\\"", + "type": "event_loop_delays_daily", + }, + ], + ] + `); + }); + + it('loops over saved objects and deletes them', async () => { + mockInternalRepository.delete.mockImplementation(async (type, id) => { + return id; + }); + + const results = await deleteHistogramSavedObjects(mockInternalRepository); + expect(results).toMatchInlineSnapshot(` + Array [ + Object { + "status": "fulfilled", + "value": "test_obj_1", + }, + Object { + "status": "fulfilled", + "value": "test_obj_1", + }, + ] + `); + }); + + it('settles all promises even if some of the deletes fail.', async () => { + mockInternalRepository.delete.mockImplementationOnce(async (type, id) => { + throw new Error('Intentional failure'); + }); + mockInternalRepository.delete.mockImplementationOnce(async (type, id) => { + return id; + }); + + const results = await deleteHistogramSavedObjects(mockInternalRepository); + expect(results).toMatchInlineSnapshot(` + Array [ + Object { + "reason": [Error: Intentional failure], + "status": "rejected", + }, + Object { + "status": "fulfilled", + "value": "test_obj_1", + }, + ] + `); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/saved_objects.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/saved_objects.ts new file mode 100644 index 00000000000000..610a6697da364d --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/saved_objects.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + SavedObjectAttributes, + SavedObjectsServiceSetup, + ISavedObjectsRepository, +} from 'kibana/server'; +import moment from 'moment'; +import type { IntervalHistogram } from './event_loop_delays'; + +export const SAVED_OBJECTS_DAILY_TYPE = 'event_loop_delays_daily'; + +export interface EventLoopDelaysDaily extends SavedObjectAttributes, IntervalHistogram { + processId: number; +} + +export function registerSavedObjectTypes(registerType: SavedObjectsServiceSetup['registerType']) { + registerType({ + name: SAVED_OBJECTS_DAILY_TYPE, + hidden: true, + namespaceType: 'agnostic', + mappings: { + dynamic: false, + properties: { + // This type requires `lastUpdatedAt` to be indexed so we can use it when rolling up totals (lastUpdatedAt < now-90d) + lastUpdatedAt: { type: 'date' }, + }, + }, + }); +} + +export function serializeSavedObjectId({ date, pid }: { date: moment.MomentInput; pid: number }) { + const formattedDate = moment(date).format('DDMMYYYY'); + + return `${pid}::${formattedDate}`; +} + +export async function deleteHistogramSavedObjects( + internalRepository: ISavedObjectsRepository, + daysTimeRange = 3 +) { + const { saved_objects: savedObjects } = await internalRepository.find({ + type: SAVED_OBJECTS_DAILY_TYPE, + filter: `${SAVED_OBJECTS_DAILY_TYPE}.attributes.lastUpdatedAt < "now-${daysTimeRange}d/d"`, + }); + + return await Promise.allSettled( + savedObjects.map(async (savedObject) => { + return await internalRepository.delete(SAVED_OBJECTS_DAILY_TYPE, savedObject.id); + }) + ); +} + +export async function storeHistogram( + histogram: IntervalHistogram, + internalRepository: ISavedObjectsRepository +) { + const pid = process.pid; + const id = serializeSavedObjectId({ date: histogram.lastUpdatedAt, pid }); + + return await internalRepository.create( + SAVED_OBJECTS_DAILY_TYPE, + { ...histogram, processId: pid }, + { id, overwrite: true } + ); +} diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/schema.ts new file mode 100644 index 00000000000000..319e8c77438b8f --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/schema.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { MakeSchemaFrom } from 'src/plugins/usage_collection/server'; + +export interface EventLoopDelaysUsageReport { + daily: Array<{ + processId: number; + lastUpdatedAt: string; + fromTimestamp: string; + min: number; + max: number; + mean: number; + exceeds: number; + stddev: number; + percentiles: { + '50': number; + '75': number; + '95': number; + '99': number; + }; + }>; +} + +export const eventLoopDelaysUsageSchema: MakeSchemaFrom = { + daily: { + type: 'array', + items: { + processId: { + type: 'long', + _meta: { + description: 'The process id of the monitored kibana instance.', + }, + }, + fromTimestamp: { + type: 'date', + _meta: { + description: 'Timestamp at which the histogram started monitoring.', + }, + }, + lastUpdatedAt: { + type: 'date', + _meta: { + description: 'Latest timestamp this histogram object was updated this day.', + }, + }, + min: { + type: 'long', + _meta: { + description: 'The minimum recorded event loop delay.', + }, + }, + max: { + type: 'long', + _meta: { + description: 'The maximum recorded event loop delay.', + }, + }, + mean: { + type: 'long', + _meta: { + description: 'The mean of the recorded event loop delays.', + }, + }, + exceeds: { + type: 'long', + _meta: { + description: + 'The number of times the event loop delay exceeded the maximum 1 hour eventloop delay threshold.', + }, + }, + stddev: { + type: 'long', + _meta: { + description: 'The standard deviation of the recorded event loop delays.', + }, + }, + percentiles: { + '50': { + type: 'long', + _meta: { + description: 'The 50th accumulated percentile distribution', + }, + }, + '75': { + type: 'long', + _meta: { + description: 'The 75th accumulated percentile distribution', + }, + }, + '95': { + type: 'long', + _meta: { + description: 'The 95th accumulated percentile distribution', + }, + }, + '99': { + type: 'long', + _meta: { + description: 'The 99th accumulated percentile distribution', + }, + }, + }, + }, + }, +}; diff --git a/src/plugins/kibana_usage_collection/server/collectors/index.ts b/src/plugins/kibana_usage_collection/server/collectors/index.ts index 761989938e56d8..e4ed24611bfa8c 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/index.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/index.ts @@ -28,3 +28,4 @@ export { registerUsageCountersRollups, registerUsageCountersUsageCollector, } from './usage_counters'; +export { registerEventLoopDelaysCollector } from './event_loop_delays'; diff --git a/src/plugins/kibana_usage_collection/server/plugin.test.ts b/src/plugins/kibana_usage_collection/server/plugin.test.ts index 2100b9bbb918b4..1584366a42dc1a 100644 --- a/src/plugins/kibana_usage_collection/server/plugin.test.ts +++ b/src/plugins/kibana_usage_collection/server/plugin.test.ts @@ -16,7 +16,6 @@ import { createUsageCollectionSetupMock, } from '../../usage_collection/server/mocks'; import { cloudDetailsMock } from './mocks'; - import { plugin } from './'; describe('kibana_usage_collection', () => { @@ -105,6 +104,10 @@ describe('kibana_usage_collection', () => { "isReady": true, "type": "localization", }, + Object { + "isReady": false, + "type": "event_loop_delays", + }, ] `); }); diff --git a/src/plugins/kibana_usage_collection/server/plugin.ts b/src/plugins/kibana_usage_collection/server/plugin.ts index da6445ce957d83..4ec717c48610ea 100644 --- a/src/plugins/kibana_usage_collection/server/plugin.ts +++ b/src/plugins/kibana_usage_collection/server/plugin.ts @@ -22,6 +22,10 @@ import type { CoreUsageDataStart, } from 'src/core/server'; import { SavedObjectsClient } from '../../../core/server'; +import { + startTrackingEventLoopDelaysUsage, + SAVED_OBJECTS_DAILY_TYPE, +} from './collectors/event_loop_delays'; import { registerApplicationUsageCollector, registerKibanaUsageCollector, @@ -39,6 +43,7 @@ import { registerUsageCountersRollups, registerUsageCountersUsageCollector, registerSavedObjectsCountUsageCollector, + registerEventLoopDelaysCollector, } from './collectors'; interface KibanaUsageCollectionPluginsDepsSetup { @@ -54,46 +59,46 @@ export class KibanaUsageCollectionPlugin implements Plugin { private uiSettingsClient?: IUiSettingsClient; private metric$: Subject; private coreUsageData?: CoreUsageDataStart; - private stopUsingUiCounterIndicies$: Subject; + private pluginStop$: Subject; constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); this.legacyConfig$ = initializerContext.config.legacy.globalConfig$; this.metric$ = new Subject(); - this.stopUsingUiCounterIndicies$ = new Subject(); + this.pluginStop$ = new Subject(); } public setup(coreSetup: CoreSetup, { usageCollection }: KibanaUsageCollectionPluginsDepsSetup) { usageCollection.createUsageCounter('uiCounters'); - this.registerUsageCollectors( usageCollection, coreSetup, this.metric$, - this.stopUsingUiCounterIndicies$, + this.pluginStop$, coreSetup.savedObjects.registerType.bind(coreSetup.savedObjects) ); } public start(core: CoreStart) { const { savedObjects, uiSettings } = core; - this.savedObjectsClient = savedObjects.createInternalRepository(); + this.savedObjectsClient = savedObjects.createInternalRepository([SAVED_OBJECTS_DAILY_TYPE]); const savedObjectsClient = new SavedObjectsClient(this.savedObjectsClient); this.uiSettingsClient = uiSettings.asScopedToClient(savedObjectsClient); core.metrics.getOpsMetrics$().subscribe(this.metric$); this.coreUsageData = core.coreUsageData; + startTrackingEventLoopDelaysUsage(this.savedObjectsClient, this.pluginStop$.asObservable()); } public stop() { this.metric$.complete(); - this.stopUsingUiCounterIndicies$.complete(); + this.pluginStop$.complete(); } private registerUsageCollectors( usageCollection: UsageCollectionSetup, coreSetup: CoreSetup, metric$: Subject, - stopUsingUiCounterIndicies$: Subject, + pluginStop$: Subject, registerType: SavedObjectsRegisterType ) { const getSavedObjectsClient = () => this.savedObjectsClient; @@ -101,12 +106,8 @@ export class KibanaUsageCollectionPlugin implements Plugin { const getCoreUsageDataService = () => this.coreUsageData!; registerUiCounterSavedObjectType(coreSetup.savedObjects); - registerUiCountersRollups( - this.logger.get('ui-counters'), - stopUsingUiCounterIndicies$, - getSavedObjectsClient - ); - registerUiCountersUsageCollector(usageCollection, stopUsingUiCounterIndicies$); + registerUiCountersRollups(this.logger.get('ui-counters'), pluginStop$, getSavedObjectsClient); + registerUiCountersUsageCollector(usageCollection, pluginStop$); registerUsageCountersRollups(this.logger.get('usage-counters-rollup'), getSavedObjectsClient); registerUsageCountersUsageCollector(usageCollection); @@ -127,5 +128,11 @@ export class KibanaUsageCollectionPlugin implements Plugin { registerCoreUsageCollector(usageCollection, getCoreUsageDataService); registerConfigUsageCollector(usageCollection, getCoreUsageDataService); registerLocalizationUsageCollector(usageCollection, coreSetup.i18n); + registerEventLoopDelaysCollector( + this.logger.get('event-loop-delays'), + usageCollection, + registerType, + getSavedObjectsClient + ); } } diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 6ab550389a12d7..99c6dcb40e57d4 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -7900,6 +7900,93 @@ } } }, + "event_loop_delays": { + "properties": { + "daily": { + "type": "array", + "items": { + "properties": { + "processId": { + "type": "long", + "_meta": { + "description": "The process id of the monitored kibana instance." + } + }, + "fromTimestamp": { + "type": "date", + "_meta": { + "description": "Timestamp at which the histogram started monitoring." + } + }, + "lastUpdatedAt": { + "type": "date", + "_meta": { + "description": "Latest timestamp this histogram object was updated this day." + } + }, + "min": { + "type": "long", + "_meta": { + "description": "The minimum recorded event loop delay." + } + }, + "max": { + "type": "long", + "_meta": { + "description": "The maximum recorded event loop delay." + } + }, + "mean": { + "type": "long", + "_meta": { + "description": "The mean of the recorded event loop delays." + } + }, + "exceeds": { + "type": "long", + "_meta": { + "description": "The number of times the event loop delay exceeded the maximum 1 hour eventloop delay threshold." + } + }, + "stddev": { + "type": "long", + "_meta": { + "description": "The standard deviation of the recorded event loop delays." + } + }, + "percentiles": { + "properties": { + "50": { + "type": "long", + "_meta": { + "description": "The 50th accumulated percentile distribution" + } + }, + "75": { + "type": "long", + "_meta": { + "description": "The 75th accumulated percentile distribution" + } + }, + "95": { + "type": "long", + "_meta": { + "description": "The 95th accumulated percentile distribution" + } + }, + "99": { + "type": "long", + "_meta": { + "description": "The 99th accumulated percentile distribution" + } + } + } + } + } + } + } + } + }, "localization": { "properties": { "locale": { From 9e1390e118273d90fe33d065398a6c3d0cc2bba1 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Tue, 22 Jun 2021 08:37:03 +0300 Subject: [PATCH 09/57] [Lens] Adds filter from legend in xy and partition charts (#102026) * WIP add filtering capabilities to XY legend * Fix filter by legend on xy axis charts * Filter pie and xy axis by legend * create a shared component * Add functional test * Add functional test for pie * Make the buttons keyboard accessible * Fix functional test * move function to retry * Give another try * Enable the rest od the tests * Address PR comments * Address PR comments * Apply PR comments, fix popover label for alreadyformatted layers Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../get_legend_action.test.tsx | 79 ++++++ .../pie_visualization/get_legend_action.tsx | 44 ++++ .../pie_visualization/render_function.tsx | 2 + .../lens/public/shared_components/index.ts | 1 + .../legend_action_popover.tsx | 102 ++++++++ .../__snapshots__/expression.test.tsx.snap | 49 ++++ .../public/xy_visualization/expression.tsx | 9 + .../get_legend_action.test.tsx | 232 ++++++++++++++++++ .../xy_visualization/get_legend_action.tsx | 72 ++++++ x-pack/test/functional/apps/lens/dashboard.ts | 2 +- .../test/functional/apps/lens/smokescreen.ts | 62 +++++ .../test/functional/page_objects/lens_page.ts | 6 + 12 files changed, 659 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugins/lens/public/pie_visualization/get_legend_action.test.tsx create mode 100644 x-pack/plugins/lens/public/pie_visualization/get_legend_action.tsx create mode 100644 x-pack/plugins/lens/public/shared_components/legend_action_popover.tsx create mode 100644 x-pack/plugins/lens/public/xy_visualization/get_legend_action.test.tsx create mode 100644 x-pack/plugins/lens/public/xy_visualization/get_legend_action.tsx diff --git a/x-pack/plugins/lens/public/pie_visualization/get_legend_action.test.tsx b/x-pack/plugins/lens/public/pie_visualization/get_legend_action.test.tsx new file mode 100644 index 00000000000000..67e57dadd49353 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/get_legend_action.test.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { LegendActionProps, SeriesIdentifier } from '@elastic/charts'; +import { EuiPopover } from '@elastic/eui'; +import { mountWithIntl } from '@kbn/test/jest'; +import { ComponentType, ReactWrapper } from 'enzyme'; +import type { Datatable } from 'src/plugins/expressions/public'; +import { getLegendAction } from './get_legend_action'; +import { LegendActionPopover } from '../shared_components'; + +const table: Datatable = { + type: 'datatable', + columns: [ + { id: 'a', name: 'A', meta: { type: 'string' } }, + { id: 'b', name: 'B', meta: { type: 'number' } }, + ], + rows: [ + { a: 'Hi', b: 2 }, + { a: 'Test', b: 4 }, + { a: 'Foo', b: 6 }, + ], +}; + +describe('getLegendAction', function () { + let wrapperProps: LegendActionProps; + const Component: ComponentType = getLegendAction(table, jest.fn()); + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapperProps = { + color: 'rgb(109, 204, 177)', + label: 'Bar', + series: ([ + { + specId: 'donut', + key: 'Bar', + }, + ] as unknown) as SeriesIdentifier[], + }; + }); + + it('is not rendered if row does not exist', () => { + wrapper = mountWithIntl(); + expect(wrapper).toEqual({}); + expect(wrapper.find(EuiPopover).length).toBe(0); + }); + + it('is rendered if row is detected', () => { + const newProps = { + ...wrapperProps, + label: 'Hi', + series: ([ + { + specId: 'donut', + key: 'Hi', + }, + ] as unknown) as SeriesIdentifier[], + }; + wrapper = mountWithIntl(); + expect(wrapper.find(EuiPopover).length).toBe(1); + expect(wrapper.find(EuiPopover).prop('title')).toEqual('Hi, filter options'); + expect(wrapper.find(LegendActionPopover).prop('context')).toEqual({ + data: [ + { + column: 0, + row: 0, + table, + value: 'Hi', + }, + ], + }); + }); +}); diff --git a/x-pack/plugins/lens/public/pie_visualization/get_legend_action.tsx b/x-pack/plugins/lens/public/pie_visualization/get_legend_action.tsx new file mode 100644 index 00000000000000..9f16ad863a4155 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/get_legend_action.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import type { LegendAction } from '@elastic/charts'; +import type { Datatable } from 'src/plugins/expressions/public'; +import type { LensFilterEvent } from '../types'; +import { LegendActionPopover } from '../shared_components'; + +export const getLegendAction = ( + table: Datatable, + onFilter: (data: LensFilterEvent['data']) => void +): LegendAction => + React.memo(({ series: [pieSeries], label }) => { + const data = table.columns.reduce((acc, { id }, column) => { + const value = pieSeries.key; + const row = table.rows.findIndex((r) => r[id] === value); + if (row > -1) { + acc.push({ + table, + column, + row, + value, + }); + } + + return acc; + }, []); + + if (data.length === 0) { + return null; + } + + const context: LensFilterEvent['data'] = { + data, + }; + + return ; + }); diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx index 6c1cbe63a5a3e3..f329cfe1bb8b9d 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx @@ -38,6 +38,7 @@ import { SeriesLayer, } from '../../../../../src/plugins/charts/public'; import { LensIconChartDonut } from '../assets/chart_donut'; +import { getLegendAction } from './get_legend_action'; declare global { interface Window { @@ -281,6 +282,7 @@ export function PieComponent( onElementClick={ props.renderMode !== 'noInteractivity' ? onElementClickHandler : undefined } + legendAction={getLegendAction(firstTable, onClickValue)} theme={{ ...chartTheme, background: { diff --git a/x-pack/plugins/lens/public/shared_components/index.ts b/x-pack/plugins/lens/public/shared_components/index.ts index cf8536884acdf8..c200a18a25cafa 100644 --- a/x-pack/plugins/lens/public/shared_components/index.ts +++ b/x-pack/plugins/lens/public/shared_components/index.ts @@ -13,3 +13,4 @@ export { TooltipWrapper } from './tooltip_wrapper'; export * from './coloring'; export { useDebouncedValue } from './debounced_value'; export * from './helpers'; +export { LegendActionPopover } from './legend_action_popover'; diff --git a/x-pack/plugins/lens/public/shared_components/legend_action_popover.tsx b/x-pack/plugins/lens/public/shared_components/legend_action_popover.tsx new file mode 100644 index 00000000000000..e344cb5289f51e --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/legend_action_popover.tsx @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiContextMenuPanelDescriptor, EuiIcon, EuiPopover, EuiContextMenu } from '@elastic/eui'; +import type { LensFilterEvent } from '../types'; +import { desanitizeFilterContext } from '../utils'; + +export interface LegendActionPopoverProps { + /** + * Determines the panels label + */ + label: string; + /** + * Callback on filter value + */ + onFilter: (data: LensFilterEvent['data']) => void; + /** + * Determines the filter event data + */ + context: LensFilterEvent['data']; +} + +export const LegendActionPopover: React.FunctionComponent = ({ + label, + onFilter, + context, +}) => { + const [popoverOpen, setPopoverOpen] = useState(false); + const panels: EuiContextMenuPanelDescriptor[] = [ + { + id: 'main', + title: label, + items: [ + { + name: i18n.translate('xpack.lens.shared.legend.filterForValueButtonAriaLabel', { + defaultMessage: 'Filter for value', + }), + 'data-test-subj': `legend-${label}-filterIn`, + icon: , + onClick: () => { + setPopoverOpen(false); + onFilter(desanitizeFilterContext(context)); + }, + }, + { + name: i18n.translate('xpack.lens.shared.legend.filterOutValueButtonAriaLabel', { + defaultMessage: 'Filter out value', + }), + 'data-test-subj': `legend-${label}-filterOut`, + icon: , + onClick: () => { + setPopoverOpen(false); + onFilter(desanitizeFilterContext({ ...context, negate: true })); + }, + }, + ], + }, + ]; + + const Button = ( +
setPopoverOpen(!popoverOpen)} + onClick={() => setPopoverOpen(!popoverOpen)} + > + +
+ ); + return ( + setPopoverOpen(false)} + panelPaddingSize="none" + anchorPosition="upLeft" + title={i18n.translate('xpack.lens.shared.legend.filterOptionsLegend', { + defaultMessage: '{legendDataLabel}, filter options', + values: { legendDataLabel: label }, + })} + > + + + ); +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap index f9b4e33072c819..1f647680408d71 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap @@ -7,6 +7,13 @@ exports[`xy_expression XYChart component it renders area 1`] = ` = {}; + // This is a safe formatter for the xAccessor that abstracts the knowledge of already formatted layers const safeXAccessorLabelRenderer = (value: unknown): string => xAxisColumn && layersAlreadyFormatted[xAxisColumn.id] @@ -629,6 +631,13 @@ export function XYChart({ xDomain={xDomain} onBrushEnd={renderMode !== 'noInteractivity' ? brushHandler : undefined} onElementClick={renderMode !== 'noInteractivity' ? clickHandler : undefined} + legendAction={getLegendAction( + filteredLayers, + data.tables, + onClickValue, + formatFactory, + layersAlreadyFormatted + )} showLegendExtra={isHistogramViz && valuesInLegend} /> diff --git a/x-pack/plugins/lens/public/xy_visualization/get_legend_action.test.tsx b/x-pack/plugins/lens/public/xy_visualization/get_legend_action.test.tsx new file mode 100644 index 00000000000000..e4edfe918a242d --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/get_legend_action.test.tsx @@ -0,0 +1,232 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { LegendActionProps, SeriesIdentifier } from '@elastic/charts'; +import { EuiPopover } from '@elastic/eui'; +import { mountWithIntl } from '@kbn/test/jest'; +import { ComponentType, ReactWrapper } from 'enzyme'; +import type { LayerArgs } from './types'; +import type { LensMultiTable } from '../types'; +import { getLegendAction } from './get_legend_action'; +import { LegendActionPopover } from '../shared_components'; + +const sampleLayer = { + layerId: 'first', + seriesType: 'line', + xAccessor: 'c', + accessors: ['a', 'b'], + splitAccessor: 'splitAccessorId', + columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', + xScaleType: 'ordinal', + yScaleType: 'linear', + isHistogram: false, +} as LayerArgs; + +const tables = { + first: { + type: 'datatable', + rows: [ + { + xAccessorId: 1585758120000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585758360000, + splitAccessorId: "Women's Accessories", + yAccessorId: 1, + }, + { + xAccessorId: 1585758360000, + splitAccessorId: "Women's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Women's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585760700000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585760760000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585760760000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + }, + { + xAccessorId: 1585761120000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + }, + ], + columns: [ + { + id: 'xAccessorId', + name: 'order_date per minute', + meta: { + type: 'date', + field: 'order_date', + source: 'esaggs', + index: 'indexPatternId', + sourceParams: { + indexPatternId: 'indexPatternId', + type: 'date_histogram', + params: { + field: 'order_date', + timeRange: { from: '2020-04-01T16:14:16.246Z', to: '2020-04-01T17:15:41.263Z' }, + useNormalizedEsInterval: true, + scaleMetricValues: false, + interval: '1m', + drop_partials: false, + min_doc_count: 0, + extended_bounds: {}, + }, + }, + params: { id: 'date', params: { pattern: 'HH:mm' } }, + }, + }, + { + id: 'splitAccessorId', + name: 'Top values of category.keyword', + meta: { + type: 'string', + field: 'category.keyword', + source: 'esaggs', + index: 'indexPatternId', + sourceParams: { + indexPatternId: 'indexPatternId', + type: 'terms', + params: { + field: 'category.keyword', + orderBy: 'yAccessorId', + order: 'desc', + size: 3, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + }, + params: { + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + parsedUrl: { + origin: 'http://localhost:5601', + pathname: '/jiy/app/kibana', + basePath: '/jiy', + }, + }, + }, + }, + }, + { + id: 'yAccessorId', + name: 'Count of records', + meta: { + type: 'number', + source: 'esaggs', + index: 'indexPatternId', + sourceParams: { + indexPatternId: 'indexPatternId', + params: {}, + }, + params: { id: 'number' }, + }, + }, + ], + }, +} as LensMultiTable['tables']; + +describe('getLegendAction', function () { + let wrapperProps: LegendActionProps; + const Component: ComponentType = getLegendAction( + [sampleLayer], + tables, + jest.fn(), + jest.fn(), + {} + ); + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapperProps = { + color: 'rgb(109, 204, 177)', + label: "Women's Accessories", + series: ([ + { + seriesKeys: ["Women's Accessories", 'test'], + }, + ] as unknown) as SeriesIdentifier[], + }; + }); + + it('is not rendered if not layer is detected', () => { + wrapper = mountWithIntl(); + expect(wrapper).toEqual({}); + expect(wrapper.find(EuiPopover).length).toBe(0); + }); + + it('is rendered if row does not exist', () => { + const newProps = { + ...wrapperProps, + series: ([ + { + seriesKeys: ['test', 'b'], + }, + ] as unknown) as SeriesIdentifier[], + }; + wrapper = mountWithIntl(); + expect(wrapper).toEqual({}); + expect(wrapper.find(EuiPopover).length).toBe(0); + }); + + it('is rendered if layer is detected', () => { + const newProps = { + ...wrapperProps, + series: ([ + { + seriesKeys: ["Women's Accessories", 'b'], + }, + ] as unknown) as SeriesIdentifier[], + }; + wrapper = mountWithIntl(); + expect(wrapper.find(EuiPopover).length).toBe(1); + expect(wrapper.find(EuiPopover).prop('title')).toEqual("Women's Accessories, filter options"); + expect(wrapper.find(LegendActionPopover).prop('context')).toEqual({ + data: [ + { + column: 1, + row: 1, + table: tables.first, + value: "Women's Accessories", + }, + ], + }); + }); +}); diff --git a/x-pack/plugins/lens/public/xy_visualization/get_legend_action.tsx b/x-pack/plugins/lens/public/xy_visualization/get_legend_action.tsx new file mode 100644 index 00000000000000..c99bf948d6e374 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/get_legend_action.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { LegendAction, XYChartSeriesIdentifier } from '@elastic/charts'; +import type { LayerArgs } from './types'; +import type { LensMultiTable, LensFilterEvent, FormatFactory } from '../types'; +import { LegendActionPopover } from '../shared_components'; + +export const getLegendAction = ( + filteredLayers: LayerArgs[], + tables: LensMultiTable['tables'], + onFilter: (data: LensFilterEvent['data']) => void, + formatFactory: FormatFactory, + layersAlreadyFormatted: Record +): LegendAction => + React.memo(({ series: [xySeries] }) => { + const series = xySeries as XYChartSeriesIdentifier; + const layer = filteredLayers.find((l) => + series.seriesKeys.some((key: string | number) => l.accessors.includes(key.toString())) + ); + + if (!layer || !layer.splitAccessor) { + return null; + } + + const splitLabel = series.seriesKeys[0] as string; + const accessor = layer.splitAccessor; + + const table = tables[layer.layerId]; + const splitColumn = table.columns.find(({ id }) => id === layer.splitAccessor); + const formatter = formatFactory(splitColumn && splitColumn.meta?.params); + + const rowIndex = table.rows.findIndex((row) => { + if (layersAlreadyFormatted[accessor]) { + // stringify the value to compare with the chart value + return formatter.convert(row[accessor]) === splitLabel; + } + return row[accessor] === splitLabel; + }); + + if (rowIndex < 0) return null; + + const data = [ + { + row: rowIndex, + column: table.columns.findIndex((col) => col.id === accessor), + value: accessor ? table.rows[rowIndex][accessor] : splitLabel, + table, + }, + ]; + + const context: LensFilterEvent['data'] = { + data, + }; + + return ( + + ); + }); diff --git a/x-pack/test/functional/apps/lens/dashboard.ts b/x-pack/test/functional/apps/lens/dashboard.ts index 9998f1dd4cdcb8..844b074e42e74b 100644 --- a/x-pack/test/functional/apps/lens/dashboard.ts +++ b/x-pack/test/functional/apps/lens/dashboard.ts @@ -68,7 +68,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await find.clickByButtonText('lnsXYvis'); await dashboardAddPanel.closeAddPanel(); await PageObjects.lens.goToTimeRange(); - await clickInChart(5, 5); // hardcoded position of bar, depends heavy on data and charts implementation + await clickInChart(6, 5); // hardcoded position of bar, depends heavy on data and charts implementation await retry.try(async () => { await testSubjects.click('applyFiltersPopoverButton'); diff --git a/x-pack/test/functional/apps/lens/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts index 5d775f154c9430..ec32d7620fcf96 100644 --- a/x-pack/test/functional/apps/lens/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/smokescreen.ts @@ -15,6 +15,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const listingTable = getService('listingTable'); const testSubjects = getService('testSubjects'); const elasticChart = getService('elasticChart'); + const filterBar = getService('filterBar'); describe('lens smokescreen tests', () => { it('should allow creation of lens xy chart', async () => { @@ -686,5 +687,66 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); expect(await testSubjects.isEnabled('lnsApp_downloadCSVButton')).to.eql(true); }); + + it('should allow filtering by legend on an xy chart', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', + operation: 'date_histogram', + field: '@timestamp', + }); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'average', + field: 'bytes', + }); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_splitDimensionPanel > lns-empty-dimension', + operation: 'terms', + field: 'extension.raw', + }); + + await PageObjects.lens.filterLegend('jpg'); + const hasExtensionFilter = await filterBar.hasFilter('extension.raw', 'jpg'); + expect(hasExtensionFilter).to.be(true); + + await filterBar.removeFilter('extension.raw'); + }); + + it('should allow filtering by legend on a pie chart', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + await PageObjects.lens.switchToVisualization('pie'); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsPie_sliceByDimensionPanel > lns-empty-dimension', + operation: 'terms', + field: 'extension.raw', + }); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsPie_sliceByDimensionPanel > lns-empty-dimension', + operation: 'terms', + field: 'agent.raw', + }); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsPie_sizeByDimensionPanel > lns-empty-dimension', + operation: 'average', + field: 'bytes', + }); + + await PageObjects.lens.filterLegend('jpg'); + const hasExtensionFilter = await filterBar.hasFilter('extension.raw', 'jpg'); + expect(hasExtensionFilter).to.be(true); + + await filterBar.removeFilter('extension.raw'); + }); }); } diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index d416d26baaf0db..9953ab3dfceadd 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -1069,5 +1069,11 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont await input.clearValueWithKeyboard({ charByChar: true }); await input.type(formula); }, + + async filterLegend(value: string) { + await testSubjects.click(`legend-${value}`); + const filterIn = await testSubjects.find(`legend-${value}-filterIn`); + await filterIn.click(); + }, }); } From e806dde4e8e4a97d0ec96b6b4cc4dcddebdd2351 Mon Sep 17 00:00:00 2001 From: Ignacio Rivas Date: Tue, 22 Jun 2021 09:17:52 +0200 Subject: [PATCH 10/57] [License management] Migrate to new page layout (#102218) * start working on license management * migrate permissions check to new layout * refactor license expiration as a subtitle of the page header * finish up working on page title * Fix linter errors and update snapshots * update method name * CR changes * update snapshots Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../__snapshots__/add_license.test.js.snap | 4 +- .../license_page_header.test.js.snap | 5 + .../__snapshots__/license_status.test.js.snap | 5 - .../request_trial_extension.test.js.snap | 8 +- .../revert_to_basic.test.js.snap | 6 +- .../__snapshots__/start_trial.test.js.snap | 8 +- .../upload_license.test.tsx.snap | 20 +- ...us.test.js => license_page_header.test.js} | 6 +- x-pack/plugins/license_management/kibana.json | 1 + .../public/application/app.js | 63 +++---- .../add_license/add_license.js | 1 + .../license_dashboard/license_dashboard.js | 33 ++-- .../index.js | 2 +- .../license_page_header.js | 106 +++++++++++ .../license_status.container.js | 36 ---- .../license_status/license_status.js | 98 ---------- .../request_trial_extension.js | 1 + .../revert_to_basic/revert_to_basic.js | 1 + .../start_trial/start_trial.tsx | 2 + .../sections/upload_license/upload_license.js | 176 +++++++++--------- .../store/reducers/license_management.js | 32 ++++ .../public/shared_imports.ts | 8 + .../translations/translations/ja-JP.json | 4 - .../translations/translations/zh-CN.json | 4 - 24 files changed, 325 insertions(+), 305 deletions(-) create mode 100644 x-pack/plugins/license_management/__jest__/__snapshots__/license_page_header.test.js.snap delete mode 100644 x-pack/plugins/license_management/__jest__/__snapshots__/license_status.test.js.snap rename x-pack/plugins/license_management/__jest__/{license_status.test.js => license_page_header.test.js} (83%) rename x-pack/plugins/license_management/public/application/sections/license_dashboard/{license_status => license_page_header}/index.js (80%) create mode 100644 x-pack/plugins/license_management/public/application/sections/license_dashboard/license_page_header/license_page_header.js delete mode 100644 x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/license_status.container.js delete mode 100644 x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/license_status.js create mode 100644 x-pack/plugins/license_management/public/shared_imports.ts diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/add_license.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/add_license.test.js.snap index 95921fa61233c8..90a3eb98c64a14 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/add_license.test.js.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/add_license.test.js.snap @@ -1,5 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`AddLicense component when license is active should display correct verbiage 1`] = `"
Update your license

If you already have a new license, upload it now.

"`; +exports[`AddLicense component when license is active should display correct verbiage 1`] = `"
Update your license

If you already have a new license, upload it now.

"`; -exports[`AddLicense component when license is expired should display with correct verbiage 1`] = `"
Update your license

If you already have a new license, upload it now.

"`; +exports[`AddLicense component when license is expired should display with correct verbiage 1`] = `"
Update your license

If you already have a new license, upload it now.

"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/license_page_header.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/license_page_header.test.js.snap new file mode 100644 index 00000000000000..047e311f3d3250 --- /dev/null +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/license_page_header.test.js.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LicenseStatus component should display display warning is expired 1`] = `"

Your Platinum license has expired

Your license expired on

"`; + +exports[`LicenseStatus component should display normally when license is active 1`] = `"

Your Gold license is active

Your license will expire on October 12, 2099 7:00 PM EST

"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/license_status.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/license_status.test.js.snap deleted file mode 100644 index 9bd1c878f86791..00000000000000 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/license_status.test.js.snap +++ /dev/null @@ -1,5 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`LicenseStatus component should display display warning is expired 1`] = `"

Your Platinum license has expired

Your license expired on
"`; - -exports[`LicenseStatus component should display normally when license is active 1`] = `"

Your Gold license is active

Your license will expire on October 12, 2099 7:00 PM EST
"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/request_trial_extension.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/request_trial_extension.test.js.snap index 4d8b653c4b10d2..fda479f2888ce5 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/request_trial_extension.test.js.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/request_trial_extension.test.js.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`RequestTrialExtension component should display when enterprise license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; +exports[`RequestTrialExtension component should display when enterprise license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; -exports[`RequestTrialExtension component should display when license is active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; +exports[`RequestTrialExtension component should display when license is active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; -exports[`RequestTrialExtension component should display when license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; +exports[`RequestTrialExtension component should display when license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; -exports[`RequestTrialExtension component should display when platinum license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; +exports[`RequestTrialExtension component should display when platinum license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/revert_to_basic.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/revert_to_basic.test.js.snap index be634a5b4f7489..4fa45c4bec5ce5 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/revert_to_basic.test.js.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/revert_to_basic.test.js.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`RevertToBasic component should display when license is about to expire 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; +exports[`RevertToBasic component should display when license is about to expire 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; -exports[`RevertToBasic component should display when license is expired 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; +exports[`RevertToBasic component should display when license is expired 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; -exports[`RevertToBasic component should display when trial is active 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; +exports[`RevertToBasic component should display when trial is active 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap index 1cacadb8246307..622bff86ead169 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`StartTrial component when trial is allowed display for basic license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; +exports[`StartTrial component when trial is allowed display for basic license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; -exports[`StartTrial component when trial is allowed should display for expired enterprise license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; +exports[`StartTrial component when trial is allowed should display for expired enterprise license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; -exports[`StartTrial component when trial is allowed should display for expired platinum license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; +exports[`StartTrial component when trial is allowed should display for expired platinum license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; -exports[`StartTrial component when trial is allowed should display for gold license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; +exports[`StartTrial component when trial is allowed should display for gold license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap index 9f89179d207e0c..29ec3ddbfdc025 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap @@ -262,16 +262,18 @@ exports[`UploadLicense should display a modal when license requires acknowledgem uploadLicenseStatus={[Function]} >
@@ -1301,16 +1303,18 @@ exports[`UploadLicense should display an error when ES says license is expired 1 uploadLicenseStatus={[Function]} >
@@ -2031,16 +2035,18 @@ exports[`UploadLicense should display an error when ES says license is invalid 1 uploadLicenseStatus={[Function]} >
@@ -2761,16 +2767,18 @@ exports[`UploadLicense should display an error when submitting invalid JSON 1`] uploadLicenseStatus={[Function]} >
@@ -3491,16 +3499,18 @@ exports[`UploadLicense should display error when ES returns error 1`] = ` uploadLicenseStatus={[Function]} >
diff --git a/x-pack/plugins/license_management/__jest__/license_status.test.js b/x-pack/plugins/license_management/__jest__/license_page_header.test.js similarity index 83% rename from x-pack/plugins/license_management/__jest__/license_status.test.js rename to x-pack/plugins/license_management/__jest__/license_page_header.test.js index 898667e13a1b36..56a71eb8d252e3 100644 --- a/x-pack/plugins/license_management/__jest__/license_status.test.js +++ b/x-pack/plugins/license_management/__jest__/license_page_header.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { LicenseStatus } from '../public/application/sections/license_dashboard/license_status'; +import { LicensePageHeader } from '../public/application/sections/license_dashboard/license_page_header'; import { createMockLicense, getComponent } from './util'; describe('LicenseStatus component', () => { @@ -14,7 +14,7 @@ describe('LicenseStatus component', () => { { license: createMockLicense('gold'), }, - LicenseStatus + LicensePageHeader ); expect(rendered.html()).toMatchSnapshot(); }); @@ -23,7 +23,7 @@ describe('LicenseStatus component', () => { { license: createMockLicense('platinum', 0), }, - LicenseStatus + LicensePageHeader ); expect(rendered.html()).toMatchSnapshot(); }); diff --git a/x-pack/plugins/license_management/kibana.json b/x-pack/plugins/license_management/kibana.json index 1f925a453898e2..be2e21c7eb41e0 100644 --- a/x-pack/plugins/license_management/kibana.json +++ b/x-pack/plugins/license_management/kibana.json @@ -9,6 +9,7 @@ "extraPublicDirs": ["common/constants"], "requiredBundles": [ "telemetryManagementSection", + "esUiShared", "kibanaReact" ] } diff --git a/x-pack/plugins/license_management/public/application/app.js b/x-pack/plugins/license_management/public/application/app.js index 3bfa22dd729217..4b5a6144dbdc9e 100644 --- a/x-pack/plugins/license_management/public/application/app.js +++ b/x-pack/plugins/license_management/public/application/app.js @@ -10,7 +10,8 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { LicenseDashboard, UploadLicense } from './sections'; import { Switch, Route } from 'react-router-dom'; import { APP_PERMISSION } from '../../common/constants'; -import { EuiPageBody, EuiEmptyPrompt, EuiText, EuiLoadingSpinner, EuiCallOut } from '@elastic/eui'; +import { SectionLoading } from '../shared_imports'; +import { EuiPageContent, EuiPageBody, EuiEmptyPrompt } from '@elastic/eui'; export class App extends Component { componentDidMount() { @@ -23,52 +24,50 @@ export class App extends Component { if (permissionsLoading) { return ( - } - body={ - - - - } - data-test-subj="sectionLoading" - /> + + + + + ); } if (permissionsError) { + const error = permissionsError?.data?.message; + return ( - - } - color="danger" - iconType="alert" - > - {permissionsError.data && permissionsError.data.message ? ( -
{permissionsError.data.message}
- ) : null} -
+ + + + + } + body={error ?

{error}

: null} + /> +
); } if (!hasPermission) { return ( - + +

-

+ } body={

@@ -82,7 +81,7 @@ export class App extends Component {

} /> -
+ ); } diff --git a/x-pack/plugins/license_management/public/application/sections/license_dashboard/add_license/add_license.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/add_license/add_license.js index 4120b2280a7a63..90de14b167e520 100644 --- a/x-pack/plugins/license_management/public/application/sections/license_dashboard/add_license/add_license.js +++ b/x-pack/plugins/license_management/public/application/sections/license_dashboard/add_license/add_license.js @@ -18,6 +18,7 @@ export const AddLicense = ({ uploadPath = `/upload_license` }) => { return ( {} }) => { useEffect(() => { @@ -19,17 +20,19 @@ export const LicenseDashboard = ({ setBreadcrumb, telemetry } = { setBreadcrumb: }); return ( -
- - - - - - - - - - -
+ <> + + + + + + + + + + + + + ); }; diff --git a/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/index.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_page_header/index.js similarity index 80% rename from x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/index.js rename to x-pack/plugins/license_management/public/application/sections/license_dashboard/license_page_header/index.js index efd4da2770db47..303e30040ab509 100644 --- a/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/index.js +++ b/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_page_header/index.js @@ -5,4 +5,4 @@ * 2.0. */ -export { LicenseStatus } from './license_status.container'; +export { LicensePageHeader } from './license_page_header'; diff --git a/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_page_header/license_page_header.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_page_header/license_page_header.js new file mode 100644 index 00000000000000..df41d46ac57899 --- /dev/null +++ b/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_page_header/license_page_header.js @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { useSelector } from 'react-redux'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiPageHeader, EuiSpacer } from '@elastic/eui'; + +import { getLicenseState } from '../../../store/reducers/license_management'; + +export const ActiveLicensePageHeader = ({ license, ...props }) => { + return ( + + + + } + description={ + + {license.expirationDate ? ( + {license.expirationDate}, + }} + /> + ) : ( + + )} + + } + /> + ); +}; + +export const ExpiredLicensePageHeader = ({ license, ...props }) => { + return ( + + + + } + description={ + + {license.expirationDate}, + }} + /> + + } + /> + ); +}; + +export const LicensePageHeader = () => { + const license = useSelector(getLicenseState); + + return ( + <> + {license.isExpired ? ( + + ) : ( + + )} + + + ); +}; diff --git a/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/license_status.container.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/license_status.container.js deleted file mode 100644 index 01577e79fd6ec4..00000000000000 --- a/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/license_status.container.js +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { LicenseStatus as PresentationComponent } from './license_status'; -import { connect } from 'react-redux'; -import { - getLicense, - getExpirationDateFormatted, - isExpired, -} from '../../../store/reducers/license_management'; -import { i18n } from '@kbn/i18n'; - -const mapStateToProps = (state) => { - const { isActive, type } = getLicense(state); - return { - status: isActive - ? i18n.translate('xpack.licenseMgmt.licenseDashboard.licenseStatus.activeLicenseStatusText', { - defaultMessage: 'Active', - }) - : i18n.translate( - 'xpack.licenseMgmt.licenseDashboard.licenseStatus.inactiveLicenseStatusText', - { - defaultMessage: 'Inactive', - } - ), - type, - isExpired: isExpired(state), - expiryDate: getExpirationDateFormatted(state), - }; -}; - -export const LicenseStatus = connect(mapStateToProps)(PresentationComponent); diff --git a/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/license_status.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/license_status.js deleted file mode 100644 index 5f7e59bf1ceba3..00000000000000 --- a/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/license_status.js +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { Fragment } from 'react'; - -import { - EuiIcon, - EuiFlexGroup, - EuiFlexItem, - EuiText, - EuiTitle, - EuiSpacer, - EuiTextAlign, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; - -export class LicenseStatus extends React.PureComponent { - render() { - const { isExpired, status, type, expiryDate } = this.props; - const typeTitleCase = type.charAt(0).toUpperCase() + type.substr(1).toLowerCase(); - let icon; - let title; - let message; - if (isExpired) { - icon = ; - message = ( - - {expiryDate}, - }} - /> - - ); - title = ( - - ); - } else { - icon = ; - message = expiryDate ? ( - - {expiryDate}, - }} - /> - - ) : ( - - - - ); - title = ( - - ); - } - return ( - - - {icon} - - -

{title}

-
-
-
- - - - {message} -
- ); - } -} diff --git a/x-pack/plugins/license_management/public/application/sections/license_dashboard/request_trial_extension/request_trial_extension.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/request_trial_extension/request_trial_extension.js index 8c694cf27765a3..e578c372b9c9f4 100644 --- a/x-pack/plugins/license_management/public/application/sections/license_dashboard/request_trial_extension/request_trial_extension.js +++ b/x-pack/plugins/license_management/public/application/sections/license_dashboard/request_trial_extension/request_trial_extension.js @@ -37,6 +37,7 @@ export const RequestTrialExtension = ({ shouldShowRequestTrialExtension }) => { return ( {this.acknowledgeModal()} { {this.acknowledgeModal(dependencies!.docLinks)} - - - -

- -

-
+ + + +

+ +

+
- + - {this.acknowledgeModal()} + {this.acknowledgeModal()} - -

- -

-

- {currentLicenseType.toUpperCase()}, - }} - /> -

-
- - - - - - - } - onChange={this.handleFile} + +

+ +

+

+ {currentLicenseType.toUpperCase()}, + }} + /> +

+
+ + + + + + + } + onChange={this.handleFile} + /> + + + + + {shouldShowTelemetryOptIn(telemetry) && ( + + )} + + + + + + + + + + {applying ? ( + -
-
-
- - {shouldShowTelemetryOptIn(telemetry) && ( - - )} - - - - + ) : ( - - - - - {applying ? ( - - ) : ( - - )} - - - -
-
-
- + )} + +
+ + +
+ ); } } diff --git a/x-pack/plugins/license_management/public/application/store/reducers/license_management.js b/x-pack/plugins/license_management/public/application/store/reducers/license_management.js index 20e31cf89da728..1a985cd8ee623e 100644 --- a/x-pack/plugins/license_management/public/application/store/reducers/license_management.js +++ b/x-pack/plugins/license_management/public/application/store/reducers/license_management.js @@ -6,6 +6,10 @@ */ import { combineReducers } from 'redux'; +import { i18n } from '@kbn/i18n'; +import { capitalize } from 'lodash'; +import { createSelector } from 'reselect'; + import { license } from './license'; import { uploadStatus } from './upload_status'; import { startBasicStatus } from './start_basic_license_status'; @@ -135,3 +139,31 @@ export const startBasicLicenseNeedsAcknowledgement = (state) => { export const getStartBasicMessages = (state) => { return state.startBasicStatus.messages; }; + +export const getLicenseState = createSelector( + getLicense, + getExpirationDateFormatted, + isExpired, + (license, expirationDate, isExpired) => { + const { isActive, type } = license; + + return { + type: capitalize(type), + isExpired, + expirationDate, + status: isActive + ? i18n.translate( + 'xpack.licenseMgmt.licenseDashboard.licenseStatus.activeLicenseStatusText', + { + defaultMessage: 'active', + } + ) + : i18n.translate( + 'xpack.licenseMgmt.licenseDashboard.licenseStatus.inactiveLicenseStatusText', + { + defaultMessage: 'inactive', + } + ), + }; + } +); diff --git a/x-pack/plugins/license_management/public/shared_imports.ts b/x-pack/plugins/license_management/public/shared_imports.ts new file mode 100644 index 00000000000000..695432684a660e --- /dev/null +++ b/x-pack/plugins/license_management/public/shared_imports.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { SectionLoading } from '../../../../src/plugins/es_ui_shared/public/'; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index b6e22dc4a519b0..227afc122f804f 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -12969,11 +12969,7 @@ "xpack.licenseMgmt.licenseDashboard.addLicense.updateLicenseButtonLabel": "ライセンスを更新", "xpack.licenseMgmt.licenseDashboard.addLicense.updateLicenseTitle": "ライセンスの更新", "xpack.licenseMgmt.licenseDashboard.addLicense.useAvailableLicenseDescription": "すでに新しいライセンスがある場合は、今すぐアップロードしてください。", - "xpack.licenseMgmt.licenseDashboard.licenseStatus.activeLicenseStatusDescription": "ライセンスは{expiryDate}に期限切れになります", "xpack.licenseMgmt.licenseDashboard.licenseStatus.activeLicenseStatusText": "アクティブ", - "xpack.licenseMgmt.licenseDashboard.licenseStatus.activeLicenseStatusTitle": "ご使用の{typeTitleCase}ライセンスは{status}です", - "xpack.licenseMgmt.licenseDashboard.licenseStatus.expiredLicenseStatusDescription": "ご使用のライセンスは{expiryDate}に期限切れになりました", - "xpack.licenseMgmt.licenseDashboard.licenseStatus.expiredLicenseStatusTitle": "ご使用の{typeTitleCase}ライセンスは期限切れです", "xpack.licenseMgmt.licenseDashboard.licenseStatus.inactiveLicenseStatusText": "非アクティブ", "xpack.licenseMgmt.licenseDashboard.licenseStatus.permanentActiveLicenseStatusDescription": "ご使用のライセンスには有効期限がありません。", "xpack.licenseMgmt.licenseDashboard.requestTrialExtension.extendTrialButtonLabel": "トライアルを延長", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 6ad4e7da082932..ac43a6938aac3b 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -13143,11 +13143,7 @@ "xpack.licenseMgmt.licenseDashboard.addLicense.updateLicenseButtonLabel": "更新许可证", "xpack.licenseMgmt.licenseDashboard.addLicense.updateLicenseTitle": "更新您的许可证", "xpack.licenseMgmt.licenseDashboard.addLicense.useAvailableLicenseDescription": "如果已有新的许可证,请立即上传。", - "xpack.licenseMgmt.licenseDashboard.licenseStatus.activeLicenseStatusDescription": "您的许可证将于 {expiryDate}过期", "xpack.licenseMgmt.licenseDashboard.licenseStatus.activeLicenseStatusText": "活动", - "xpack.licenseMgmt.licenseDashboard.licenseStatus.activeLicenseStatusTitle": "您的{typeTitleCase}许可证{status}", - "xpack.licenseMgmt.licenseDashboard.licenseStatus.expiredLicenseStatusDescription": "您的许可证已于 {expiryDate}过期", - "xpack.licenseMgmt.licenseDashboard.licenseStatus.expiredLicenseStatusTitle": "您的{typeTitleCase}许可证已过期", "xpack.licenseMgmt.licenseDashboard.licenseStatus.inactiveLicenseStatusText": "非活动", "xpack.licenseMgmt.licenseDashboard.licenseStatus.permanentActiveLicenseStatusDescription": "您的许可证永不会过期。", "xpack.licenseMgmt.licenseDashboard.requestTrialExtension.extendTrialButtonLabel": "延期试用", From 7df924f828272554f662aa3b97ce2d7f7905f6c1 Mon Sep 17 00:00:00 2001 From: Katrin Freihofner Date: Tue, 22 Jun 2021 09:31:15 +0200 Subject: [PATCH 11/57] Wording update for case settings, fixes #102462 (#102496) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/components/configure_cases/translations.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/cases/public/components/configure_cases/translations.ts b/x-pack/plugins/cases/public/components/configure_cases/translations.ts index 1a60521667bba3..ca41db577700ea 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/configure_cases/translations.ts @@ -12,7 +12,7 @@ export * from '../../common/translations'; export const INCIDENT_MANAGEMENT_SYSTEM_TITLE = i18n.translate( 'xpack.cases.configureCases.incidentManagementSystemTitle', { - defaultMessage: 'Connect to external incident management system', + defaultMessage: 'External incident management system', } ); @@ -20,7 +20,7 @@ export const INCIDENT_MANAGEMENT_SYSTEM_DESC = i18n.translate( 'xpack.cases.configureCases.incidentManagementSystemDesc', { defaultMessage: - 'You may optionally connect cases to an external incident management system of your choosing. This will allow you to push case data as an incident in your chosen third-party system.', + 'Connect your cases to an external incident management system. You can then push case data as an incident in a third-party system.', } ); @@ -38,7 +38,7 @@ export const ADD_NEW_CONNECTOR = i18n.translate('xpack.cases.configureCases.addN export const CASE_CLOSURE_OPTIONS_TITLE = i18n.translate( 'xpack.cases.configureCases.caseClosureOptionsTitle', { - defaultMessage: 'Case Closures', + defaultMessage: 'Case closures', } ); @@ -46,14 +46,14 @@ export const CASE_CLOSURE_OPTIONS_DESC = i18n.translate( 'xpack.cases.configureCases.caseClosureOptionsDesc', { defaultMessage: - 'Define how you wish cases to be closed. Automated case closures require an established connection to an external incident management system.', + 'Define how to close your cases. Automatic closures require an established connection to an external incident management system.', } ); export const CASE_CLOSURE_OPTIONS_SUB_CASES = i18n.translate( 'xpack.cases.configureCases.caseClosureOptionsSubCases', { - defaultMessage: 'Automated closures of sub-cases is not currently supported.', + defaultMessage: 'Automatic closure of sub-cases is not supported.', } ); From 1ea35069c08938027567ecb360ea0730efbfa42a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= Date: Tue, 22 Jun 2021 09:47:05 +0200 Subject: [PATCH 12/57] [Security solution][Endpoint] Removes 'none' compression as it not used anymore (#102767) * Removes 'none' compression as it not used anymore * Revert type because none type is needed for the first time the artifact is created befor the compression --- .../services/artifacts/manifest_manager/manifest_manager.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts index 27108a03f34033..f2d1d3660d78e3 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts @@ -380,7 +380,6 @@ export class ManifestManager { for (const result of results) { await iterateArtifactsBuildResult(result, async (artifact, policyId) => { const artifactToAdd = baselineManifest.getArtifact(getArtifactId(artifact)) || artifact; - artifactToAdd.compressionAlgorithm = 'none'; if (!internalArtifactCompleteSchema.is(artifactToAdd)) { throw new Error(`Incomplete artifact detected: ${getArtifactId(artifactToAdd)}`); } From 62fc27bf5583c5d87af202b1cff8479335ec6545 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 22 Jun 2021 09:59:36 +0200 Subject: [PATCH 13/57] unksip functional test (#102633) --- test/functional/page_objects/time_to_visualize_page.ts | 5 ++++- x-pack/test/functional/apps/lens/add_to_dashboard.ts | 3 +-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/test/functional/page_objects/time_to_visualize_page.ts b/test/functional/page_objects/time_to_visualize_page.ts index 287b03ec60d88a..57a22103f64094 100644 --- a/test/functional/page_objects/time_to_visualize_page.ts +++ b/test/functional/page_objects/time_to_visualize_page.ts @@ -51,7 +51,10 @@ export class TimeToVisualizePageObject extends FtrService { vizName: string, { saveAsNew, redirectToOrigin, addToDashboard, dashboardId, saveToLibrary }: SaveModalArgs = {} ) { - await this.testSubjects.setValue('savedObjectTitle', vizName); + await this.testSubjects.setValue('savedObjectTitle', vizName, { + typeCharByChar: true, + clearWithKeyboard: true, + }); const hasSaveAsNew = await this.testSubjects.exists('saveAsNewCheckbox'); if (hasSaveAsNew && saveAsNew !== undefined) { diff --git a/x-pack/test/functional/apps/lens/add_to_dashboard.ts b/x-pack/test/functional/apps/lens/add_to_dashboard.ts index 61b0c63d226fa3..5e51573e325030 100644 --- a/x-pack/test/functional/apps/lens/add_to_dashboard.ts +++ b/x-pack/test/functional/apps/lens/add_to_dashboard.ts @@ -62,8 +62,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.lens.assertMetric('Maximum of bytes', '19,986'); }; - // flaky https://github.com/elastic/kibana/issues/102332 - describe.skip('lens add-to-dashboards tests', () => { + describe('lens add-to-dashboards tests', () => { it('should allow new lens to be added by value to a new dashboard', async () => { await createNewLens(); await PageObjects.lens.save('New Lens from Modal', false, false, false, 'new'); From 38604863e593e9c7aa239d077a1317709ac50131 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Casper=20H=C3=BCbertz?= Date: Tue, 22 Jun 2021 10:07:20 +0200 Subject: [PATCH 14/57] [Metrics] Update ActionsMenu create alert styles (#102316) * [Metrics] Add divider in the actions menu * [Metrics] Add color and icon to the alert link Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../inventory_view/components/waffle/node_context_menu.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx index 94b16448a6b613..ea80bd13e8a4d8 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx @@ -25,6 +25,7 @@ import { SectionSubtitle, SectionLinks, SectionLink, + ActionMenuDivider, } from '../../../../../../../observability/public'; import { useLinkProps } from '../../../../../hooks/use_link_props'; @@ -173,7 +174,10 @@ export const NodeContextMenu: React.FC = withTheme - + + + +
From df8637ae47747091d6e1ae2caafadc8fe69f913c Mon Sep 17 00:00:00 2001 From: Julien Mailleret <8582351+jmlrt@users.noreply.github.com> Date: Tue, 22 Jun 2021 12:30:05 +0200 Subject: [PATCH 15/57] Fix UBI source URL (#102736) * Fix UBI source URL This commit fix the source URL for UBI image to ensure that it stays consistent with the URL generated in https://artifacts.elastic.co/reports/dependencies/dependencies-current.html * Update src/dev/run_licenses_csv_report.js Co-authored-by: Jonathan Budzenski * Update src/dev/run_licenses_csv_report.js Co-authored-by: Jonathan Budzenski * try to make eslint happy Co-authored-by: Jonathan Budzenski --- src/dev/run_licenses_csv_report.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/dev/run_licenses_csv_report.js b/src/dev/run_licenses_csv_report.js index 8a612c9e3d8784..1923eddff33e92 100644 --- a/src/dev/run_licenses_csv_report.js +++ b/src/dev/run_licenses_csv_report.js @@ -71,7 +71,8 @@ run( licenses: [ 'Custom;https://www.redhat.com/licenses/EULA_Red_Hat_Universal_Base_Image_English_20190422.pdf', ], - sourceURL: 'https://oss-dependencies.elastic.co/redhat/ubi/ubi-minimal-8-source.tar.gz', + sourceURL: + 'https://oss-dependencies.elastic.co/red-hat-universal-base-image-minimal/8/ubi-minimal-8-source.tar.gz', } ); From fc55c30e8bff46b4e39928ca423fa7f425d8b8ec Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Tue, 22 Jun 2021 12:53:32 +0200 Subject: [PATCH 16/57] Add cache-control for assets served via `registerStaticDir` (#102756) * Add cache-control for assets served via `registerStaticDir` * fix case * add test for 'dynamic' file content --- src/core/server/http/http_server.test.ts | 127 +++++++++++++++++- src/core/server/http/http_server.ts | 8 +- .../static/compression_available.json | 3 + .../static/compression_available.json.gz | Bin 0 -> 70 bytes .../fixtures/static/some_json.json | 3 + 5 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 src/core/server/http/integration_tests/fixtures/static/compression_available.json create mode 100644 src/core/server/http/integration_tests/fixtures/static/compression_available.json.gz create mode 100644 src/core/server/http/integration_tests/fixtures/static/some_json.json diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index 7624a11a6f03fa..ffbd91c645382c 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -7,9 +7,10 @@ */ import { Server } from 'http'; -import { readFileSync } from 'fs'; +import { rmdir, mkdtemp, readFile, writeFile } from 'fs/promises'; import supertest from 'supertest'; import { omit } from 'lodash'; +import { join } from 'path'; import { ByteSizeValue, schema } from '@kbn/config-schema'; import { HttpConfig } from './http_config'; @@ -47,9 +48,9 @@ const enhanceWithContext = (fn: (...args: any[]) => any) => fn.bind(null, {}); let certificate: string; let key: string; -beforeAll(() => { - certificate = readFileSync(KBN_CERT_PATH, 'utf8'); - key = readFileSync(KBN_KEY_PATH, 'utf8'); +beforeAll(async () => { + certificate = await readFile(KBN_CERT_PATH, 'utf8'); + key = await readFile(KBN_KEY_PATH, 'utf8'); }); beforeEach(() => { @@ -1409,6 +1410,19 @@ describe('setup contract', () => { }); describe('#registerStaticDir', () => { + const assetFolder = join(__dirname, 'integration_tests', 'fixtures', 'static'); + let tempDir: string; + + beforeAll(async () => { + tempDir = await mkdtemp('cache-test'); + }); + + afterAll(async () => { + if (tempDir) { + await rmdir(tempDir, { recursive: true }); + } + }); + test('does not throw if called after stop', async () => { const { registerStaticDir } = await server.setup(config); await server.stop(); @@ -1416,6 +1430,111 @@ describe('setup contract', () => { registerStaticDir('/path1/{path*}', '/path/to/resource'); }).not.toThrow(); }); + + test('returns correct headers for static assets', async () => { + const { registerStaticDir, server: innerServer } = await server.setup(config); + + registerStaticDir('/static/{path*}', assetFolder); + + await server.start(); + const response = await supertest(innerServer.listener) + .get('/static/some_json.json') + .expect(200); + + expect(response.get('cache-control')).toEqual('must-revalidate'); + expect(response.get('etag')).not.toBeUndefined(); + }); + + test('returns compressed version if present', async () => { + const { registerStaticDir, server: innerServer } = await server.setup(config); + + registerStaticDir('/static/{path*}', assetFolder); + + await server.start(); + const response = await supertest(innerServer.listener) + .get('/static/compression_available.json') + .set('accept-encoding', 'gzip') + .expect(200); + + expect(response.get('cache-control')).toEqual('must-revalidate'); + expect(response.get('etag')).not.toBeUndefined(); + expect(response.get('content-encoding')).toEqual('gzip'); + }); + + test('returns uncompressed version if compressed asset is not available', async () => { + const { registerStaticDir, server: innerServer } = await server.setup(config); + + registerStaticDir('/static/{path*}', assetFolder); + + await server.start(); + const response = await supertest(innerServer.listener) + .get('/static/some_json.json') + .set('accept-encoding', 'gzip') + .expect(200); + + expect(response.get('cache-control')).toEqual('must-revalidate'); + expect(response.get('etag')).not.toBeUndefined(); + expect(response.get('content-encoding')).toBeUndefined(); + }); + + test('returns a 304 if etag value matches', async () => { + const { registerStaticDir, server: innerServer } = await server.setup(config); + + registerStaticDir('/static/{path*}', assetFolder); + + await server.start(); + const response = await supertest(innerServer.listener) + .get('/static/some_json.json') + .expect(200); + + const etag = response.get('etag'); + expect(etag).not.toBeUndefined(); + + await supertest(innerServer.listener) + .get('/static/some_json.json') + .set('If-None-Match', etag) + .expect(304); + }); + + test('serves content if etag values does not match', async () => { + const { registerStaticDir, server: innerServer } = await server.setup(config); + + registerStaticDir('/static/{path*}', assetFolder); + + await server.start(); + + await supertest(innerServer.listener) + .get('/static/some_json.json') + .set('If-None-Match', `"definitely not a valid etag"`) + .expect(200); + }); + + test('dynamically updates depending on the content of the file', async () => { + const tempFile = join(tempDir, 'some_file.json'); + + const { registerStaticDir, server: innerServer } = await server.setup(config); + registerStaticDir('/static/{path*}', tempDir); + + await server.start(); + + await supertest(innerServer.listener).get('/static/some_file.json').expect(404); + + await writeFile(tempFile, `{ "over": 9000 }`); + + let response = await supertest(innerServer.listener) + .get('/static/some_file.json') + .expect(200); + + const etag1 = response.get('etag'); + + await writeFile(tempFile, `{ "over": 42 }`); + + response = await supertest(innerServer.listener).get('/static/some_file.json').expect(200); + + const etag2 = response.get('etag'); + + expect(etag1).not.toEqual(etag2); + }); }); describe('#registerOnPreRouting', () => { diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 8b4c3b9416152f..d43d86d587d060 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -465,7 +465,13 @@ export class HttpServer { lookupCompressed: true, }, }, - options: { auth: false }, + options: { + auth: false, + cache: { + privacy: 'public', + otherwise: 'must-revalidate', + }, + }, }); } diff --git a/src/core/server/http/integration_tests/fixtures/static/compression_available.json b/src/core/server/http/integration_tests/fixtures/static/compression_available.json new file mode 100644 index 00000000000000..1f878fb465cff8 --- /dev/null +++ b/src/core/server/http/integration_tests/fixtures/static/compression_available.json @@ -0,0 +1,3 @@ +{ + "hello": "dolly" +} diff --git a/src/core/server/http/integration_tests/fixtures/static/compression_available.json.gz b/src/core/server/http/integration_tests/fixtures/static/compression_available.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..e77819d2e8e59a357c56c3c74624d3b82476bdf1 GIT binary patch literal 70 zcmV-M0J;AkiwFp-o6%qZ17mM(aB^jHb7^mGUtxA(X>4I)Y-KKLb8l_{tL9QrP|8Tn c$;nr;Qcz0C&&jD&;;Q8W0MZJ;EEfO(0RJ`}XaE2J literal 0 HcmV?d00001 diff --git a/src/core/server/http/integration_tests/fixtures/static/some_json.json b/src/core/server/http/integration_tests/fixtures/static/some_json.json new file mode 100644 index 00000000000000..c8c4105eb57cda --- /dev/null +++ b/src/core/server/http/integration_tests/fixtures/static/some_json.json @@ -0,0 +1,3 @@ +{ + "foo": "bar" +} From 65de579d5a2e85f5295e98ad94163c3af2ebec7e Mon Sep 17 00:00:00 2001 From: Katrin Freihofner Date: Tue, 22 Jun 2021 13:15:17 +0200 Subject: [PATCH 17/57] Renamed button and dropdown items in headers (apm, logs, metrics and uptime) from alerts to rules (#100918) Co-authored-by: Jonathan Buttner Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Christos Nasikas Co-authored-by: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Co-authored-by: Steph Milovic --- .../alerting_popover_flyout.tsx | 8 ++-- .../components/metrics_alert_dropdown.tsx | 12 +++--- .../manage_alerts_context_menu_item.tsx | 2 +- .../components/alert_dropdown.tsx | 4 +- .../public/alerts/configuration.tsx | 10 ++--- .../public/pages/overview/empty_section.ts | 2 +- .../translations/translations/ja-JP.json | 38 ------------------- .../translations/translations/zh-CN.json | 38 ------------------- .../header/action_menu_content.test.tsx | 6 +-- .../alerts/toggle_alert_flyout_button.tsx | 6 +-- .../overview/alerts/translations.ts | 19 ++++++---- .../__snapshots__/monitor_list.test.tsx.snap | 4 +- .../columns/define_connectors.tsx | 14 +++---- .../columns/enable_alert.test.tsx | 4 +- .../monitor_list/columns/translations.ts | 4 +- .../monitor_list_drawer/enabled_alerts.tsx | 6 +-- .../public/lib/alert_types/alert_messages.tsx | 2 +- .../uptime/public/state/alerts/alerts.ts | 6 +-- 18 files changed, 55 insertions(+), 130 deletions(-) diff --git a/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx index 5b4f4e24af44d5..ca73f6ddd05b34 100644 --- a/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx +++ b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx @@ -18,7 +18,7 @@ import { AlertType } from '../../../../common/alert_types'; import { AlertingFlyout } from '../../alerting/alerting_flyout'; const alertLabel = i18n.translate('xpack.apm.home.alertsMenu.alerts', { - defaultMessage: 'Alerts', + defaultMessage: 'Alerts and rules', }); const transactionDurationLabel = i18n.translate( 'xpack.apm.home.alertsMenu.transactionDuration', @@ -33,11 +33,11 @@ const errorCountLabel = i18n.translate('xpack.apm.home.alertsMenu.errorCount', { }); const createThresholdAlertLabel = i18n.translate( 'xpack.apm.home.alertsMenu.createThresholdAlert', - { defaultMessage: 'Create threshold alert' } + { defaultMessage: 'Create threshold rule' } ); const createAnomalyAlertAlertLabel = i18n.translate( 'xpack.apm.home.alertsMenu.createAnomalyAlert', - { defaultMessage: 'Create anomaly alert' } + { defaultMessage: 'Create anomaly rule' } ); const CREATE_TRANSACTION_DURATION_ALERT_PANEL_ID = @@ -102,7 +102,7 @@ export function AlertingPopoverAndFlyout({ { name: i18n.translate( 'xpack.apm.home.alertsMenu.viewActiveAlerts', - { defaultMessage: 'View active alerts' } + { defaultMessage: 'Manage rules' } ), href: basePath.prepend( '/app/management/insightsAndAlerting/triggersActions/alerts' diff --git a/x-pack/plugins/infra/public/alerting/common/components/metrics_alert_dropdown.tsx b/x-pack/plugins/infra/public/alerting/common/components/metrics_alert_dropdown.tsx index 41867053c3a0fa..c3327dc3fe85dd 100644 --- a/x-pack/plugins/infra/public/alerting/common/components/metrics_alert_dropdown.tsx +++ b/x-pack/plugins/infra/public/alerting/common/components/metrics_alert_dropdown.tsx @@ -36,12 +36,12 @@ export const MetricsAlertDropdown = () => { () => ({ id: 1, title: i18n.translate('xpack.infra.alerting.infrastructureDropdownTitle', { - defaultMessage: 'Infrastructure alerts', + defaultMessage: 'Infrastructure rules', }), items: [ { name: i18n.translate('xpack.infra.alerting.createInventoryAlertButton', { - defaultMessage: 'Create inventory alert', + defaultMessage: 'Create inventory rule', }), onClick: () => setVisibleFlyoutType('inventory'), }, @@ -54,12 +54,12 @@ export const MetricsAlertDropdown = () => { () => ({ id: 2, title: i18n.translate('xpack.infra.alerting.metricsDropdownTitle', { - defaultMessage: 'Metrics alerts', + defaultMessage: 'Metrics rules', }), items: [ { name: i18n.translate('xpack.infra.alerting.createThresholdAlertButton', { - defaultMessage: 'Create threshold alert', + defaultMessage: 'Create threshold rule', }), onClick: () => setVisibleFlyoutType('threshold'), }, @@ -76,7 +76,7 @@ export const MetricsAlertDropdown = () => { const manageAlertsMenuItem = useMemo( () => ({ name: i18n.translate('xpack.infra.alerting.manageAlerts', { - defaultMessage: 'Manage alerts', + defaultMessage: 'Manage rules', }), icon: 'tableOfContents', onClick: manageAlertsLinkProps.onClick, @@ -112,7 +112,7 @@ export const MetricsAlertDropdown = () => { { id: 0, title: i18n.translate('xpack.infra.alerting.alertDropdownTitle', { - defaultMessage: 'Alerts', + defaultMessage: 'Alerts and rules', }), items: firstPanelMenuItems, }, diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/manage_alerts_context_menu_item.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/manage_alerts_context_menu_item.tsx index a6b69a37f780ef..c9b6275264f914 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/manage_alerts_context_menu_item.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/manage_alerts_context_menu_item.tsx @@ -17,7 +17,7 @@ export const ManageAlertsContextMenuItem = () => { }); return ( - + ); }; diff --git a/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx b/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx index 66c77fbf875a45..c1733d4af05894 100644 --- a/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx +++ b/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx @@ -66,13 +66,13 @@ export const AlertDropdown = () => { > , , ]; diff --git a/x-pack/plugins/monitoring/public/alerts/configuration.tsx b/x-pack/plugins/monitoring/public/alerts/configuration.tsx index 5416095671d718..7825fe8e206174 100644 --- a/x-pack/plugins/monitoring/public/alerts/configuration.tsx +++ b/x-pack/plugins/monitoring/public/alerts/configuration.tsx @@ -32,7 +32,7 @@ export const AlertConfiguration: React.FC = (props: Props) => { } catch (err) { Legacy.shims.toastNotifications.addDanger({ title: i18n.translate('xpack.monitoring.alerts.panel.disableAlert.errorTitle', { - defaultMessage: `Unable to disable alert`, + defaultMessage: `Unable to disable rule`, }), text: err.message, }); @@ -46,7 +46,7 @@ export const AlertConfiguration: React.FC = (props: Props) => { } catch (err) { Legacy.shims.toastNotifications.addDanger({ title: i18n.translate('xpack.monitoring.alerts.panel.enableAlert.errorTitle', { - defaultMessage: `Unable to enable alert`, + defaultMessage: `Unable to enable rule`, }), text: err.message, }); @@ -60,7 +60,7 @@ export const AlertConfiguration: React.FC = (props: Props) => { } catch (err) { Legacy.shims.toastNotifications.addDanger({ title: i18n.translate('xpack.monitoring.alerts.panel.muteAlert.errorTitle', { - defaultMessage: `Unable to mute alert`, + defaultMessage: `Unable to mute rule`, }), text: err.message, }); @@ -74,7 +74,7 @@ export const AlertConfiguration: React.FC = (props: Props) => { } catch (err) { Legacy.shims.toastNotifications.addDanger({ title: i18n.translate('xpack.monitoring.alerts.panel.ummuteAlert.errorTitle', { - defaultMessage: `Unable to unmute alert`, + defaultMessage: `Unable to unmute rule`, }), text: err.message, }); @@ -112,7 +112,7 @@ export const AlertConfiguration: React.FC = (props: Props) => { }} > {i18n.translate('xpack.monitoring.alerts.panel.editAlert', { - defaultMessage: `Edit alert`, + defaultMessage: `Edit rule`, })} diff --git a/x-pack/plugins/observability/public/pages/overview/empty_section.ts b/x-pack/plugins/observability/public/pages/overview/empty_section.ts index 40b1157b29e35c..2747b2ecdebc99 100644 --- a/x-pack/plugins/observability/public/pages/overview/empty_section.ts +++ b/x-pack/plugins/observability/public/pages/overview/empty_section.ts @@ -97,7 +97,7 @@ export const getEmptySections = ({ core }: { core: CoreStart }): ISection[] => { 'Are 503 errors stacking up? Are services responding? Is CPU and RAM utilization jumping? See warnings as they happen—not as part of the post-mortem.', }), linkTitle: i18n.translate('xpack.observability.emptySection.apps.alert.link', { - defaultMessage: 'Create alert', + defaultMessage: 'Create rule', }), href: core.http.basePath.prepend( '/app/management/insightsAndAlerting/triggersActions/alerts' diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 227afc122f804f..9520c1ad0d9c1d 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5490,13 +5490,9 @@ "xpack.apm.header.badge.readOnly.text": "読み取り専用", "xpack.apm.header.badge.readOnly.tooltip": "を保存できませんでした", "xpack.apm.helpMenu.upgradeAssistantLink": "アップグレードアシスタント", - "xpack.apm.home.alertsMenu.alerts": "アラート", "xpack.apm.home.alertsMenu.createAnomalyAlert": "異常アラートを作成", - "xpack.apm.home.alertsMenu.createThresholdAlert": "しきい値アラートを作成", "xpack.apm.home.alertsMenu.errorCount": "エラー数", - "xpack.apm.home.alertsMenu.transactionDuration": "レイテンシ", "xpack.apm.home.alertsMenu.transactionErrorRate": "トランザクションエラー率", - "xpack.apm.home.alertsMenu.viewActiveAlerts": "アクティブアラートを表示", "xpack.apm.home.serviceMapTabLabel": "サービスマップ", "xpack.apm.instancesLatencyDistributionChartLegend": "インスタンス", "xpack.apm.instancesLatencyDistributionChartLegend.previousPeriod": "前の期間", @@ -10876,20 +10872,10 @@ "xpack.indexLifecycleMgmt.timeline.title": "ポリシー概要", "xpack.indexLifecycleMgmt.timeline.warmPhaseSectionTitle": "ウォームフェーズ", "xpack.indexLifecycleMgmt.warmPhase.dataTier.description": "頻度が低い読み取り専用アクセス用に最適化されたノードにデータを移動します。", - "xpack.infra.alerting.alertDropdownTitle": "アラート", "xpack.infra.alerting.alertFlyout.groupBy.placeholder": "なし (グループなし) ", "xpack.infra.alerting.alertFlyout.groupByLabel": "グループ分けの条件", - "xpack.infra.alerting.alertsButton": "アラート", - "xpack.infra.alerting.createInventoryAlertButton": "インベントリアラートの作成", - "xpack.infra.alerting.createThresholdAlertButton": "しきい値アラートを作成", "xpack.infra.alerting.infrastructureDropdownMenu": "インフラストラクチャー", - "xpack.infra.alerting.infrastructureDropdownTitle": "インフラストラクチャーアラート", - "xpack.infra.alerting.logs.alertsButton": "アラート", - "xpack.infra.alerting.logs.createAlertButton": "アラートの作成", - "xpack.infra.alerting.logs.manageAlerts": "アラートを管理", - "xpack.infra.alerting.manageAlerts": "アラートを管理", "xpack.infra.alerting.metricsDropdownMenu": "メトリック", - "xpack.infra.alerting.metricsDropdownTitle": "メトリックアラート", "xpack.infra.alerts.charts.errorMessage": "問題が発生しました", "xpack.infra.alerts.charts.loadingMessage": "読み込み中", "xpack.infra.alerts.charts.noDataMessage": "グラフデータがありません", @@ -15902,13 +15888,8 @@ "xpack.monitoring.alerts.nodesChanged.ui.removedFiringMessage": "Elasticsearchノード「{removed}」がこのクラスターから削除されました。", "xpack.monitoring.alerts.nodesChanged.ui.resolvedMessage": "このクラスターのElasticsearchノードは変更されていません。", "xpack.monitoring.alerts.nodesChanged.ui.restartedFiringMessage": "このクラスターでElasticsearchノード「{restarted}」が再起動しました。", - "xpack.monitoring.alerts.panel.disableAlert.errorTitle": "アラートを無効にできません", "xpack.monitoring.alerts.panel.disableTitle": "無効にする", - "xpack.monitoring.alerts.panel.editAlert": "アラートを編集", - "xpack.monitoring.alerts.panel.enableAlert.errorTitle": "アラートを有効にできません", - "xpack.monitoring.alerts.panel.muteAlert.errorTitle": "アラートをミュートできません", "xpack.monitoring.alerts.panel.muteTitle": "ミュート", - "xpack.monitoring.alerts.panel.ummuteAlert.errorTitle": "アラートをミュート解除できません", "xpack.monitoring.alerts.rejection.paramDetails.duration.label": "最後の", "xpack.monitoring.alerts.rejection.paramDetails.threshold.label": "{type} 拒否カウントが超過するときに通知", "xpack.monitoring.alerts.searchThreadPoolRejections.description": "検索スレッドプールの拒否数がしきい値を超過するときにアラートを発行します。", @@ -17235,7 +17216,6 @@ "xpack.observability.alertsTable.viewInAppButtonLabel": "アプリで表示", "xpack.observability.alertsTitle": "アラート", "xpack.observability.emptySection.apps.alert.description": "503 エラーが累積していますか?サービスは応答していますか?CPUとRAMの使用量が跳ね上がっていますか?このような警告を、事後にではなく、発生と同時に把握しましょう。", - "xpack.observability.emptySection.apps.alert.link": "アラートの作成", "xpack.observability.emptySection.apps.alert.title": "アラートが見つかりません。", "xpack.observability.emptySection.apps.apm.description": "分散アーキテクチャ全体でトランザクションを追跡し、サービスの通信をマップ化して、簡単にパフォーマンスボトルネックを特定できます。", "xpack.observability.emptySection.apps.apm.link": "エージェントのインストール", @@ -23526,8 +23506,6 @@ "xpack.uptime.alerts.tls.validAfterExpiringString": "{relativeDate}日以内、{date}に期限切れになります。", "xpack.uptime.alerts.tls.validBeforeExpiredString": "{relativeDate}日前、{date}以降有効です。", "xpack.uptime.alerts.tls.validBeforeExpiringString": "今から{relativeDate}日間、{date}まで無効です。", - "xpack.uptime.alerts.toggleAlertFlyoutButtonText": "アラート", - "xpack.uptime.alertsPopover.toggleButton.ariaLabel": "アラートコンテキストメニューを開く", "xpack.uptime.apmIntegrationAction.description": "このモニターの検索 APM", "xpack.uptime.apmIntegrationAction.text": "APMデータを表示", "xpack.uptime.availabilityLabelText": "{value} %", @@ -23746,15 +23724,11 @@ "xpack.uptime.monitorDetails.title.pingType.tcp": "TCP ping", "xpack.uptime.monitorList.anomalyColumn.label": "レスポンス異常スコア", "xpack.uptime.monitorList.defineConnector.description": "アラートを有効にするには、デフォルトのアラートアクションコネクターを定義してください。", - "xpack.uptime.monitorList.disableDownAlert": "ステータスアラートを無効にする", "xpack.uptime.monitorList.downLineSeries.downLabel": "ダウン", "xpack.uptime.monitorList.drawer.missingLocation": "一部の Heartbeat インスタンスには位置情報が定義されていません。Heartbeat 構成への{link}。", "xpack.uptime.monitorList.drawer.mostRecentRun": "直近のテスト実行", "xpack.uptime.monitorList.drawer.statusRowLocationList": "前回の確認時に\"{status}\"ステータスだった場所のリスト。", "xpack.uptime.monitorList.drawer.url": "Url", - "xpack.uptime.monitorList.enabledAlerts.noAlert": "このモニターではアラートが有効ではありません。", - "xpack.uptime.monitorList.enabledAlerts.title": "有効なアラート", - "xpack.uptime.monitorList.enableDownAlert": "ステータスアラートを有効にする", "xpack.uptime.monitorList.expandDrawerButton.ariaLabel": "ID {id}のモニターの行を展開", "xpack.uptime.monitorList.geoName.helpLinkAnnotation": "場所を追加", "xpack.uptime.monitorList.infraIntegrationAction.container.message": "コンテナーメトリックを表示", @@ -23828,15 +23802,7 @@ "xpack.uptime.monitorStatusBar.timestampFromNowTextAriaLabel": "最終確認からの経過時間", "xpack.uptime.monitorStatusBar.type.ariaLabel": "モニタータイプ", "xpack.uptime.monitorStatusBar.type.label": "型", - "xpack.uptime.navigateToAlertingButton.content": "アラートを管理", - "xpack.uptime.navigateToAlertingUi": "Uptime を離れてアラート管理ページに移動します", "xpack.uptime.notFountPage.homeLinkText": "ホームへ戻る", - "xpack.uptime.openAlertContextPanel.ariaLabel": "アラートコンテキストパネルを開くと、アラートタイプを選択できます", - "xpack.uptime.openAlertContextPanel.label": "アラートの作成", - "xpack.uptime.overview.alerts.disabled.failed": "アラートを無効にできません。", - "xpack.uptime.overview.alerts.disabled.success": "アラートが正常に無効にされました。", - "xpack.uptime.overview.alerts.enabled.failed": "アラートを有効にできません。", - "xpack.uptime.overview.alerts.enabled.success": "アラートが正常に有効にされました。 ", "xpack.uptime.overview.alerts.enabled.success.description": "この監視が停止しているときには、メッセージが {actionConnectors} に送信されます。", "xpack.uptime.overview.filterButton.label": "{title}フィルターのフィルターグループを展開", "xpack.uptime.overview.pageHeader.syntheticsCallout.announcementLink": "お知らせを読む", @@ -24004,10 +23970,6 @@ "xpack.uptime.synthetics.waterfallChart.labels.timings.ssl": "TLS", "xpack.uptime.synthetics.waterfallChart.labels.timings.wait": "待機中 (TTFB) ", "xpack.uptime.title": "アップタイム", - "xpack.uptime.toggleAlertButton.content": "ステータスアラートを監視", - "xpack.uptime.toggleAlertFlyout.ariaLabel": "アラートの追加ポップアップを開く", - "xpack.uptime.toggleTlsAlertButton.ariaLabel": "TLSアラートの追加ポップアップを開く", - "xpack.uptime.toggleTlsAlertButton.content": "TLSアラート", "xpack.uptime.uptimeFeatureCatalogueTitle": "アップタイム", "xpack.urlDrilldown.click.event.key.documentation": "クリックしたデータポイントの後ろのフィールド名。", "xpack.urlDrilldown.click.event.key.title": "クリックしたフィールドの名前。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index ac43a6938aac3b..f74d27eb8b2142 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5520,13 +5520,9 @@ "xpack.apm.header.badge.readOnly.text": "只读", "xpack.apm.header.badge.readOnly.tooltip": "无法保存", "xpack.apm.helpMenu.upgradeAssistantLink": "升级助手", - "xpack.apm.home.alertsMenu.alerts": "告警", "xpack.apm.home.alertsMenu.createAnomalyAlert": "创建异常告警", - "xpack.apm.home.alertsMenu.createThresholdAlert": "创建阈值告警", "xpack.apm.home.alertsMenu.errorCount": "错误计数", - "xpack.apm.home.alertsMenu.transactionDuration": "延迟", "xpack.apm.home.alertsMenu.transactionErrorRate": "事务错误率", - "xpack.apm.home.alertsMenu.viewActiveAlerts": "查看活动告警", "xpack.apm.home.serviceMapTabLabel": "服务地图", "xpack.apm.instancesLatencyDistributionChartLegend": "实例", "xpack.apm.instancesLatencyDistributionChartLegend.previousPeriod": "上一时段", @@ -11015,20 +11011,10 @@ "xpack.indexLifecycleMgmt.timeline.title": "策略摘要", "xpack.indexLifecycleMgmt.timeline.warmPhaseSectionTitle": "温阶段", "xpack.indexLifecycleMgmt.warmPhase.dataTier.description": "将数据移到针对不太频繁的只读访问优化的节点。", - "xpack.infra.alerting.alertDropdownTitle": "告警", "xpack.infra.alerting.alertFlyout.groupBy.placeholder": "无内容 (未分组) ", "xpack.infra.alerting.alertFlyout.groupByLabel": "分组依据", - "xpack.infra.alerting.alertsButton": "告警", - "xpack.infra.alerting.createInventoryAlertButton": "创建库存告警", - "xpack.infra.alerting.createThresholdAlertButton": "创建阈值告警", "xpack.infra.alerting.infrastructureDropdownMenu": "基础设施", - "xpack.infra.alerting.infrastructureDropdownTitle": "基础架构告警", - "xpack.infra.alerting.logs.alertsButton": "告警", - "xpack.infra.alerting.logs.createAlertButton": "创建告警", - "xpack.infra.alerting.logs.manageAlerts": "管理告警", - "xpack.infra.alerting.manageAlerts": "管理告警", "xpack.infra.alerting.metricsDropdownMenu": "指标", - "xpack.infra.alerting.metricsDropdownTitle": "指标告警", "xpack.infra.alerts.charts.errorMessage": "哇哦,出问题了", "xpack.infra.alerts.charts.loadingMessage": "正在加载", "xpack.infra.alerts.charts.noDataMessage": "没有可用图表数据", @@ -16138,13 +16124,8 @@ "xpack.monitoring.alerts.nodesChanged.ui.removedFiringMessage": "Elasticsearch 节点“{removed}”已从此集群中移除。", "xpack.monitoring.alerts.nodesChanged.ui.resolvedMessage": "此集群的 Elasticsearch 节点中没有更改。", "xpack.monitoring.alerts.nodesChanged.ui.restartedFiringMessage": "此集群中 Elasticsearch 节点“{restarted}”已重新启动。", - "xpack.monitoring.alerts.panel.disableAlert.errorTitle": "无法禁用告警", "xpack.monitoring.alerts.panel.disableTitle": "禁用", - "xpack.monitoring.alerts.panel.editAlert": "编辑告警", - "xpack.monitoring.alerts.panel.enableAlert.errorTitle": "无法启用告警", - "xpack.monitoring.alerts.panel.muteAlert.errorTitle": "无法静音告警", "xpack.monitoring.alerts.panel.muteTitle": "静音", - "xpack.monitoring.alerts.panel.ummuteAlert.errorTitle": "无法取消告警静音", "xpack.monitoring.alerts.rejection.paramDetails.duration.label": "过去", "xpack.monitoring.alerts.rejection.paramDetails.threshold.label": "当 {type} 拒绝计数超过以下阈值时通知:", "xpack.monitoring.alerts.searchThreadPoolRejections.description": "当搜索线程池中的拒绝数目超过阈值时告警。", @@ -17471,7 +17452,6 @@ "xpack.observability.alertsTable.viewInAppButtonLabel": "在应用中查看", "xpack.observability.alertsTitle": "告警", "xpack.observability.emptySection.apps.alert.description": "503 错误是否越来越多?服务是否响应?CPU 和 RAM 利用率是否激增?实时查看警告,而不是事后再进行剖析。", - "xpack.observability.emptySection.apps.alert.link": "创建告警", "xpack.observability.emptySection.apps.alert.title": "未找到告警。", "xpack.observability.emptySection.apps.apm.description": "通过分布式体系结构跟踪事务并映射服务的交互以轻松发现性能瓶颈。", "xpack.observability.emptySection.apps.apm.link": "安装代理", @@ -23892,8 +23872,6 @@ "xpack.uptime.alerts.tls.validAfterExpiringString": "将在{relativeDate} 天后,即 {date}到期。", "xpack.uptime.alerts.tls.validBeforeExpiredString": "自 {relativeDate} 天前,即 {date}开始生效。", "xpack.uptime.alerts.tls.validBeforeExpiringString": "从现在到 {date}的 {relativeDate} 天里无效。", - "xpack.uptime.alerts.toggleAlertFlyoutButtonText": "告警", - "xpack.uptime.alertsPopover.toggleButton.ariaLabel": "打开告警上下文菜单", "xpack.uptime.apmIntegrationAction.description": "在 APM 中搜索此监测", "xpack.uptime.apmIntegrationAction.text": "显示 APM 数据", "xpack.uptime.availabilityLabelText": "{value} %", @@ -24112,15 +24090,11 @@ "xpack.uptime.monitorDetails.title.pingType.tcp": "TCP ping", "xpack.uptime.monitorList.anomalyColumn.label": "响应异常分数", "xpack.uptime.monitorList.defineConnector.description": "要开始启用告警,请在以下位置定义默认告警操作连接器", - "xpack.uptime.monitorList.disableDownAlert": "禁用状态告警", "xpack.uptime.monitorList.downLineSeries.downLabel": "关闭检查", "xpack.uptime.monitorList.drawer.missingLocation": "某些 Heartbeat 实例未定义位置。{link}到您的 Heartbeat 配置。", "xpack.uptime.monitorList.drawer.mostRecentRun": "最新测试运行", "xpack.uptime.monitorList.drawer.statusRowLocationList": "上次检查时状态为“{status}”的位置列表。", "xpack.uptime.monitorList.drawer.url": "URL", - "xpack.uptime.monitorList.enabledAlerts.noAlert": "没有为此监测启用告警。", - "xpack.uptime.monitorList.enabledAlerts.title": "已启用的告警", - "xpack.uptime.monitorList.enableDownAlert": "启用状态告警", "xpack.uptime.monitorList.expandDrawerButton.ariaLabel": "展开 ID {id} 的监测行", "xpack.uptime.monitorList.geoName.helpLinkAnnotation": "添加位置", "xpack.uptime.monitorList.infraIntegrationAction.container.message": "显示容器指标", @@ -24194,15 +24168,7 @@ "xpack.uptime.monitorStatusBar.timestampFromNowTextAriaLabel": "自上次检查以来经过的时间", "xpack.uptime.monitorStatusBar.type.ariaLabel": "监测类型", "xpack.uptime.monitorStatusBar.type.label": "类型", - "xpack.uptime.navigateToAlertingButton.content": "管理告警", - "xpack.uptime.navigateToAlertingUi": "离开 Uptime 并前往“Alerting 管理”页面", "xpack.uptime.notFountPage.homeLinkText": "返回主页", - "xpack.uptime.openAlertContextPanel.ariaLabel": "打开告警上下文面板,以便可以选择告警类型", - "xpack.uptime.openAlertContextPanel.label": "创建告警", - "xpack.uptime.overview.alerts.disabled.failed": "无法禁用告警!", - "xpack.uptime.overview.alerts.disabled.success": "已成功禁用告警!", - "xpack.uptime.overview.alerts.enabled.failed": "无法启用告警!", - "xpack.uptime.overview.alerts.enabled.success": "已成功启用告警 ", "xpack.uptime.overview.alerts.enabled.success.description": "此监测关闭时,将有消息发送到 {actionConnectors}。", "xpack.uptime.overview.filterButton.label": "展开筛选 {title} 的筛选组", "xpack.uptime.overview.pageHeader.syntheticsCallout.announcementLink": "阅读公告", @@ -24370,10 +24336,6 @@ "xpack.uptime.synthetics.waterfallChart.labels.timings.ssl": "TLS", "xpack.uptime.synthetics.waterfallChart.labels.timings.wait": "等待中 (TTFB)", "xpack.uptime.title": "运行时间", - "xpack.uptime.toggleAlertButton.content": "监测状态告警", - "xpack.uptime.toggleAlertFlyout.ariaLabel": "打开添加告警浮出控件", - "xpack.uptime.toggleTlsAlertButton.ariaLabel": "打开 TLS 告警浮出控件", - "xpack.uptime.toggleTlsAlertButton.content": "TLS 告警", "xpack.uptime.uptimeFeatureCatalogueTitle": "运行时间", "xpack.urlDrilldown.click.event.key.documentation": "已点击数据点背后的字段名称。", "xpack.urlDrilldown.click.event.key.title": "已点击字段的名称。", diff --git a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.test.tsx b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.test.tsx index 89d8f38b1e3b3b..0265588c3fdeb9 100644 --- a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.test.tsx +++ b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.test.tsx @@ -14,12 +14,12 @@ describe('ActionMenuContent', () => { it('renders alerts dropdown', async () => { const { getByLabelText, getByText } = render(); - const alertsDropdown = getByLabelText('Open alert context menu'); + const alertsDropdown = getByLabelText('Open alerts and rules context menu'); fireEvent.click(alertsDropdown); await waitFor(() => { - expect(getByText('Create alert')); - expect(getByText('Manage alerts')); + expect(getByText('Create rule')); + expect(getByText('Manage rules')); }); }); diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/toggle_alert_flyout_button.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/toggle_alert_flyout_button.tsx index a1b745d07924ef..278958bd1987bb 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/toggle_alert_flyout_button.tsx +++ b/x-pack/plugins/uptime/public/components/overview/alerts/toggle_alert_flyout_button.tsx @@ -67,7 +67,7 @@ export const ToggleAlertFlyoutButtonComponent: React.FC = ({ > ), @@ -114,7 +114,7 @@ export const ToggleAlertFlyoutButtonComponent: React.FC = ({ }, { id: ALERT_CONTEXT_SELECT_TYPE_PANEL_ID, - title: 'create alerts', + title: ToggleFlyoutTranslations.toggleAlertFlyoutButtonLabel, items: selectionItems, }, ]; @@ -134,7 +134,7 @@ export const ToggleAlertFlyoutButtonComponent: React.FC = ({ > } diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/translations.ts b/x-pack/plugins/uptime/public/components/overview/alerts/translations.ts index 00a00a4664cd87..7cfcdabe5562bc 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/translations.ts +++ b/x-pack/plugins/uptime/public/components/overview/alerts/translations.ts @@ -283,30 +283,33 @@ export const TlsTranslations = { export const ToggleFlyoutTranslations = { toggleButtonAriaLabel: i18n.translate('xpack.uptime.alertsPopover.toggleButton.ariaLabel', { - defaultMessage: 'Open alert context menu', + defaultMessage: 'Open alerts and rules context menu', }), openAlertContextPanelAriaLabel: i18n.translate('xpack.uptime.openAlertContextPanel.ariaLabel', { - defaultMessage: 'Open the alert context panel so you can choose an alert type', + defaultMessage: 'Open the rule context panel so you can choose a rule type', }), openAlertContextPanelLabel: i18n.translate('xpack.uptime.openAlertContextPanel.label', { - defaultMessage: 'Create alert', + defaultMessage: 'Create rule', }), toggleTlsAriaLabel: i18n.translate('xpack.uptime.toggleTlsAlertButton.ariaLabel', { - defaultMessage: 'Open TLS alert flyout', + defaultMessage: 'Open TLS rule flyout', }), toggleTlsContent: i18n.translate('xpack.uptime.toggleTlsAlertButton.content', { - defaultMessage: 'TLS alert', + defaultMessage: 'TLS rule', }), toggleMonitorStatusAriaLabel: i18n.translate('xpack.uptime.toggleAlertFlyout.ariaLabel', { - defaultMessage: 'Open add alert flyout', + defaultMessage: 'Open add rule flyout', }), toggleMonitorStatusContent: i18n.translate('xpack.uptime.toggleAlertButton.content', { - defaultMessage: 'Monitor status alert', + defaultMessage: 'Monitor status rule', }), navigateToAlertingUIAriaLabel: i18n.translate('xpack.uptime.navigateToAlertingUi', { defaultMessage: 'Leave Uptime and go to Alerting Management page', }), navigateToAlertingButtonContent: i18n.translate('xpack.uptime.navigateToAlertingButton.content', { - defaultMessage: 'Manage alerts', + defaultMessage: 'Manage rules', + }), + toggleAlertFlyoutButtonLabel: i18n.translate('xpack.uptime.alerts.createRulesPanel.title', { + defaultMessage: 'Create rules', }), }; diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/__snapshots__/monitor_list.test.tsx.snap b/x-pack/plugins/uptime/public/components/overview/monitor_list/__snapshots__/monitor_list.test.tsx.snap index 115dab1095dc11..cfdf7afba4e85e 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/__snapshots__/monitor_list.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/__snapshots__/monitor_list.test.tsx.snap @@ -1303,7 +1303,7 @@ exports[`MonitorList component renders the monitor list 1`] = ` >
- {!details.error && showFooter && ( - - )} ); } diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.tsx index 0bebec61657b4a..7fbbf6fd3ffdc7 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.tsx @@ -21,6 +21,7 @@ import { EuiPageSideBar, useResizeObserver, } from '@elastic/eui'; +import useShallowCompareEffect from 'react-use/lib/useShallowCompareEffect'; import { isEqual, sortBy } from 'lodash'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -205,7 +206,7 @@ export function DiscoverSidebar({ return result; }, [fields]); - const multiFields = useMemo(() => { + const calculateMultiFields = () => { if (!useNewFieldsApi || !fields) { return undefined; } @@ -224,7 +225,13 @@ export function DiscoverSidebar({ map.set(parent, value); }); return map; - }, [fields, useNewFieldsApi, selectedFields]); + }; + + const [multiFields, setMultiFields] = useState(() => calculateMultiFields()); + + useShallowCompareEffect(() => { + setMultiFields(calculateMultiFields()); + }, [fields, selectedFields, useNewFieldsApi]); const deleteField = useMemo( () => diff --git a/src/plugins/kibana_react/public/field_button/field_button.scss b/src/plugins/kibana_react/public/field_button/field_button.scss index 43f60e4503576c..f71e097ab71380 100644 --- a/src/plugins/kibana_react/public/field_button/field_button.scss +++ b/src/plugins/kibana_react/public/field_button/field_button.scss @@ -38,6 +38,7 @@ padding: $euiSizeS; display: flex; align-items: flex-start; + line-height: normal; } .kbnFieldButton__fieldIcon { From dc9daed8c29d0acd61a8f6f72b22571ec0b2f40b Mon Sep 17 00:00:00 2001 From: Thomas Neirynck Date: Tue, 22 Jun 2021 15:10:53 +0200 Subject: [PATCH 22/57] [Maps] bump ems client to 7.14 (#102770) --- package.json | 2 +- src/dev/license_checker/config.ts | 2 ++ yarn.lock | 8 ++++---- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 29371c9532915b..114a9ac98df72b 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ "@elastic/charts": "30.1.0", "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.13", - "@elastic/ems-client": "7.13.0", + "@elastic/ems-client": "7.14.0", "@elastic/eui": "33.0.0", "@elastic/filesaver": "1.1.2", "@elastic/good": "^9.0.1-kibana3", diff --git a/src/dev/license_checker/config.ts b/src/dev/license_checker/config.ts index ebf56166a89221..b3b7bf5e8eed7d 100644 --- a/src/dev/license_checker/config.ts +++ b/src/dev/license_checker/config.ts @@ -10,6 +10,7 @@ // used as dependencies or dev dependencies export const LICENSE_ALLOWED = [ 'Elastic-License', + 'Elastic License 2.0', 'SSPL-1.0 OR Elastic License 2.0', '0BSD', '(BSD-2-Clause OR MIT OR Apache-2.0)', @@ -72,6 +73,7 @@ export const DEV_ONLY_LICENSE_ALLOWED = ['MPL-2.0']; export const LICENSE_OVERRIDES = { 'jsts@1.6.2': ['Eclipse Distribution License - v 1.0'], // cf. https://github.com/bjornharrtell/jsts '@mapbox/jsonlint-lines-primitives@2.0.2': ['MIT'], // license in readme https://github.com/tmcw/jsonlint + '@elastic/ems-client@7.14.0': ['Elastic License 2.0'], // TODO can be removed if the https://github.com/jindw/xmldom/issues/239 is released 'xmldom@0.1.27': ['MIT'], diff --git a/yarn.lock b/yarn.lock index cfdac6108b6cfe..bcb5e607a44ee1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1413,10 +1413,10 @@ ms "^2.1.3" secure-json-parse "^2.4.0" -"@elastic/ems-client@7.13.0": - version "7.13.0" - resolved "https://registry.yarnpkg.com/@elastic/ems-client/-/ems-client-7.13.0.tgz#de291a6eb25523e5844a9e74ae72fd2e81a1f4d9" - integrity sha512-VdK5jZdnC+5BSkMRQsqHqrsZ9HttnPjQmCjRlAGuV8y6g0eKVP9ZiMRQFKFKmuSKpx0kHGsSV/1kBglTmSl/3g== +"@elastic/ems-client@7.14.0": + version "7.14.0" + resolved "https://registry.yarnpkg.com/@elastic/ems-client/-/ems-client-7.14.0.tgz#7c8095086bd9a637f72d6d810d494a460c68e0fc" + integrity sha512-axXTyBrC1I2TMmcxGC04SgODwb5Cp6svcW64RoTr8X2XrSSuH0gh+X5qMsC9FgGGnmbVNCEYIs3JK4AJ7X4bxA== dependencies: "@types/geojson" "^7946.0.7" "@types/lru-cache" "^5.1.0" From 01ce7ac6e1698ffdcd898dc4e726e3b552291d5d Mon Sep 17 00:00:00 2001 From: Sergi Massaneda Date: Tue, 22 Jun 2021 15:14:22 +0200 Subject: [PATCH 23/57] [core][deepLinks] Fix getAppInfo deepLinks order (#102879) * getAppInfo sets deepLinks order properly * remove overriding same spread fields --- src/core/public/application/utils/get_app_info.test.ts | 6 ++++++ src/core/public/application/utils/get_app_info.ts | 4 +--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/core/public/application/utils/get_app_info.test.ts b/src/core/public/application/utils/get_app_info.test.ts index fa1e2dd9a4537d..25614d1d1dca9c 100644 --- a/src/core/public/application/utils/get_app_info.test.ts +++ b/src/core/public/application/utils/get_app_info.test.ts @@ -185,15 +185,18 @@ describe('getAppInfo', () => { it('adds default deepLinks when needed', () => { const app = createApp({ + order: 3, deepLinks: [ { id: 'sub-id', title: 'sub-title', + order: 2, deepLinks: [ { id: 'sub-sub-id', title: 'sub-sub-title', path: '/sub-sub', + order: 1, keywords: ['sub sub'], }, ], @@ -210,12 +213,14 @@ describe('getAppInfo', () => { searchable: true, appRoute: `/app/some-id`, keywords: [], + order: 3, deepLinks: [ { id: 'sub-id', title: 'sub-title', navLinkStatus: AppNavLinkStatus.hidden, searchable: true, + order: 2, keywords: [], deepLinks: [ { @@ -223,6 +228,7 @@ describe('getAppInfo', () => { title: 'sub-sub-title', navLinkStatus: AppNavLinkStatus.hidden, searchable: true, + order: 1, path: '/sub-sub', keywords: ['sub sub'], deepLinks: [], diff --git a/src/core/public/application/utils/get_app_info.ts b/src/core/public/application/utils/get_app_info.ts index 6c753b7a71a0f7..b5a3f0b0a0f13d 100644 --- a/src/core/public/application/utils/get_app_info.ts +++ b/src/core/public/application/utils/get_app_info.ts @@ -41,9 +41,7 @@ function getDeepLinkInfos(deepLinks?: AppDeepLink[]): PublicAppDeepLinkInfo[] { return deepLinks.map( ({ navLinkStatus = AppNavLinkStatus.default, ...rawDeepLink }): PublicAppDeepLinkInfo => { return { - id: rawDeepLink.id, - title: rawDeepLink.title, - path: rawDeepLink.path, + ...rawDeepLink, keywords: rawDeepLink.keywords ?? [], navLinkStatus: navLinkStatus === AppNavLinkStatus.default ? AppNavLinkStatus.hidden : navLinkStatus, From 06900307169ecd99d1a13c9cab0cf19415248f02 Mon Sep 17 00:00:00 2001 From: Robert Oskamp Date: Tue, 22 Jun 2021 15:37:08 +0200 Subject: [PATCH 24/57] [ML] Functional tests - explicitly delete jobs after setupModule tests (#102882) This PR explicitly deletes the jobs created by the `setupModule` tests. --- x-pack/test/api_integration/apis/ml/modules/setup_module.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/x-pack/test/api_integration/apis/ml/modules/setup_module.ts b/x-pack/test/api_integration/apis/ml/modules/setup_module.ts index 6011c38255cdc9..c4dd529ac14f59 100644 --- a/x-pack/test/api_integration/apis/ml/modules/setup_module.ts +++ b/x-pack/test/api_integration/apis/ml/modules/setup_module.ts @@ -1048,6 +1048,9 @@ export default ({ getService }: FtrProviderContext) => { for (const dashboard of testData.expected.dashboards) { await ml.testResources.deleteDashboardById(dashboard); } + for (const job of testData.expected.jobs) { + await ml.api.deleteAnomalyDetectionJobES(job.jobId); + } await ml.api.cleanMlIndices(); }); From 564807c0b05b32baf81c57b07bfc5e0026fe134f Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 22 Jun 2021 15:51:37 +0200 Subject: [PATCH 25/57] increase chart switch width (#102520) --- .../editor_frame/workspace_panel/chart_switch.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss index 3fafa8b37a42fe..a4e22b4ef558c1 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss @@ -22,5 +22,5 @@ img.lnsChartSwitch__chartIcon { // stylelint-disable-line selector-no-qualifying } .lnsChartSwitch__search { - width: 7 * $euiSizeXXL; + width: 10 * $euiSizeXXL; } From 34490a355e9f498a7001c54002aed83610e1807b Mon Sep 17 00:00:00 2001 From: Dominique Clarke Date: Tue, 22 Jun 2021 10:14:56 -0400 Subject: [PATCH 26/57] [Uptime] [Synthetics Integration] transition to monaco code editor (#102642) * update synthetics integration code editor * add basic support for xml and javascript * fix types Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- packages/kbn-monaco/src/monaco_imports.ts | 4 +- .../components/fleet_package/code_editor.tsx | 45 ++++++++++ .../contexts/advanced_fields_http_context.tsx | 2 +- .../fleet_package/header_field.test.tsx | 4 +- .../components/fleet_package/header_field.tsx | 2 +- .../fleet_package/request_body_field.test.tsx | 4 +- .../fleet_package/request_body_field.tsx | 83 ++++--------------- .../public/components/fleet_package/types.tsx | 13 ++- .../apps/uptime/synthetics_integration.ts | 4 +- 9 files changed, 84 insertions(+), 77 deletions(-) create mode 100644 x-pack/plugins/uptime/public/components/fleet_package/code_editor.tsx diff --git a/packages/kbn-monaco/src/monaco_imports.ts b/packages/kbn-monaco/src/monaco_imports.ts index 92ea23347c374c..3f689e6ec0c017 100644 --- a/packages/kbn-monaco/src/monaco_imports.ts +++ b/packages/kbn-monaco/src/monaco_imports.ts @@ -7,7 +7,6 @@ */ /* eslint-disable @kbn/eslint/module_migration */ - import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; import 'monaco-editor/esm/vs/base/common/worker/simpleWorker'; @@ -23,4 +22,7 @@ import 'monaco-editor/esm/vs/editor/contrib/hover/hover.js'; // Needed for hover import 'monaco-editor/esm/vs/editor/contrib/parameterHints/parameterHints.js'; // Needed for signature import 'monaco-editor/esm/vs/editor/contrib/bracketMatching/bracketMatching.js'; // Needed for brackets matching highlight +import 'monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution.js'; // Needed for basic javascript support +import 'monaco-editor/esm/vs/basic-languages/xml/xml.contribution.js'; // Needed for basic xml support + export { monaco }; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/code_editor.tsx b/x-pack/plugins/uptime/public/components/fleet_package/code_editor.tsx new file mode 100644 index 00000000000000..d2fe3f9b30e84f --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/code_editor.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; + +import styled from 'styled-components'; + +import { EuiPanel } from '@elastic/eui'; +import { CodeEditor as MonacoCodeEditor } from '../../../../../../src/plugins/kibana_react/public'; + +import { MonacoEditorLangId } from './types'; + +const CodeEditorContainer = styled(EuiPanel)` + padding: 0; +`; + +interface Props { + ariaLabel: string; + id: string; + languageId: MonacoEditorLangId; + onChange: (value: string) => void; + value: string; +} + +export const CodeEditor = ({ ariaLabel, id, languageId, onChange, value }: Props) => { + return ( + +
+ +
+
+ ); +}; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/contexts/advanced_fields_http_context.tsx b/x-pack/plugins/uptime/public/components/fleet_package/contexts/advanced_fields_http_context.tsx index c257a8f71b77a6..b51aa6cbf3a7cb 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/contexts/advanced_fields_http_context.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/contexts/advanced_fields_http_context.tsx @@ -36,7 +36,7 @@ export const initialValues = { [ConfigKeys.RESPONSE_STATUS_CHECK]: [], [ConfigKeys.REQUEST_BODY_CHECK]: { value: '', - type: Mode.TEXT, + type: Mode.PLAINTEXT, }, [ConfigKeys.REQUEST_HEADERS_CHECK]: {}, [ConfigKeys.REQUEST_METHOD_CHECK]: HTTPMethod.GET, diff --git a/x-pack/plugins/uptime/public/components/fleet_package/header_field.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/header_field.test.tsx index ee33083b3eae91..6d9e578fe53f5b 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/header_field.test.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/header_field.test.tsx @@ -76,14 +76,14 @@ describe('', () => { }); it('handles content mode', async () => { - const contentMode: Mode = Mode.TEXT; + const contentMode: Mode = Mode.PLAINTEXT; render( ); await waitFor(() => { expect(onChange).toBeCalledWith({ - 'Content-Type': contentTypes[Mode.TEXT], + 'Content-Type': contentTypes[Mode.PLAINTEXT], }); }); }); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/header_field.tsx b/x-pack/plugins/uptime/public/components/fleet_package/header_field.tsx index 9f337d4b00704b..eaf9be50e9665d 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/header_field.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/header_field.tsx @@ -61,7 +61,7 @@ export const HeaderField = ({ contentMode, defaultValue, onChange }: Props) => { export const contentTypes: Record = { [Mode.JSON]: ContentType.JSON, - [Mode.TEXT]: ContentType.TEXT, + [Mode.PLAINTEXT]: ContentType.TEXT, [Mode.XML]: ContentType.XML, [Mode.FORM]: ContentType.FORM, }; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/request_body_field.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/request_body_field.test.tsx index 849809eae52a4d..fa666ac764ac7c 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/request_body_field.test.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/request_body_field.test.tsx @@ -5,6 +5,8 @@ * 2.0. */ +import 'jest-canvas-mock'; + import React, { useState, useCallback } from 'react'; import { fireEvent, waitFor } from '@testing-library/react'; import { render } from '../../lib/helper/rtl_helpers'; @@ -16,7 +18,7 @@ jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ })); describe('', () => { - const defaultMode = Mode.TEXT; + const defaultMode = Mode.PLAINTEXT; const defaultValue = 'sample value'; const WrappedComponent = () => { const [config, setConfig] = useState({ diff --git a/x-pack/plugins/uptime/public/components/fleet_package/request_body_field.tsx b/x-pack/plugins/uptime/public/components/fleet_package/request_body_field.tsx index 1ef8fdd75e7f36..1fdde7c2b63fce 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/request_body_field.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/request_body_field.tsx @@ -5,67 +5,13 @@ * 2.0. */ import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { stringify, parse } from 'query-string'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { stringify, parse } from 'query-string'; - -import styled from 'styled-components'; - -import { EuiCodeEditor, EuiPanel, EuiTabbedContent } from '@elastic/eui'; - -import { Mode } from './types'; - +import { EuiTabbedContent } from '@elastic/eui'; +import { Mode, MonacoEditorLangId } from './types'; import { KeyValuePairsField, Pair } from './key_value_field'; - -import 'brace/theme/github'; -import 'brace/mode/xml'; -import 'brace/mode/json'; -import 'brace/ext/language_tools'; - -const CodeEditorContainer = styled(EuiPanel)` - padding: 0; -`; - -enum ResponseBodyType { - CODE = 'code', - FORM = 'form', -} - -const CodeEditor = ({ - ariaLabel, - id, - mode, - onChange, - value, -}: { - ariaLabel: string; - id: string; - mode: Mode; - onChange: (value: string) => void; - value: string; -}) => { - return ( - -
- -
-
- ); -}; +import { CodeEditor } from './code_editor'; interface Props { onChange: (requestBody: { type: Mode; value: string }) => void; @@ -73,6 +19,11 @@ interface Props { value: string; } +enum ResponseBodyType { + CODE = 'code', + FORM = 'form', +} + // TO DO: Look into whether or not code editor reports errors, in order to prevent form submission on an error export const RequestBodyField = ({ onChange, type, value }: Props) => { const [values, setValues] = useState>({ @@ -129,9 +80,9 @@ export const RequestBodyField = ({ onChange, type, value }: Props) => { const tabs = [ { - id: Mode.TEXT, - name: modeLabels[Mode.TEXT], - 'data-test-subj': `syntheticsRequestBodyTab__${Mode.TEXT}`, + id: Mode.PLAINTEXT, + name: modeLabels[Mode.PLAINTEXT], + 'data-test-subj': `syntheticsRequestBodyTab__${Mode.PLAINTEXT}`, content: ( { defaultMessage: 'Text code editor', } )} - id={Mode.TEXT} - mode={Mode.TEXT} + id={Mode.PLAINTEXT} + languageId={MonacoEditorLangId.PLAINTEXT} onChange={(code) => setValues((prevValues) => ({ ...prevValues, [ResponseBodyType.CODE]: code })) } @@ -162,7 +113,7 @@ export const RequestBodyField = ({ onChange, type, value }: Props) => { } )} id={Mode.JSON} - mode={Mode.JSON} + languageId={MonacoEditorLangId.JSON} onChange={(code) => setValues((prevValues) => ({ ...prevValues, [ResponseBodyType.CODE]: code })) } @@ -183,7 +134,7 @@ export const RequestBodyField = ({ onChange, type, value }: Props) => { } )} id={Mode.XML} - mode={Mode.XML} + languageId={MonacoEditorLangId.XML} onChange={(code) => setValues((prevValues) => ({ ...prevValues, [ResponseBodyType.CODE]: code })) } @@ -229,7 +180,7 @@ const modeLabels = { defaultMessage: 'Form', } ), - [Mode.TEXT]: i18n.translate( + [Mode.PLAINTEXT]: i18n.translate( 'xpack.uptime.createPackagePolicy.stepConfigure.requestBodyType.text', { defaultMessage: 'Text', diff --git a/x-pack/plugins/uptime/public/components/fleet_package/types.tsx b/x-pack/plugins/uptime/public/components/fleet_package/types.tsx index 4d44b4f074e829..7a16d1352c40a1 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/types.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/types.tsx @@ -25,10 +25,17 @@ export enum ResponseBodyIndexPolicy { ON_ERROR = 'on_error', } +export enum MonacoEditorLangId { + JSON = 'xjson', + PLAINTEXT = 'plaintext', + XML = 'xml', + JAVASCRIPT = 'javascript', +} + export enum Mode { FORM = 'form', JSON = 'json', - TEXT = 'text', + PLAINTEXT = 'text', XML = 'xml', } @@ -192,11 +199,11 @@ export interface PolicyConfig { [DataStream.ICMP]: ICMPFields; } -export type Validation = Partial void>>; +export type Validation = Partial boolean>>; export const contentTypesToMode = { [ContentType.FORM]: Mode.FORM, [ContentType.JSON]: Mode.JSON, - [ContentType.TEXT]: Mode.TEXT, + [ContentType.TEXT]: Mode.PLAINTEXT, [ContentType.XML]: Mode.XML, }; diff --git a/x-pack/test/functional/apps/uptime/synthetics_integration.ts b/x-pack/test/functional/apps/uptime/synthetics_integration.ts index 0872abfcaa4f86..a4740de8e9a2b0 100644 --- a/x-pack/test/functional/apps/uptime/synthetics_integration.ts +++ b/x-pack/test/functional/apps/uptime/synthetics_integration.ts @@ -277,7 +277,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }, requestBody: { type: 'xml', - value: 'samplexml', + value: 'samplexml', }, indexResponseBody: false, indexResponseHeaders: false, @@ -308,7 +308,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }, 'check.response.headers': advancedConfig.responseHeaders, 'check.response.status': [advancedConfig.responseStatusCheck], - 'check.request.body': `${advancedConfig.requestBody.value}`, // code editor adds closing tag + 'check.request.body': advancedConfig.requestBody.value, 'check.response.body.positive': [advancedConfig.responseBodyCheckPositive], 'check.response.body.negative': [advancedConfig.responseBodyCheckNegative], 'response.include_body': advancedConfig.indexResponseBody ? 'on_error' : 'never', From fbf4f26e398f049640cc46731e48001fd8d9bd3b Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Tue, 22 Jun 2021 17:26:28 +0300 Subject: [PATCH 27/57] [Docs] Drilldowns only for timeseries TSVB charts (#102481) * [Docs] Drilldowns only for timeseries TSVB charts * Update docs/user/dashboard/drilldowns.asciidoc Co-authored-by: Kaarina Tungseth Co-authored-by: Wylie Conlon Co-authored-by: Kaarina Tungseth --- docs/user/dashboard/drilldowns.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/dashboard/drilldowns.asciidoc b/docs/user/dashboard/drilldowns.asciidoc index 0eb4b43466ff9a..84c33db31d5750 100644 --- a/docs/user/dashboard/drilldowns.asciidoc +++ b/docs/user/dashboard/drilldowns.asciidoc @@ -112,7 +112,7 @@ The following panel types support drilldowns. ^| X ^| X -| TSVB +| TSVB (only for time series visualizations) ^| X ^| X From 0ba8b43228bde0407e724b0fa732227fa91716b6 Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Tue, 22 Jun 2021 16:28:10 +0200 Subject: [PATCH 28/57] [Lens] Clicking number histogram bar applies global filter instead of time filter (#102730) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../xy_visualization/expression.test.tsx | 146 ++++++++++++++++++ .../public/xy_visualization/expression.tsx | 8 +- 2 files changed, 150 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx index ee1f66063ad1db..930f6888ce5320 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx @@ -1099,6 +1099,152 @@ describe('xy_expression', () => { }); }); + test('onElementClick returns correct context data for date histogram', () => { + const geometry: GeometryValue = { + x: 1585758120000, + y: 1, + accessor: 'y1', + mark: null, + datum: {}, + }; + const series = { + key: 'spec{d}yAccessor{d}splitAccessors{b-2}', + specId: 'd', + yAccessor: 'yAccessorId', + splitAccessors: {}, + seriesKeys: ['yAccessorId'], + }; + + const { args } = sampleArgs(); + + const wrapper = mountWithIntl( + + ); + + wrapper.find(Settings).first().prop('onElementClick')!([ + [geometry, series as XYChartSeriesIdentifier], + ]); + + expect(onClickValue).toHaveBeenCalledWith({ + data: [ + { + column: 0, + row: 0, + table: dateHistogramData.tables.timeLayer, + value: 1585758120000, + }, + ], + timeFieldName: 'order_date', + }); + }); + + test('onElementClick returns correct context data for numeric histogram', () => { + const { args } = sampleArgs(); + + const numberLayer: LayerArgs = { + layerId: 'numberLayer', + hide: false, + xAccessor: 'xAccessorId', + yScaleType: 'linear', + xScaleType: 'linear', + isHistogram: true, + seriesType: 'bar_stacked', + accessors: ['yAccessorId'], + palette: mockPaletteOutput, + }; + + const numberHistogramData: LensMultiTable = { + type: 'lens_multitable', + tables: { + numberLayer: { + type: 'datatable', + rows: [ + { + xAccessorId: 5, + yAccessorId: 1, + }, + { + xAccessorId: 7, + yAccessorId: 1, + }, + { + xAccessorId: 8, + yAccessorId: 1, + }, + { + xAccessorId: 10, + yAccessorId: 1, + }, + ], + columns: [ + { + id: 'xAccessorId', + name: 'bytes', + meta: { type: 'number' }, + }, + { + id: 'yAccessorId', + name: 'Count of records', + meta: { type: 'number' }, + }, + ], + }, + }, + dateRange: { + fromDate: new Date('2020-04-01T16:14:16.246Z'), + toDate: new Date('2020-04-01T17:15:41.263Z'), + }, + }; + const geometry: GeometryValue = { + x: 5, + y: 1, + accessor: 'y1', + mark: null, + datum: {}, + }; + const series = { + key: 'spec{d}yAccessor{d}splitAccessors{b-2}', + specId: 'd', + yAccessor: 'yAccessorId', + splitAccessors: {}, + seriesKeys: ['yAccessorId'], + }; + + const wrapper = mountWithIntl( + + ); + + wrapper.find(Settings).first().prop('onElementClick')!([ + [geometry, series as XYChartSeriesIdentifier], + ]); + + expect(onClickValue).toHaveBeenCalledWith({ + data: [ + { + column: 0, + row: 0, + table: numberHistogramData.tables.numberLayer, + value: 5, + }, + ], + timeFieldName: undefined, + }); + }); + test('returns correct original data for ordinal x axis with special formatter', () => { const geometry: GeometryValue = { x: 'BAR', y: 1, accessor: 'y1', mark: null, datum: {} }; const series = { diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 24842c83c23b11..1de5cf6b305335 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -562,9 +562,9 @@ export function XYChart({ value: pointValue, }); } - - const xAxisFieldName = table.columns.find((el) => el.id === layer.xAccessor)?.meta?.field; - const timeFieldName = xDomain && xAxisFieldName; + const currentColumnMeta = table.columns.find((el) => el.id === layer.xAccessor)?.meta; + const xAxisFieldName = currentColumnMeta?.field; + const isDateField = currentColumnMeta?.type === 'date'; const context: LensFilterEvent['data'] = { data: points.map((point) => ({ @@ -573,7 +573,7 @@ export function XYChart({ value: point.value, table, })), - timeFieldName, + timeFieldName: xDomain && isDateField ? xAxisFieldName : undefined, }; onClickValue(desanitizeFilterContext(context)); }; From a2e7b388a4072b3e64721e0e55d345381ced469d Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Tue, 22 Jun 2021 16:30:02 +0200 Subject: [PATCH 29/57] [Lens] Update dimension panel copy to suggested one (#102890) --- .../editor_frame/config_panel/dimension_container.tsx | 2 +- .../dimension_panel/dimension_editor.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx index 2f3eb5043d6106..c62b10093e6e59 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx @@ -113,7 +113,7 @@ export function DimensionContainer({ > {i18n.translate('xpack.lens.configure.configurePanelTitle', { - defaultMessage: '{groupLabel} configuration', + defaultMessage: '{groupLabel}', values: { groupLabel, }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx index dca8e926646f04..b35986c42054d0 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx @@ -750,7 +750,7 @@ function getErrorMessage( if (selectedColumn && incompleteOperation) { if (input === 'field') { return i18n.translate('xpack.lens.indexPattern.invalidOperationLabel', { - defaultMessage: 'To use this function, select a different field.', + defaultMessage: 'This field does not work with the selected function.', }); } return i18n.translate('xpack.lens.indexPattern.chooseFieldLabel', { From 1397461aca4b586f4740de50a83064fc560397a2 Mon Sep 17 00:00:00 2001 From: Corey Robertson Date: Tue, 22 Jun 2021 10:56:17 -0400 Subject: [PATCH 30/57] Fixes onDestroy handler (#101959) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/canvas/public/lib/create_handlers.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/x-pack/plugins/canvas/public/lib/create_handlers.ts b/x-pack/plugins/canvas/public/lib/create_handlers.ts index 9f531d6921417f..928a33de808485 100644 --- a/x-pack/plugins/canvas/public/lib/create_handlers.ts +++ b/x-pack/plugins/canvas/public/lib/create_handlers.ts @@ -44,6 +44,10 @@ export const createHandlers = (baseHandlers = createBaseHandlers()): RendererHan this.done = fn; }, + onDestroy(fn: () => void) { + this.destroy = fn; + }, + // TODO: these functions do not match the `onXYZ` and `xyz` pattern elsewhere. onEmbeddableDestroyed() {}, onEmbeddableInputChange() {}, From 46f43784b0c4911f717e45aed020e18e4d9a7967 Mon Sep 17 00:00:00 2001 From: Corey Robertson Date: Tue, 22 Jun 2021 10:56:47 -0400 Subject: [PATCH 31/57] Handle element changing into a filter (#97890) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../renderers/filters/dropdown_filter/index.tsx | 7 +++++-- .../renderers/filters/time_filter/index.tsx | 9 +++++++-- x-pack/plugins/canvas/public/lib/create_handlers.ts | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/index.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/index.tsx index 97b5e592552ed0..fbcba9e56aef52 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/index.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/index.tsx @@ -45,9 +45,12 @@ export const dropdownFilter: RendererFactory = () => ({ reuseDomNode: true, height: 50, render(domNode, config, handlers) { - const filterExpression = handlers.getFilter(); + let filterExpression = handlers.getFilter(); - if (filterExpression !== '') { + if (filterExpression === undefined || filterExpression.indexOf('exactly')) { + filterExpression = ''; + handlers.setFilter(filterExpression); + } else if (filterExpression !== '') { // NOTE: setFilter() will cause a data refresh, avoid calling unless required // compare expression and filter, update filter if needed const { changed, newAst } = syncFilterExpression(config, filterExpression, ['filterGroup']); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/time_filter/index.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/time_filter/index.tsx index ff781bb294db47..02a36b80fa364e 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/time_filter/index.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/time_filter/index.tsx @@ -19,6 +19,8 @@ import { RendererFactory } from '../../../../types'; const { timeFilter: strings } = RendererStrings; +const defaultTimeFilterExpression = 'timefilter column=@timestamp from=now-24h to=now'; + export const timeFilterFactory: StartInitializer> = (core, plugins) => { const { uiSettings } = core; @@ -38,9 +40,12 @@ export const timeFilterFactory: StartInitializer> = ( help: strings.getHelpDescription(), reuseDomNode: true, // must be true, otherwise popovers don't work render: async (domNode: HTMLElement, config: Arguments, handlers: RendererHandlers) => { - const filterExpression = handlers.getFilter(); + let filterExpression = handlers.getFilter(); - if (filterExpression !== '') { + if (filterExpression === undefined || filterExpression.indexOf('timefilter') !== 0) { + filterExpression = defaultTimeFilterExpression; + handlers.setFilter(filterExpression); + } else if (filterExpression !== '') { // NOTE: setFilter() will cause a data refresh, avoid calling unless required // compare expression and filter, update filter if needed const { changed, newAst } = syncFilterExpression(config, filterExpression, [ diff --git a/x-pack/plugins/canvas/public/lib/create_handlers.ts b/x-pack/plugins/canvas/public/lib/create_handlers.ts index 928a33de808485..aba29ccd542be1 100644 --- a/x-pack/plugins/canvas/public/lib/create_handlers.ts +++ b/x-pack/plugins/canvas/public/lib/create_handlers.ts @@ -109,7 +109,7 @@ export const createDispatchedHandlerFactory = ( }, getFilter() { - return element.filter; + return element.filter || ''; }, onComplete(fn: () => void) { From 11e68fda87ba1b410fc2fa0642909a4084130ac9 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Tue, 22 Jun 2021 09:59:20 -0500 Subject: [PATCH 32/57] [packages] Move @kbn/interpreter to Bazel (#101089) Co-authored-by: Tiago Costa Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .eslintignore | 2 - .../monorepo-packages.asciidoc | 1 + package.json | 2 +- packages/BUILD.bazel | 1 + packages/kbn-interpreter/.babelrc | 9 - packages/kbn-interpreter/.npmignore | 3 - packages/kbn-interpreter/BUILD.bazel | 99 ++ packages/kbn-interpreter/common/package.json | 3 +- .../lib/grammar.peg => grammar/grammar.pegjs} | 0 packages/kbn-interpreter/package.json | 8 +- packages/kbn-interpreter/scripts/build.js | 9 - .../kbn-interpreter/src/common/index.d.ts | 12 - .../src/common/{index.js => index.ts} | 12 +- .../kbn-interpreter/src/common/lib/ast.d.ts | 25 - .../common/lib/ast.from_expression.test.js | 2 +- .../src/common/lib/ast.to_expression.test.js | 2 +- .../src/common/lib/{ast.js => ast.ts} | 48 +- .../src/common/lib/get_type.d.ts | 9 - .../common/lib/{get_type.js => get_type.ts} | 2 +- .../kbn-interpreter/src/common/lib/grammar.js | 1053 ----------------- .../src/common/lib/registry.d.ts | 25 - .../common/lib/{registry.js => registry.ts} | 26 +- .../tasks/build/__fixtures__/sample.js | 3 - packages/kbn-interpreter/tasks/build/cli.js | 82 -- packages/kbn-interpreter/tasks/build/paths.js | 15 - packages/kbn-interpreter/tsconfig.json | 18 +- .../charts/public/services/palettes/types.ts | 4 +- x-pack/package.json | 1 - x-pack/plugins/canvas/public/functions/to.ts | 1 - x-pack/plugins/canvas/public/registries.ts | 1 - .../canvas/public/state/selectors/workpad.ts | 1 - yarn.lock | 2 +- 32 files changed, 191 insertions(+), 1290 deletions(-) delete mode 100644 packages/kbn-interpreter/.babelrc delete mode 100644 packages/kbn-interpreter/.npmignore create mode 100644 packages/kbn-interpreter/BUILD.bazel rename packages/kbn-interpreter/{src/common/lib/grammar.peg => grammar/grammar.pegjs} (100%) delete mode 100644 packages/kbn-interpreter/scripts/build.js delete mode 100644 packages/kbn-interpreter/src/common/index.d.ts rename packages/kbn-interpreter/src/common/{index.js => index.ts} (76%) delete mode 100644 packages/kbn-interpreter/src/common/lib/ast.d.ts rename packages/kbn-interpreter/src/common/lib/{ast.js => ast.ts} (75%) delete mode 100644 packages/kbn-interpreter/src/common/lib/get_type.d.ts rename packages/kbn-interpreter/src/common/lib/{get_type.js => get_type.ts} (92%) delete mode 100644 packages/kbn-interpreter/src/common/lib/grammar.js delete mode 100644 packages/kbn-interpreter/src/common/lib/registry.d.ts rename packages/kbn-interpreter/src/common/lib/{registry.js => registry.ts} (73%) delete mode 100644 packages/kbn-interpreter/tasks/build/__fixtures__/sample.js delete mode 100644 packages/kbn-interpreter/tasks/build/cli.js delete mode 100644 packages/kbn-interpreter/tasks/build/paths.js diff --git a/.eslintignore b/.eslintignore index 63cd01d6e90db8..f757ed9a1bf98c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -30,8 +30,6 @@ snapshots.js # package overrides /packages/elastic-eslint-config-kibana -/packages/kbn-interpreter/src/common/lib/grammar.js -/packages/kbn-tinymath/src/grammar.js /packages/kbn-plugin-generator/template /packages/kbn-pm/dist /packages/kbn-test/src/functional_test_runner/__tests__/fixtures/ diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index ebab9de66032fd..48d0d40d0abb06 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -80,6 +80,7 @@ yarn kbn watch-bazel - @kbn/eslint-plugin-eslint - @kbn/expect - @kbn/i18n +- @kbn/interpreter - @kbn/io-ts-utils - @kbn/legacy-logging - @kbn/logging diff --git a/package.json b/package.json index 114a9ac98df72b..873dffeed38f8a 100644 --- a/package.json +++ b/package.json @@ -133,7 +133,7 @@ "@kbn/crypto": "link:bazel-bin/packages/kbn-crypto", "@kbn/mapbox-gl": "link:bazel-bin/packages/kbn-mapbox-gl", "@kbn/i18n": "link:bazel-bin/packages/kbn-i18n", - "@kbn/interpreter": "link:packages/kbn-interpreter", + "@kbn/interpreter": "link:bazel-bin/packages/kbn-interpreter", "@kbn/io-ts-utils": "link:bazel-bin/packages/kbn-io-ts-utils", "@kbn/legacy-logging": "link:bazel-bin/packages/kbn-legacy-logging", "@kbn/logging": "link:bazel-bin/packages/kbn-logging", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 61034c562b4475..70a3d1eacc7c58 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -23,6 +23,7 @@ filegroup( "//packages/kbn-eslint-plugin-eslint:build", "//packages/kbn-expect:build", "//packages/kbn-i18n:build", + "//packages/kbn-interpreter:build", "//packages/kbn-io-ts-utils:build", "//packages/kbn-legacy-logging:build", "//packages/kbn-logging:build", diff --git a/packages/kbn-interpreter/.babelrc b/packages/kbn-interpreter/.babelrc deleted file mode 100644 index 309b3d5b3233db..00000000000000 --- a/packages/kbn-interpreter/.babelrc +++ /dev/null @@ -1,9 +0,0 @@ -{ - "presets": ["@kbn/babel-preset/webpack_preset"], - "plugins": [ - "@babel/plugin-transform-modules-commonjs", - ["@babel/plugin-transform-runtime", { - "regenerator": true - }] - ] -} diff --git a/packages/kbn-interpreter/.npmignore b/packages/kbn-interpreter/.npmignore deleted file mode 100644 index b9bc539e63ce43..00000000000000 --- a/packages/kbn-interpreter/.npmignore +++ /dev/null @@ -1,3 +0,0 @@ -src -tasks -.babelrc diff --git a/packages/kbn-interpreter/BUILD.bazel b/packages/kbn-interpreter/BUILD.bazel new file mode 100644 index 00000000000000..4492faabfdf81e --- /dev/null +++ b/packages/kbn-interpreter/BUILD.bazel @@ -0,0 +1,99 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@npm//pegjs:index.bzl", "pegjs") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-interpreter" +PKG_REQUIRE_NAME = "@kbn/interpreter" + +SOURCE_FILES = glob( + [ + "src/**/*", + ] +) + +TYPE_FILES = [] + +SRCS = SOURCE_FILES + TYPE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "common/package.json", + "package.json", +] + +SRC_DEPS = [ + "@npm//lodash", +] + +TYPES_DEPS = [ + "@npm//@types/jest", + "@npm//@types/lodash", + "@npm//@types/node", +] + +DEPS = SRC_DEPS + TYPES_DEPS + +pegjs( + name = "grammar", + data = [ + ":grammar/grammar.pegjs" + ], + output_dir = True, + args = [ + "--allowed-start-rules", + "expression,argument", + "-o", + "$(@D)/index.js", + "./%s/grammar/grammar.pegjs" % package_name() + ], +) + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_project( + name = "tsc", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + allow_js = True, + declaration = True, + declaration_map = True, + incremental = True, + out_dir = "target", + source_map = True, + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES + [":grammar"], + deps = DEPS + [":tsc"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-interpreter/common/package.json b/packages/kbn-interpreter/common/package.json index 62061138234d9f..2f5277a8e86520 100644 --- a/packages/kbn-interpreter/common/package.json +++ b/packages/kbn-interpreter/common/package.json @@ -1,6 +1,5 @@ { "private": true, "main": "../target/common/index.js", - "types": "../target/common/index.d.ts", - "jsnext:main": "../src/common/index.js" + "types": "../target/common/index.d.ts" } \ No newline at end of file diff --git a/packages/kbn-interpreter/src/common/lib/grammar.peg b/packages/kbn-interpreter/grammar/grammar.pegjs similarity index 100% rename from packages/kbn-interpreter/src/common/lib/grammar.peg rename to packages/kbn-interpreter/grammar/grammar.pegjs diff --git a/packages/kbn-interpreter/package.json b/packages/kbn-interpreter/package.json index fc0936f4b5f53b..efdb30e105186a 100644 --- a/packages/kbn-interpreter/package.json +++ b/packages/kbn-interpreter/package.json @@ -2,11 +2,5 @@ "name": "@kbn/interpreter", "private": "true", "version": "1.0.0", - "license": "SSPL-1.0 OR Elastic License 2.0", - "scripts": { - "interpreter:peg": "../../node_modules/.bin/pegjs src/common/lib/grammar.peg", - "build": "node scripts/build", - "kbn:bootstrap": "node scripts/build --dev", - "kbn:watch": "node scripts/build --dev --watch" - } + "license": "SSPL-1.0 OR Elastic License 2.0" } \ No newline at end of file diff --git a/packages/kbn-interpreter/scripts/build.js b/packages/kbn-interpreter/scripts/build.js deleted file mode 100644 index 21b7f86c6bc344..00000000000000 --- a/packages/kbn-interpreter/scripts/build.js +++ /dev/null @@ -1,9 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -require('../tasks/build/cli'); diff --git a/packages/kbn-interpreter/src/common/index.d.ts b/packages/kbn-interpreter/src/common/index.d.ts deleted file mode 100644 index 6f54d07590973f..00000000000000 --- a/packages/kbn-interpreter/src/common/index.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export { Registry } from './lib/registry'; - -export { fromExpression, toExpression, Ast, ExpressionFunctionAST } from './lib/ast'; -export { getType } from './lib/get_type'; diff --git a/packages/kbn-interpreter/src/common/index.js b/packages/kbn-interpreter/src/common/index.ts similarity index 76% rename from packages/kbn-interpreter/src/common/index.js rename to packages/kbn-interpreter/src/common/index.ts index b83d8180980cdc..524c854b404292 100644 --- a/packages/kbn-interpreter/src/common/index.js +++ b/packages/kbn-interpreter/src/common/index.ts @@ -6,11 +6,19 @@ * Side Public License, v 1. */ -export { fromExpression, toExpression, safeElementFromExpression } from './lib/ast'; +export { + fromExpression, + toExpression, + safeElementFromExpression, + Ast, + ExpressionFunctionAST, +} from './lib/ast'; export { Fn } from './lib/fn'; export { getType } from './lib/get_type'; export { castProvider } from './lib/cast'; -export { parse } from './lib/grammar'; +// @ts-expect-error +// @internal +export { parse } from '../../grammar'; export { getByAlias } from './lib/get_by_alias'; export { Registry } from './lib/registry'; export { addRegistries, register, registryFactory } from './registries'; diff --git a/packages/kbn-interpreter/src/common/lib/ast.d.ts b/packages/kbn-interpreter/src/common/lib/ast.d.ts deleted file mode 100644 index 0e95cb9901df05..00000000000000 --- a/packages/kbn-interpreter/src/common/lib/ast.d.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export type ExpressionArgAST = string | boolean | number | Ast; - -export interface ExpressionFunctionAST { - type: 'function'; - function: string; - arguments: { - [key: string]: ExpressionArgAST[]; - }; -} - -export interface Ast { - type: 'expression'; - chain: ExpressionFunctionAST[]; -} - -export declare function fromExpression(expression: string): Ast; -export declare function toExpression(astObj: Ast, type?: string): string; diff --git a/packages/kbn-interpreter/src/common/lib/ast.from_expression.test.js b/packages/kbn-interpreter/src/common/lib/ast.from_expression.test.js index c67a266e1276a0..a098a3fdce0f6b 100644 --- a/packages/kbn-interpreter/src/common/lib/ast.from_expression.test.js +++ b/packages/kbn-interpreter/src/common/lib/ast.from_expression.test.js @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { fromExpression } from './ast'; +import { fromExpression } from '@kbn/interpreter/target/common/lib/ast'; import { getType } from './get_type'; describe('ast fromExpression', () => { diff --git a/packages/kbn-interpreter/src/common/lib/ast.to_expression.test.js b/packages/kbn-interpreter/src/common/lib/ast.to_expression.test.js index c60412f05c15ab..b500ca06836a4d 100644 --- a/packages/kbn-interpreter/src/common/lib/ast.to_expression.test.js +++ b/packages/kbn-interpreter/src/common/lib/ast.to_expression.test.js @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { toExpression } from './ast'; +import { toExpression } from '@kbn/interpreter/common'; describe('ast toExpression', () => { describe('single expression', () => { diff --git a/packages/kbn-interpreter/src/common/lib/ast.js b/packages/kbn-interpreter/src/common/lib/ast.ts similarity index 75% rename from packages/kbn-interpreter/src/common/lib/ast.js rename to packages/kbn-interpreter/src/common/lib/ast.ts index fb471e34ccc69f..791c94809f35c4 100644 --- a/packages/kbn-interpreter/src/common/lib/ast.js +++ b/packages/kbn-interpreter/src/common/lib/ast.ts @@ -7,12 +7,35 @@ */ import { getType } from './get_type'; -import { parse } from './grammar'; +// @ts-expect-error +import { parse } from '../../../grammar'; -function getArgumentString(arg, argKey, level = 0) { +export type ExpressionArgAST = string | boolean | number | Ast; + +export interface ExpressionFunctionAST { + type: 'function'; + function: string; + arguments: { + [key: string]: ExpressionArgAST[]; + }; +} + +export interface Ast { + /** @internal */ + function: any; + /** @internal */ + arguments: any; + type: 'expression'; + chain: ExpressionFunctionAST[]; + /** @internal */ + replace(regExp: RegExp, s: string): string; +} + +function getArgumentString(arg: Ast, argKey: string | undefined, level = 0) { const type = getType(arg); - function maybeArgKey(argKey, argString) { + // eslint-disable-next-line @typescript-eslint/no-shadow + function maybeArgKey(argKey: string | null | undefined, argString: string) { return argKey == null || argKey === '_' ? argString : `${argKey}=${argString}`; } @@ -36,7 +59,7 @@ function getArgumentString(arg, argKey, level = 0) { throw new Error(`Invalid argument type in AST: ${type}`); } -function getExpressionArgs(block, level = 0) { +function getExpressionArgs(block: Ast, level = 0) { const args = block.arguments; const hasValidArgs = typeof args === 'object' && args != null && !Array.isArray(args); @@ -45,7 +68,7 @@ function getExpressionArgs(block, level = 0) { const argKeys = Object.keys(args); const MAX_LINE_LENGTH = 80; // length before wrapping arguments return argKeys.map((argKey) => - args[argKey].reduce((acc, arg) => { + args[argKey].reduce((acc: any, arg: any) => { const argString = getArgumentString(arg, argKey, level); const lineLength = acc.split('\n').pop().length; @@ -63,12 +86,12 @@ function getExpressionArgs(block, level = 0) { ); } -function fnWithArgs(fnName, args) { +function fnWithArgs(fnName: any, args: any[]) { if (!args || args.length === 0) return fnName; return `${fnName} ${args.join(' ')}`; } -function getExpression(chain, level = 0) { +function getExpression(chain: any[], level = 0) { if (!chain) throw new Error('Expressions must contain a chain'); // break new functions onto new lines if we're not in a nested/sub-expression @@ -90,7 +113,7 @@ function getExpression(chain, level = 0) { .join(separator); } -export function fromExpression(expression, type = 'expression') { +export function fromExpression(expression: string, type = 'expression'): Ast { try { return parse(String(expression), { startRule: type }); } catch (e) { @@ -99,7 +122,7 @@ export function fromExpression(expression, type = 'expression') { } // TODO: OMG This is so bad, we need to talk about the right way to handle bad expressions since some are element based and others not -export function safeElementFromExpression(expression) { +export function safeElementFromExpression(expression: string) { try { return fromExpression(expression); } catch (e) { @@ -116,8 +139,11 @@ Thanks for understanding, } // TODO: Respect the user's existing formatting -export function toExpression(astObj, type = 'expression') { - if (type === 'argument') return getArgumentString(astObj); +export function toExpression(astObj: Ast, type = 'expression'): string { + if (type === 'argument') { + // @ts-ignore + return getArgumentString(astObj); + } const validType = ['expression', 'function'].includes(getType(astObj)); if (!validType) throw new Error('Expression must be an expression or argument function'); diff --git a/packages/kbn-interpreter/src/common/lib/get_type.d.ts b/packages/kbn-interpreter/src/common/lib/get_type.d.ts deleted file mode 100644 index 568658c7803331..00000000000000 --- a/packages/kbn-interpreter/src/common/lib/get_type.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export declare function getType(node: any): string; diff --git a/packages/kbn-interpreter/src/common/lib/get_type.js b/packages/kbn-interpreter/src/common/lib/get_type.ts similarity index 92% rename from packages/kbn-interpreter/src/common/lib/get_type.js rename to packages/kbn-interpreter/src/common/lib/get_type.ts index 7ae6dab0291761..b6dff67bf5dc97 100644 --- a/packages/kbn-interpreter/src/common/lib/get_type.js +++ b/packages/kbn-interpreter/src/common/lib/get_type.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -export function getType(node) { +export function getType(node: any): string { if (node == null) return 'null'; if (typeof node === 'object') { if (!node.type) throw new Error('Objects must have a type property'); diff --git a/packages/kbn-interpreter/src/common/lib/grammar.js b/packages/kbn-interpreter/src/common/lib/grammar.js deleted file mode 100644 index 3f473b1beea635..00000000000000 --- a/packages/kbn-interpreter/src/common/lib/grammar.js +++ /dev/null @@ -1,1053 +0,0 @@ -/* - * Generated by PEG.js 0.10.0. - * - * http://pegjs.org/ - */ - -"use strict"; - -function peg$subclass(child, parent) { - function ctor() { this.constructor = child; } - ctor.prototype = parent.prototype; - child.prototype = new ctor(); -} - -function peg$SyntaxError(message, expected, found, location) { - this.message = message; - this.expected = expected; - this.found = found; - this.location = location; - this.name = "SyntaxError"; - - if (typeof Error.captureStackTrace === "function") { - Error.captureStackTrace(this, peg$SyntaxError); - } -} - -peg$subclass(peg$SyntaxError, Error); - -peg$SyntaxError.buildMessage = function(expected, found) { - var DESCRIBE_EXPECTATION_FNS = { - literal: function(expectation) { - return "\"" + literalEscape(expectation.text) + "\""; - }, - - "class": function(expectation) { - var escapedParts = "", - i; - - for (i = 0; i < expectation.parts.length; i++) { - escapedParts += expectation.parts[i] instanceof Array - ? classEscape(expectation.parts[i][0]) + "-" + classEscape(expectation.parts[i][1]) - : classEscape(expectation.parts[i]); - } - - return "[" + (expectation.inverted ? "^" : "") + escapedParts + "]"; - }, - - any: function(expectation) { - return "any character"; - }, - - end: function(expectation) { - return "end of input"; - }, - - other: function(expectation) { - return expectation.description; - } - }; - - function hex(ch) { - return ch.charCodeAt(0).toString(16).toUpperCase(); - } - - function literalEscape(s) { - return s - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"') - .replace(/\0/g, '\\0') - .replace(/\t/g, '\\t') - .replace(/\n/g, '\\n') - .replace(/\r/g, '\\r') - .replace(/[\x00-\x0F]/g, function(ch) { return '\\x0' + hex(ch); }) - .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return '\\x' + hex(ch); }); - } - - function classEscape(s) { - return s - .replace(/\\/g, '\\\\') - .replace(/\]/g, '\\]') - .replace(/\^/g, '\\^') - .replace(/-/g, '\\-') - .replace(/\0/g, '\\0') - .replace(/\t/g, '\\t') - .replace(/\n/g, '\\n') - .replace(/\r/g, '\\r') - .replace(/[\x00-\x0F]/g, function(ch) { return '\\x0' + hex(ch); }) - .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return '\\x' + hex(ch); }); - } - - function describeExpectation(expectation) { - return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); - } - - function describeExpected(expected) { - var descriptions = new Array(expected.length), - i, j; - - for (i = 0; i < expected.length; i++) { - descriptions[i] = describeExpectation(expected[i]); - } - - descriptions.sort(); - - if (descriptions.length > 0) { - for (i = 1, j = 1; i < descriptions.length; i++) { - if (descriptions[i - 1] !== descriptions[i]) { - descriptions[j] = descriptions[i]; - j++; - } - } - descriptions.length = j; - } - - switch (descriptions.length) { - case 1: - return descriptions[0]; - - case 2: - return descriptions[0] + " or " + descriptions[1]; - - default: - return descriptions.slice(0, -1).join(", ") - + ", or " - + descriptions[descriptions.length - 1]; - } - } - - function describeFound(found) { - return found ? "\"" + literalEscape(found) + "\"" : "end of input"; - } - - return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; -}; - -function peg$parse(input, options) { - options = options !== void 0 ? options : {}; - - var peg$FAILED = {}, - - peg$startRuleFunctions = { expression: peg$parseexpression, argument: peg$parseargument }, - peg$startRuleFunction = peg$parseexpression, - - peg$c0 = "|", - peg$c1 = peg$literalExpectation("|", false), - peg$c2 = function(first, fn) { return fn; }, - peg$c3 = function(first, rest) { - return addMeta({ - type: 'expression', - chain: first ? [first].concat(rest) : [] - }, text(), location()); - }, - peg$c4 = peg$otherExpectation("function"), - peg$c5 = function(name, arg_list) { - return addMeta({ - type: 'function', - function: name, - arguments: arg_list - }, text(), location()); - }, - peg$c6 = "=", - peg$c7 = peg$literalExpectation("=", false), - peg$c8 = function(name, value) { - return { name, value }; - }, - peg$c9 = function(value) { - return { name: '_', value }; - }, - peg$c10 = "$", - peg$c11 = peg$literalExpectation("$", false), - peg$c12 = "{", - peg$c13 = peg$literalExpectation("{", false), - peg$c14 = "}", - peg$c15 = peg$literalExpectation("}", false), - peg$c16 = function(expression) { return expression; }, - peg$c17 = function(value) { - return addMeta(value, text(), location()); - }, - peg$c18 = function(arg) { return arg; }, - peg$c19 = function(args) { - return args.reduce((accumulator, { name, value }) => ({ - ...accumulator, - [name]: (accumulator[name] || []).concat(value) - }), {}); - }, - peg$c20 = /^[a-zA-Z0-9_\-]/, - peg$c21 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"], "_", "-"], false, false), - peg$c22 = function(name) { - return name.join(''); - }, - peg$c23 = peg$otherExpectation("literal"), - peg$c24 = "\"", - peg$c25 = peg$literalExpectation("\"", false), - peg$c26 = function(chars) { return chars.join(''); }, - peg$c27 = "'", - peg$c28 = peg$literalExpectation("'", false), - peg$c29 = function(string) { // this also matches nulls, booleans, and numbers - var result = string.join(''); - // Sort of hacky, but PEG doesn't have backtracking so - // a null/boolean/number rule is hard to read, and performs worse - if (result === 'null') return null; - if (result === 'true') return true; - if (result === 'false') return false; - if (isNaN(Number(result))) return result; // 5bears - return Number(result); - }, - peg$c30 = /^[ \t\r\n]/, - peg$c31 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false), - peg$c32 = "\\", - peg$c33 = peg$literalExpectation("\\", false), - peg$c34 = /^["'(){}<>[\]$`|= \t\n\r]/, - peg$c35 = peg$classExpectation(["\"", "'", "(", ")", "{", "}", "<", ">", "[", "]", "$", "`", "|", "=", " ", "\t", "\n", "\r"], false, false), - peg$c36 = function(sequence) { return sequence; }, - peg$c37 = /^[^"'(){}<>[\]$`|= \t\n\r]/, - peg$c38 = peg$classExpectation(["\"", "'", "(", ")", "{", "}", "<", ">", "[", "]", "$", "`", "|", "=", " ", "\t", "\n", "\r"], true, false), - peg$c39 = /^[^"]/, - peg$c40 = peg$classExpectation(["\""], true, false), - peg$c41 = /^[^']/, - peg$c42 = peg$classExpectation(["'"], true, false), - - peg$currPos = 0, - peg$savedPos = 0, - peg$posDetailsCache = [{ line: 1, column: 1 }], - peg$maxFailPos = 0, - peg$maxFailExpected = [], - peg$silentFails = 0, - - peg$result; - - if ("startRule" in options) { - if (!(options.startRule in peg$startRuleFunctions)) { - throw new Error("Can't start parsing from rule \"" + options.startRule + "\"."); - } - - peg$startRuleFunction = peg$startRuleFunctions[options.startRule]; - } - - function text() { - return input.substring(peg$savedPos, peg$currPos); - } - - function location() { - return peg$computeLocation(peg$savedPos, peg$currPos); - } - - function expected(description, location) { - location = location !== void 0 ? location : peg$computeLocation(peg$savedPos, peg$currPos) - - throw peg$buildStructuredError( - [peg$otherExpectation(description)], - input.substring(peg$savedPos, peg$currPos), - location - ); - } - - function error(message, location) { - location = location !== void 0 ? location : peg$computeLocation(peg$savedPos, peg$currPos) - - throw peg$buildSimpleError(message, location); - } - - function peg$literalExpectation(text, ignoreCase) { - return { type: "literal", text: text, ignoreCase: ignoreCase }; - } - - function peg$classExpectation(parts, inverted, ignoreCase) { - return { type: "class", parts: parts, inverted: inverted, ignoreCase: ignoreCase }; - } - - function peg$anyExpectation() { - return { type: "any" }; - } - - function peg$endExpectation() { - return { type: "end" }; - } - - function peg$otherExpectation(description) { - return { type: "other", description: description }; - } - - function peg$computePosDetails(pos) { - var details = peg$posDetailsCache[pos], p; - - if (details) { - return details; - } else { - p = pos - 1; - while (!peg$posDetailsCache[p]) { - p--; - } - - details = peg$posDetailsCache[p]; - details = { - line: details.line, - column: details.column - }; - - while (p < pos) { - if (input.charCodeAt(p) === 10) { - details.line++; - details.column = 1; - } else { - details.column++; - } - - p++; - } - - peg$posDetailsCache[pos] = details; - return details; - } - } - - function peg$computeLocation(startPos, endPos) { - var startPosDetails = peg$computePosDetails(startPos), - endPosDetails = peg$computePosDetails(endPos); - - return { - start: { - offset: startPos, - line: startPosDetails.line, - column: startPosDetails.column - }, - end: { - offset: endPos, - line: endPosDetails.line, - column: endPosDetails.column - } - }; - } - - function peg$fail(expected) { - if (peg$currPos < peg$maxFailPos) { return; } - - if (peg$currPos > peg$maxFailPos) { - peg$maxFailPos = peg$currPos; - peg$maxFailExpected = []; - } - - peg$maxFailExpected.push(expected); - } - - function peg$buildSimpleError(message, location) { - return new peg$SyntaxError(message, null, null, location); - } - - function peg$buildStructuredError(expected, found, location) { - return new peg$SyntaxError( - peg$SyntaxError.buildMessage(expected, found), - expected, - found, - location - ); - } - - function peg$parseexpression() { - var s0, s1, s2, s3, s4, s5, s6, s7; - - s0 = peg$currPos; - s1 = peg$parsespace(); - if (s1 === peg$FAILED) { - s1 = null; - } - if (s1 !== peg$FAILED) { - s2 = peg$parsefunction(); - if (s2 === peg$FAILED) { - s2 = null; - } - if (s2 !== peg$FAILED) { - s3 = []; - s4 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 124) { - s5 = peg$c0; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c1); } - } - if (s5 !== peg$FAILED) { - s6 = peg$parsespace(); - if (s6 === peg$FAILED) { - s6 = null; - } - if (s6 !== peg$FAILED) { - s7 = peg$parsefunction(); - if (s7 !== peg$FAILED) { - peg$savedPos = s4; - s5 = peg$c2(s2, s7); - s4 = s5; - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - while (s4 !== peg$FAILED) { - s3.push(s4); - s4 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 124) { - s5 = peg$c0; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c1); } - } - if (s5 !== peg$FAILED) { - s6 = peg$parsespace(); - if (s6 === peg$FAILED) { - s6 = null; - } - if (s6 !== peg$FAILED) { - s7 = peg$parsefunction(); - if (s7 !== peg$FAILED) { - peg$savedPos = s4; - s5 = peg$c2(s2, s7); - s4 = s5; - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - } - if (s3 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c3(s2, s3); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsefunction() { - var s0, s1, s2; - - peg$silentFails++; - s0 = peg$currPos; - s1 = peg$parseidentifier(); - if (s1 !== peg$FAILED) { - s2 = peg$parsearg_list(); - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c5(s1, s2); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - peg$silentFails--; - if (s0 === peg$FAILED) { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c4); } - } - - return s0; - } - - function peg$parseargument_assignment() { - var s0, s1, s2, s3, s4, s5; - - s0 = peg$currPos; - s1 = peg$parseidentifier(); - if (s1 !== peg$FAILED) { - s2 = peg$parsespace(); - if (s2 === peg$FAILED) { - s2 = null; - } - if (s2 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 61) { - s3 = peg$c6; - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c7); } - } - if (s3 !== peg$FAILED) { - s4 = peg$parsespace(); - if (s4 === peg$FAILED) { - s4 = null; - } - if (s4 !== peg$FAILED) { - s5 = peg$parseargument(); - if (s5 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c8(s1, s5); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = peg$parseargument(); - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c9(s1); - } - s0 = s1; - } - - return s0; - } - - function peg$parseargument() { - var s0, s1, s2, s3, s4; - - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 36) { - s1 = peg$c10; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c11); } - } - if (s1 === peg$FAILED) { - s1 = null; - } - if (s1 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 123) { - s2 = peg$c12; - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c13); } - } - if (s2 !== peg$FAILED) { - s3 = peg$parseexpression(); - if (s3 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 125) { - s4 = peg$c14; - peg$currPos++; - } else { - s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c15); } - } - if (s4 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c16(s3); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = peg$parseliteral(); - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c17(s1); - } - s0 = s1; - } - - return s0; - } - - function peg$parsearg_list() { - var s0, s1, s2, s3, s4; - - s0 = peg$currPos; - s1 = []; - s2 = peg$currPos; - s3 = peg$parsespace(); - if (s3 !== peg$FAILED) { - s4 = peg$parseargument_assignment(); - if (s4 !== peg$FAILED) { - peg$savedPos = s2; - s3 = peg$c18(s4); - s2 = s3; - } else { - peg$currPos = s2; - s2 = peg$FAILED; - } - } else { - peg$currPos = s2; - s2 = peg$FAILED; - } - while (s2 !== peg$FAILED) { - s1.push(s2); - s2 = peg$currPos; - s3 = peg$parsespace(); - if (s3 !== peg$FAILED) { - s4 = peg$parseargument_assignment(); - if (s4 !== peg$FAILED) { - peg$savedPos = s2; - s3 = peg$c18(s4); - s2 = s3; - } else { - peg$currPos = s2; - s2 = peg$FAILED; - } - } else { - peg$currPos = s2; - s2 = peg$FAILED; - } - } - if (s1 !== peg$FAILED) { - s2 = peg$parsespace(); - if (s2 === peg$FAILED) { - s2 = null; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c19(s1); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parseidentifier() { - var s0, s1, s2; - - s0 = peg$currPos; - s1 = []; - if (peg$c20.test(input.charAt(peg$currPos))) { - s2 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c21); } - } - if (s2 !== peg$FAILED) { - while (s2 !== peg$FAILED) { - s1.push(s2); - if (peg$c20.test(input.charAt(peg$currPos))) { - s2 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c21); } - } - } - } else { - s1 = peg$FAILED; - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c22(s1); - } - s0 = s1; - - return s0; - } - - function peg$parseliteral() { - var s0, s1; - - peg$silentFails++; - s0 = peg$parsephrase(); - if (s0 === peg$FAILED) { - s0 = peg$parseunquoted_string_or_number(); - } - peg$silentFails--; - if (s0 === peg$FAILED) { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c23); } - } - - return s0; - } - - function peg$parsephrase() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 34) { - s1 = peg$c24; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c25); } - } - if (s1 !== peg$FAILED) { - s2 = []; - s3 = peg$parsedq_char(); - while (s3 !== peg$FAILED) { - s2.push(s3); - s3 = peg$parsedq_char(); - } - if (s2 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 34) { - s3 = peg$c24; - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c25); } - } - if (s3 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c26(s2); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 39) { - s1 = peg$c27; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c28); } - } - if (s1 !== peg$FAILED) { - s2 = []; - s3 = peg$parsesq_char(); - while (s3 !== peg$FAILED) { - s2.push(s3); - s3 = peg$parsesq_char(); - } - if (s2 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 39) { - s3 = peg$c27; - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c28); } - } - if (s3 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c26(s2); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } - - return s0; - } - - function peg$parseunquoted_string_or_number() { - var s0, s1, s2; - - s0 = peg$currPos; - s1 = []; - s2 = peg$parseunquoted(); - if (s2 !== peg$FAILED) { - while (s2 !== peg$FAILED) { - s1.push(s2); - s2 = peg$parseunquoted(); - } - } else { - s1 = peg$FAILED; - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c29(s1); - } - s0 = s1; - - return s0; - } - - function peg$parsespace() { - var s0, s1; - - s0 = []; - if (peg$c30.test(input.charAt(peg$currPos))) { - s1 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c31); } - } - if (s1 !== peg$FAILED) { - while (s1 !== peg$FAILED) { - s0.push(s1); - if (peg$c30.test(input.charAt(peg$currPos))) { - s1 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c31); } - } - } - } else { - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parseunquoted() { - var s0, s1, s2; - - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 92) { - s1 = peg$c32; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c33); } - } - if (s1 !== peg$FAILED) { - if (peg$c34.test(input.charAt(peg$currPos))) { - s2 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c35); } - } - if (s2 === peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 92) { - s2 = peg$c32; - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c33); } - } - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c36(s2); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - if (peg$c37.test(input.charAt(peg$currPos))) { - s0 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c38); } - } - } - - return s0; - } - - function peg$parsedq_char() { - var s0, s1, s2; - - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 92) { - s1 = peg$c32; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c33); } - } - if (s1 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 34) { - s2 = peg$c24; - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c25); } - } - if (s2 === peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 92) { - s2 = peg$c32; - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c33); } - } - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c36(s2); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - if (peg$c39.test(input.charAt(peg$currPos))) { - s0 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c40); } - } - } - - return s0; - } - - function peg$parsesq_char() { - var s0, s1, s2; - - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 92) { - s1 = peg$c32; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c33); } - } - if (s1 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 39) { - s2 = peg$c27; - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c28); } - } - if (s2 === peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 92) { - s2 = peg$c32; - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c33); } - } - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c36(s2); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - if (peg$c41.test(input.charAt(peg$currPos))) { - s0 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c42); } - } - } - - return s0; - } - - - function addMeta(node, text, { start: { offset: start }, end: { offset: end } }) { - if (!options.addMeta) return node; - return { node, text, start, end }; - } - - - peg$result = peg$startRuleFunction(); - - if (peg$result !== peg$FAILED && peg$currPos === input.length) { - return peg$result; - } else { - if (peg$result !== peg$FAILED && peg$currPos < input.length) { - peg$fail(peg$endExpectation()); - } - - throw peg$buildStructuredError( - peg$maxFailExpected, - peg$maxFailPos < input.length ? input.charAt(peg$maxFailPos) : null, - peg$maxFailPos < input.length - ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1) - : peg$computeLocation(peg$maxFailPos, peg$maxFailPos) - ); - } -} - -module.exports = { - SyntaxError: peg$SyntaxError, - parse: peg$parse -}; diff --git a/packages/kbn-interpreter/src/common/lib/registry.d.ts b/packages/kbn-interpreter/src/common/lib/registry.d.ts deleted file mode 100644 index 766839ebf0e021..00000000000000 --- a/packages/kbn-interpreter/src/common/lib/registry.d.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export class Registry { - constructor(prop?: string); - - public wrapper(obj: ItemSpec): Item; - - public register(fn: () => ItemSpec): void; - - public toJS(): { [key: string]: any }; - - public toArray(): Item[]; - - public get(name: string): Item; - - public getProp(): string; - - public reset(): void; -} diff --git a/packages/kbn-interpreter/src/common/lib/registry.js b/packages/kbn-interpreter/src/common/lib/registry.ts similarity index 73% rename from packages/kbn-interpreter/src/common/lib/registry.js rename to packages/kbn-interpreter/src/common/lib/registry.ts index 309f92ea24f6d9..11f41ff736e96f 100644 --- a/packages/kbn-interpreter/src/common/lib/registry.js +++ b/packages/kbn-interpreter/src/common/lib/registry.ts @@ -8,49 +8,59 @@ import { clone } from 'lodash'; -export class Registry { +export class Registry { + private readonly _prop: string; + // eslint-disable-next-line @typescript-eslint/ban-types + private _indexed: Object; + constructor(prop = 'name') { if (typeof prop !== 'string') throw new Error('Registry property name must be a string'); this._prop = prop; this._indexed = new Object(); } - wrapper(obj) { + wrapper(obj: ItemSpec): Item { + // @ts-ignore return obj; } - register(fn) { + register(fn: () => ItemSpec): void { const obj = typeof fn === 'function' ? fn() : fn; + // @ts-ignore if (typeof obj !== 'object' || !obj[this._prop]) { throw new Error(`Registered functions must return an object with a ${this._prop} property`); } + // @ts-ignore this._indexed[obj[this._prop].toLowerCase()] = this.wrapper(obj); } - toJS() { + toJS(): { [key: string]: any } { return Object.keys(this._indexed).reduce((acc, key) => { + // @ts-ignore acc[key] = this.get(key); return acc; }, {}); } - toArray() { + toArray(): Item[] { return Object.keys(this._indexed).map((key) => this.get(key)); } - get(name) { + get(name: string): Item { + // @ts-ignore if (name === undefined) return null; const lowerCaseName = name.toLowerCase(); + // @ts-ignore return this._indexed[lowerCaseName] ? clone(this._indexed[lowerCaseName]) : null; } - getProp() { + getProp(): string { return this._prop; } - reset() { + reset(): void { this._indexed = new Object(); } } diff --git a/packages/kbn-interpreter/tasks/build/__fixtures__/sample.js b/packages/kbn-interpreter/tasks/build/__fixtures__/sample.js deleted file mode 100644 index f831545743f106..00000000000000 --- a/packages/kbn-interpreter/tasks/build/__fixtures__/sample.js +++ /dev/null @@ -1,3 +0,0 @@ -/* eslint-disable */ -import util from 'util'; -console.log(util.format('hello world')); diff --git a/packages/kbn-interpreter/tasks/build/cli.js b/packages/kbn-interpreter/tasks/build/cli.js deleted file mode 100644 index 82e4475b409c30..00000000000000 --- a/packages/kbn-interpreter/tasks/build/cli.js +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -const { relative } = require('path'); - -const getopts = require('getopts'); -const del = require('del'); -const supportsColor = require('supports-color'); -const { ToolingLog, withProcRunner, pickLevelFromFlags } = require('@kbn/dev-utils'); - -const { ROOT_DIR, BUILD_DIR } = require('./paths'); - -const unknownFlags = []; -const flags = getopts(process.argv, { - boolean: ['watch', 'dev', 'help', 'debug'], - unknown(name) { - unknownFlags.push(name); - }, -}); - -const log = new ToolingLog({ - level: pickLevelFromFlags(flags), - writeTo: process.stdout, -}); - -if (unknownFlags.length) { - log.error(`Unknown flag(s): ${unknownFlags.join(', ')}`); - flags.help = true; - process.exitCode = 1; -} - -if (flags.help) { - log.info(` - Simple build tool for @kbn/interpreter package - - --dev Build for development, include source maps - --watch Run in watch mode - --debug Turn on debug logging - `); - process.exit(); -} - -withProcRunner(log, async (proc) => { - log.info('Deleting old output'); - await del(BUILD_DIR); - - const cwd = ROOT_DIR; - const env = { ...process.env }; - if (supportsColor.stdout) { - env.FORCE_COLOR = 'true'; - } - - log.info(`Starting babel ${flags.watch ? ' in watch mode' : ''}`); - await Promise.all([ - proc.run('babel ', { - cmd: 'babel', - args: [ - 'src', - '--ignore', - `*.test.js`, - '--out-dir', - relative(cwd, BUILD_DIR), - '--copy-files', - ...(flags.dev ? ['--source-maps', 'inline'] : []), - ...(flags.watch ? ['--watch'] : ['--quiet']), - ], - wait: true, - env, - cwd, - }), - ]); - - log.success('Complete'); -}).catch((error) => { - log.error(error); - process.exit(1); -}); diff --git a/packages/kbn-interpreter/tasks/build/paths.js b/packages/kbn-interpreter/tasks/build/paths.js deleted file mode 100644 index a4cdba90a110ab..00000000000000 --- a/packages/kbn-interpreter/tasks/build/paths.js +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -const { resolve } = require('path'); - -exports.ROOT_DIR = resolve(__dirname, '../../'); -exports.SOURCE_DIR = resolve(exports.ROOT_DIR, 'src'); -exports.BUILD_DIR = resolve(exports.ROOT_DIR, 'target'); - -exports.BABEL_PRESET_PATH = require.resolve('@kbn/babel-preset/webpack_preset'); diff --git a/packages/kbn-interpreter/tsconfig.json b/packages/kbn-interpreter/tsconfig.json index 3b81bbb118a55e..011ed877146e83 100644 --- a/packages/kbn-interpreter/tsconfig.json +++ b/packages/kbn-interpreter/tsconfig.json @@ -1,7 +1,21 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "tsBuildInfoFile": "../../build/tsbuildinfo/packages/kbn-interpreter" + "allowJs": true, + "incremental": true, + "outDir": "./target", + "declaration": true, + "declarationMap": true, + "rootDir": "src", + "sourceMap": true, + "sourceRoot": "../../../../packages/kbn-interpreter/src", + "stripInternal": true, + "types": [ + "jest", + "node" + ] }, - "include": ["index.d.ts", "src/**/*.d.ts"] + "include": [ + "src/**/*", + ] } diff --git a/src/plugins/charts/public/services/palettes/types.ts b/src/plugins/charts/public/services/palettes/types.ts index 6f13f621783640..7a870504270d70 100644 --- a/src/plugins/charts/public/services/palettes/types.ts +++ b/src/plugins/charts/public/services/palettes/types.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { Ast } from '@kbn/interpreter/common'; +import { ExpressionAstExpression } from '../../../../expressions/common/ast'; /** * Information about a series in a chart used to determine its color. @@ -78,7 +78,7 @@ export interface PaletteDefinition { * This function should be used to pass the palette to the expression function applying color and other styles * @param state The internal state of the palette */ - toExpression: (state?: T) => Ast; + toExpression: (state?: T) => ExpressionAstExpression; /** * Color a series according to the internal rules of the palette. * @param series The current series along with its ancestors. diff --git a/x-pack/package.json b/x-pack/package.json index 0d2a170d83170d..01571cbb823fd6 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -30,7 +30,6 @@ "@kbn/test": "link:../packages/kbn-test" }, "dependencies": { - "@kbn/interpreter": "link:../packages/kbn-interpreter", "@kbn/ui-framework": "link:../packages/kbn-ui-framework" } } \ No newline at end of file diff --git a/x-pack/plugins/canvas/public/functions/to.ts b/x-pack/plugins/canvas/public/functions/to.ts index dcb1972c6bdfbc..907d1f3d3a635d 100644 --- a/x-pack/plugins/canvas/public/functions/to.ts +++ b/x-pack/plugins/canvas/public/functions/to.ts @@ -5,7 +5,6 @@ * 2.0. */ -// @ts-expect-error untyped Elastic library import { castProvider } from '@kbn/interpreter/common'; import { ExpressionFunctionDefinition } from 'src/plugins/expressions/public'; import { getFunctionHelp, getFunctionErrors } from '../../i18n'; diff --git a/x-pack/plugins/canvas/public/registries.ts b/x-pack/plugins/canvas/public/registries.ts index 8484380830422e..1ad7fa6905c22c 100644 --- a/x-pack/plugins/canvas/public/registries.ts +++ b/x-pack/plugins/canvas/public/registries.ts @@ -5,7 +5,6 @@ * 2.0. */ -// @ts-expect-error untyped module import { addRegistries, register } from '@kbn/interpreter/common'; // @ts-expect-error untyped local import { elementsRegistry } from './lib/elements_registry'; diff --git a/x-pack/plugins/canvas/public/state/selectors/workpad.ts b/x-pack/plugins/canvas/public/state/selectors/workpad.ts index 43e4bc6bdb64f2..e1cebeb65bd219 100644 --- a/x-pack/plugins/canvas/public/state/selectors/workpad.ts +++ b/x-pack/plugins/canvas/public/state/selectors/workpad.ts @@ -6,7 +6,6 @@ */ import { get, omit } from 'lodash'; -// @ts-expect-error untyped local import { safeElementFromExpression, fromExpression } from '@kbn/interpreter/common'; import { append } from '../../lib/modify_path'; import { getAssets } from './assets'; diff --git a/yarn.lock b/yarn.lock index bcb5e607a44ee1..153309ad56f191 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2668,7 +2668,7 @@ version "0.0.0" uid "" -"@kbn/interpreter@link:packages/kbn-interpreter": +"@kbn/interpreter@link:bazel-bin/packages/kbn-interpreter": version "0.0.0" uid "" From 494a841a59c4be07df526dcf9e9b9029c32edf77 Mon Sep 17 00:00:00 2001 From: John Dorlus Date: Tue, 22 Jun 2021 10:59:47 -0400 Subject: [PATCH 33/57] Unskipping test to test on Firefox. (#102839) * Unskipping test to test on Firefox. * Added .only to only run those tests * Reenabled test after troubleshooting tests. No failures on FF. --- x-pack/test/functional/apps/grok_debugger/grok_debugger.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/x-pack/test/functional/apps/grok_debugger/grok_debugger.js b/x-pack/test/functional/apps/grok_debugger/grok_debugger.js index 68cd5820e2a32f..0162b660a14081 100644 --- a/x-pack/test/functional/apps/grok_debugger/grok_debugger.js +++ b/x-pack/test/functional/apps/grok_debugger/grok_debugger.js @@ -11,8 +11,7 @@ export default function ({ getService, getPageObjects }) { const esArchiver = getService('esArchiver'); const PageObjects = getPageObjects(['grokDebugger']); - // FLAKY: https://github.com/elastic/kibana/issues/84440 - describe.skip('grok debugger app', function () { + describe('grok debugger app', function () { this.tags('includeFirefox'); before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/empty_kibana'); From 6cc3b84d6fa97fb6f2db24d30ab84ac7b35bc3ce Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Tue, 22 Jun 2021 17:10:37 +0200 Subject: [PATCH 34/57] [Fleet] Add assets tab (#102517) * very wip * added new assets screen * added routes to new assets view on the package details view * Finished styling the assets page layout, need to work on adding links * rather use EuiHorizontalRule * only show the assets tab if installed * Added hacky version of linking to assets. * added comment about deprecation of current linking functionality * added an initial version of the success toast with a link to the agent flyout * First iteration of end-to-end UX working. Need to add a lot of tests! * fixed navigation bug and added a comment * added a lot more padding to bottom of form * restructured code for clarity, updated deprecation comments and moved relevant code closer together * added a longer form comment about the origin policyId * added logic for handling load error * refactor assets accordions out of assets page component * slightly larger text in badge * added some basic jest test for view data step in enrollment flyout * adjusted sizing of numbers in badges again, EuiText does not know about size="l" * updated size limits for fleet * updated styling and layout of assets accordion based on original designs * remove unused EuiTitle Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- packages/kbn-optimizer/limits.yml | 2 +- .../plugins/fleet/common/types/models/epm.ts | 2 +- .../create_package_policy_page/index.tsx | 91 ++++++++-- .../package_policies/no_package_policies.tsx | 12 +- .../epm/screens/detail/assets/assets.tsx | 138 +++++++++++++++ .../detail/assets/assets_accordion.tsx | 92 ++++++++++ .../epm/screens/detail/assets/constants.ts | 16 ++ .../epm/screens/detail/assets/index.ts | 7 + .../epm/screens/detail/assets/types.ts | 20 +++ .../sections/epm/screens/detail/index.tsx | 22 +++ .../detail/policies/package_policies.tsx | 73 ++++++-- .../agent_enrollment_flyout.test.mocks.ts | 1 + .../agent_enrollment_flyout.test.tsx | 42 ++++- .../agent_enrollment_flyout/index.tsx | 13 +- .../managed_instructions.tsx | 167 ++++++++++-------- .../agent_enrollment_flyout/steps.tsx | 13 ++ .../agent_enrollment_flyout/types.ts | 12 +- .../package_policy_actions_menu.tsx | 9 +- .../fleet/public/constants/page_paths.ts | 13 +- x-pack/plugins/fleet/public/hooks/index.ts | 2 +- .../fleet/public/hooks/use_kibana_link.ts | 54 +++++- 21 files changed, 676 insertions(+), 125 deletions(-) create mode 100644 x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/assets.tsx create mode 100644 x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/assets_accordion.tsx create mode 100644 x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/constants.ts create mode 100644 x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/index.ts create mode 100644 x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/types.ts diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 9adc075a7983f8..f9127e4629f43e 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -34,7 +34,7 @@ pageLoadAssetSize: indexManagement: 140608 indexPatternManagement: 28222 infra: 184320 - fleet: 450005 + fleet: 465774 ingestPipelines: 58003 inputControlVis: 172675 inspector: 148711 diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index 83875801300d32..aece6580831960 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -43,7 +43,7 @@ export type InstallSource = 'registry' | 'upload'; export type EpmPackageInstallStatus = 'installed' | 'installing'; -export type DetailViewPanelName = 'overview' | 'policies' | 'settings' | 'custom'; +export type DetailViewPanelName = 'overview' | 'policies' | 'assets' | 'settings' | 'custom'; export type ServiceName = 'kibana' | 'elasticsearch'; export type AgentAssetType = typeof agentAssetTypes; export type DocAssetType = 'doc' | 'notice'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx index 75fc06c1a44945..b3b0d6ed51cb4d 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx @@ -19,10 +19,12 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer, + EuiLink, } from '@elastic/eui'; import type { EuiStepProps } from '@elastic/eui/src/components/steps/step'; import type { ApplicationStart } from 'kibana/public'; +import { toMountPoint } from '../../../../../../../../../src/plugins/kibana_react/public'; import type { AgentPolicy, PackageInfo, @@ -60,7 +62,7 @@ const StepsWithLessPadding = styled(EuiSteps)` `; const CustomEuiBottomBar = styled(EuiBottomBar)` - // Set a relatively _low_ z-index value here to account for EuiComboBox popover that might appear under the bottom bar + /* A relatively _low_ z-index value here to account for EuiComboBox popover that might appear under the bottom bar */ z-index: 50; `; @@ -84,11 +86,26 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { const history = useHistory(); const handleNavigateTo = useNavigateToCallback(); const routeState = useIntraAppState(); - const from: CreatePackagePolicyFrom = 'policyId' in params ? 'policy' : 'package'; const { search } = useLocation(); const queryParams = useMemo(() => new URLSearchParams(search), [search]); - const policyId = useMemo(() => queryParams.get('policyId') ?? undefined, [queryParams]); + const queryParamsPolicyId = useMemo(() => queryParams.get('policyId') ?? undefined, [ + queryParams, + ]); + + /** + * Please note: policyId can come from one of two sources. The URL param (in the URL path) or + * in the query params (?policyId=foo). + * + * Either way, we take this as an indication that a user is "coming from" the fleet policy UI + * since we link them out to packages (a.k.a. integrations) UI when choosing a new package. It is + * no longer possible to choose a package directly in the create package form. + * + * We may want to deprecate the ability to pass in policyId from URL params since there is no package + * creation possible if a user has not chosen one from the packages UI. + */ + const from: CreatePackagePolicyFrom = + 'policyId' in params || queryParamsPolicyId ? 'policy' : 'package'; // Agent policy and package info states const [agentPolicy, setAgentPolicy] = useState(); @@ -280,6 +297,13 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { ); } + const fromPolicyWithoutAgentsAssigned = from === 'policy' && agentPolicy && agentCount === 0; + + const fromPackageWithoutAgentsAssigned = + from === 'package' && packageInfo && agentPolicy && agentCount === 0; + + const hasAgentsAssigned = agentCount && agentPolicy; + notifications.toasts.addSuccess({ title: i18n.translate('xpack.fleet.createPackagePolicy.addedNotificationTitle', { defaultMessage: `'{packagePolicyName}' integration added.`, @@ -287,22 +311,47 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { packagePolicyName: packagePolicy.name, }, }), - text: - agentCount && agentPolicy - ? i18n.translate('xpack.fleet.createPackagePolicy.addedNotificationMessage', { - defaultMessage: `Fleet will deploy updates to all agents that use the '{agentPolicyName}' policy.`, - values: { - agentPolicyName: agentPolicy.name, - }, - }) - : (params as AddToPolicyParams)?.policyId && agentPolicy && agentCount === 0 - ? i18n.translate('xpack.fleet.createPackagePolicy.addAgentNextNotification', { + text: fromPolicyWithoutAgentsAssigned + ? i18n.translate( + 'xpack.fleet.createPackagePolicy.policyContextAddAgentNextNotificationMessage', + { defaultMessage: `The policy has been updated. Add an agent to the '{agentPolicyName}' policy to deploy this policy.`, values: { - agentPolicyName: agentPolicy.name, + agentPolicyName: agentPolicy!.name, }, - }) - : undefined, + } + ) + : fromPackageWithoutAgentsAssigned + ? toMountPoint( + // To render the link below we need to mount this JSX in the success toast + + {i18n.translate( + 'xpack.fleet.createPackagePolicy.integrationsContextAddAgentLinkMessage', + { defaultMessage: 'add an agent' } + )} + + ), + }} + /> + ) + : hasAgentsAssigned + ? i18n.translate('xpack.fleet.createPackagePolicy.addedNotificationMessage', { + defaultMessage: `Fleet will deploy updates to all agents that use the '{agentPolicyName}' policy.`, + values: { + agentPolicyName: agentPolicy!.name, + }, + }) + : undefined, 'data-test-subj': 'packagePolicyCreateSuccessToast', }); } else { @@ -312,6 +361,9 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { setFormState('VALID'); } }, [ + getHref, + from, + packageInfo, agentCount, agentPolicy, formState, @@ -353,13 +405,13 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { ), - [params, updatePackageInfo, agentPolicy, updateAgentPolicy, policyId] + [params, updatePackageInfo, agentPolicy, updateAgentPolicy, queryParamsPolicyId] ); const ExtensionView = useUIExtension(packagePolicy.package?.name ?? '', 'package-policy-create'); @@ -455,7 +507,8 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { )} - + + diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/package_policies/no_package_policies.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/package_policies/no_package_policies.tsx index 54adbd78ab75aa..39340a21d349bd 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/package_policies/no_package_policies.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/package_policies/no_package_policies.tsx @@ -9,10 +9,11 @@ import React, { memo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; -import { useCapabilities, useLink } from '../../../../../hooks'; +import { useCapabilities, useStartServices } from '../../../../../hooks'; +import { pagePathGetters, INTEGRATIONS_PLUGIN_ID } from '../../../../../constants'; export const NoPackagePolicies = memo<{ policyId: string }>(({ policyId }) => { - const { getHref } = useLink(); + const { application } = useStartServices(); const hasWriteCapabilities = useCapabilities().write; return ( @@ -36,7 +37,12 @@ export const NoPackagePolicies = memo<{ policyId: string }>(({ policyId }) => { + application.navigateToApp(INTEGRATIONS_PLUGIN_ID, { + path: `#${pagePathGetters.integrations_all()[1]}`, + state: { forAgentPolicyId: policyId }, + }) + } > { + const { name, version } = packageInfo; + const { + savedObjects: { client: savedObjectsClient }, + } = useStartServices(); + + const { getPath } = useLink(); + const getPackageInstallStatus = useGetPackageInstallStatus(); + const packageInstallStatus = getPackageInstallStatus(packageInfo.name); + + const [assetSavedObjects, setAssetsSavedObjects] = useState(); + const [fetchError, setFetchError] = useState(); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchAssetSavedObjects = async () => { + if ('savedObject' in packageInfo) { + const { + savedObject: { attributes: packageAttributes }, + } = packageInfo; + + if ( + !packageAttributes.installed_kibana || + packageAttributes.installed_kibana.length === 0 + ) { + setIsLoading(false); + return; + } + + try { + const objectsToGet = packageAttributes.installed_kibana.map(({ id, type }) => ({ + id, + type, + })); + const { savedObjects } = await savedObjectsClient.bulkGet(objectsToGet); + setAssetsSavedObjects(savedObjects as AssetSavedObject[]); + } catch (e) { + setFetchError(e); + } finally { + setIsLoading(false); + } + } else { + setIsLoading(false); + } + }; + fetchAssetSavedObjects(); + }, [savedObjectsClient, packageInfo]); + + // if they arrive at this page and the package is not installed, send them to overview + // this happens if they arrive with a direct url or they uninstall while on this tab + if (packageInstallStatus.status !== InstallStatus.installed) { + return ( + + ); + } + + let content: JSX.Element | Array; + + if (isLoading) { + content = ; + } else if (fetchError) { + content = ( + + } + error={fetchError} + /> + ); + } else if (assetSavedObjects === undefined) { + content = ( + +

+ +

+
+ ); + } else { + content = allowedAssetTypes.map((assetType) => { + const sectionAssetSavedObjects = assetSavedObjects.filter((so) => so.type === assetType); + + if (!sectionAssetSavedObjects.length) { + return null; + } + + return ( + <> + + + + ); + }); + } + + return ( + + + {content} + + ); +}; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/assets_accordion.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/assets_accordion.tsx new file mode 100644 index 00000000000000..abfdd88d271622 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/assets_accordion.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { FunctionComponent } from 'react'; + +import { + EuiAccordion, + EuiFlexGroup, + EuiFlexItem, + EuiSplitPanel, + EuiSpacer, + EuiText, + EuiLink, + EuiHorizontalRule, + EuiNotificationBadge, +} from '@elastic/eui'; + +import { AssetTitleMap } from '../../../../../constants'; + +import { getHrefToObjectInKibanaApp, useStartServices } from '../../../../../hooks'; + +import type { AllowedAssetType, AssetSavedObject } from './types'; + +interface Props { + type: AllowedAssetType; + savedObjects: AssetSavedObject[]; +} + +export const AssetsAccordion: FunctionComponent = ({ savedObjects, type }) => { + const { http } = useStartServices(); + return ( + + + +

{AssetTitleMap[type]}

+
+
+ + +

{savedObjects.length}

+
+
+
+ } + id={type} + > + <> + + + {savedObjects.map(({ id, attributes: { title, description } }, idx) => { + const pathToObjectInApp = getHrefToObjectInKibanaApp({ + http, + id, + type, + }); + return ( + <> + + +

+ {pathToObjectInApp ? ( + {title} + ) : ( + title + )} +

+
+ {description && ( + <> + + +

{description}

+
+ + )} +
+ {idx + 1 < savedObjects.length && } + + ); + })} +
+ + + ); +}; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/constants.ts b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/constants.ts new file mode 100644 index 00000000000000..d6d88f7935eb44 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/constants.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { KibanaAssetType } from '../../../../../types'; + +import type { AllowedAssetTypes } from './types'; + +export const allowedAssetTypes: AllowedAssetTypes = [ + KibanaAssetType.dashboard, + KibanaAssetType.search, + KibanaAssetType.visualization, +]; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/index.ts b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/index.ts new file mode 100644 index 00000000000000..ceb030b7ce02eb --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +export { AssetsPage } from './assets'; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/types.ts b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/types.ts new file mode 100644 index 00000000000000..21efd1cd562e80 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/types.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SimpleSavedObject } from 'src/core/public'; + +import type { KibanaAssetType } from '../../../../../types'; + +export type AssetSavedObject = SimpleSavedObject<{ title: string; description?: string }>; + +export type AllowedAssetTypes = [ + KibanaAssetType.dashboard, + KibanaAssetType.search, + KibanaAssetType.visualization +]; + +export type AllowedAssetType = AllowedAssetTypes[number]; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx index 99a29a8194f9b0..cf6007026afebc 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx @@ -56,6 +56,7 @@ import { WithHeaderLayout } from '../../../../layouts'; import { RELEASE_BADGE_DESCRIPTION, RELEASE_BADGE_LABEL } from '../../components/release_badge'; import { IntegrationAgentPolicyCount, UpdateIcon, IconPanel, LoadingIconPanel } from './components'; +import { AssetsPage } from './assets'; import { OverviewPage } from './overview'; import { PackagePoliciesPage } from './policies'; import { SettingsPage } from './settings'; @@ -408,6 +409,24 @@ export function Detail() { }); } + if (packageInstallStatus === InstallStatus.installed && packageInfo.assets) { + tabs.push({ + id: 'assets', + name: ( + + ), + isSelected: panel === 'assets', + 'data-test-subj': `tab-assets`, + href: getHref('integration_details_assets', { + pkgkey: packageInfoKey, + ...(integration ? { integration } : {}), + }), + }); + } + tabs.push({ id: 'settings', name: ( @@ -476,6 +495,9 @@ export function Detail() { + + + diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx index 7da7328fdebbca..c672abeb1c9036 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx @@ -4,9 +4,9 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { stringify, parse } from 'query-string'; import React, { memo, useCallback, useMemo, useState } from 'react'; -import { Redirect } from 'react-router-dom'; +import { Redirect, useLocation, useHistory } from 'react-router-dom'; import type { CriteriaWithPagination, EuiTableFieldDataColumnType } from '@elastic/eui'; import { EuiButtonIcon, @@ -15,6 +15,9 @@ import { EuiFlexGroup, EuiFlexItem, EuiToolTip, + EuiText, + EuiButton, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedRelative, FormattedMessage } from '@kbn/i18n/react'; @@ -66,8 +69,16 @@ interface PackagePoliciesPanelProps { version: string; } export const PackagePoliciesPage = ({ name, version }: PackagePoliciesPanelProps) => { - const [flyoutOpenForPolicyId, setFlyoutOpenForPolicyId] = useState(null); - const { getPath } = useLink(); + const { search } = useLocation(); + const history = useHistory(); + const queryParams = useMemo(() => new URLSearchParams(search), [search]); + const agentPolicyIdFromParams = useMemo(() => queryParams.get('addAgentToPolicyId'), [ + queryParams, + ]); + const [flyoutOpenForPolicyId, setFlyoutOpenForPolicyId] = useState( + agentPolicyIdFromParams + ); + const { getPath, getHref } = useLink(); const getPackageInstallStatus = useGetPackageInstallStatus(); const packageInstallStatus = getPackageInstallStatus(name); const { pagination, pageSizeOptions, setPagination } = useUrlPagination(); @@ -87,6 +98,36 @@ export const PackagePoliciesPage = ({ name, version }: PackagePoliciesPanelProps [setPagination] ); + const renderViewDataStepContent = useCallback( + () => ( + <> + + + {i18n.translate( + 'xpack.fleet.epm.agentEnrollment.viewDataDescription.pleaseNoteLabel', + { defaultMessage: 'Please note' } + )} +
+ ), + }} + /> + + + + {i18n.translate('xpack.fleet.epm.agentEnrollment.viewDataAssetsLabel', { + defaultMessage: 'View assets', + })} + + + ), + [name, version, getHref] + ); + const columns: Array> = useMemo( () => [ { @@ -186,12 +227,16 @@ export const PackagePoliciesPage = ({ name, version }: PackagePoliciesPanelProps align: 'right', render({ agentPolicy, packagePolicy }) { return ( - + ); }, }, ], - [] + [renderViewDataStepContent] ); const noItemsMessage = useMemo(() => { @@ -236,14 +281,18 @@ export const PackagePoliciesPage = ({ name, version }: PackagePoliciesPanelProps /> - {flyoutOpenForPolicyId && ( + {flyoutOpenForPolicyId && !isLoading && ( setFlyoutOpenForPolicyId(null)} - agentPolicies={ - data?.items - .filter(({ agentPolicy }) => agentPolicy.id === flyoutOpenForPolicyId) - .map(({ agentPolicy }) => agentPolicy) ?? [] + onClose={() => { + setFlyoutOpenForPolicyId(null); + const { addAgentToPolicyId, ...rest } = parse(search); + history.replace({ search: stringify(rest) }); + }} + agentPolicy={ + data?.items.find(({ agentPolicy }) => agentPolicy.id === flyoutOpenForPolicyId) + ?.agentPolicy } + viewDataStepContent={renderViewDataStepContent()} /> )} diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.mocks.ts b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.mocks.ts index f1055e7e2583ee..fcf10785664986 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.mocks.ts +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.mocks.ts @@ -37,6 +37,7 @@ jest.mock('./steps', () => { ...module, AgentPolicySelectionStep: jest.fn(), AgentEnrollmentKeySelectionStep: jest.fn(), + ViewDataStep: jest.fn(), }; }); diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.tsx index db9245b11b0f99..65118044e98c56 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.tsx @@ -21,7 +21,7 @@ import { FleetStatusProvider, ConfigContext } from '../../hooks'; import { useFleetServerInstructions } from '../../applications/fleet/sections/agents/agent_requirements_page'; -import { AgentEnrollmentKeySelectionStep, AgentPolicySelectionStep } from './steps'; +import { AgentEnrollmentKeySelectionStep, AgentPolicySelectionStep, ViewDataStep } from './steps'; import type { Props } from '.'; import { AgentEnrollmentFlyout } from '.'; @@ -128,6 +128,46 @@ describe('', () => { expect(AgentEnrollmentKeySelectionStep).toHaveBeenCalled(); }); }); + + describe('"View data" extension point', () => { + it('calls the "View data" step when UI extension is provided', async () => { + jest.clearAllMocks(); + await act(async () => { + testBed = await setup({ + agentPolicies: [], + onClose: jest.fn(), + viewDataStepContent:
, + }); + testBed.component.update(); + }); + const { exists, actions } = testBed; + expect(exists('agentEnrollmentFlyout')).toBe(true); + expect(ViewDataStep).toHaveBeenCalled(); + + jest.clearAllMocks(); + actions.goToStandaloneTab(); + expect(ViewDataStep).not.toHaveBeenCalled(); + }); + + it('does not call the "View data" step when UI extension is not provided', async () => { + jest.clearAllMocks(); + await act(async () => { + testBed = await setup({ + agentPolicies: [], + onClose: jest.fn(), + viewDataStepContent: undefined, + }); + testBed.component.update(); + }); + const { exists, actions } = testBed; + expect(exists('agentEnrollmentFlyout')).toBe(true); + expect(ViewDataStep).not.toHaveBeenCalled(); + + jest.clearAllMocks(); + actions.goToStandaloneTab(); + expect(ViewDataStep).not.toHaveBeenCalled(); + }); + }); }); describe('standalone instructions', () => { diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/index.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/index.tsx index b91af80691033e..58362d85e2fb32 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/index.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/index.tsx @@ -42,6 +42,7 @@ export const AgentEnrollmentFlyout: React.FunctionComponent = ({ onClose, agentPolicy, agentPolicies, + viewDataStepContent, }) => { const [mode, setMode] = useState<'managed' | 'standalone'>('managed'); @@ -109,9 +110,17 @@ export const AgentEnrollmentFlyout: React.FunctionComponent = ({ } > {fleetServerHosts.length === 0 && mode === 'managed' ? null : mode === 'managed' ? ( - + ) : ( - + )} diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/managed_instructions.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/managed_instructions.tsx index e7045173f1257e..919f0c3052db91 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/managed_instructions.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/managed_instructions.tsx @@ -21,7 +21,12 @@ import { useFleetServerInstructions, } from '../../applications/fleet/sections/agents/agent_requirements_page'; -import { DownloadStep, AgentPolicySelectionStep, AgentEnrollmentKeySelectionStep } from './steps'; +import { + DownloadStep, + AgentPolicySelectionStep, + AgentEnrollmentKeySelectionStep, + ViewDataStep, +} from './steps'; import type { BaseProps } from './types'; type Props = BaseProps; @@ -53,83 +58,91 @@ const FleetServerMissingRequirements = () => { return ; }; -export const ManagedInstructions = React.memo(({ agentPolicy, agentPolicies }) => { - const fleetStatus = useFleetStatus(); +export const ManagedInstructions = React.memo( + ({ agentPolicy, agentPolicies, viewDataStepContent }) => { + const fleetStatus = useFleetStatus(); - const [selectedAPIKeyId, setSelectedAPIKeyId] = useState(); - const [isFleetServerPolicySelected, setIsFleetServerPolicySelected] = useState(false); + const [selectedAPIKeyId, setSelectedAPIKeyId] = useState(); + const [isFleetServerPolicySelected, setIsFleetServerPolicySelected] = useState(false); - const apiKey = useGetOneEnrollmentAPIKey(selectedAPIKeyId); - const settings = useGetSettings(); - const fleetServerInstructions = useFleetServerInstructions(apiKey?.data?.item?.policy_id); + const apiKey = useGetOneEnrollmentAPIKey(selectedAPIKeyId); + const settings = useGetSettings(); + const fleetServerInstructions = useFleetServerInstructions(apiKey?.data?.item?.policy_id); - const steps = useMemo(() => { - const { - serviceToken, - getServiceToken, - isLoadingServiceToken, - installCommand, - platform, - setPlatform, - } = fleetServerInstructions; - const fleetServerHosts = settings.data?.item?.fleet_server_hosts || []; - const baseSteps: EuiContainedStepProps[] = [ - DownloadStep(), - !agentPolicy - ? AgentPolicySelectionStep({ - agentPolicies, - setSelectedAPIKeyId, - setIsFleetServerPolicySelected, - }) - : AgentEnrollmentKeySelectionStep({ agentPolicy, setSelectedAPIKeyId }), - ]; - if (isFleetServerPolicySelected) { - baseSteps.push( - ...[ - ServiceTokenStep({ serviceToken, getServiceToken, isLoadingServiceToken }), - FleetServerCommandStep({ serviceToken, installCommand, platform, setPlatform }), - ] - ); - } else { - baseSteps.push({ - title: i18n.translate('xpack.fleet.agentEnrollment.stepEnrollAndRunAgentTitle', { - defaultMessage: 'Enroll and start the Elastic Agent', - }), - children: selectedAPIKeyId && apiKey.data && ( - - ), - }); - } - return baseSteps; - }, [ - agentPolicy, - agentPolicies, - selectedAPIKeyId, - apiKey.data, - isFleetServerPolicySelected, - settings.data?.item?.fleet_server_hosts, - fleetServerInstructions, - ]); + const steps = useMemo(() => { + const { + serviceToken, + getServiceToken, + isLoadingServiceToken, + installCommand, + platform, + setPlatform, + } = fleetServerInstructions; + const fleetServerHosts = settings.data?.item?.fleet_server_hosts || []; + const baseSteps: EuiContainedStepProps[] = [ + DownloadStep(), + !agentPolicy + ? AgentPolicySelectionStep({ + agentPolicies, + setSelectedAPIKeyId, + setIsFleetServerPolicySelected, + }) + : AgentEnrollmentKeySelectionStep({ agentPolicy, setSelectedAPIKeyId }), + ]; + if (isFleetServerPolicySelected) { + baseSteps.push( + ...[ + ServiceTokenStep({ serviceToken, getServiceToken, isLoadingServiceToken }), + FleetServerCommandStep({ serviceToken, installCommand, platform, setPlatform }), + ] + ); + } else { + baseSteps.push({ + title: i18n.translate('xpack.fleet.agentEnrollment.stepEnrollAndRunAgentTitle', { + defaultMessage: 'Enroll and start the Elastic Agent', + }), + children: selectedAPIKeyId && apiKey.data && ( + + ), + }); + } - return ( - <> - {fleetStatus.isReady ? ( - <> - - - - - - - ) : fleetStatus.missingRequirements?.length === 1 && - fleetStatus.missingRequirements[0] === 'fleet_server' ? ( - - ) : ( - - )} - - ); -}); + if (viewDataStepContent) { + baseSteps.push(ViewDataStep(viewDataStepContent)); + } + + return baseSteps; + }, [ + agentPolicy, + agentPolicies, + selectedAPIKeyId, + apiKey.data, + isFleetServerPolicySelected, + settings.data?.item?.fleet_server_hosts, + fleetServerInstructions, + viewDataStepContent, + ]); + + return ( + <> + {fleetStatus.isReady ? ( + <> + + + + + + + ) : fleetStatus.missingRequirements?.length === 1 && + fleetStatus.missingRequirements[0] === 'fleet_server' ? ( + + ) : ( + + )} + + ); + } +); diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps.tsx index ea4fa626afbb6a..03cff88e639695 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps.tsx @@ -138,3 +138,16 @@ export const AgentEnrollmentKeySelectionStep = ({ ), }; }; + +/** + * Send users to assets installed by the package in Kibana so they can + * view their data. + */ +export const ViewDataStep = (content: JSX.Element) => { + return { + title: i18n.translate('xpack.fleet.agentEnrollment.stepViewDataTitle', { + defaultMessage: 'View your data', + }), + children: content, + }; +}; diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/types.ts b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/types.ts index b9bcf8fb3e4b27..e0c5b040a61fb2 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/types.ts +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/types.ts @@ -9,12 +9,20 @@ import type { AgentPolicy } from '../../types'; export interface BaseProps { /** - * The user selected policy to be used + * The user selected policy to be used. If this value is `undefined` a value must be provided for `agentPolicies`. */ agentPolicy?: AgentPolicy; /** - * A selection of policies for the user to choose from, will be ignored if `agentPolicy` has been provided + * A selection of policies for the user to choose from, will be ignored if `agentPolicy` has been provided. + * + * If this value is `undefined` a value must be provided for `agentPolicy`. */ agentPolicies?: AgentPolicy[]; + + /** + * There is a step in the agent enrollment process that allows users to see the data from an integration represented in the UI + * in some way. This is an area for consumers to render a button and text explaining how data can be viewed. + */ + viewDataStepContent?: JSX.Element; } diff --git a/x-pack/plugins/fleet/public/components/package_policy_actions_menu.tsx b/x-pack/plugins/fleet/public/components/package_policy_actions_menu.tsx index 03bf2095f7f3e0..1f64de27fce392 100644 --- a/x-pack/plugins/fleet/public/components/package_policy_actions_menu.tsx +++ b/x-pack/plugins/fleet/public/components/package_policy_actions_menu.tsx @@ -21,7 +21,8 @@ import { PackagePolicyDeleteProvider } from './package_policy_delete_provider'; export const PackagePolicyActionsMenu: React.FunctionComponent<{ agentPolicy: AgentPolicy; packagePolicy: PackagePolicy; -}> = ({ agentPolicy, packagePolicy }) => { + viewDataStepContent?: JSX.Element; +}> = ({ agentPolicy, packagePolicy, viewDataStepContent }) => { const [isEnrollmentFlyoutOpen, setIsEnrollmentFlyoutOpen] = useState(false); const { getHref } = useLink(); const hasWriteCapabilities = useCapabilities().write; @@ -103,7 +104,11 @@ export const PackagePolicyActionsMenu: React.FunctionComponent<{ <> {isEnrollmentFlyoutOpen && ( - + )} diff --git a/x-pack/plugins/fleet/public/constants/page_paths.ts b/x-pack/plugins/fleet/public/constants/page_paths.ts index 326cfd804bd570..1688a396cd5a15 100644 --- a/x-pack/plugins/fleet/public/constants/page_paths.ts +++ b/x-pack/plugins/fleet/public/constants/page_paths.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { stringify } from 'query-string'; + export type StaticPage = | 'base' | 'overview' @@ -19,6 +21,7 @@ export type StaticPage = export type DynamicPage = | 'integration_details_overview' | 'integration_details_policies' + | 'integration_details_assets' | 'integration_details_settings' | 'integration_details_custom' | 'integration_policy_edit' @@ -66,6 +69,7 @@ export const INTEGRATIONS_ROUTING_PATHS = { integration_details: '/detail/:pkgkey/:panel?', integration_details_overview: '/detail/:pkgkey/overview', integration_details_policies: '/detail/:pkgkey/policies', + integration_details_assets: '/detail/:pkgkey/assets', integration_details_settings: '/detail/:pkgkey/settings', integration_details_custom: '/detail/:pkgkey/custom', integration_policy_edit: '/edit-integration/:packagePolicyId', @@ -86,9 +90,13 @@ export const pagePathGetters: { INTEGRATIONS_BASE_PATH, `/detail/${pkgkey}/overview${integration ? `?integration=${integration}` : ''}`, ], - integration_details_policies: ({ pkgkey, integration }) => [ + integration_details_policies: ({ pkgkey, integration, addAgentToPolicyId }) => { + const qs = stringify({ integration, addAgentToPolicyId }); + return [INTEGRATIONS_BASE_PATH, `/detail/${pkgkey}/policies${qs ? `?${qs}` : ''}`]; + }, + integration_details_assets: ({ pkgkey, integration }) => [ INTEGRATIONS_BASE_PATH, - `/detail/${pkgkey}/policies${integration ? `?integration=${integration}` : ''}`, + `/detail/${pkgkey}/assets${integration ? `?integration=${integration}` : ''}`, ], integration_details_settings: ({ pkgkey, integration }) => [ INTEGRATIONS_BASE_PATH, @@ -108,6 +116,7 @@ export const pagePathGetters: { FLEET_BASE_PATH, `/policies/${policyId}${tabId ? `/${tabId}` : ''}`, ], + // TODO: This might need to be removed because we do not have a way to pick an integration in line anymore add_integration_from_policy: ({ policyId }) => [ FLEET_BASE_PATH, `/policies/${policyId}/add-integration`, diff --git a/x-pack/plugins/fleet/public/hooks/index.ts b/x-pack/plugins/fleet/public/hooks/index.ts index 9f41e5c7cc92b5..a00c0c5dacf111 100644 --- a/x-pack/plugins/fleet/public/hooks/index.ts +++ b/x-pack/plugins/fleet/public/hooks/index.ts @@ -11,7 +11,7 @@ export { useConfig, ConfigContext } from './use_config'; export { useKibanaVersion, KibanaVersionContext } from './use_kibana_version'; export { licenseService, useLicense } from './use_license'; export { useLink } from './use_link'; -export { useKibanaLink } from './use_kibana_link'; +export { useKibanaLink, getHrefToObjectInKibanaApp } from './use_kibana_link'; export { usePackageIconType, UsePackageIconType } from './use_package_icon_type'; export { usePagination, Pagination, PAGE_SIZE_OPTIONS } from './use_pagination'; export { useUrlPagination } from './use_url_pagination'; diff --git a/x-pack/plugins/fleet/public/hooks/use_kibana_link.ts b/x-pack/plugins/fleet/public/hooks/use_kibana_link.ts index 29f4f8748d1a0e..3ad01620b9780f 100644 --- a/x-pack/plugins/fleet/public/hooks/use_kibana_link.ts +++ b/x-pack/plugins/fleet/public/hooks/use_kibana_link.ts @@ -4,12 +4,62 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import type { HttpStart } from 'src/core/public'; + +import { KibanaAssetType } from '../types'; import { useStartServices } from './'; const KIBANA_BASE_PATH = '/app/kibana'; +const getKibanaLink = (http: HttpStart, path: string) => { + return http.basePath.prepend(`${KIBANA_BASE_PATH}#${path}`); +}; + +/** + * TODO: This is a temporary solution for getting links to various assets. It is very risky because: + * + * 1. The plugin might not exist/be enabled + * 2. URLs and paths might not always be supported + * + * We should migrate to using the new URL service locators. + * + * @deprecated {@link Locators} from the new URL service need to be used instead. + + */ +export const getHrefToObjectInKibanaApp = ({ + type, + id, + http, +}: { + type: KibanaAssetType; + id: string; + http: HttpStart; +}): undefined | string => { + let kibanaAppPath: undefined | string; + switch (type) { + case KibanaAssetType.dashboard: + kibanaAppPath = `/dashboard/${id}`; + break; + case KibanaAssetType.search: + kibanaAppPath = `/discover/${id}`; + break; + case KibanaAssetType.visualization: + kibanaAppPath = `/visualize/edit/${id}`; + break; + default: + return undefined; + } + + return getKibanaLink(http, kibanaAppPath); +}; + +/** + * TODO: This functionality needs to be replaced with use of the new URL service locators + * + * @deprecated {@link Locators} from the new URL service need to be used instead. + */ export function useKibanaLink(path: string = '/') { - const core = useStartServices(); - return core.http.basePath.prepend(`${KIBANA_BASE_PATH}#${path}`); + const { http } = useStartServices(); + return getKibanaLink(http, path); } From c940da4bd0ecd5dd3c5d8d6f4f6b3aa259633727 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= Date: Tue, 22 Jun 2021 17:25:41 +0200 Subject: [PATCH 35/57] Wraps query in parentheses to avoid quering exception lists (#102612) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/management/common/utils.test.ts | 8 ++++---- .../security_solution/public/management/common/utils.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/common/utils.test.ts b/x-pack/plugins/security_solution/public/management/common/utils.test.ts index 59455ccd6bb042..30354c141f8335 100644 --- a/x-pack/plugins/security_solution/public/management/common/utils.test.ts +++ b/x-pack/plugins/security_solution/public/management/common/utils.test.ts @@ -15,17 +15,17 @@ describe('utils', () => { }); it('should parse simple query with term', () => { expect(parseQueryFilterToKQL('simpleQuery', searchableFields)).toBe( - 'exception-list-agnostic.attributes.name:(*simpleQuery*) OR exception-list-agnostic.attributes.description:(*simpleQuery*) OR exception-list-agnostic.attributes.entries.value:(*simpleQuery*) OR exception-list-agnostic.attributes.entries.entries.value:(*simpleQuery*)' + '(exception-list-agnostic.attributes.name:(*simpleQuery*) OR exception-list-agnostic.attributes.description:(*simpleQuery*) OR exception-list-agnostic.attributes.entries.value:(*simpleQuery*) OR exception-list-agnostic.attributes.entries.entries.value:(*simpleQuery*))' ); }); it('should parse complex query with term', () => { expect(parseQueryFilterToKQL('complex query', searchableFields)).toBe( - 'exception-list-agnostic.attributes.name:(*complex*query*) OR exception-list-agnostic.attributes.description:(*complex*query*) OR exception-list-agnostic.attributes.entries.value:(*complex*query*) OR exception-list-agnostic.attributes.entries.entries.value:(*complex*query*)' + '(exception-list-agnostic.attributes.name:(*complex*query*) OR exception-list-agnostic.attributes.description:(*complex*query*) OR exception-list-agnostic.attributes.entries.value:(*complex*query*) OR exception-list-agnostic.attributes.entries.entries.value:(*complex*query*))' ); }); it('should parse complex query with colon and backslash chars term', () => { expect(parseQueryFilterToKQL('C:\\tmpes', searchableFields)).toBe( - 'exception-list-agnostic.attributes.name:(*C\\:\\\\tmpes*) OR exception-list-agnostic.attributes.description:(*C\\:\\\\tmpes*) OR exception-list-agnostic.attributes.entries.value:(*C\\:\\\\tmpes*) OR exception-list-agnostic.attributes.entries.entries.value:(*C\\:\\\\tmpes*)' + '(exception-list-agnostic.attributes.name:(*C\\:\\\\tmpes*) OR exception-list-agnostic.attributes.description:(*C\\:\\\\tmpes*) OR exception-list-agnostic.attributes.entries.value:(*C\\:\\\\tmpes*) OR exception-list-agnostic.attributes.entries.entries.value:(*C\\:\\\\tmpes*))' ); }); it('should parse complex query with special chars term', () => { @@ -35,7 +35,7 @@ describe('utils', () => { searchableFields ) ).toBe( - "exception-list-agnostic.attributes.name:(*this'is%&query\\{\\}[]!¿?with.,-+`´special\\<\\>ºª@#|·chars*) OR exception-list-agnostic.attributes.description:(*this'is%&query\\{\\}[]!¿?with.,-+`´special\\<\\>ºª@#|·chars*) OR exception-list-agnostic.attributes.entries.value:(*this'is%&query\\{\\}[]!¿?with.,-+`´special\\<\\>ºª@#|·chars*) OR exception-list-agnostic.attributes.entries.entries.value:(*this'is%&query\\{\\}[]!¿?with.,-+`´special\\<\\>ºª@#|·chars*)" + "(exception-list-agnostic.attributes.name:(*this'is%&query\\{\\}[]!¿?with.,-+`´special\\<\\>ºª@#|·chars*) OR exception-list-agnostic.attributes.description:(*this'is%&query\\{\\}[]!¿?with.,-+`´special\\<\\>ºª@#|·chars*) OR exception-list-agnostic.attributes.entries.value:(*this'is%&query\\{\\}[]!¿?with.,-+`´special\\<\\>ºª@#|·chars*) OR exception-list-agnostic.attributes.entries.entries.value:(*this'is%&query\\{\\}[]!¿?with.,-+`´special\\<\\>ºª@#|·chars*))" ); }); }); diff --git a/x-pack/plugins/security_solution/public/management/common/utils.ts b/x-pack/plugins/security_solution/public/management/common/utils.ts index c8cf761ccaf864..616e395c8ad479 100644 --- a/x-pack/plugins/security_solution/public/management/common/utils.ts +++ b/x-pack/plugins/security_solution/public/management/common/utils.ts @@ -17,5 +17,5 @@ export const parseQueryFilterToKQL = (filter: string, fields: Readonly ) .join(' OR '); - return kuery; + return `(${kuery})`; }; From fd0c1fa490c2e4eab5a3cbc96c8f3703cc4962f6 Mon Sep 17 00:00:00 2001 From: Andrew Stucki Date: Tue, 22 Jun 2021 11:30:32 -0400 Subject: [PATCH 36/57] [Agent Packages] Extend 'contains' helper to work on strings (#102786) * Extend 'contains' helper to work on strings * remove stray import --- .../server/services/epm/agent/agent.test.ts | 29 +++++++++++++++++++ .../fleet/server/services/epm/agent/agent.ts | 5 ++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts b/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts index bc4ffffb683589..1be0f73a347e95 100644 --- a/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts @@ -129,6 +129,13 @@ processors: password: {{password}} {{#if password}} hidden_password: {{password}} +{{/if}} + `; + const streamTemplateWithString = ` +{{#if (contains ".pcap" file)}} +pcap: true +{{else}} +pcap: false {{/if}} `; @@ -168,6 +175,28 @@ hidden_password: {{password}} tags: ['foo', 'bar'], }); }); + + it('should support strings', () => { + const vars = { + file: { value: 'foo.pcap' }, + }; + + const output = compileTemplate(vars, streamTemplateWithString); + expect(output).toEqual({ + pcap: true, + }); + }); + + it('should support strings with no match', () => { + const vars = { + file: { value: 'file' }, + }; + + const output = compileTemplate(vars, streamTemplateWithString); + expect(output).toEqual({ + pcap: false, + }); + }); }); it('should support optional yaml values at root level', () => { diff --git a/x-pack/plugins/fleet/server/services/epm/agent/agent.ts b/x-pack/plugins/fleet/server/services/epm/agent/agent.ts index 84a8ab581354af..a0d14e6962a8d4 100644 --- a/x-pack/plugins/fleet/server/services/epm/agent/agent.ts +++ b/x-pack/plugins/fleet/server/services/epm/agent/agent.ts @@ -111,11 +111,12 @@ function buildTemplateVariables(variables: PackagePolicyConfigRecord, templateSt return { vars, yamlValues }; } -function containsHelper(this: any, item: string, list: string[], options: any) { - if (Array.isArray(list) && list.includes(item)) { +function containsHelper(this: any, item: string, check: string | string[], options: any) { + if ((Array.isArray(check) || typeof check === 'string') && check.includes(item)) { if (options && options.fn) { return options.fn(this); } + return true; } return ''; } From 00a9f8495152b7fb0d7284e963f86da11d6aee5f Mon Sep 17 00:00:00 2001 From: Constance Date: Tue, 22 Jun 2021 08:40:58 -0700 Subject: [PATCH 37/57] [App Search] Convert Analytics views to new page template (#102851) * Convert AnalyticsHeader to AnalyticsFilters - it's basically the same component as before, but without the title section/log retention tooltip, since the header/title will be handled by the new page template * Update AnalyticsLayout to use new page template + add new test_helper for header children * Update breadcrumb behavior - Set analytic breadcrumbs in AnalyticsLayout rather than AnalyticsRouter - Update individual views to pass breadcrumbs (consistent with new page template API) * Update router --- .../analytics/analytics_layout.test.tsx | 28 ++-- .../components/analytics/analytics_layout.tsx | 34 +++-- .../components/analytics/analytics_router.tsx | 25 +--- ...er.test.tsx => analytics_filters.test.tsx} | 24 ++-- .../components/analytics_filters.tsx | 111 ++++++++++++++ .../components/analytics_header.scss | 15 -- .../analytics/components/analytics_header.tsx | 136 ------------------ .../components/analytics/components/index.ts | 2 +- .../analytics/views/query_detail.test.tsx | 16 +-- .../analytics/views/query_detail.tsx | 11 +- .../analytics/views/recent_queries.tsx | 2 +- .../analytics/views/top_queries.tsx | 2 +- .../analytics/views/top_queries_no_clicks.tsx | 6 +- .../views/top_queries_no_results.tsx | 6 +- .../views/top_queries_with_clicks.tsx | 6 +- .../components/engine/engine_router.tsx | 10 +- .../test_helpers/get_page_header.tsx | 6 + .../public/applications/test_helpers/index.ts | 1 + 18 files changed, 199 insertions(+), 242 deletions(-) rename x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/{analytics_header.test.tsx => analytics_filters.test.tsx} (87%) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_filters.tsx delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.scss delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_layout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_layout.test.tsx index 9832915f19e9ed..280282a2fc6ecd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_layout.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_layout.test.tsx @@ -8,18 +8,22 @@ import '../../../__mocks__/shallow_useeffect.mock'; import { mockKibanaValues, setMockValues, setMockActions } from '../../../__mocks__/kea_logic'; import { mockUseParams } from '../../../__mocks__/react_router'; +import '../../__mocks__/engine_logic.mock'; import React from 'react'; import { shallow } from 'enzyme'; -import { FlashMessages } from '../../../shared/flash_messages'; -import { Loading } from '../../../shared/loading'; -import { rerender } from '../../../test_helpers'; -import { LogRetentionCallout } from '../log_retention'; +import { + rerender, + getPageTitle, + getPageHeaderActions, + getPageHeaderChildren, +} from '../../../test_helpers'; +import { LogRetentionTooltip, LogRetentionCallout } from '../log_retention'; import { AnalyticsLayout } from './analytics_layout'; -import { AnalyticsHeader } from './components'; +import { AnalyticsFilters } from './components'; describe('AnalyticsLayout', () => { const { history } = mockKibanaValues; @@ -47,18 +51,20 @@ describe('AnalyticsLayout', () => { ); - expect(wrapper.find(FlashMessages)).toHaveLength(1); expect(wrapper.find(LogRetentionCallout)).toHaveLength(1); + expect(getPageHeaderActions(wrapper).find(LogRetentionTooltip)).toHaveLength(1); + expect(getPageHeaderChildren(wrapper).find(AnalyticsFilters)).toHaveLength(1); - expect(wrapper.find(AnalyticsHeader).prop('title')).toEqual('Hello'); + expect(getPageTitle(wrapper)).toEqual('Hello'); expect(wrapper.find('[data-test-subj="world"]').text()).toEqual('World!'); + + expect(wrapper.prop('pageChrome')).toEqual(['Engines', 'some-engine', 'Analytics']); }); - it('renders a loading component if data is not done loading', () => { - setMockValues({ ...values, dataLoading: true }); - const wrapper = shallow(); + it('passes analytics breadcrumbs', () => { + const wrapper = shallow(); - expect(wrapper.type()).toEqual(Loading); + expect(wrapper.prop('pageChrome')).toEqual(['Engines', 'some-engine', 'Analytics', 'Queries']); }); describe('data loading', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_layout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_layout.tsx index 91de4cc4989886..0923f9497a8fe5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_layout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_layout.tsx @@ -10,25 +10,27 @@ import { useParams } from 'react-router-dom'; import { useValues, useActions } from 'kea'; -import { EuiSpacer } from '@elastic/eui'; - -import { FlashMessages } from '../../../shared/flash_messages'; import { KibanaLogic } from '../../../shared/kibana'; -import { Loading } from '../../../shared/loading'; +import { BreadcrumbTrail } from '../../../shared/kibana_chrome/generate_breadcrumbs'; +import { getEngineBreadcrumbs } from '../engine'; +import { AppSearchPageTemplate } from '../layout'; -import { LogRetentionCallout, LogRetentionOptions } from '../log_retention'; +import { LogRetentionTooltip, LogRetentionCallout, LogRetentionOptions } from '../log_retention'; -import { AnalyticsHeader } from './components'; +import { AnalyticsFilters } from './components'; +import { ANALYTICS_TITLE } from './constants'; import { AnalyticsLogic } from './'; interface Props { title: string; + breadcrumbs?: BreadcrumbTrail; isQueryView?: boolean; isAnalyticsView?: boolean; } export const AnalyticsLayout: React.FC = ({ title, + breadcrumbs = [], isQueryView, isAnalyticsView, children, @@ -43,15 +45,21 @@ export const AnalyticsLayout: React.FC = ({ if (isAnalyticsView) loadAnalyticsData(); }, [history.location.search]); - if (dataLoading) return ; - return ( - <> - - + , + ], + children: , + responsive: false, + }} + isLoading={dataLoading} + > {children} - - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.tsx index 397f1f1e1e1c38..d56fe949431c3c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.tsx @@ -9,7 +9,6 @@ import React from 'react'; import { Route, Switch, Redirect } from 'react-router-dom'; import { APP_SEARCH_PLUGIN } from '../../../../../common/constants'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { NotFound } from '../../../shared/not_found'; import { ENGINE_ANALYTICS_PATH, @@ -23,14 +22,7 @@ import { } from '../../routes'; import { generateEnginePath, getEngineBreadcrumbs } from '../engine'; -import { - ANALYTICS_TITLE, - TOP_QUERIES, - TOP_QUERIES_NO_RESULTS, - TOP_QUERIES_NO_CLICKS, - TOP_QUERIES_WITH_CLICKS, - RECENT_QUERIES, -} from './constants'; +import { ANALYTICS_TITLE } from './constants'; import { Analytics, TopQueries, @@ -42,42 +34,37 @@ import { } from './views'; export const AnalyticsRouter: React.FC = () => { - const ANALYTICS_BREADCRUMB = getEngineBreadcrumbs([ANALYTICS_TITLE]); - return ( - - - - - - - + - + ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_filters.test.tsx similarity index 87% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_filters.test.tsx index 5269ea91100659..7abb02110e2d91 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_filters.test.tsx @@ -12,15 +12,13 @@ import React, { ReactElement } from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; import moment, { Moment } from 'moment'; -import { EuiPageHeader, EuiSelect, EuiDatePickerRange, EuiButton } from '@elastic/eui'; - -import { LogRetentionTooltip } from '../../log_retention'; +import { EuiSelect, EuiDatePickerRange, EuiButton } from '@elastic/eui'; import { DEFAULT_START_DATE, DEFAULT_END_DATE } from '../constants'; -import { AnalyticsHeader } from './'; +import { AnalyticsFilters } from './'; -describe('AnalyticsHeader', () => { +describe('AnalyticsFilters', () => { const { history } = mockKibanaValues; const values = { @@ -45,18 +43,14 @@ describe('AnalyticsHeader', () => { }); it('renders', () => { - wrapper = shallow(); - - expect(wrapper.type()).toEqual(EuiPageHeader); - expect(wrapper.find('h1').text()).toEqual('Hello world'); + wrapper = shallow(); - expect(wrapper.find(LogRetentionTooltip)).toHaveLength(1); expect(wrapper.find(EuiSelect)).toHaveLength(1); expect(wrapper.find(EuiDatePickerRange)).toHaveLength(1); }); it('renders tags & dates with default values when no search query params are present', () => { - wrapper = shallow(); + wrapper = shallow(); expect(getTagsSelect().prop('value')).toEqual(''); expect(getStartDatePicker().props.startDate._i).toEqual(DEFAULT_START_DATE); @@ -69,7 +63,7 @@ describe('AnalyticsHeader', () => { const allTags = [...values.allTags, 'tag1', 'tag2', 'tag3']; setMockValues({ ...values, allTags }); - wrapper = shallow(); + wrapper = shallow(); }); it('renders the tags select with currentTag value and allTags options', () => { @@ -95,7 +89,7 @@ describe('AnalyticsHeader', () => { beforeEach(() => { history.location.search = '?start=1970-01-01&end=1970-01-02'; - wrapper = shallow(); + wrapper = shallow(); }); it('renders the start date picker', () => { @@ -127,7 +121,7 @@ describe('AnalyticsHeader', () => { beforeEach(() => { history.location.search = '?start=1970-01-02&end=1970-01-01'; - wrapper = shallow(); + wrapper = shallow(); }); it('renders the date pickers as invalid', () => { @@ -148,7 +142,7 @@ describe('AnalyticsHeader', () => { }; beforeEach(() => { - wrapper = shallow(); + wrapper = shallow(); }); it('pushes up new tag & date state to the search query', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_filters.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_filters.tsx new file mode 100644 index 00000000000000..0c8455e986ae1d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_filters.tsx @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; + +import { useValues } from 'kea'; +import moment from 'moment'; +import queryString from 'query-string'; + +import { + EuiFlexGroup, + EuiFlexItem, + EuiSelect, + EuiDatePickerRange, + EuiDatePicker, + EuiButton, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { AnalyticsLogic } from '../'; +import { KibanaLogic } from '../../../../shared/kibana'; + +import { DEFAULT_START_DATE, DEFAULT_END_DATE, SERVER_DATE_FORMAT } from '../constants'; +import { convertTagsToSelectOptions } from '../utils'; + +export const AnalyticsFilters: React.FC = () => { + const { allTags } = useValues(AnalyticsLogic); + const { history } = useValues(KibanaLogic); + + // Parse out existing filters from URL query string + const { start, end, tag } = queryString.parse(history.location.search); + const [startDate, setStartDate] = useState( + start ? moment(start, SERVER_DATE_FORMAT) : moment(DEFAULT_START_DATE) + ); + const [endDate, setEndDate] = useState( + end ? moment(end, SERVER_DATE_FORMAT) : moment(DEFAULT_END_DATE) + ); + const [currentTag, setCurrentTag] = useState((tag as string) || ''); + + // Set the current URL query string on filter + const onApplyFilters = () => { + const search = queryString.stringify({ + start: moment(startDate).format(SERVER_DATE_FORMAT), + end: moment(endDate).format(SERVER_DATE_FORMAT), + tag: currentTag || undefined, + }); + history.push({ search }); + }; + + const hasInvalidDateRange = startDate > endDate; + + return ( + + + setCurrentTag(e.target.value)} + aria-label={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.filters.tagAriaLabel', + { defaultMessage: 'Filter by analytics tag"' } + )} + fullWidth + /> + + + date && setStartDate(date)} + startDate={startDate} + endDate={endDate} + isInvalid={hasInvalidDateRange} + aria-label={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.filters.startDateAriaLabel', + { defaultMessage: 'Filter by start date' } + )} + /> + } + endDateControl={ + date && setEndDate(date)} + startDate={startDate} + endDate={endDate} + isInvalid={hasInvalidDateRange} + aria-label={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.filters.endDateAriaLabel', + { defaultMessage: 'Filter by end date' } + )} + /> + } + fullWidth + /> + + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.filters.applyButtonLabel', + { defaultMessage: 'Apply filters' } + )} + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.scss deleted file mode 100644 index abe6c0e0789a8d..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.scss +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -.analyticsHeader { - flex-wrap: wrap; - - &__filters.euiPageHeaderSection { - width: 100%; - margin: $euiSizeM 0; - } -} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.tsx deleted file mode 100644 index 8a87a5e8c211c8..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.tsx +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useState } from 'react'; - -import { useValues } from 'kea'; -import moment from 'moment'; -import queryString from 'query-string'; - -import { - EuiPageHeader, - EuiPageHeaderSection, - EuiTitle, - EuiFlexGroup, - EuiFlexItem, - EuiSelect, - EuiDatePickerRange, - EuiDatePicker, - EuiButton, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -import { AnalyticsLogic } from '../'; -import { KibanaLogic } from '../../../../shared/kibana'; -import { LogRetentionTooltip, LogRetentionOptions } from '../../log_retention'; - -import { DEFAULT_START_DATE, DEFAULT_END_DATE, SERVER_DATE_FORMAT } from '../constants'; -import { convertTagsToSelectOptions } from '../utils'; - -import './analytics_header.scss'; - -interface Props { - title: string; -} -export const AnalyticsHeader: React.FC = ({ title }) => { - const { allTags } = useValues(AnalyticsLogic); - const { history } = useValues(KibanaLogic); - - // Parse out existing filters from URL query string - const { start, end, tag } = queryString.parse(history.location.search); - const [startDate, setStartDate] = useState( - start ? moment(start, SERVER_DATE_FORMAT) : moment(DEFAULT_START_DATE) - ); - const [endDate, setEndDate] = useState( - end ? moment(end, SERVER_DATE_FORMAT) : moment(DEFAULT_END_DATE) - ); - const [currentTag, setCurrentTag] = useState((tag as string) || ''); - - // Set the current URL query string on filter - const onApplyFilters = () => { - const search = queryString.stringify({ - start: moment(startDate).format(SERVER_DATE_FORMAT), - end: moment(endDate).format(SERVER_DATE_FORMAT), - tag: currentTag || undefined, - }); - history.push({ search }); - }; - - const hasInvalidDateRange = startDate > endDate; - - return ( - - - - - -

{title}

-
-
- - - -
-
- - - - setCurrentTag(e.target.value)} - aria-label={i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.analytics.filters.tagAriaLabel', - { defaultMessage: 'Filter by analytics tag"' } - )} - fullWidth - /> - - - date && setStartDate(date)} - startDate={startDate} - endDate={endDate} - isInvalid={hasInvalidDateRange} - aria-label={i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.analytics.filters.startDateAriaLabel', - { defaultMessage: 'Filter by start date' } - )} - /> - } - endDateControl={ - date && setEndDate(date)} - startDate={startDate} - endDate={endDate} - isInvalid={hasInvalidDateRange} - aria-label={i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.analytics.filters.endDateAriaLabel', - { defaultMessage: 'Filter by end date' } - )} - /> - } - fullWidth - /> - - - - {i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.analytics.filters.applyButtonLabel', - { defaultMessage: 'Apply filters' } - )} - - - - -
- ); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/index.ts index de5c8209d2347c..5309681b80d6d0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/index.ts @@ -7,7 +7,7 @@ export { AnalyticsCards } from './analytics_cards'; export { AnalyticsChart } from './analytics_chart'; -export { AnalyticsHeader } from './analytics_header'; +export { AnalyticsFilters } from './analytics_filters'; export { AnalyticsSection } from './analytics_section'; export { AnalyticsSearch } from './analytics_search'; export { AnalyticsTable, RecentQueriesTable, QueryClicksTable } from './analytics_tables'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.test.tsx index a942918fa9c623..f3fee2553d2fde 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.test.tsx @@ -12,16 +12,12 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome'; - import { AnalyticsLayout } from '../analytics_layout'; import { AnalyticsCards, AnalyticsChart, QueryClicksTable } from '../components'; import { QueryDetail } from './'; describe('QueryDetail', () => { - const mockBreadcrumbs = ['Engines', 'some-engine', 'Analytics']; - beforeEach(() => { mockUseParams.mockReturnValue({ query: 'some-query' }); @@ -32,16 +28,10 @@ describe('QueryDetail', () => { }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(AnalyticsLayout).prop('title')).toEqual('"some-query"'); - expect(wrapper.find(SetPageChrome).prop('trail')).toEqual([ - 'Engines', - 'some-engine', - 'Analytics', - 'Query', - 'some-query', - ]); + expect(wrapper.find(AnalyticsLayout).prop('breadcrumbs')).toEqual(['Query', 'some-query']); expect(wrapper.find(AnalyticsCards)).toHaveLength(1); expect(wrapper.find(AnalyticsChart)).toHaveLength(1); @@ -50,7 +40,7 @@ describe('QueryDetail', () => { it('renders empty "" search titles correctly', () => { mockUseParams.mockReturnValue({ query: '""' }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(AnalyticsLayout).prop('title')).toEqual('""'); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.tsx index 83c83aa36f1bbf..e68984459cf10f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.tsx @@ -12,8 +12,6 @@ import { useValues } from 'kea'; import { EuiPanel, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome'; -import { BreadcrumbTrail } from '../../../../shared/kibana_chrome/generate_breadcrumbs'; import { useDecodedParams } from '../../../utils/encode_path_params'; import { AnalyticsLayout } from '../analytics_layout'; @@ -25,10 +23,7 @@ const QUERY_DETAIL_TITLE = i18n.translate( { defaultMessage: 'Query' } ); -interface Props { - breadcrumbs: BreadcrumbTrail; -} -export const QueryDetail: React.FC = ({ breadcrumbs }) => { +export const QueryDetail: React.FC = () => { const { query } = useDecodedParams(); const queryTitle = query === '""' ? query : `"${query}"`; @@ -37,9 +32,7 @@ export const QueryDetail: React.FC = ({ breadcrumbs }) => { ); return ( - - - + { const { recentQueries } = useValues(AnalyticsLogic); return ( - + 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 6459126560b3aa..81b3d08770be6b 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 @@ -18,7 +18,7 @@ export const TopQueries: React.FC = () => { const { topQueries } = useValues(AnalyticsLogic); return ( - + 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 8e2591697feaad..2aec88bd372fe3 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 @@ -18,7 +18,11 @@ export const TopQueriesNoClicks: React.FC = () => { const { topQueriesNoClicks } = useValues(AnalyticsLogic); return ( - + 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 e093a5130d2042..835b259330c839 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 @@ -18,7 +18,11 @@ export const TopQueriesNoResults: React.FC = () => { const { topQueriesNoResults } = useValues(AnalyticsLogic); return ( - + 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 87e276a8382c33..9bea265df55aeb 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 @@ -18,7 +18,11 @@ export const TopQueriesWithClicks: React.FC = () => { const { topQueriesWithClicks } = useValues(AnalyticsLogic); return ( - + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx index 98627950016fb4..b390b1a52b9278 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx @@ -94,6 +94,11 @@ export const EngineRouter: React.FC = () => { + {canViewEngineAnalytics && ( + + + + )} {canViewEngineDocuments && ( @@ -106,11 +111,6 @@ export const EngineRouter: React.FC = () => { )} {/* TODO: Remove layout once page template migration is over */} }> - {canViewEngineAnalytics && ( - - - - )} {canViewEngineSchema && ( diff --git a/x-pack/plugins/enterprise_search/public/applications/test_helpers/get_page_header.tsx b/x-pack/plugins/enterprise_search/public/applications/test_helpers/get_page_header.tsx index 6e89274dca5703..a251188b5cd90b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/test_helpers/get_page_header.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/test_helpers/get_page_header.tsx @@ -41,3 +41,9 @@ export const getPageHeaderActions = (wrapper: ShallowWrapper) => {
); }; + +export const getPageHeaderChildren = (wrapper: ShallowWrapper) => { + const children = getPageHeader(wrapper).children || null; + + return shallow(
{children}
); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/test_helpers/index.ts b/x-pack/plugins/enterprise_search/public/applications/test_helpers/index.ts index ed5c3f85a888ee..7903b4a31c8a96 100644 --- a/x-pack/plugins/enterprise_search/public/applications/test_helpers/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/test_helpers/index.ts @@ -15,6 +15,7 @@ export { getPageTitle, getPageDescription, getPageHeaderActions, + getPageHeaderChildren, } from './get_page_header'; // Misc From 69a5d01bde7d9d42bed5549cfd511c0ee54d4b0d Mon Sep 17 00:00:00 2001 From: Ignacio Rivas Date: Tue, 22 Jun 2021 17:47:24 +0200 Subject: [PATCH 38/57] [CCR] Migrate to new page layout structure (#102507) * wip: start migrating views from ccr * finish up migrating ccr pages to new nav layout * Fix tests, linter errors and i18n strings * remove todo * Render loading and error states centered in screen without page title * Keep loader going while we still setting the payload Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../auto_follow_pattern_add.test.js | 8 +- .../follower_index_add.test.js | 8 +- .../public/app/app.tsx | 62 ++---- .../auto_follow_pattern_page_title.js | 61 ++---- .../components/follower_index_page_title.js | 61 ++---- .../auto_follow_pattern_add.js | 56 ++--- .../auto_follow_pattern_edit.js | 152 ++++++------- .../follower_index_add/follower_index_add.js | 55 ++--- .../follower_index_edit.js | 162 +++++++------- .../auto_follow_pattern_list.js | 204 +++++++---------- .../auto_follow_pattern_table.js | 17 ++ .../components/context_menu/context_menu.js | 2 +- .../follower_indices_table.js | 16 ++ .../follower_indices_list.js | 205 +++++++----------- .../public/app/sections/home/home.js | 51 ++--- .../public/shared_imports.ts | 7 +- 16 files changed, 513 insertions(+), 614 deletions(-) diff --git a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_add.test.js b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_add.test.js index 86abbba9687810..e49751cecc1d0e 100644 --- a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_add.test.js +++ b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_add.test.js @@ -39,10 +39,6 @@ describe('Create Auto-follow pattern', () => { expect(exists('remoteClustersLoading')).toBe(true); expect(find('remoteClustersLoading').text()).toBe('Loading remote clusters…'); }); - - test('should have a link to the documentation', () => { - expect(exists('docsButton')).toBe(true); - }); }); describe('when remote clusters are loaded', () => { @@ -59,6 +55,10 @@ describe('Create Auto-follow pattern', () => { component.update(); }); + test('should have a link to the documentation', () => { + expect(exists('docsButton')).toBe(true); + }); + test('should display the Auto-follow pattern form', async () => { expect(exists('autoFollowPatternForm')).toBe(true); }); diff --git a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_index_add.test.js b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_index_add.test.js index 228868194b2312..6d54444df42735 100644 --- a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_index_add.test.js +++ b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_index_add.test.js @@ -42,10 +42,6 @@ describe('Create Follower index', () => { expect(exists('remoteClustersLoading')).toBe(true); expect(find('remoteClustersLoading').text()).toBe('Loading remote clusters…'); }); - - test('should have a link to the documentation', () => { - expect(exists('docsButton')).toBe(true); - }); }); describe('when remote clusters are loaded', () => { @@ -62,6 +58,10 @@ describe('Create Follower index', () => { component.update(); }); + test('should have a link to the documentation', () => { + expect(exists('docsButton')).toBe(true); + }); + test('should display the Follower index form', async () => { expect(exists('followerIndexForm')).toBe(true); }); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/app.tsx b/x-pack/plugins/cross_cluster_replication/public/app/app.tsx index 50a6cfb1b4bb91..c6144143e18496 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/app.tsx +++ b/x-pack/plugins/cross_cluster_replication/public/app/app.tsx @@ -5,27 +5,19 @@ * 2.0. */ -import React, { Component, Fragment } from 'react'; +import React, { Component } from 'react'; import { Route, Switch, Router, Redirect } from 'react-router-dom'; import { ScopedHistory, ApplicationStart } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiEmptyPrompt, - EuiFlexGroup, - EuiFlexItem, - EuiLoadingSpinner, - EuiPageContent, - EuiSpacer, - EuiTitle, -} from '@elastic/eui'; +import { EuiEmptyPrompt, EuiPageContent } from '@elastic/eui'; import { getFatalErrors } from './services/notifications'; -import { SectionError } from './components'; import { routing } from './services/routing'; // @ts-ignore import { loadPermissions } from './services/api'; +import { SectionLoading, PageError } from '../shared_imports'; // @ts-ignore import { @@ -119,48 +111,34 @@ class AppComponent extends Component { if (isFetchingPermissions) { return ( - - - - - - - - -

- -

-
-
-
+ + + + ); } if (fetchPermissionError) { return ( - - - } - error={fetchPermissionError} - /> - - - + + } + error={fetchPermissionError} + /> ); } if (!hasPermission) { return ( - + ( - - + <> + {title}} + rightSideItems={[ + + + , + ]} + /> - - - - -

{title}

-
-
- - - - - - -
-
-
+ + ); AutoFollowPatternPageTitle.propTypes = { diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_page_title.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_page_title.js index b5652d3f2b6e62..6d523cf2c470f7 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_page_title.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_page_title.js @@ -5,51 +5,38 @@ * 2.0. */ -import React, { Fragment } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiPageContentHeader, - EuiSpacer, - EuiTitle, -} from '@elastic/eui'; +import { EuiSpacer, EuiPageHeader, EuiButtonEmpty } from '@elastic/eui'; import { documentationLinks } from '../services/documentation_links'; export const FollowerIndexPageTitle = ({ title }) => ( - - + <> + {title}} + rightSideItems={[ + + + , + ]} + /> - - - - -

{title}

-
-
- - - - - - -
-
-
+ + ); FollowerIndexPageTitle.propTypes = { diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js index 0fe562b7a8f052..118e3103008d01 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js @@ -8,16 +8,15 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; - -import { EuiPageContent } from '@elastic/eui'; +import { EuiPageContentBody, EuiPageContent } from '@elastic/eui'; import { listBreadcrumb, addBreadcrumb, setBreadcrumbs } from '../../services/breadcrumbs'; import { AutoFollowPatternForm, AutoFollowPatternPageTitle, RemoteClustersProvider, - SectionLoading, } from '../../components'; +import { SectionLoading } from '../../../shared_imports'; export class AutoFollowPatternAdd extends PureComponent { static propTypes = { @@ -44,30 +43,37 @@ export class AutoFollowPatternAdd extends PureComponent { } = this.props; return ( - - - } - /> - - - {({ isLoading, error, remoteClusters }) => { - if (isLoading) { - return ( - + + {({ isLoading, error, remoteClusters }) => { + if (isLoading) { + return ( + + - ); - } + + ); + } + + return ( + + + } + /> - return ( } /> - ); - }} - - +
+ ); + }} + ); } } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js index d060fb83832c62..fa97b28c8b4722 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js @@ -5,12 +5,12 @@ * 2.0. */ -import React, { PureComponent, Fragment } from 'react'; +import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiPageContent, EuiSpacer } from '@elastic/eui'; +import { EuiButton, EuiPageContent, EuiEmptyPrompt, EuiPageContentBody } from '@elastic/eui'; import { listBreadcrumb, editBreadcrumb, setBreadcrumbs } from '../../services/breadcrumbs'; import { reactRouterNavigate } from '../../../../../../../src/plugins/kibana_react/public'; @@ -18,10 +18,9 @@ import { AutoFollowPatternForm, AutoFollowPatternPageTitle, RemoteClustersProvider, - SectionLoading, - SectionError, } from '../../components'; import { API_STATUS } from '../../constants'; +import { SectionLoading } from '../../../shared_imports'; export class AutoFollowPatternEdit extends PureComponent { static propTypes = { @@ -80,13 +79,6 @@ export class AutoFollowPatternEdit extends PureComponent { }, } = this.props; - const title = i18n.translate( - 'xpack.crossClusterReplication.autoFollowPatternEditForm.loadingErrorTitle', - { - defaultMessage: 'Error loading auto-follow pattern', - } - ); - const errorMessage = error.body.statusCode === 404 ? { @@ -101,38 +93,42 @@ export class AutoFollowPatternEdit extends PureComponent { : error; return ( - - - - - - - - + + + + } + body={

{errorMessage}

} + actions={ + -
-
-
-
+ + } + /> + ); } - renderLoadingAutoFollowPattern() { + renderLoading(loadingTitle) { return ( - - - + + {loadingTitle} + ); } @@ -145,55 +141,59 @@ export class AutoFollowPatternEdit extends PureComponent { match: { url: currentUrl }, } = this.props; + if (apiStatus.get === API_STATUS.LOADING || !autoFollowPattern) { + return this.renderLoading( + i18n.translate('xpack.crossClusterReplication.autoFollowPatternEditForm.loadingTitle', { + defaultMessage: 'Loading auto-follow pattern…', + }) + ); + } + + if (apiError.get) { + return this.renderGetAutoFollowPatternError(apiError.get); + } + return ( - - + + {({ isLoading, error, remoteClusters }) => { + if (isLoading) { + return this.renderLoading( + i18n.translate( + 'xpack.crossClusterReplication.autoFollowPatternEditForm.loadingRemoteClustersMessage', + { defaultMessage: 'Loading remote clusters…' } + ) + ); } - /> - {apiStatus.get === API_STATUS.LOADING && this.renderLoadingAutoFollowPattern()} - - {apiError.get && this.renderGetAutoFollowPatternError(apiError.get)} - - {autoFollowPattern && ( - - {({ isLoading, error, remoteClusters }) => { - if (isLoading) { - return ( - - - - ); - } + return ( + + + } + /> - return ( - - } - /> - ); - }} - - )} - + + } + /> + + ); + }} + ); } } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js index 836a4f5cc36fa2..325c23641580cb 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js @@ -8,16 +8,15 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; - -import { EuiPageContent } from '@elastic/eui'; +import { EuiPageContentBody, EuiPageContent } from '@elastic/eui'; import { setBreadcrumbs, listBreadcrumb, addBreadcrumb } from '../../services/breadcrumbs'; import { FollowerIndexForm, FollowerIndexPageTitle, RemoteClustersProvider, - SectionLoading, } from '../../components'; +import { SectionLoading } from '../../../shared_imports'; export class FollowerIndexAdd extends PureComponent { static propTypes = { @@ -45,30 +44,36 @@ export class FollowerIndexAdd extends PureComponent { } = this.props; return ( - - - } - /> - - - {({ isLoading, error, remoteClusters }) => { - if (isLoading) { - return ( - + + {({ isLoading, error, remoteClusters }) => { + if (isLoading) { + return ( + + - ); - } + + ); + } - return ( + return ( + + + } + /> } /> - ); - }} - - + + ); + }} + ); } } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js index 41b09a398b1f24..618d97f1865166 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js @@ -5,18 +5,17 @@ * 2.0. */ -import React, { PureComponent, Fragment } from 'react'; +import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { - EuiButtonEmpty, + EuiButton, EuiConfirmModal, - EuiFlexGroup, - EuiFlexItem, + EuiPageContentBody, EuiPageContent, - EuiSpacer, + EuiEmptyPrompt, } from '@elastic/eui'; import { setBreadcrumbs, listBreadcrumb, editBreadcrumb } from '../../services/breadcrumbs'; @@ -24,11 +23,10 @@ import { reactRouterNavigate } from '../../../../../../../src/plugins/kibana_rea import { FollowerIndexForm, FollowerIndexPageTitle, - SectionLoading, - SectionError, RemoteClustersProvider, } from '../../components'; import { API_STATUS } from '../../constants'; +import { SectionLoading } from '../../../shared_imports'; export class FollowerIndexEdit extends PureComponent { static propTypes = { @@ -104,14 +102,11 @@ export class FollowerIndexEdit extends PureComponent { closeConfirmModal = () => this.setState({ showConfirmModal: false }); - renderLoadingFollowerIndex() { + renderLoading(loadingTitle) { return ( - - - + + {loadingTitle} + ); } @@ -122,13 +117,6 @@ export class FollowerIndexEdit extends PureComponent { }, } = this.props; - const title = i18n.translate( - 'xpack.crossClusterReplication.followerIndexEditForm.loadingErrorTitle', - { - defaultMessage: 'Error loading follower index', - } - ); - const errorMessage = error.body.statusCode === 404 ? { @@ -143,27 +131,33 @@ export class FollowerIndexEdit extends PureComponent { : error; return ( - - - - - - - - + + + + } + body={

{errorMessage}

} + actions={ + -
-
-
-
+ + } + /> + ); } @@ -237,57 +231,63 @@ export class FollowerIndexEdit extends PureComponent { /* remove non-editable properties */ const { shards, ...rest } = followerIndex || {}; // eslint-disable-line no-unused-vars + if (apiStatus.get === API_STATUS.LOADING || !followerIndex) { + return this.renderLoading( + i18n.translate( + 'xpack.crossClusterReplication.followerIndexEditForm.loadingFollowerIndexTitle', + { defaultMessage: 'Loading follower index…' } + ) + ); + } + + if (apiError.get) { + return this.renderGetFollowerIndexError(apiError.get); + } + return ( - - + + {({ isLoading, error, remoteClusters }) => { + if (isLoading) { + return this.renderLoading( + i18n.translate( + 'xpack.crossClusterReplication.followerIndexEditForm.loadingRemoteClustersMessage', + { defaultMessage: 'Loading remote clusters…' } + ) + ); } - /> - {apiStatus.get === API_STATUS.LOADING && this.renderLoadingFollowerIndex()} - - {apiError.get && this.renderGetFollowerIndexError(apiError.get)} - {followerIndex && ( - - {({ isLoading, error, remoteClusters }) => { - if (isLoading) { - return ( - - - - ); - } + return ( + + + } + /> - return ( - - } - /> - ); - }} - - )} + + } + /> - {showConfirmModal && this.renderConfirmModal()} - + {showConfirmModal && this.renderConfirmModal()} + + ); + }} + ); } } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.js index 1885f33f9d6331..1ab4e1a3e003a8 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.js @@ -5,24 +5,17 @@ * 2.0. */ -import React, { PureComponent, Fragment } from 'react'; +import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiButton, - EuiEmptyPrompt, - EuiFlexGroup, - EuiFlexItem, - EuiText, - EuiSpacer, -} from '@elastic/eui'; +import { EuiButton, EuiEmptyPrompt, EuiText, EuiSpacer } from '@elastic/eui'; import { reactRouterNavigate } from '../../../../../../../../src/plugins/kibana_react/public'; -import { extractQueryParams } from '../../../../shared_imports'; +import { extractQueryParams, SectionLoading } from '../../../../shared_imports'; import { trackUiMetric, METRIC_TYPE } from '../../../services/track_ui_metric'; import { API_STATUS, UIM_AUTO_FOLLOW_PATTERN_LIST_LOAD } from '../../../constants'; -import { SectionLoading, SectionError, SectionUnauthorized } from '../../../components'; +import { SectionError, SectionUnauthorized } from '../../../components'; import { AutoFollowPatternTable, DetailPanel } from './components'; const REFRESH_RATE_MS = 30000; @@ -103,47 +96,77 @@ export class AutoFollowPatternList extends PureComponent { clearInterval(this.interval); } - renderHeader() { - const { isAuthorized, history } = this.props; + renderEmpty() { + return ( +
+ + + + } + body={ +

+ +

+ } + actions={ + + + + } + /> +
+ ); + } + + renderList() { + const { selectAutoFollowPattern, autoFollowPatterns } = this.props; + const { isDetailPanelOpen } = this.state; + return ( - - - - -

- -

-
-
+ <> + +

+ +

+
+ + - - {isAuthorized && ( - - - - )} - -
+ - -
+ {isDetailPanelOpen && ( + selectAutoFollowPattern(null)} /> + )} + ); } - renderContent(isEmpty) { - const { apiError, apiStatus, isAuthorized } = this.props; + render() { + const { autoFollowPatterns, apiError, apiStatus, isAuthorized } = this.props; + const isEmpty = apiStatus === API_STATUS.IDLE && !autoFollowPatterns.length; if (!isAuthorized) { return ( @@ -171,12 +194,7 @@ export class AutoFollowPatternList extends PureComponent { } ); - return ( - - - - - ); + return ; } if (isEmpty) { @@ -185,83 +203,17 @@ export class AutoFollowPatternList extends PureComponent { if (apiStatus === API_STATUS.LOADING) { return ( - - - +
+ + + +
); } return this.renderList(); } - - renderEmpty() { - return ( - - - - } - body={ - -

- -

-
- } - actions={ - - - - } - data-test-subj="emptyPrompt" - /> - ); - } - - renderList() { - const { selectAutoFollowPattern, autoFollowPatterns } = this.props; - - const { isDetailPanelOpen } = this.state; - - return ( - <> - - {isDetailPanelOpen && ( - selectAutoFollowPattern(null)} /> - )} - - ); - } - - render() { - const { autoFollowPatterns, apiStatus } = this.props; - const isEmpty = apiStatus === API_STATUS.IDLE && !autoFollowPatterns.length; - - return ( - - {!isEmpty && this.renderHeader()} - {this.renderContent(isEmpty)} - - ); - } } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js index 87002c936179ae..0d228f2e63802f 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js @@ -8,13 +8,17 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + import { EuiInMemoryTable, + EuiButton, EuiLink, EuiLoadingKibana, EuiOverlayMask, EuiHealth, } from '@elastic/eui'; +import { reactRouterNavigate } from '../../../../../../../../../../src/plugins/kibana_react/public'; import { API_STATUS, UIM_AUTO_FOLLOW_PATTERN_SHOW_DETAILS_CLICK } from '../../../../../constants'; import { AutoFollowPatternDeleteProvider, @@ -305,6 +309,19 @@ export class AutoFollowPatternTable extends PureComponent { )} /> ) : undefined, + toolsRight: ( + + + + ), onChange: this.onSearch, box: { incremental: true, diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/context_menu.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/context_menu.js index 0d0943d8702665..866afa3e6e6dc3 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/context_menu.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/context_menu.js @@ -97,7 +97,7 @@ export class ContextMenu extends PureComponent { anchorPosition={anchorPosition} repositionOnScroll > - + ) : undefined, + toolsRight: ( + + + + ), onChange: this.onSearch, box: { incremental: true, diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js index 743a9ec47e6894..a52ba0e613ca9e 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js @@ -5,24 +5,17 @@ * 2.0. */ -import React, { PureComponent, Fragment } from 'react'; +import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiButton, - EuiEmptyPrompt, - EuiFlexGroup, - EuiFlexItem, - EuiText, - EuiSpacer, -} from '@elastic/eui'; +import { EuiButton, EuiEmptyPrompt, EuiText, EuiSpacer } from '@elastic/eui'; import { reactRouterNavigate } from '../../../../../../../../src/plugins/kibana_react/public'; -import { extractQueryParams } from '../../../../shared_imports'; +import { extractQueryParams, SectionLoading } from '../../../../shared_imports'; import { trackUiMetric, METRIC_TYPE } from '../../../services/track_ui_metric'; import { API_STATUS, UIM_FOLLOWER_INDEX_LIST_LOAD } from '../../../constants'; -import { SectionLoading, SectionError, SectionUnauthorized } from '../../../components'; +import { SectionError, SectionUnauthorized } from '../../../components'; import { FollowerIndicesTable, DetailPanel } from './components'; const REFRESH_RATE_MS = 30000; @@ -94,47 +87,87 @@ export class FollowerIndicesList extends PureComponent { clearInterval(this.interval); } - renderHeader() { - const { isAuthorized, history } = this.props; + renderEmpty() { + return ( +
+ + + + } + body={ +

+ +

+ } + actions={ + + + + } + /> +
+ ); + } + renderLoading() { return ( - - - - -

- -

-
-
+
+ + + +
+ ); + } + + renderList() { + const { selectFollowerIndex, followerIndices } = this.props; + + const { isDetailPanelOpen } = this.state; + + return ( + <> + +

+ +

+
- - {isAuthorized && ( - - - - )} - -
+ - -
+ + + {isDetailPanelOpen && selectFollowerIndex(null)} />} + ); } - renderContent(isEmpty) { - const { apiError, isAuthorized, apiStatus } = this.props; + render() { + const { followerIndices, apiError, isAuthorized, apiStatus } = this.props; + const isEmpty = apiStatus === API_STATUS.IDLE && !followerIndices.length; if (!isAuthorized) { return ( @@ -162,12 +195,7 @@ export class FollowerIndicesList extends PureComponent { } ); - return ( - - - - - ); + return ; } if (isEmpty) { @@ -180,79 +208,4 @@ export class FollowerIndicesList extends PureComponent { return this.renderList(); } - - renderEmpty() { - return ( - - - - } - body={ - -

- -

-
- } - actions={ - - - - } - data-test-subj="emptyPrompt" - /> - ); - } - - renderLoading() { - return ( - - - - ); - } - - renderList() { - const { selectFollowerIndex, followerIndices } = this.props; - - const { isDetailPanelOpen } = this.state; - - return ( - - - {isDetailPanelOpen && selectFollowerIndex(null)} />} - - ); - } - - render() { - const { followerIndices, apiStatus } = this.props; - const isEmpty = apiStatus === API_STATUS.IDLE && !followerIndices.length; - return ( - - {!isEmpty && this.renderHeader()} - {this.renderContent(isEmpty)} - - ); - } } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.js index ff37c2157d5155..70d35dcb225695 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.js @@ -9,7 +9,7 @@ import React, { PureComponent } from 'react'; import { Route, Switch } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTab, EuiTabs, EuiTitle } from '@elastic/eui'; +import { EuiSpacer, EuiPageHeader } from '@elastic/eui'; import { setBreadcrumbs, listBreadcrumb } from '../../services/breadcrumbs'; import { routing } from '../../services/routing'; @@ -66,40 +66,33 @@ export class CrossClusterReplicationHome extends PureComponent { render() { return ( - - - -

+ <> + -

-
- - - - - {this.tabs.map((tab) => ( - this.onSectionChange(tab.id)} - isSelected={tab.id === this.state.activeSection} - key={tab.id} - data-test-subj={tab.testSubj} - > - {tab.name} - - ))} - + + } + tabs={this.tabs.map((tab) => ({ + onClick: () => this.onSectionChange(tab.id), + isSelected: tab.id === this.state.activeSection, + key: tab.id, + 'data-test-subj': tab.testSubj, + label: tab.name, + }))} + /> - + - - - - -
-
+ + + + + ); } } diff --git a/x-pack/plugins/cross_cluster_replication/public/shared_imports.ts b/x-pack/plugins/cross_cluster_replication/public/shared_imports.ts index fd281753186665..55a10749230c74 100644 --- a/x-pack/plugins/cross_cluster_replication/public/shared_imports.ts +++ b/x-pack/plugins/cross_cluster_replication/public/shared_imports.ts @@ -5,4 +5,9 @@ * 2.0. */ -export { extractQueryParams, indices } from '../../../../src/plugins/es_ui_shared/public'; +export { + extractQueryParams, + indices, + SectionLoading, + PageError, +} from '../../../../src/plugins/es_ui_shared/public'; From 016259d19c55d4d7be88adc445af3a6001391566 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Tue, 22 Jun 2021 17:48:20 +0200 Subject: [PATCH 39/57] [AppService] fix deepLinks being lost when updating the app with other fields (#102895) * fix app updater for deepLinks * improve implem --- .../application/application_service.test.ts | 83 ++++++++++++++++++- .../application/application_service.tsx | 7 +- 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts index 3ed164088bf5c7..de9e4d4496f3b2 100644 --- a/src/core/public/application/application_service.test.ts +++ b/src/core/public/application/application_service.test.ts @@ -15,13 +15,13 @@ import { import { createElement } from 'react'; import { BehaviorSubject, Subject } from 'rxjs'; import { bufferCount, take, takeUntil } from 'rxjs/operators'; -import { shallow, mount } from 'enzyme'; +import { mount, shallow } from 'enzyme'; import { httpServiceMock } from '../http/http_service.mock'; import { overlayServiceMock } from '../overlays/overlay_service.mock'; import { MockLifecycle } from './test_types'; import { ApplicationService } from './application_service'; -import { App, PublicAppInfo, AppNavLinkStatus, AppStatus, AppUpdater } from './types'; +import { App, AppDeepLink, AppNavLinkStatus, AppStatus, AppUpdater, PublicAppInfo } from './types'; import { act } from 'react-dom/test-utils'; const createApp = (props: Partial): App => { @@ -365,6 +365,85 @@ describe('#setup()', () => { expect(MockHistory.push).toHaveBeenCalledWith('/app/app1', undefined); MockHistory.push.mockClear(); }); + + it('preserves the deep links if the update does not modify them', async () => { + const setup = service.setup(setupDeps); + + const pluginId = Symbol('plugin'); + const updater$ = new BehaviorSubject((app) => ({})); + + const deepLinks: AppDeepLink[] = [ + { + id: 'foo', + title: 'Foo', + searchable: true, + navLinkStatus: AppNavLinkStatus.visible, + path: '/foo', + }, + { + id: 'bar', + title: 'Bar', + searchable: false, + navLinkStatus: AppNavLinkStatus.hidden, + path: '/bar', + }, + ]; + + setup.register(pluginId, createApp({ id: 'app1', deepLinks, updater$ })); + + const { applications$ } = await service.start(startDeps); + + updater$.next((app) => ({ defaultPath: '/foo' })); + + let appInfos = await applications$.pipe(take(1)).toPromise(); + + expect(appInfos.get('app1')!.deepLinks).toEqual([ + { + deepLinks: [], + id: 'foo', + keywords: [], + navLinkStatus: 1, + path: '/foo', + searchable: true, + title: 'Foo', + }, + { + deepLinks: [], + id: 'bar', + keywords: [], + navLinkStatus: 3, + path: '/bar', + searchable: false, + title: 'Bar', + }, + ]); + + updater$.next((app) => ({ + deepLinks: [ + { + id: 'bar', + title: 'Bar', + searchable: false, + navLinkStatus: AppNavLinkStatus.hidden, + path: '/bar', + }, + ], + })); + + appInfos = await applications$.pipe(take(1)).toPromise(); + + expect(appInfos.get('app1')!.deepLinks).toEqual([ + { + deepLinks: [], + id: 'bar', + keywords: [], + navLinkStatus: 3, + path: '/bar', + searchable: false, + title: 'Bar', + }, + ]); + }); }); }); diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 8c6090caabce19..2e804bf2f5413c 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -54,6 +54,7 @@ function filterAvailable(m: Map, capabilities: Capabilities) { ) ); } + const findMounter = (mounters: Map, appRoute?: string) => [...mounters].find(([, mounter]) => mounter.appRoute === appRoute); @@ -414,13 +415,11 @@ const updateStatus = (app: App, statusUpdaters: AppUpdaterWrapper[]): App => { changes.navLinkStatus ?? AppNavLinkStatus.default, fields.navLinkStatus ?? AppNavLinkStatus.default ), - // deepLinks take the last defined update - deepLinks: fields.deepLinks - ? populateDeepLinkDefaults(fields.deepLinks) - : changes.deepLinks, + ...(fields.deepLinks ? { deepLinks: populateDeepLinkDefaults(fields.deepLinks) } : {}), }; } }); + return { ...app, ...changes, From 2323b9864239e8db789a70b24327ac17d7353fdd Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Tue, 22 Jun 2021 17:48:47 +0200 Subject: [PATCH 40/57] [jest] use circus runner for the integration tests (#102782) * use circus runner for integration tests * do not use done callback. https://github.com/facebook/jest/issues/10529 * fix type error --- jest.config.integration.js | 1 - .../http/integration_tests/request.test.ts | 51 ++++++++++--------- .../integration_tests/migration.test.ts | 6 +++ .../integration_tests/index.test.ts | 2 +- .../integration_tests/lib/servers.ts | 4 +- 5 files changed, 37 insertions(+), 27 deletions(-) diff --git a/jest.config.integration.js b/jest.config.integration.js index 50767932a52d70..b6ecb4569b643a 100644 --- a/jest.config.integration.js +++ b/jest.config.integration.js @@ -13,7 +13,6 @@ module.exports = { rootDir: '.', roots: ['/src', '/packages'], testMatch: ['**/integration_tests**/*.test.{js,mjs,ts,tsx}'], - testRunner: 'jasmine2', testPathIgnorePatterns: preset.testPathIgnorePatterns.filter( (pattern) => !pattern.includes('integration_tests') ), diff --git a/src/core/server/http/integration_tests/request.test.ts b/src/core/server/http/integration_tests/request.test.ts index 7571184363d2ee..dfc47098724cc3 100644 --- a/src/core/server/http/integration_tests/request.test.ts +++ b/src/core/server/http/integration_tests/request.test.ts @@ -163,24 +163,24 @@ describe('KibanaRequest', () => { describe('events', () => { describe('aborted$', () => { - it('emits once and completes when request aborted', async (done) => { + it('emits once and completes when request aborted', async () => { expect.assertions(1); const { server: innerServer, createRouter } = await server.setup(setupDeps); const router = createRouter('/'); const nextSpy = jest.fn(); - router.get({ path: '/', validate: false }, async (context, request, res) => { - request.events.aborted$.subscribe({ - next: nextSpy, - complete: () => { - expect(nextSpy).toHaveBeenCalledTimes(1); - done(); - }, - }); - // prevents the server to respond - await delay(30000); - return res.ok({ body: 'ok' }); + const done = new Promise((resolve) => { + router.get({ path: '/', validate: false }, async (context, request, res) => { + request.events.aborted$.subscribe({ + next: nextSpy, + complete: resolve, + }); + + // prevents the server to respond + await delay(30000); + return res.ok({ body: 'ok' }); + }); }); await server.start(); @@ -191,6 +191,8 @@ describe('KibanaRequest', () => { .end(); setTimeout(() => incomingRequest.abort(), 50); + await done; + expect(nextSpy).toHaveBeenCalledTimes(1); }); it('completes & does not emit when request handled', async () => { @@ -299,25 +301,24 @@ describe('KibanaRequest', () => { expect(completeSpy).toHaveBeenCalledTimes(1); }); - it('emits once and completes when response is aborted', async (done) => { + it('emits once and completes when response is aborted', async () => { expect.assertions(2); const { server: innerServer, createRouter } = await server.setup(setupDeps); const router = createRouter('/'); const nextSpy = jest.fn(); - router.get({ path: '/', validate: false }, async (context, req, res) => { - req.events.completed$.subscribe({ - next: nextSpy, - complete: () => { - expect(nextSpy).toHaveBeenCalledTimes(1); - done(); - }, - }); + const done = new Promise((resolve) => { + router.get({ path: '/', validate: false }, async (context, req, res) => { + req.events.completed$.subscribe({ + next: nextSpy, + complete: resolve, + }); - expect(nextSpy).not.toHaveBeenCalled(); - await delay(30000); - return res.ok({ body: 'ok' }); + expect(nextSpy).not.toHaveBeenCalled(); + await delay(30000); + return res.ok({ body: 'ok' }); + }); }); await server.start(); @@ -327,6 +328,8 @@ describe('KibanaRequest', () => { // end required to send request .end(); setTimeout(() => incomingRequest.abort(), 50); + await done; + expect(nextSpy).toHaveBeenCalledTimes(1); }); }); }); diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts index f4e0dd8fffcab1..4c9e37d17f2e7c 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts @@ -95,6 +95,12 @@ describe('migration v2', () => { }, ], }, + // reporting loads headless browser, that prevents nodejs process from exiting. + xpack: { + reporting: { + enabled: false, + }, + }, }, { oss, diff --git a/src/core/server/ui_settings/integration_tests/index.test.ts b/src/core/server/ui_settings/integration_tests/index.test.ts index 6c7cdfa43cf57f..61e55284a20b85 100644 --- a/src/core/server/ui_settings/integration_tests/index.test.ts +++ b/src/core/server/ui_settings/integration_tests/index.test.ts @@ -17,7 +17,7 @@ const kibanaVersion = Env.createDefault(REPO_ROOT, getEnvOptions()).packageInfo. const savedObjectIndex = `.kibana_${kibanaVersion}_001`; describe('uiSettings/routes', function () { - jest.setTimeout(10000); + jest.setTimeout(120_000); beforeAll(startServers); /* eslint-disable jest/valid-describe */ diff --git a/src/core/server/ui_settings/integration_tests/lib/servers.ts b/src/core/server/ui_settings/integration_tests/lib/servers.ts index b18d9926649aaa..96ba08a0728ab0 100644 --- a/src/core/server/ui_settings/integration_tests/lib/servers.ts +++ b/src/core/server/ui_settings/integration_tests/lib/servers.ts @@ -75,8 +75,10 @@ export function getServices() { export async function stopServers() { services = null!; - if (servers) { + if (esServer) { await esServer.stop(); + } + if (kbn) { await kbn.stop(); } } From 84d999d7478bce6656877cdc259b552c59463076 Mon Sep 17 00:00:00 2001 From: Constance Date: Tue, 22 Jun 2021 09:01:25 -0700 Subject: [PATCH 41/57] [App Search] Convert Search UI view to new page template + minor UI polish (#102813) * Convert Search UI view to use new page template + update tests TODO * [UX polish] Add empty state to Search UI view - On a totally new engine, all pages except this one* had an empty state, so per Davey's recommendations I whipped up a new empty state for this page * Overview has a custom 'empty' state, analytics does not have an empty state * Update router * Fix bad merge conflict resolution * [Polish] Copy feedback proposed by Davey - see https://github.com/elastic/kibana/pull/101958/commits/cbc3706223eb47be3d854a1cf4e3c7275d88ca39 --- .../components/engine/engine_router.tsx | 10 +- .../search_ui/components/empty_state.test.tsx | 27 ++++ .../search_ui/components/empty_state.tsx | 46 +++++++ .../components/search_ui/search_ui.test.tsx | 13 +- .../components/search_ui/search_ui.tsx | 120 ++++++++---------- 5 files changed, 143 insertions(+), 73 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/empty_state.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/empty_state.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx index b390b1a52b9278..3e18c9e680de22 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx @@ -109,6 +109,11 @@ export const EngineRouter: React.FC = () => { )} + {canManageEngineSearchUi && ( + + + + )} {/* TODO: Remove layout once page template migration is over */} }> {canViewEngineSchema && ( @@ -141,11 +146,6 @@ export const EngineRouter: React.FC = () => { )} - {canManageEngineSearchUi && ( - - - - )} {canViewMetaEngineSourceEngines && ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/empty_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/empty_state.test.tsx new file mode 100644 index 00000000000000..39f0cb376b325c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/empty_state.test.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiEmptyPrompt, EuiButton } from '@elastic/eui'; + +import { EmptyState } from './empty_state'; + +describe('EmptyState', () => { + it('renders', () => { + const wrapper = shallow() + .find(EuiEmptyPrompt) + .dive(); + + expect(wrapper.find('h2').text()).toEqual('Add documents to generate a Search UI'); + expect(wrapper.find(EuiButton).prop('href')).toEqual( + expect.stringContaining('/reference-ui-guide.html') + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/empty_state.tsx new file mode 100644 index 00000000000000..b7665a58de300b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/empty_state.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { DOCS_PREFIX } from '../../../routes'; + +export const EmptyState: React.FC = () => ( + + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.searchUI.empty.title', { + defaultMessage: 'Add documents to generate a Search UI', + })} + + } + body={ +

+ {i18n.translate('xpack.enterpriseSearch.appSearch.engine.searchUI.empty.description', { + defaultMessage: + 'A schema will be automatically created for you after you index some documents.', + })} +

+ } + actions={ + + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.searchUI.empty.buttonLabel', { + defaultMessage: 'Read the Search UI guide', + })} + + } + /> +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.test.tsx index edec376dd3edd1..f9f0dd611b9539 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.test.tsx @@ -6,14 +6,17 @@ */ import '../../../__mocks__/shallow_useeffect.mock'; -import '../../__mocks__/engine_logic.mock'; -import { setMockActions } from '../../../__mocks__/kea_logic'; +import { setMockActions, setMockValues } from '../../../__mocks__/kea_logic'; +import { mockEngineValues } from '../../__mocks__/engine_logic.mock'; import React from 'react'; import { shallow } from 'enzyme'; +import { SearchUIForm } from './components/search_ui_form'; +import { SearchUIGraphic } from './components/search_ui_graphic'; + import { SearchUI } from './'; describe('SearchUI', () => { @@ -24,11 +27,13 @@ describe('SearchUI', () => { beforeEach(() => { jest.clearAllMocks(); setMockActions(actions); + setMockValues(mockEngineValues); }); it('renders', () => { - shallow(); - // TODO: Check for form + const wrapper = shallow(); + expect(wrapper.find(SearchUIForm).exists()).toBe(true); + expect(wrapper.find(SearchUIGraphic).exists()).toBe(true); }); it('initializes data on mount', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.tsx index e75bc36177151f..0ac59a33068baf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.tsx @@ -7,25 +7,16 @@ import React, { useEffect } from 'react'; -import { useActions } from 'kea'; +import { useActions, useValues } from 'kea'; -import { - EuiPageHeader, - EuiPageContentBody, - EuiText, - EuiFlexItem, - EuiFlexGroup, - EuiSpacer, - EuiLink, -} from '@elastic/eui'; +import { EuiText, EuiFlexItem, EuiFlexGroup, EuiSpacer, EuiLink } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { FlashMessages } from '../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; - import { DOCS_PREFIX } from '../../routes'; -import { getEngineBreadcrumbs } from '../engine'; +import { EngineLogic, getEngineBreadcrumbs } from '../engine'; +import { AppSearchPageTemplate } from '../layout'; +import { EmptyState } from './components/empty_state'; import { SearchUIForm } from './components/search_ui_form'; import { SearchUIGraphic } from './components/search_ui_graphic'; import { SEARCH_UI_TITLE } from './i18n'; @@ -33,61 +24,62 @@ import { SearchUILogic } from './search_ui_logic'; export const SearchUI: React.FC = () => { const { loadFieldData } = useActions(SearchUILogic); + const { engine } = useValues(EngineLogic); useEffect(() => { loadFieldData(); }, []); return ( - <> - - - - - - - -

- - - - ), - }} - /> -

-

- - - - ), - }} - /> -

-
- - -
- - - -
-
- + } + > + + + +

+ + + + ), + }} + /> +

+

+ + + + ), + }} + /> +

+
+ + +
+ + + +
+
); }; From 21f6a1bc92dd30b4eaff1a14ba14538aa25be46d Mon Sep 17 00:00:00 2001 From: Matthias Wilhelm Date: Tue, 22 Jun 2021 18:23:30 +0200 Subject: [PATCH 42/57] [Discover][Main] Improve state related code (#102028) --- .../layout/discover_layout.test.tsx | 4 +- .../components/layout/discover_layout.tsx | 91 ++------- .../apps/main/components/layout/types.ts | 8 +- .../components/sidebar/discover_sidebar.tsx | 22 +- .../discover_sidebar_responsive.test.tsx | 11 - .../sidebar/discover_sidebar_responsive.tsx | 9 - .../sidebar/lib/group_fields.test.ts | 6 +- .../components/sidebar/lib/group_fields.tsx | 4 +- .../apps/main/discover_main_app.tsx | 46 +---- .../apps/main/services/discover_state.ts | 68 ++++++- .../main/services/use_discover_state.test.ts | 4 - .../apps/main/services/use_discover_state.ts | 188 ++++++++++++------ .../main/services/use_saved_search.test.ts | 52 ++++- .../apps/main/services/use_saved_search.ts | 135 +++++-------- 14 files changed, 323 insertions(+), 325 deletions(-) diff --git a/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.test.tsx b/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.test.tsx index 2fd394d98281b7..57a9d518f838ef 100644 --- a/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.test.tsx +++ b/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.test.tsx @@ -18,7 +18,6 @@ import { createSearchSourceMock } from '../../../../../../../data/common/search/ import { IndexPattern, IndexPatternAttributes } from '../../../../../../../data/common'; import { SavedObject } from '../../../../../../../../core/types'; import { indexPatternWithTimefieldMock } from '../../../../../__mocks__/index_pattern_with_timefield'; -import { DiscoverSearchSessionManager } from '../../services/discover_search_session'; import { GetStateReturn } from '../../services/discover_state'; import { DiscoverLayoutProps } from './types'; import { SavedSearchDataSubject } from '../../services/use_saved_search'; @@ -50,11 +49,12 @@ function getProps(indexPattern: IndexPattern): DiscoverLayoutProps { indexPattern, indexPatternList, navigateTo: jest.fn(), + onChangeIndexPattern: jest.fn(), + onUpdateQuery: jest.fn(), resetQuery: jest.fn(), savedSearch: savedSearchMock, savedSearchData$: savedSearch$, savedSearchRefetch$: new Subject(), - searchSessionManager: {} as DiscoverSearchSessionManager, searchSource: searchSourceMock, services, state: { columns: [] }, diff --git a/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.tsx b/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.tsx index 0430614d413b6b..a10674323e5cbf 100644 --- a/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.tsx +++ b/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.tsx @@ -36,10 +36,8 @@ import { SortPairArr } from '../../../../angular/doc_table/lib/get_sort'; import { DOC_HIDE_TIME_COLUMN_SETTING, DOC_TABLE_LEGACY, - MODIFY_COLUMNS_ON_SWITCH, SAMPLE_SIZE_SETTING, SEARCH_FIELDS_FROM_SOURCE, - SORT_DEFAULT_ORDER_SETTING, } from '../../../../../../common'; import { popularizeField } from '../../../../helpers/popularize_field'; import { DocViewFilterFn } from '../../../../doc_views/doc_views_types'; @@ -52,7 +50,6 @@ import { InspectorSession } from '../../../../../../../inspector/public'; import { DiscoverUninitialized } from '../uninitialized/uninitialized'; import { SavedSearchDataMessage } from '../../services/use_saved_search'; import { useDataGridColumns } from '../../../../helpers/use_data_grid_columns'; -import { getSwitchIndexPatternAppState } from '../../utils/get_switch_index_pattern_app_state'; import { FetchStatus } from '../../../../types'; const DocTableLegacyMemoized = React.memo(DocTableLegacy); @@ -72,26 +69,20 @@ export function DiscoverLayout({ indexPattern, indexPatternList, navigateTo, + onChangeIndexPattern, + onUpdateQuery, savedSearchRefetch$, resetQuery, savedSearchData$, savedSearch, - searchSessionManager, searchSource, services, state, stateContainer, }: DiscoverLayoutProps) { - const { - trackUiMetric, - capabilities, - indexPatterns, - data, - uiSettings: config, - filterManager, - } = services; + const { trackUiMetric, capabilities, indexPatterns, data, uiSettings, filterManager } = services; - const sampleSize = useMemo(() => config.get(SAMPLE_SIZE_SETTING), [config]); + const sampleSize = useMemo(() => uiSettings.get(SAMPLE_SIZE_SETTING), [uiSettings]); const [expandedDoc, setExpandedDoc] = useState(undefined); const [inspectorSession, setInspectorSession] = useState(undefined); const scrollableDesktop = useRef(null); @@ -121,42 +112,21 @@ export function DiscoverLayout({ }; }, [savedSearchData$, fetchState]); - const isMobile = () => { - // collapse icon isn't displayed in mobile view, use it to detect which view is displayed - return collapseIcon && !collapseIcon.current; - }; + // collapse icon isn't displayed in mobile view, use it to detect which view is displayed + const isMobile = () => collapseIcon && !collapseIcon.current; const timeField = useMemo(() => { return indexPatternsUtils.isDefault(indexPattern) ? indexPattern.timeFieldName : undefined; }, [indexPattern]); const [isSidebarClosed, setIsSidebarClosed] = useState(false); - const isLegacy = useMemo(() => services.uiSettings.get(DOC_TABLE_LEGACY), [services]); - const useNewFieldsApi = useMemo(() => !services.uiSettings.get(SEARCH_FIELDS_FROM_SOURCE), [ - services, - ]); - - const unmappedFieldsConfig = useMemo( - () => ({ - showUnmappedFields: useNewFieldsApi, - }), - [useNewFieldsApi] - ); + const isLegacy = useMemo(() => uiSettings.get(DOC_TABLE_LEGACY), [uiSettings]); + const useNewFieldsApi = useMemo(() => !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE), [uiSettings]); const resultState = useMemo(() => getResultState(fetchStatus, rows!), [fetchStatus, rows]); - const updateQuery = useCallback( - (_payload, isUpdate?: boolean) => { - if (isUpdate === false) { - searchSessionManager.removeSearchSessionIdFromURL({ replace: false }); - savedSearchRefetch$.next(); - } - }, - [savedSearchRefetch$, searchSessionManager] - ); - const { columns, onAddColumn, onRemoveColumn, onMoveColumn, onSetColumns } = useDataGridColumns({ capabilities, - config, + config: uiSettings, indexPattern, indexPatterns, setAppState: stateContainer.setAppState, @@ -243,42 +213,8 @@ export function DiscoverLayout({ const contentCentered = resultState === 'uninitialized'; const showTimeCol = useMemo( - () => !config.get(DOC_HIDE_TIME_COLUMN_SETTING, false) && !!indexPattern.timeFieldName, - [config, indexPattern.timeFieldName] - ); - - const onChangeIndexPattern = useCallback( - async (id: string) => { - const nextIndexPattern = await indexPatterns.get(id); - if (nextIndexPattern && indexPattern) { - /** - * Without resetting the fetch state, e.g. a time column would be displayed when switching - * from a index pattern without to a index pattern with time filter for a brief moment - * That's because appState is updated before savedSearchData$ - * The following line of code catches this, but should be improved - */ - savedSearchData$.next({ rows: [], state: FetchStatus.LOADING, fieldCounts: {} }); - - const nextAppState = getSwitchIndexPatternAppState( - indexPattern, - nextIndexPattern, - state.columns || [], - (state.sort || []) as SortPairArr[], - config.get(MODIFY_COLUMNS_ON_SWITCH), - config.get(SORT_DEFAULT_ORDER_SETTING) - ); - stateContainer.setAppState(nextAppState); - } - }, - [ - config, - indexPattern, - indexPatterns, - savedSearchData$, - state.columns, - state.sort, - stateContainer, - ] + () => !uiSettings.get(DOC_HIDE_TIME_COLUMN_SETTING, false) && !!indexPattern.timeFieldName, + [uiSettings, indexPattern.timeFieldName] ); return ( @@ -294,7 +230,7 @@ export function DiscoverLayout({ searchSource={searchSource} services={services} stateContainer={stateContainer} - updateQuery={updateQuery} + updateQuery={onUpdateQuery} />

@@ -316,7 +252,6 @@ export function DiscoverLayout({ state={state} isClosed={isSidebarClosed} trackUiMetric={trackUiMetric} - unmappedFieldsConfig={unmappedFieldsConfig} useNewFieldsApi={useNewFieldsApi} onEditRuntimeField={onEditRuntimeField} /> @@ -373,7 +308,7 @@ export function DiscoverLayout({ > >; - resetQuery: () => void; navigateTo: (url: string) => void; + onChangeIndexPattern: (id: string) => void; + onUpdateQuery: (payload: { dateRange: TimeRange; query?: Query }, isUpdate?: boolean) => void; + resetQuery: () => void; savedSearch: SavedSearch; savedSearchData$: SavedSearchDataSubject; savedSearchRefetch$: SavedSearchRefetchSubject; - searchSessionManager: DiscoverSearchSessionManager; searchSource: ISearchSource; services: DiscoverServices; state: AppState; diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.tsx index 7fbbf6fd3ffdc7..7f8866a2ee369d 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.tsx @@ -82,7 +82,6 @@ export function DiscoverSidebar({ trackUiMetric, useNewFieldsApi = false, useFlyout = false, - unmappedFieldsConfig, onEditRuntimeField, onChangeIndexPattern, setFieldEditorRef, @@ -129,25 +128,8 @@ export function DiscoverSidebar({ popular: popularFields, unpopular: unpopularFields, } = useMemo( - () => - groupFields( - fields, - columns, - popularLimit, - fieldCounts, - fieldFilter, - useNewFieldsApi, - !!unmappedFieldsConfig?.showUnmappedFields - ), - [ - fields, - columns, - popularLimit, - fieldCounts, - fieldFilter, - useNewFieldsApi, - unmappedFieldsConfig?.showUnmappedFields, - ] + () => groupFields(fields, columns, popularLimit, fieldCounts, fieldFilter, useNewFieldsApi), + [fields, columns, popularLimit, fieldCounts, fieldFilter, useNewFieldsApi] ); const paginate = useCallback(() => { diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.test.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.test.tsx index 2ad75806173eb0..6973221fd36248 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.test.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.test.tsx @@ -25,7 +25,6 @@ import { } from './discover_sidebar_responsive'; import { DiscoverServices } from '../../../../../build_services'; import { ElasticSearchHit } from '../../../../doc_views/doc_views_types'; -import { DiscoverSidebar } from './discover_sidebar'; const mockServices = ({ history: () => ({ @@ -132,14 +131,4 @@ describe('discover responsive sidebar', function () { findTestSubject(comp, 'plus-extension-gif').simulate('click'); expect(props.onAddFilter).toHaveBeenCalled(); }); - it('renders sidebar with unmapped fields config', function () { - const unmappedFieldsConfig = { - showUnmappedFields: false, - }; - const componentProps = { ...props, unmappedFieldsConfig }; - const component = mountWithIntl(); - const discoverSidebar = component.find(DiscoverSidebar); - expect(discoverSidebar).toHaveLength(1); - expect(discoverSidebar.props().unmappedFieldsConfig).toEqual(unmappedFieldsConfig); - }); }); diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.tsx index cc33601f77728f..003bb22599e480 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.tsx @@ -105,15 +105,6 @@ export interface DiscoverSidebarResponsiveProps { * Read from the Fields API */ useNewFieldsApi?: boolean; - /** - * an object containing properties for proper handling of unmapped fields - */ - unmappedFieldsConfig?: { - /** - * determines whether to display unmapped fields - */ - showUnmappedFields: boolean; - }; /** * callback to execute on edit runtime field */ diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/lib/group_fields.test.ts b/src/plugins/discover/public/application/apps/main/components/sidebar/lib/group_fields.test.ts index 58697206356214..cd9f6b3cac4a51 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/lib/group_fields.test.ts +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/lib/group_fields.test.ts @@ -244,8 +244,7 @@ describe('group_fields', function () { 5, fieldCounts, fieldFilterState, - true, - false + true ); expect(actual.unpopular).toEqual([]); }); @@ -270,8 +269,7 @@ describe('group_fields', function () { 5, fieldCounts, fieldFilterState, - false, - undefined + false ); expect(actual.unpopular.map((field) => field.name)).toEqual(['unknown_field']); }); diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/lib/group_fields.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/lib/group_fields.tsx index dc6cbcedc80864..2007d32fe84bee 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/lib/group_fields.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/lib/group_fields.tsx @@ -24,9 +24,9 @@ export function groupFields( popularLimit: number, fieldCounts: Record, fieldFilterState: FieldFilterState, - useNewFieldsApi: boolean, - showUnmappedFields = true + useNewFieldsApi: boolean ): GroupedFields { + const showUnmappedFields = useNewFieldsApi; const result: GroupedFields = { selected: [], popular: [], diff --git a/src/plugins/discover/public/application/apps/main/discover_main_app.tsx b/src/plugins/discover/public/application/apps/main/discover_main_app.tsx index 5cc7147b49ff98..07939fff6e7f48 100644 --- a/src/plugins/discover/public/application/apps/main/discover_main_app.tsx +++ b/src/plugins/discover/public/application/apps/main/discover_main_app.tsx @@ -5,15 +5,12 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import React, { useMemo, useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { History } from 'history'; import { DiscoverLayout } from './components/layout'; -import { SEARCH_FIELDS_FROM_SOURCE } from '../../../../common'; -import { useSavedSearch as useSavedSearchData } from './services/use_saved_search'; import { setBreadcrumbsTitle } from '../../helpers/breadcrumbs'; import { addHelpMenuToAppChrome } from '../../components/help_menu/help_menu_util'; import { useDiscoverState } from './services/use_discover_state'; -import { useSearchSession } from './services/use_search_session'; import { useUrl } from './services/use_url'; import { IndexPattern, IndexPatternAttributes, SavedObject } from '../../../../../data/common'; import { DiscoverServices } from '../../../build_services'; @@ -55,18 +52,20 @@ export function DiscoverMainApp(props: DiscoverMainProps) { const { services, history, navigateTo, indexPatternList } = props.opts; const { chrome, docLinks, uiSettings: config, data } = services; - const useNewFieldsApi = useMemo(() => !config.get(SEARCH_FIELDS_FROM_SOURCE), [config]); - /** * State related logic */ const { - stateContainer, - state, + data$, indexPattern, - searchSource, - savedSearch, + onChangeIndexPattern, + onUpdateQuery, + refetch$, resetSavedSearch, + savedSearch, + searchSource, + state, + stateContainer, } = useDiscoverState({ services, history, @@ -79,25 +78,6 @@ export function DiscoverMainApp(props: DiscoverMainProps) { */ useUrl({ history, resetSavedSearch }); - /** - * Search session logic - */ - const searchSessionManager = useSearchSession({ services, history, stateContainer, savedSearch }); - - /** - * Data fetching logic - */ - const { data$, refetch$ } = useSavedSearchData({ - indexPattern, - savedSearch, - searchSessionManager, - searchSource, - services, - state, - stateContainer, - useNewFieldsApi, - }); - /** * SavedSearch depended initializing */ @@ -115,11 +95,6 @@ export function DiscoverMainApp(props: DiscoverMainProps) { */ useEffect(() => { addHelpMenuToAppChrome(chrome, docLinks); - stateContainer.replaceUrlAppState({}).then(() => { - stateContainer.startSync(); - }); - - return () => stateContainer.stopSync(); }, [stateContainer, chrome, docLinks]); const resetQuery = useCallback(() => { @@ -130,12 +105,13 @@ export function DiscoverMainApp(props: DiscoverMainProps) { ; + /** + * Function starting state sync when Discover main is loaded + */ + initializeAndSync: ( + indexPattern: IndexPattern, + filterManager: FilterManager, + data: DataPublicPluginStart + ) => () => void; /** * Start sync between state and URL */ @@ -204,16 +216,18 @@ export function getState({ stateStorage, }); + const replaceUrlAppState = async (newPartial: AppState = {}) => { + const state = { ...appStateContainer.getState(), ...newPartial }; + await stateStorage.set(APP_STATE_URL_KEY, state, { replace: true }); + }; + return { kbnUrlStateStorage: stateStorage, appStateContainer: appStateContainerModified, startSync: start, stopSync: stop, setAppState: (newPartial: AppState) => setState(appStateContainerModified, newPartial), - replaceUrlAppState: async (newPartial: AppState = {}) => { - const state = { ...appStateContainer.getState(), ...newPartial }; - await stateStorage.set(APP_STATE_URL_KEY, state, { replace: true }); - }, + replaceUrlAppState, resetInitialAppState: () => { initialAppState = appStateContainer.getState(); }, @@ -224,6 +238,50 @@ export function getState({ getPreviousAppState: () => previousAppState, flushToUrl: () => stateStorage.kbnUrlControls.flush(), isAppStateDirty: () => !isEqualState(initialAppState, appStateContainer.getState()), + initializeAndSync: ( + indexPattern: IndexPattern, + filterManager: FilterManager, + data: DataPublicPluginStart + ) => { + if (appStateContainer.getState().index !== indexPattern.id) { + // used index pattern is different than the given by url/state which is invalid + setState(appStateContainerModified, { index: indexPattern.id }); + } + // sync initial app filters from state to filterManager + const filters = appStateContainer.getState().filters; + if (filters) { + filterManager.setAppFilters(cloneDeep(filters)); + } + const query = appStateContainer.getState().query; + if (query) { + data.query.queryString.setQuery(query); + } + + const stopSyncingQueryAppStateWithStateContainer = connectToQueryState( + data.query, + appStateContainer, + { + filters: esFilters.FilterStateStore.APP_STATE, + query: true, + } + ); + + // syncs `_g` portion of url with query services + const { stop: stopSyncingGlobalStateWithUrl } = syncQueryStateWithUrl( + data.query, + stateStorage + ); + + replaceUrlAppState({}).then(() => { + start(); + }); + + return () => { + stopSyncingQueryAppStateWithStateContainer(); + stopSyncingGlobalStateWithUrl(); + stop(); + }; + }, }; } diff --git a/src/plugins/discover/public/application/apps/main/services/use_discover_state.test.ts b/src/plugins/discover/public/application/apps/main/services/use_discover_state.test.ts index 051a2d2dcd9cc7..4c3d819f063a0d 100644 --- a/src/plugins/discover/public/application/apps/main/services/use_discover_state.test.ts +++ b/src/plugins/discover/public/application/apps/main/services/use_discover_state.test.ts @@ -62,10 +62,6 @@ describe('test useDiscoverState', () => { }); }); - await act(async () => { - result.current.stateContainer.startSync(); - }); - const initialColumns = result.current.state.columns; await act(async () => { result.current.setState({ columns: ['123'] }); diff --git a/src/plugins/discover/public/application/apps/main/services/use_discover_state.ts b/src/plugins/discover/public/application/apps/main/services/use_discover_state.ts index a3546d54cd4932..3c736f09a82967 100644 --- a/src/plugins/discover/public/application/apps/main/services/use_discover_state.ts +++ b/src/plugins/discover/public/application/apps/main/services/use_discover_state.ts @@ -6,19 +6,25 @@ * Side Public License, v 1. */ import { useMemo, useEffect, useState, useCallback } from 'react'; -import { cloneDeep } from 'lodash'; +import { isEqual } from 'lodash'; import { History } from 'history'; import { getState } from './discover_state'; import { getStateDefaults } from '../utils/get_state_defaults'; -import { - esFilters, - connectToQueryState, - syncQueryStateWithUrl, - IndexPattern, -} from '../../../../../../data/public'; +import { IndexPattern } from '../../../../../../data/public'; import { DiscoverServices } from '../../../../build_services'; import { SavedSearch } from '../../../../saved_searches'; import { loadIndexPattern } from '../utils/resolve_index_pattern'; +import { useSavedSearch as useSavedSearchData } from './use_saved_search'; +import { + MODIFY_COLUMNS_ON_SWITCH, + SEARCH_FIELDS_FROM_SOURCE, + SEARCH_ON_PAGE_LOAD_SETTING, + SORT_DEFAULT_ORDER_SETTING, +} from '../../../../../common'; +import { useSearchSession } from './use_search_session'; +import { FetchStatus } from '../../../types'; +import { getSwitchIndexPatternAppState } from '../utils/get_switch_index_pattern_app_state'; +import { SortPairArr } from '../../../angular/doc_table/lib/get_sort'; export function useDiscoverState({ services, @@ -31,9 +37,11 @@ export function useDiscoverState({ history: History; initialIndexPattern: IndexPattern; }) { - const { uiSettings: config, data, filterManager } = services; + const { uiSettings: config, data, filterManager, indexPatterns } = services; const [indexPattern, setIndexPattern] = useState(initialIndexPattern); const [savedSearch, setSavedSearch] = useState(initialSavedSearch); + const useNewFieldsApi = useMemo(() => !config.get(SEARCH_FIELDS_FROM_SOURCE), [config]); + const timefilter = data.query.timefilter.timefilter; const searchSource = useMemo(() => { savedSearch.searchSource.setField('index', indexPattern); @@ -57,73 +65,80 @@ export function useDiscoverState({ [config, data, history, savedSearch, services.core.notifications.toasts] ); - const { appStateContainer, getPreviousAppState } = stateContainer; + const { appStateContainer } = stateContainer; const [state, setState] = useState(appStateContainer.getState()); - useEffect(() => { - if (stateContainer.appStateContainer.getState().index !== indexPattern.id) { - // used index pattern is different than the given by url/state which is invalid - stateContainer.setAppState({ index: indexPattern.id }); - } - // sync initial app filters from state to filterManager - const filters = appStateContainer.getState().filters; - if (filters) { - filterManager.setAppFilters(cloneDeep(filters)); - } - const query = appStateContainer.getState().query; - if (query) { - data.query.queryString.setQuery(query); - } + /** + * Search session logic + */ + const searchSessionManager = useSearchSession({ services, history, stateContainer, savedSearch }); - const stopSyncingQueryAppStateWithStateContainer = connectToQueryState( - data.query, - appStateContainer, - { - filters: esFilters.FilterStateStore.APP_STATE, - query: true, - } - ); + const initialFetchStatus: FetchStatus = useMemo(() => { + // A saved search is created on every page load, so we check the ID to see if we're loading a + // previously saved search or if it is just transient + const shouldSearchOnPageLoad = + config.get(SEARCH_ON_PAGE_LOAD_SETTING) || + savedSearch.id !== undefined || + timefilter.getRefreshInterval().pause === false || + searchSessionManager.hasSearchSessionIdInURL(); + return shouldSearchOnPageLoad ? FetchStatus.LOADING : FetchStatus.UNINITIALIZED; + }, [config, savedSearch.id, searchSessionManager, timefilter]); - // syncs `_g` portion of url with query services - const { stop: stopSyncingGlobalStateWithUrl } = syncQueryStateWithUrl( - data.query, - stateContainer.kbnUrlStateStorage - ); + /** + * Data fetching logic + */ + const { data$, refetch$, reset } = useSavedSearchData({ + indexPattern, + initialFetchStatus, + searchSessionManager, + searchSource, + services, + stateContainer, + useNewFieldsApi, + }); + + useEffect(() => { + const stopSync = stateContainer.initializeAndSync(indexPattern, filterManager, data); return () => { - stopSyncingQueryAppStateWithStateContainer(); - stopSyncingGlobalStateWithUrl(); + stopSync(); }; - }, [ - appStateContainer, - config, - data.query, - data.search.session, - getPreviousAppState, - indexPattern.id, - filterManager, - services.indexPatterns, - stateContainer, - ]); + }, [stateContainer, filterManager, data, indexPattern]); + /** + * Track state changes that should trigger a fetch + */ useEffect(() => { - const unsubscribe = stateContainer.appStateContainer.subscribe(async (nextState) => { + const unsubscribe = appStateContainer.subscribe(async (nextState) => { + const { hideChart, interval, sort, index } = state; + // chart was hidden, now it should be displayed, so data is needed + const chartDisplayChanged = nextState.hideChart !== hideChart && hideChart; + const chartIntervalChanged = nextState.interval !== interval; + const docTableSortChanged = !isEqual(nextState.sort, sort); + const indexPatternChanged = !isEqual(nextState.index, index); // NOTE: this is also called when navigating from discover app to context app - if (nextState.index && state.index !== nextState.index) { - const nextIndexPattern = await loadIndexPattern( - nextState.index, - services.indexPatterns, - config - ); + if (nextState.index && indexPatternChanged) { + /** + * Without resetting the fetch state, e.g. a time column would be displayed when switching + * from a index pattern without to a index pattern with time filter for a brief moment + * That's because appState is updated before savedSearchData$ + * The following line of code catches this, but should be improved + */ + reset(); + const nextIndexPattern = await loadIndexPattern(nextState.index, indexPatterns, config); if (nextIndexPattern) { setIndexPattern(nextIndexPattern.loaded); } } + + if (chartDisplayChanged || chartIntervalChanged || docTableSortChanged) { + refetch$.next(); + } setState(nextState); }); return () => unsubscribe(); - }, [config, services.indexPatterns, state.index, stateContainer.appStateContainer, setState]); + }, [config, indexPatterns, appStateContainer, setState, state, refetch$, data$, reset]); const resetSavedSearch = useCallback( async (id?: string) => { @@ -143,13 +158,62 @@ export function useDiscoverState({ [services, indexPattern, config, data, stateContainer, savedSearch.id] ); + /** + * Function triggered when user changes index pattern in the sidebar + */ + const onChangeIndexPattern = useCallback( + async (id: string) => { + const nextIndexPattern = await indexPatterns.get(id); + if (nextIndexPattern && indexPattern) { + const nextAppState = getSwitchIndexPatternAppState( + indexPattern, + nextIndexPattern, + state.columns || [], + (state.sort || []) as SortPairArr[], + config.get(MODIFY_COLUMNS_ON_SWITCH), + config.get(SORT_DEFAULT_ORDER_SETTING) + ); + stateContainer.setAppState(nextAppState); + } + }, + [config, indexPattern, indexPatterns, state.columns, state.sort, stateContainer] + ); + /** + * Function triggered when the user changes the query in the search bar + */ + const onUpdateQuery = useCallback( + (_payload, isUpdate?: boolean) => { + if (isUpdate === false) { + searchSessionManager.removeSearchSessionIdFromURL({ replace: false }); + refetch$.next(); + } + }, + [refetch$, searchSessionManager] + ); + + /** + * Initial data fetching, also triggered when index pattern changes + */ + useEffect(() => { + if (!indexPattern) { + return; + } + if (initialFetchStatus === FetchStatus.LOADING) { + refetch$.next(); + } + }, [initialFetchStatus, refetch$, indexPattern, data$]); + return { - state, - setState, - stateContainer, + data$, indexPattern, - searchSource, - savedSearch, + refetch$, resetSavedSearch, + onChangeIndexPattern, + onUpdateQuery, + savedSearch, + searchSource, + setState, + state, + stateContainer, }; } diff --git a/src/plugins/discover/public/application/apps/main/services/use_saved_search.test.ts b/src/plugins/discover/public/application/apps/main/services/use_saved_search.test.ts index 5976c8fea5ea4f..128c94f284f56c 100644 --- a/src/plugins/discover/public/application/apps/main/services/use_saved_search.test.ts +++ b/src/plugins/discover/public/application/apps/main/services/use_saved_search.test.ts @@ -12,9 +12,10 @@ import { discoverServiceMock } from '../../../../__mocks__/services'; import { savedSearchMock } from '../../../../__mocks__/saved_search'; import { indexPatternMock } from '../../../../__mocks__/index_pattern'; import { useSavedSearch } from './use_saved_search'; -import { AppState, getState } from './discover_state'; +import { getState } from './discover_state'; import { uiSettingsMock } from '../../../../__mocks__/ui_settings'; import { useDiscoverState } from './use_discover_state'; +import { FetchStatus } from '../../../types'; describe('test useSavedSearch', () => { test('useSavedSearch return is valid', async () => { @@ -28,11 +29,10 @@ describe('test useSavedSearch', () => { const { result } = renderHook(() => { return useSavedSearch({ indexPattern: indexPatternMock, - savedSearch: savedSearchMock, + initialFetchStatus: FetchStatus.LOADING, searchSessionManager, searchSource: savedSearchMock.searchSource.createCopy(), services: discoverServiceMock, - state: {} as AppState, stateContainer, useNewFieldsApi: true, }); @@ -69,11 +69,10 @@ describe('test useSavedSearch', () => { const { result, waitForValueToChange } = renderHook(() => { return useSavedSearch({ indexPattern: indexPatternMock, - savedSearch: savedSearchMock, + initialFetchStatus: FetchStatus.LOADING, searchSessionManager, searchSource: resultState.current.searchSource, services: discoverServiceMock, - state: {} as AppState, stateContainer, useNewFieldsApi: true, }); @@ -88,4 +87,47 @@ describe('test useSavedSearch', () => { expect(result.current.data$.value.hits).toBe(0); expect(result.current.data$.value.rows).toEqual([]); }); + + test('reset sets back to initial state', async () => { + const { history, searchSessionManager } = createSearchSessionMock(); + const stateContainer = getState({ + getStateDefaults: () => ({ index: 'the-index-pattern-id' }), + history, + uiSettings: uiSettingsMock, + }); + + discoverServiceMock.data.query.timefilter.timefilter.getTime = jest.fn(() => { + return { from: '2021-05-01T20:00:00Z', to: '2021-05-02T20:00:00Z' }; + }); + + const { result: resultState } = renderHook(() => { + return useDiscoverState({ + services: discoverServiceMock, + history, + initialIndexPattern: indexPatternMock, + initialSavedSearch: savedSearchMock, + }); + }); + + const { result, waitForValueToChange } = renderHook(() => { + return useSavedSearch({ + indexPattern: indexPatternMock, + initialFetchStatus: FetchStatus.LOADING, + searchSessionManager, + searchSource: resultState.current.searchSource, + services: discoverServiceMock, + stateContainer, + useNewFieldsApi: true, + }); + }); + + result.current.refetch$.next(); + + await waitForValueToChange(() => { + return result.current.data$.value.state === FetchStatus.COMPLETE; + }); + + result.current.reset(); + expect(result.current.data$.value.state).toBe(FetchStatus.LOADING); + }); }); diff --git a/src/plugins/discover/public/application/apps/main/services/use_saved_search.ts b/src/plugins/discover/public/application/apps/main/services/use_saved_search.ts index 2b0d9517248694..8c847b54078eb7 100644 --- a/src/plugins/discover/public/application/apps/main/services/use_saved_search.ts +++ b/src/plugins/discover/public/application/apps/main/services/use_saved_search.ts @@ -5,11 +5,10 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { useEffect, useRef, useCallback, useMemo } from 'react'; +import { useEffect, useRef, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { merge, Subject, BehaviorSubject } from 'rxjs'; import { debounceTime, tap, filter } from 'rxjs/operators'; -import { isEqual } from 'lodash'; import { DiscoverServices } from '../../../../build_services'; import { DiscoverSearchSessionManager } from './discover_search_session'; import { @@ -18,13 +17,11 @@ import { SearchSource, tabifyAggResponse, } from '../../../../../../data/common'; -import { SavedSearch } from '../../../../saved_searches'; -import { AppState, GetStateReturn } from './discover_state'; +import { GetStateReturn } from './discover_state'; import { ElasticSearchHit } from '../../../doc_views/doc_views_types'; import { RequestAdapter } from '../../../../../../inspector/public'; import { AutoRefreshDoneFn, search } from '../../../../../../data/public'; import { calcFieldCounts } from '../utils/calc_field_counts'; -import { SEARCH_ON_PAGE_LOAD_SETTING } from '../../../../../common'; import { validateTimeRange } from '../utils/validate_time_range'; import { updateSearchSource } from '../utils/update_search_source'; import { SortOrder } from '../../../../saved_searches/types'; @@ -40,6 +37,7 @@ export type SavedSearchRefetchSubject = Subject; export interface UseSavedSearch { refetch$: SavedSearchRefetchSubject; data$: SavedSearchDataSubject; + reset: () => void; } export type SavedSearchRefetchMsg = 'reset' | undefined; @@ -59,48 +57,27 @@ export interface SavedSearchDataMessage { /** * This hook return 2 observables, refetch$ allows to trigger data fetching, data$ to subscribe * to the data fetching - * @param indexPattern - * @param savedSearch - * @param searchSessionManager - * @param searchSource - * @param services - * @param state - * @param stateContainer - * @param useNewFieldsApi */ export const useSavedSearch = ({ indexPattern, - savedSearch, + initialFetchStatus, searchSessionManager, searchSource, services, - state, stateContainer, useNewFieldsApi, }: { indexPattern: IndexPattern; - savedSearch: SavedSearch; + initialFetchStatus: FetchStatus; searchSessionManager: DiscoverSearchSessionManager; searchSource: SearchSource; services: DiscoverServices; - state: AppState; stateContainer: GetStateReturn; useNewFieldsApi: boolean; }): UseSavedSearch => { - const { data, filterManager, uiSettings } = services; + const { data, filterManager } = services; const timefilter = data.query.timefilter.timefilter; - const initFetchState: FetchStatus = useMemo(() => { - // A saved search is created on every page load, so we check the ID to see if we're loading a - // previously saved search or if it is just transient - const shouldSearchOnPageLoad = - uiSettings.get(SEARCH_ON_PAGE_LOAD_SETTING) || - savedSearch.id !== undefined || - timefilter.getRefreshInterval().pause === false || - searchSessionManager.hasSearchSessionIdInURL(); - return shouldSearchOnPageLoad ? FetchStatus.LOADING : FetchStatus.UNINITIALIZED; - }, [uiSettings, savedSearch.id, searchSessionManager, timefilter]); - /** * The observable the UI (aka React component) subscribes to get notified about * the changes in the data fetching process (high level: fetching started, data was received) @@ -108,7 +85,7 @@ export const useSavedSearch = ({ const data$: SavedSearchDataSubject = useSingleton( () => new BehaviorSubject({ - state: initFetchState, + state: initialFetchStatus, }) ); /** @@ -123,15 +100,14 @@ export const useSavedSearch = ({ */ const refs = useRef<{ abortController?: AbortController; - /** - * used to compare a new state against an old one, to evaluate if data needs to be fetched - */ - appState: AppState; /** * handler emitted by `timefilter.getAutoRefreshFetch$()` * to notify when data completed loading and to start a new autorefresh loop */ autoRefreshDoneCb?: AutoRefreshDoneFn; + /** + * Number of fetches used for functional testing + */ fetchCounter: number; /** * needed to right auto refresh behavior, a new auto refresh shouldnt be triggered when @@ -144,12 +120,34 @@ export const useSavedSearch = ({ */ fieldCounts: Record; }>({ - appState: state, fetchCounter: 0, fieldCounts: {}, - fetchStatus: initFetchState, + fetchStatus: initialFetchStatus, }); + /** + * Resets the fieldCounts cache and sends a reset message + * It is set to initial state (no documents, fetchCounter to 0) + * Needed when index pattern is switched or a new runtime field is added + */ + const sendResetMsg = useCallback( + (fetchStatus?: FetchStatus) => { + refs.current.fieldCounts = {}; + refs.current.fetchStatus = fetchStatus ?? initialFetchStatus; + data$.next({ + state: initialFetchStatus, + fetchCounter: 0, + rows: [], + fieldCounts: {}, + chartData: undefined, + bucketInterval: undefined, + }); + }, + [data$, initialFetchStatus] + ); + /** + * Function to fetch data from ElasticSearch + */ const fetchAll = useCallback( (reset = false) => { if (!validateTimeRange(timefilter.getTime(), services.toastNotifications)) { @@ -161,23 +159,18 @@ export const useSavedSearch = ({ refs.current.abortController = new AbortController(); const sessionId = searchSessionManager.getNextSearchSessionId(); - // Let the UI know, data fetching started - const loadingMessage: SavedSearchDataMessage = { - state: FetchStatus.LOADING, - fetchCounter: ++refs.current.fetchCounter, - }; - if (reset) { - // when runtime field was added, changed, deleted, index pattern was switched - loadingMessage.rows = []; - loadingMessage.fieldCounts = {}; - loadingMessage.chartData = undefined; - loadingMessage.bucketInterval = undefined; + sendResetMsg(FetchStatus.LOADING); + } else { + // Let the UI know, data fetching started + data$.next({ + state: FetchStatus.LOADING, + fetchCounter: ++refs.current.fetchCounter, + }); + refs.current.fetchStatus = FetchStatus.LOADING; } - data$.next(loadingMessage); - refs.current.fetchStatus = loadingMessage.state; - const { sort } = stateContainer.appStateContainer.getState(); + const { sort, hideChart, interval } = stateContainer.appStateContainer.getState(); updateSearchSource(searchSource, false, { indexPattern, services, @@ -185,8 +178,8 @@ export const useSavedSearch = ({ useNewFieldsApi, }); const chartAggConfigs = - indexPattern.timeFieldName && !state.hideChart && state.interval - ? getChartAggConfigs(searchSource, state.interval, data) + indexPattern.timeFieldName && !hideChart && interval + ? getChartAggConfigs(searchSource, interval, data) : undefined; if (!chartAggConfigs) { @@ -217,16 +210,12 @@ export const useSavedSearch = ({ state: FetchStatus.COMPLETE, rows: documents, inspectorAdapters, - fieldCounts: calcFieldCounts( - reset ? {} : refs.current.fieldCounts, - documents, - indexPattern - ), + fieldCounts: calcFieldCounts(refs.current.fieldCounts, documents, indexPattern), hits: res.rawResponse.hits.total as number, }; if (chartAggConfigs) { - const bucketAggConfig = chartAggConfigs!.aggs[1]; + const bucketAggConfig = chartAggConfigs.aggs[1]; const tabifiedData = tabifyAggResponse(chartAggConfigs, res.rawResponse); const dimensions = getDimensions(chartAggConfigs, data); if (dimensions) { @@ -259,14 +248,13 @@ export const useSavedSearch = ({ [ timefilter, services, + searchSessionManager, stateContainer.appStateContainer, searchSource, indexPattern, useNewFieldsApi, - state.hideChart, - state.interval, data, - searchSessionManager, + sendResetMsg, data$, ] ); @@ -306,32 +294,9 @@ export const useSavedSearch = ({ fetchAll, ]); - /** - * Track state changes that should trigger a fetch - */ - useEffect(() => { - const prevAppState = refs.current.appState; - - // chart was hidden, now it should be displayed, so data is needed - const chartDisplayChanged = state.hideChart !== prevAppState.hideChart && !state.hideChart; - const chartIntervalChanged = state.interval !== prevAppState.interval; - const docTableSortChanged = !isEqual(state.sort, prevAppState.sort); - const indexPatternChanged = !isEqual(state.index, prevAppState.index); - - refs.current.appState = state; - if (chartDisplayChanged || chartIntervalChanged || docTableSortChanged || indexPatternChanged) { - refetch$.next(indexPatternChanged ? 'reset' : undefined); - } - }, [refetch$, state.interval, state.sort, state]); - - useEffect(() => { - if (initFetchState === FetchStatus.LOADING) { - refetch$.next(); - } - }, [initFetchState, refetch$]); - return { refetch$, data$, + reset: sendResetMsg, }; }; From 19d2d17158e27597df713a7f4f5c999ba83632fa Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Tue, 22 Jun 2021 12:25:10 -0400 Subject: [PATCH 43/57] [Security Solution][Endpoint] Rename `Unisolating` and other like words to `Releasing` (#102582) * Update all instances of `unisolate` to `release` (along with variation of unisolate) * just width of Agent Status column on endpoint list --- .../host_isolation/endpoint_host_isolation_status.test.tsx | 4 ++-- .../host_isolation/endpoint_host_isolation_status.tsx | 6 +++--- .../components/endpoint/host_isolation/translations.ts | 6 +++--- .../detections/components/host_isolation/translations.ts | 2 +- .../view/components/endpoint_agent_status.test.tsx | 4 +--- .../endpoint_hosts/view/hooks/use_endpoint_action_items.tsx | 2 +- .../public/management/pages/endpoint_hosts/view/index.tsx | 4 ++-- .../management/pages/endpoint_hosts/view/translations.ts | 6 +++--- 8 files changed, 16 insertions(+), 18 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_status.test.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_status.test.tsx index 44405748b6373b..4ceacc40942e20 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_status.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_status.test.tsx @@ -44,8 +44,8 @@ describe('when using the EndpointHostIsolationStatus component', () => { }); it.each([ - ['Isolating pending', { pendingIsolate: 2 }], - ['Unisolating pending', { pendingUnIsolate: 2 }], + ['Isolating', { pendingIsolate: 2 }], + ['Releasing', { pendingUnIsolate: 2 }], ['4 actions pending', { isIsolated: true, pendingUnIsolate: 2, pendingIsolate: 2 }], ])('should show %s}', (expectedLabel, componentProps) => { const { getByTestId } = render(componentProps); diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_status.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_status.tsx index 0fe3a8e4337cb2..7ae7cae89f19ed 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_status.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/endpoint_host_isolation_status.tsx @@ -74,7 +74,7 @@ export const EndpointHostIsolationStatus = memo {pendingUnIsolate} @@ -101,12 +101,12 @@ export const EndpointHostIsolationStatus = memo ) : ( )} diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/translations.ts b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/translations.ts index 790c951f61ccd9..66d9bf3a7c71b7 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/translations.ts @@ -26,13 +26,13 @@ export const COMMENT_PLACEHOLDER = i18n.translate( export const GET_ISOLATION_SUCCESS_MESSAGE = (hostName: string) => i18n.translate('xpack.securitySolution.endpoint.hostIsolation.isolation.successfulMessage', { - defaultMessage: 'Host Isolation on {hostName} successfully submitted', + defaultMessage: 'Isolation on host {hostName} successfully submitted', values: { hostName }, }); export const GET_UNISOLATION_SUCCESS_MESSAGE = (hostName: string) => i18n.translate('xpack.securitySolution.endpoint.hostIsolation.unisolate.successfulMessage', { - defaultMessage: 'Host Unisolation on {hostName} successfully submitted', + defaultMessage: 'Release on host {hostName} successfully submitted', values: { hostName }, }); @@ -41,7 +41,7 @@ export const ISOLATE = i18n.translate('xpack.securitySolution.endpoint.hostisola }); export const UNISOLATE = i18n.translate('xpack.securitySolution.endpoint.hostisolation.unisolate', { - defaultMessage: 'unisolate', + defaultMessage: 'release', }); export const NOT_ISOLATED = i18n.translate( diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts b/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts index 98b74817cabb67..58667c26ce2e6b 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts @@ -17,7 +17,7 @@ export const ISOLATE_HOST = i18n.translate( export const UNISOLATE_HOST = i18n.translate( 'xpack.securitySolution.endpoint.hostIsolation.unisolateHost', { - defaultMessage: 'Unisolate host', + defaultMessage: 'Release host', } ); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/endpoint_agent_status.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/endpoint_agent_status.test.tsx index 9010bb5785c1d4..a860e3c45deeef 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/endpoint_agent_status.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/endpoint_agent_status.test.tsx @@ -82,9 +82,7 @@ describe('When using the EndpointAgentStatus component', () => { }); it('should show host pending action', () => { - expect(renderResult.getByTestId('rowIsolationStatus').textContent).toEqual( - 'Isolating pending' - ); + expect(renderResult.getByTestId('rowIsolationStatus').textContent).toEqual('Isolating'); }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx index 7c38c935a0b9f3..408e1794ef680a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx @@ -76,7 +76,7 @@ export const useEndpointActionItems = ( children: ( ), }); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index d1dab3dd07a7e3..9316d2539d1338 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -272,7 +272,7 @@ export const EndpointList = () => { }, { field: 'host_status', - width: '9%', + width: '14%', name: i18n.translate('xpack.securitySolution.endpoint.list.hostStatus', { defaultMessage: 'Agent Status', }), @@ -356,7 +356,7 @@ export const EndpointList = () => { }, { field: 'metadata.host.os.name', - width: '10%', + width: '9%', name: i18n.translate('xpack.securitySolution.endpoint.list.os', { defaultMessage: 'Operating System', }), diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts index 1a7889f22db16c..18a5bd1e5130a5 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts @@ -26,7 +26,7 @@ export const ACTIVITY_LOG = { unisolatedAction: i18n.translate( 'xpack.securitySolution.endpointDetails.activityLog.logEntry.action.unisolated', { - defaultMessage: 'unisolated host', + defaultMessage: 'released host', } ), }, @@ -46,13 +46,13 @@ export const ACTIVITY_LOG = { unisolationSuccessful: i18n.translate( 'xpack.securitySolution.endpointDetails.activityLog.logEntry.response.unisolationSuccessful', { - defaultMessage: 'host unisolation successful', + defaultMessage: 'host release successful', } ), unisolationFailed: i18n.translate( 'xpack.securitySolution.endpointDetails.activityLog.logEntry.response.unisolationFailed', { - defaultMessage: 'host unisolation failed', + defaultMessage: 'host release failed', } ), }, From 5ffe26cbf3e0ef0764a7c296634cd0ed1214b7f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20C=C3=B4t=C3=A9?= Date: Tue, 22 Jun 2021 12:30:40 -0400 Subject: [PATCH 44/57] Add "Unable to decrypt attribute apiKey" to the alerting troubleshooting docs (#101315) * Initial commit * PR feedback * PR feedback pt 2 * PR feedback pt 3 --- .../alerting-troubleshooting.asciidoc | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/user/alerting/alerting-troubleshooting.asciidoc b/docs/user/alerting/alerting-troubleshooting.asciidoc index b7b0c749dfe149..08655508b3cbab 100644 --- a/docs/user/alerting/alerting-troubleshooting.asciidoc +++ b/docs/user/alerting/alerting-troubleshooting.asciidoc @@ -12,6 +12,32 @@ If your problem isn’t described here, please review open issues in the followi Have a question? Contact us in the https://discuss.elastic.co/[discuss forum]. +[float] +[[rule-cannot-decrypt-api-key]] +=== Rule cannot decrypt apiKey + +*Problem*: + +The rule fails to execute and has an `Unable to decrypt attribute "apiKey"` error. + +*Solution*: + +This error happens when the `xpack.encryptedSavedObjects.encryptionKey` value used to create the rule does not match the value used during rule execution. Depending on the scenario, there are different ways to solve this problem: + +[cols="2*<"] +|=== + +| If the value in `xpack.encryptedSavedObjects.encryptionKey` was manually changed, and the previous encryption key is still known. +| Ensure any previous encryption key is included in the keys used for <>. + +| If another {kib} instance with a different encryption key connects to the cluster. +| The other {kib} instance might be trying to run the rule using a different encryption key than what the rule was created with. Ensure the encryption keys among all the {kib} instances are the same, and setting <> for previously used encryption keys. + +| If other scenarios don't apply. +| Generate a new API key for the rule by disabling then enabling the rule. + +|=== + [float] [[rules-small-check-interval-run-late]] === Rules with small check intervals run late @@ -29,7 +55,6 @@ Either tweak the <> or increa For more details, see <>. - [float] [[scheduled-rules-run-late]] === Rules run late From 81e1debf7407a07e54810fcab2ddb274fe5ea767 Mon Sep 17 00:00:00 2001 From: Maja Grubic Date: Tue, 22 Jun 2021 17:33:23 +0100 Subject: [PATCH 45/57] [Discover] Add source to doc viewer (#101392) * [Discover] Add source to doc viewer * Updating a unit test * Fix typescript errors * Add unit test * Add a functional test * Fixing a typo * Remove unnecessary import * Always request fields and source * Remove unused import * Move initialization of SourceViewer back to setup * Trying to get rid of null value * Readding null * Try to get rid of null value * Addressing PR comments * Return early if jsonValue is not set * Fix loading spinner style * Add refresh on error * Fix error message * Add loading indicator on an empty string Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/application/components/doc/doc.tsx | 7 +- .../components/doc/elastic_request_state.ts | 15 + .../components/doc/use_es_doc_search.test.tsx | 39 +- .../components/doc/use_es_doc_search.ts | 84 +- .../json_code_editor.test.tsx.snap | 60 +- .../json_code_editor/json_code_editor.scss | 2 +- .../json_code_editor/json_code_editor.tsx | 59 +- .../json_code_editor_common.tsx | 86 ++ .../__snapshots__/source_viewer.test.tsx.snap | 760 ++++++++++++++++++ .../source_viewer/source_viewer.scss | 14 + .../source_viewer/source_viewer.test.tsx | 118 +++ .../source_viewer/source_viewer.tsx | 129 +++ src/plugins/discover/public/plugin.tsx | 15 +- .../apps/discover/_discover_fields_api.ts | 9 + test/functional/page_objects/discover_page.ts | 8 + 15 files changed, 1248 insertions(+), 157 deletions(-) create mode 100644 src/plugins/discover/public/application/components/doc/elastic_request_state.ts create mode 100644 src/plugins/discover/public/application/components/json_code_editor/json_code_editor_common.tsx create mode 100644 src/plugins/discover/public/application/components/source_viewer/__snapshots__/source_viewer.test.tsx.snap create mode 100644 src/plugins/discover/public/application/components/source_viewer/source_viewer.scss create mode 100644 src/plugins/discover/public/application/components/source_viewer/source_viewer.test.tsx create mode 100644 src/plugins/discover/public/application/components/source_viewer/source_viewer.tsx diff --git a/src/plugins/discover/public/application/components/doc/doc.tsx b/src/plugins/discover/public/application/components/doc/doc.tsx index e38709b4651740..ed8bcf30d2bd17 100644 --- a/src/plugins/discover/public/application/components/doc/doc.tsx +++ b/src/plugins/discover/public/application/components/doc/doc.tsx @@ -10,9 +10,10 @@ import React from 'react'; import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; import { EuiCallOut, EuiLink, EuiLoadingSpinner, EuiPageContent, EuiPage } from '@elastic/eui'; import { IndexPatternsContract } from 'src/plugins/data/public'; -import { ElasticRequestState, useEsDocSearch } from './use_es_doc_search'; +import { useEsDocSearch } from './use_es_doc_search'; import { getServices } from '../../../kibana_services'; import { DocViewer } from '../doc_viewer/doc_viewer'; +import { ElasticRequestState } from './elastic_request_state'; export interface DocProps { /** @@ -32,6 +33,10 @@ export interface DocProps { * IndexPatternService to get a given index pattern by ID */ indexPatternService: IndexPatternsContract; + /** + * If set, will always request source, regardless of the global `fieldsFromSource` setting + */ + requestSource?: boolean; } export function Doc(props: DocProps) { diff --git a/src/plugins/discover/public/application/components/doc/elastic_request_state.ts b/src/plugins/discover/public/application/components/doc/elastic_request_state.ts new file mode 100644 index 00000000000000..241e37c47a7e7b --- /dev/null +++ b/src/plugins/discover/public/application/components/doc/elastic_request_state.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export enum ElasticRequestState { + Loading, + NotFound, + Found, + Error, + NotFoundIndexPattern, +} diff --git a/src/plugins/discover/public/application/components/doc/use_es_doc_search.test.tsx b/src/plugins/discover/public/application/components/doc/use_es_doc_search.test.tsx index f3a6b274649f5b..9fdb564cb518d2 100644 --- a/src/plugins/discover/public/application/components/doc/use_es_doc_search.test.tsx +++ b/src/plugins/discover/public/application/components/doc/use_es_doc_search.test.tsx @@ -7,11 +7,12 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; -import { buildSearchBody, useEsDocSearch, ElasticRequestState } from './use_es_doc_search'; +import { buildSearchBody, useEsDocSearch } from './use_es_doc_search'; import { DocProps } from './doc'; import { Observable } from 'rxjs'; import { SEARCH_FIELDS_FROM_SOURCE as mockSearchFieldsFromSource } from '../../../../common'; import { IndexPattern } from 'src/plugins/data/common'; +import { ElasticRequestState } from './elastic_request_state'; const mockSearchResult = new Observable(); @@ -88,6 +89,36 @@ describe('Test of helper / hook', () => { `); }); + test('buildSearchBody with requestSource', () => { + const indexPattern = ({ + getComputedFields: () => ({ storedFields: [], scriptFields: [], docvalueFields: [] }), + } as unknown) as IndexPattern; + const actual = buildSearchBody('1', indexPattern, true, true); + expect(actual).toMatchInlineSnapshot(` + Object { + "body": Object { + "_source": true, + "fields": Array [ + Object { + "field": "*", + "include_unmapped": "true", + }, + ], + "query": Object { + "ids": Object { + "values": Array [ + "1", + ], + }, + }, + "runtime_mappings": Object {}, + "script_fields": Array [], + "stored_fields": Array [], + }, + } + `); + }); + test('buildSearchBody with runtime fields', () => { const indexPattern = ({ getComputedFields: () => ({ @@ -155,7 +186,11 @@ describe('Test of helper / hook', () => { await act(async () => { hook = renderHook((p: DocProps) => useEsDocSearch(p), { initialProps: props }); }); - expect(hook.result.current).toEqual([ElasticRequestState.Loading, null, indexPattern]); + expect(hook.result.current.slice(0, 3)).toEqual([ + ElasticRequestState.Loading, + null, + indexPattern, + ]); expect(getMock).toHaveBeenCalled(); }); }); diff --git a/src/plugins/discover/public/application/components/doc/use_es_doc_search.ts b/src/plugins/discover/public/application/components/doc/use_es_doc_search.ts index 7a3320d43c8b51..71a32b758aca79 100644 --- a/src/plugins/discover/public/application/components/doc/use_es_doc_search.ts +++ b/src/plugins/discover/public/application/components/doc/use_es_doc_search.ts @@ -6,23 +6,16 @@ * Side Public License, v 1. */ -import { useEffect, useState, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import type { estypes } from '@elastic/elasticsearch'; -import { IndexPattern, getServices } from '../../../kibana_services'; +import { getServices, IndexPattern } from '../../../kibana_services'; import { DocProps } from './doc'; import { ElasticSearchHit } from '../../doc_views/doc_views_types'; import { SEARCH_FIELDS_FROM_SOURCE } from '../../../../common'; +import { ElasticRequestState } from './elastic_request_state'; type RequestBody = Pick; -export enum ElasticRequestState { - Loading, - NotFound, - Found, - Error, - NotFoundIndexPattern, -} - /** * helper function to build a query body for Elasticsearch * https://www.elastic.co/guide/en/elasticsearch/reference/current//query-dsl-ids-query.html @@ -30,7 +23,8 @@ export enum ElasticRequestState { export function buildSearchBody( id: string, indexPattern: IndexPattern, - useNewFieldsApi: boolean + useNewFieldsApi: boolean, + requestAllFields?: boolean ): RequestBody | undefined { const computedFields = indexPattern.getComputedFields(); const runtimeFields = computedFields.runtimeFields as estypes.MappingRuntimeFields; @@ -52,6 +46,9 @@ export function buildSearchBody( // @ts-expect-error request.body.fields = [{ field: '*', include_unmapped: 'true' }]; request.body.runtime_mappings = runtimeFields ? runtimeFields : {}; + if (requestAllFields) { + request.body._source = true; + } } else { request.body._source = true; } @@ -67,47 +64,50 @@ export function useEsDocSearch({ index, indexPatternId, indexPatternService, -}: DocProps): [ElasticRequestState, ElasticSearchHit | null, IndexPattern | null] { + requestSource, +}: DocProps): [ElasticRequestState, ElasticSearchHit | null, IndexPattern | null, () => void] { const [indexPattern, setIndexPattern] = useState(null); const [status, setStatus] = useState(ElasticRequestState.Loading); const [hit, setHit] = useState(null); const { data, uiSettings } = useMemo(() => getServices(), []); const useNewFieldsApi = useMemo(() => !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE), [uiSettings]); - useEffect(() => { - async function requestData() { - try { - const indexPatternEntity = await indexPatternService.get(indexPatternId); - setIndexPattern(indexPatternEntity); + const requestData = useCallback(async () => { + try { + const indexPatternEntity = await indexPatternService.get(indexPatternId); + setIndexPattern(indexPatternEntity); - const { rawResponse } = await data.search - .search({ - params: { - index, - body: buildSearchBody(id, indexPatternEntity, useNewFieldsApi)?.body, - }, - }) - .toPromise(); + const { rawResponse } = await data.search + .search({ + params: { + index, + body: buildSearchBody(id, indexPatternEntity, useNewFieldsApi, requestSource)?.body, + }, + }) + .toPromise(); - const hits = rawResponse.hits; + const hits = rawResponse.hits; - if (hits?.hits?.[0]) { - setStatus(ElasticRequestState.Found); - setHit(hits.hits[0]); - } else { - setStatus(ElasticRequestState.NotFound); - } - } catch (err) { - if (err.savedObjectId) { - setStatus(ElasticRequestState.NotFoundIndexPattern); - } else if (err.status === 404) { - setStatus(ElasticRequestState.NotFound); - } else { - setStatus(ElasticRequestState.Error); - } + if (hits?.hits?.[0]) { + setStatus(ElasticRequestState.Found); + setHit(hits.hits[0]); + } else { + setStatus(ElasticRequestState.NotFound); + } + } catch (err) { + if (err.savedObjectId) { + setStatus(ElasticRequestState.NotFoundIndexPattern); + } else if (err.status === 404) { + setStatus(ElasticRequestState.NotFound); + } else { + setStatus(ElasticRequestState.Error); } } + }, [id, index, indexPatternId, indexPatternService, data.search, useNewFieldsApi, requestSource]); + + useEffect(() => { requestData(); - }, [id, index, indexPatternId, indexPatternService, data.search, useNewFieldsApi]); - return [status, hit, indexPattern]; + }, [requestData]); + + return [status, hit, indexPattern, requestData]; } diff --git a/src/plugins/discover/public/application/components/json_code_editor/__snapshots__/json_code_editor.test.tsx.snap b/src/plugins/discover/public/application/components/json_code_editor/__snapshots__/json_code_editor.test.tsx.snap index 8f076148134956..31dd6347218b5a 100644 --- a/src/plugins/discover/public/application/components/json_code_editor/__snapshots__/json_code_editor.test.tsx.snap +++ b/src/plugins/discover/public/application/components/json_code_editor/__snapshots__/json_code_editor.test.tsx.snap @@ -1,21 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`returns the \`JsonCodeEditor\` component 1`] = ` - - - -
- - - -
-
- - - -
+ onEditorDidMount={[Function]} +/> `; diff --git a/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.scss b/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.scss index 5521df5b363acd..335805ed284934 100644 --- a/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.scss +++ b/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.scss @@ -1,3 +1,3 @@ .dscJsonCodeEditor { - width: 100% + width: 100%; } diff --git a/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.tsx b/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.tsx index b8427bb6bbdd25..f1ecd3ae3b70bd 100644 --- a/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.tsx +++ b/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.tsx @@ -9,17 +9,8 @@ import './json_code_editor.scss'; import React, { useCallback } from 'react'; -import { i18n } from '@kbn/i18n'; -import { monaco, XJsonLang } from '@kbn/monaco'; -import { EuiButtonEmpty, EuiCopy, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import { CodeEditor } from '../../../../../kibana_react/public'; - -const codeEditorAriaLabel = i18n.translate('discover.json.codeEditorAriaLabel', { - defaultMessage: 'Read only JSON view of an elasticsearch document', -}); -const copyToClipboardLabel = i18n.translate('discover.json.copyToClipboardLabel', { - defaultMessage: 'Copy to clipboard', -}); +import { monaco } from '@kbn/monaco'; +import { JsonCodeEditorCommon } from './json_code_editor_common'; interface JsonCodeEditorProps { json: Record; @@ -47,45 +38,11 @@ export const JsonCodeEditor = ({ json, width, hasLineNumbers }: JsonCodeEditorPr }, []); return ( - - - -
- - {(copy) => ( - - {copyToClipboardLabel} - - )} - -
-
- - {}} - editorDidMount={setEditorCalculatedHeight} - aria-label={codeEditorAriaLabel} - options={{ - automaticLayout: true, - fontSize: 12, - lineNumbers: hasLineNumbers ? 'on' : 'off', - minimap: { - enabled: false, - }, - overviewRulerBorder: false, - readOnly: true, - scrollbar: { - alwaysConsumeMouseWheel: false, - }, - scrollBeyondLastLine: false, - wordWrap: 'on', - wrappingIndent: 'indent', - }} - /> - -
+ ); }; diff --git a/src/plugins/discover/public/application/components/json_code_editor/json_code_editor_common.tsx b/src/plugins/discover/public/application/components/json_code_editor/json_code_editor_common.tsx new file mode 100644 index 00000000000000..e5ab8bf4d1a0d1 --- /dev/null +++ b/src/plugins/discover/public/application/components/json_code_editor/json_code_editor_common.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import './json_code_editor.scss'; + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { monaco, XJsonLang } from '@kbn/monaco'; +import { EuiButtonEmpty, EuiCopy, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { CodeEditor } from '../../../../../kibana_react/public'; + +const codeEditorAriaLabel = i18n.translate('discover.json.codeEditorAriaLabel', { + defaultMessage: 'Read only JSON view of an elasticsearch document', +}); +const copyToClipboardLabel = i18n.translate('discover.json.copyToClipboardLabel', { + defaultMessage: 'Copy to clipboard', +}); + +interface JsonCodeEditorCommonProps { + jsonValue: string; + onEditorDidMount: (editor: monaco.editor.IStandaloneCodeEditor) => void; + width?: string | number; + hasLineNumbers?: boolean; +} + +export const JsonCodeEditorCommon = ({ + jsonValue, + width, + hasLineNumbers, + onEditorDidMount, +}: JsonCodeEditorCommonProps) => { + if (jsonValue === '') { + return null; + } + return ( + + + +
+ + {(copy) => ( + + {copyToClipboardLabel} + + )} + +
+
+ + {}} + editorDidMount={onEditorDidMount} + aria-label={codeEditorAriaLabel} + options={{ + automaticLayout: true, + fontSize: 12, + lineNumbers: hasLineNumbers ? 'on' : 'off', + minimap: { + enabled: false, + }, + overviewRulerBorder: false, + readOnly: true, + scrollbar: { + alwaysConsumeMouseWheel: false, + }, + scrollBeyondLastLine: false, + wordWrap: 'on', + wrappingIndent: 'indent', + }} + /> + +
+ ); +}; + +export const JSONCodeEditorCommonMemoized = React.memo((props: JsonCodeEditorCommonProps) => { + return ; +}); diff --git a/src/plugins/discover/public/application/components/source_viewer/__snapshots__/source_viewer.test.tsx.snap b/src/plugins/discover/public/application/components/source_viewer/__snapshots__/source_viewer.test.tsx.snap new file mode 100644 index 00000000000000..f40dbbbae1f877 --- /dev/null +++ b/src/plugins/discover/public/application/components/source_viewer/__snapshots__/source_viewer.test.tsx.snap @@ -0,0 +1,760 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Source Viewer component renders error state 1`] = ` + + + Could not fetch data at this time. Refresh the tab to try again. + + + Refresh + +

+ } + iconType="alert" + title={ +

+ An Error Occurred +

+ } + > +
+ + + + +
+ + + + +

+ An Error Occurred +

+
+ +
+ + +
+
+ Could not fetch data at this time. Refresh the tab to try again. + +
+ + + + + + +
+
+ + + +
+ + +`; + +exports[`Source Viewer component renders json code editor 1`] = ` + + + + +
+ +
+ +
+ +
+ + + + + + + + + +
+
+ + +
+ + + } + > + + + + + + +
+
+
+ + + + +`; + +exports[`Source Viewer component renders loading state 1`] = ` + +
+ + + + +
+ +
+ + Loading JSON + +
+
+
+
+
+
+`; diff --git a/src/plugins/discover/public/application/components/source_viewer/source_viewer.scss b/src/plugins/discover/public/application/components/source_viewer/source_viewer.scss new file mode 100644 index 00000000000000..224e84ca50b52e --- /dev/null +++ b/src/plugins/discover/public/application/components/source_viewer/source_viewer.scss @@ -0,0 +1,14 @@ +.sourceViewer__loading { + display: flex; + flex-direction: row; + justify-content: left; + flex: 1 0 100%; + text-align: center; + height: 100%; + width: 100%; + margin-top: $euiSizeS; +} + +.sourceViewer__loadingSpinner { + margin-right: $euiSizeS; +} diff --git a/src/plugins/discover/public/application/components/source_viewer/source_viewer.test.tsx b/src/plugins/discover/public/application/components/source_viewer/source_viewer.test.tsx new file mode 100644 index 00000000000000..86433e5df64014 --- /dev/null +++ b/src/plugins/discover/public/application/components/source_viewer/source_viewer.test.tsx @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { mountWithIntl } from '@kbn/test/jest'; +import { SourceViewer } from './source_viewer'; +import * as hooks from '../doc/use_es_doc_search'; +import * as useUiSettingHook from 'src/plugins/kibana_react/public/ui_settings/use_ui_setting'; +import { EuiButton, EuiEmptyPrompt, EuiLoadingSpinner } from '@elastic/eui'; +import { JsonCodeEditorCommon } from '../json_code_editor/json_code_editor_common'; + +jest.mock('../../../kibana_services', () => ({ + getServices: jest.fn(), +})); + +import { getServices, IndexPattern } from '../../../kibana_services'; + +const mockIndexPattern = { + getComputedFields: () => [], +} as never; +const getMock = jest.fn(() => Promise.resolve(mockIndexPattern)); +const mockIndexPatternService = ({ + get: getMock, +} as unknown) as IndexPattern; + +(getServices as jest.Mock).mockImplementation(() => ({ + uiSettings: { + get: (key: string) => { + if (key === 'discover:useNewFieldsApi') { + return true; + } + }, + }, + data: { + indexPatternService: mockIndexPatternService, + }, +})); +describe('Source Viewer component', () => { + test('renders loading state', () => { + jest.spyOn(hooks, 'useEsDocSearch').mockImplementation(() => [0, null, null, () => {}]); + + const comp = mountWithIntl( + + ); + expect(comp).toMatchSnapshot(); + const loadingIndicator = comp.find(EuiLoadingSpinner); + expect(loadingIndicator).not.toBe(null); + }); + + test('renders error state', () => { + jest.spyOn(hooks, 'useEsDocSearch').mockImplementation(() => [3, null, null, () => {}]); + + const comp = mountWithIntl( + + ); + expect(comp).toMatchSnapshot(); + const errorPrompt = comp.find(EuiEmptyPrompt); + expect(errorPrompt.length).toBe(1); + const refreshButton = comp.find(EuiButton); + expect(refreshButton.length).toBe(1); + }); + + test('renders json code editor', () => { + const mockHit = { + _index: 'logstash-2014.09.09', + _type: 'doc', + _id: 'id123', + _score: 1, + _source: { + message: 'Lorem ipsum dolor sit amet', + extension: 'html', + not_mapped: 'yes', + bytes: 100, + objectArray: [{ foo: true }], + relatedContent: { + test: 1, + }, + scripted: 123, + _underscore: 123, + }, + } as never; + jest + .spyOn(hooks, 'useEsDocSearch') + .mockImplementation(() => [2, mockHit, mockIndexPattern, () => {}]); + jest.spyOn(useUiSettingHook, 'useUiSetting').mockImplementation(() => { + return false; + }); + const comp = mountWithIntl( + + ); + expect(comp).toMatchSnapshot(); + const jsonCodeEditor = comp.find(JsonCodeEditorCommon); + expect(jsonCodeEditor).not.toBe(null); + }); +}); diff --git a/src/plugins/discover/public/application/components/source_viewer/source_viewer.tsx b/src/plugins/discover/public/application/components/source_viewer/source_viewer.tsx new file mode 100644 index 00000000000000..94a12c04613a95 --- /dev/null +++ b/src/plugins/discover/public/application/components/source_viewer/source_viewer.tsx @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import './source_viewer.scss'; + +import React, { useEffect, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { monaco } from '@kbn/monaco'; +import { EuiButton, EuiEmptyPrompt, EuiLoadingSpinner, EuiSpacer, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useEsDocSearch } from '../doc/use_es_doc_search'; +import { JSONCodeEditorCommonMemoized } from '../json_code_editor/json_code_editor_common'; +import { ElasticRequestState } from '../doc/elastic_request_state'; +import { getServices } from '../../../../public/kibana_services'; +import { SEARCH_FIELDS_FROM_SOURCE } from '../../../../common'; + +interface SourceViewerProps { + id: string; + index: string; + indexPatternId: string; + hasLineNumbers: boolean; + width?: number; +} + +export const SourceViewer = ({ + id, + index, + indexPatternId, + width, + hasLineNumbers, +}: SourceViewerProps) => { + const [editor, setEditor] = useState(); + const [jsonValue, setJsonValue] = useState(''); + const indexPatternService = getServices().data.indexPatterns; + const useNewFieldsApi = !getServices().uiSettings.get(SEARCH_FIELDS_FROM_SOURCE); + const [reqState, hit, , requestData] = useEsDocSearch({ + id, + index, + indexPatternId, + indexPatternService, + requestSource: useNewFieldsApi, + }); + + useEffect(() => { + if (reqState === ElasticRequestState.Found && hit) { + setJsonValue(JSON.stringify(hit, undefined, 2)); + } + }, [reqState, hit]); + + // setting editor height based on lines height and count to stretch and fit its content + useEffect(() => { + if (!editor) { + return; + } + const editorElement = editor.getDomNode(); + + if (!editorElement) { + return; + } + + const lineHeight = editor.getOption(monaco.editor.EditorOption.lineHeight); + const lineCount = editor.getModel()?.getLineCount() || 1; + const height = editor.getTopForLineNumber(lineCount + 1) + lineHeight; + if (!jsonValue || jsonValue === '') { + editorElement.style.height = '0px'; + } else { + editorElement.style.height = `${height}px`; + } + editor.layout(); + }, [editor, jsonValue]); + + const loadingState = ( +
+ + + + +
+ ); + + const errorMessageTitle = ( +

+ {i18n.translate('discover.sourceViewer.errorMessageTitle', { + defaultMessage: 'An Error Occurred', + })} +

+ ); + const errorMessage = ( +
+ {i18n.translate('discover.sourceViewer.errorMessage', { + defaultMessage: 'Could not fetch data at this time. Refresh the tab to try again.', + })} + + + {i18n.translate('discover.sourceViewer.refresh', { + defaultMessage: 'Refresh', + })} + +
+ ); + const errorState = ( + + ); + + if ( + reqState === ElasticRequestState.Error || + reqState === ElasticRequestState.NotFound || + reqState === ElasticRequestState.NotFoundIndexPattern + ) { + return errorState; + } + + if (reqState === ElasticRequestState.Loading || jsonValue === '') { + return loadingState; + } + + return ( + setEditor(editorNode)} + /> + ); +}; diff --git a/src/plugins/discover/public/plugin.tsx b/src/plugins/discover/public/plugin.tsx index 139b23d28a1d43..7b4e7bb67c00ec 100644 --- a/src/plugins/discover/public/plugin.tsx +++ b/src/plugins/discover/public/plugin.tsx @@ -37,7 +37,7 @@ import { UrlGeneratorState } from '../../share/public'; import { DocViewInput, DocViewInputFn } from './application/doc_views/doc_views_types'; import { DocViewsRegistry } from './application/doc_views/doc_views_registry'; import { DocViewTable } from './application/components/table/table'; -import { JsonCodeEditor } from './application/components/json_code_editor/json_code_editor'; + import { setDocViewsRegistry, setUrlTracker, @@ -63,6 +63,7 @@ import { SearchEmbeddableFactory } from './application/embeddable'; import { UsageCollectionSetup } from '../../usage_collection/public'; import { replaceUrlHashQuery } from '../../kibana_utils/public/'; import { IndexPatternFieldEditorStart } from '../../../plugins/index_pattern_field_editor/public'; +import { SourceViewer } from './application/components/source_viewer/source_viewer'; declare module '../../share/public' { export interface UrlGeneratorStateMapping { @@ -178,7 +179,6 @@ export class DiscoverPlugin }) ); } - this.docViewsRegistry = new DocViewsRegistry(); setDocViewsRegistry(this.docViewsRegistry); this.docViewsRegistry.addDocView({ @@ -193,8 +193,14 @@ export class DiscoverPlugin defaultMessage: 'JSON', }), order: 20, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - component: ({ hit }) => , + component: ({ hit, indexPattern }) => ( + + ), }); const { @@ -273,6 +279,7 @@ export class DiscoverPlugin // make sure the index pattern list is up to date await dataStart.indexPatterns.clearCache(); + const { renderApp } = await import('./application/application'); params.element.classList.add('dscAppWrapper'); const unmount = await renderApp(innerAngularName, params.element); diff --git a/test/functional/apps/discover/_discover_fields_api.ts b/test/functional/apps/discover/_discover_fields_api.ts index 614a0794ffb3b2..42e2a94b364620 100644 --- a/test/functional/apps/discover/_discover_fields_api.ts +++ b/test/functional/apps/discover/_discover_fields_api.ts @@ -11,6 +11,7 @@ import { FtrProviderContext } from './ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); + const docTable = getService('docTable'); const retry = getService('retry'); const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); @@ -58,5 +59,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await PageObjects.discover.getDocHeader()).not.to.have.string('_score'); expect(await PageObjects.discover.getDocHeader()).to.have.string('Document'); }); + + it('displays _source viewer in doc viewer', async function () { + await docTable.clickRowToggle({ rowIndex: 0 }); + + await PageObjects.discover.isShowingDocViewer(); + await PageObjects.discover.clickDocViewerTab(1); + await PageObjects.discover.expectSourceViewerToExist(); + }); }); } diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index 41c4441a1c95de..65b899d2e2fb08 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -289,6 +289,14 @@ export class DiscoverPageObject extends FtrService { return await this.testSubjects.exists('kbnDocViewer'); } + public async clickDocViewerTab(index: number) { + return await this.find.clickByCssSelector(`#kbn_doc_viewer_tab_${index}`); + } + + public async expectSourceViewerToExist() { + return await this.find.byClassName('monaco-editor'); + } + public async getMarks() { const table = await this.docTable.getTable(); const marks = await table.findAllByTagName('mark'); From 91c584d766b8983694868a2869363d7ad5ebe4c0 Mon Sep 17 00:00:00 2001 From: Spencer Date: Tue, 22 Jun 2021 09:41:38 -0700 Subject: [PATCH 46/57] remove duplicate apm-rum deps from devDeps (#102838) Co-authored-by: spalger --- package.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/package.json b/package.json index 873dffeed38f8a..f654f5f62ba9ce 100644 --- a/package.json +++ b/package.json @@ -446,8 +446,6 @@ "@bazel/typescript": "^3.5.1", "@cypress/snapshot": "^2.1.7", "@cypress/webpack-preprocessor": "^5.6.0", - "@elastic/apm-rum": "^5.6.1", - "@elastic/apm-rum-react": "^1.2.5", "@elastic/eslint-config-kibana": "link:bazel-bin/packages/elastic-eslint-config-kibana", "@elastic/eslint-plugin-eui": "0.0.2", "@elastic/github-checks-reporter": "0.0.20b3", From 537fcf4ff2eefd15ffb928e002327849fc17639b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= Date: Tue, 22 Jun 2021 18:49:34 +0200 Subject: [PATCH 47/57] [Security Solution][Endpoint] Don't create event filters list from manifest manager (#102618) * Check if endpoint event filters list exists before create and create it without specific id * Removes creation of endpoint event filters list in manifest manager Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../create_endoint_event_filters_list.ts | 82 ------------------- .../exception_lists/exception_list_client.ts | 13 --- .../server/endpoint/lib/artifacts/lists.ts | 2 - 3 files changed, 97 deletions(-) delete mode 100644 x-pack/plugins/lists/server/services/exception_lists/create_endoint_event_filters_list.ts diff --git a/x-pack/plugins/lists/server/services/exception_lists/create_endoint_event_filters_list.ts b/x-pack/plugins/lists/server/services/exception_lists/create_endoint_event_filters_list.ts deleted file mode 100644 index 94a049d10cc45d..00000000000000 --- a/x-pack/plugins/lists/server/services/exception_lists/create_endoint_event_filters_list.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { SavedObjectsClientContract } from 'kibana/server'; -import uuid from 'uuid'; -import { Version } from '@kbn/securitysolution-io-ts-types'; -import type { ExceptionListSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { getSavedObjectType } from '@kbn/securitysolution-list-utils'; -import { - ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION, - ENDPOINT_EVENT_FILTERS_LIST_ID, - ENDPOINT_EVENT_FILTERS_LIST_NAME, -} from '@kbn/securitysolution-list-constants'; - -import { ExceptionListSoSchema } from '../../schemas/saved_objects'; - -import { transformSavedObjectToExceptionList } from './utils'; - -interface CreateEndpointEventFiltersListOptions { - savedObjectsClient: SavedObjectsClientContract; - user: string; - tieBreaker?: string; - version: Version; -} - -/** - * Creates the Endpoint Trusted Apps agnostic list if it does not yet exist - * - * @param savedObjectsClient - * @param user - * @param tieBreaker - * @param version - */ -export const createEndpointEventFiltersList = async ({ - savedObjectsClient, - user, - tieBreaker, - version, -}: CreateEndpointEventFiltersListOptions): Promise => { - const savedObjectType = getSavedObjectType({ namespaceType: 'agnostic' }); - const dateNow = new Date().toISOString(); - try { - const savedObject = await savedObjectsClient.create( - savedObjectType, - { - comments: undefined, - created_at: dateNow, - created_by: user, - description: ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION, - entries: undefined, - immutable: false, - item_id: undefined, - list_id: ENDPOINT_EVENT_FILTERS_LIST_ID, - list_type: 'list', - meta: undefined, - name: ENDPOINT_EVENT_FILTERS_LIST_NAME, - os_types: [], - tags: [], - tie_breaker_id: tieBreaker ?? uuid.v4(), - type: 'endpoint_events', - updated_by: user, - version, - }, - { - // We intentionally hard coding the id so that there can only be one Event Filters list within the space - id: ENDPOINT_EVENT_FILTERS_LIST_ID, - } - ); - - return transformSavedObjectToExceptionList({ savedObject }); - } catch (err) { - if (savedObjectsClient.errors.isConflictError(err)) { - return null; - } else { - throw err; - } - } -}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts index 4ccff2dd000b9b..77e82bf0f75783 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts @@ -54,7 +54,6 @@ import { } from './find_exception_list_items'; import { createEndpointList } from './create_endpoint_list'; import { createEndpointTrustedAppsList } from './create_endpoint_trusted_apps_list'; -import { createEndpointEventFiltersList } from './create_endoint_event_filters_list'; export class ExceptionListClient { private readonly user: string; @@ -120,18 +119,6 @@ export class ExceptionListClient { }); }; - /** - * Create the Endpoint Event Filters Agnostic list if it does not yet exist (`null` is returned if it does exist) - */ - public createEndpointEventFiltersList = async (): Promise => { - const { savedObjectsClient, user } = this; - return createEndpointEventFiltersList({ - savedObjectsClient, - user, - version: 1, - }); - }; - /** * This is the same as "createListItem" except it applies specifically to the agnostic endpoint list and will * auto-call the "createEndpointList" for you so that you have the best chance of the agnostic endpoint diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts index f5d3b30bf15faa..e27a09efd97108 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts @@ -140,8 +140,6 @@ export async function getEndpointEventFiltersList( policyId ? ` or exception-list-agnostic.attributes.tags:\"policy:${policyId}\"` : '' })`; - await eClient.createEndpointEventFiltersList(); - return getFilteredEndpointExceptionList( eClient, schemaVersion, From 3da2ac8927eaaeae7fecfb49aba9687454358310 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 22 Jun 2021 18:11:24 +0100 Subject: [PATCH 48/57] chore(NA): moving @kbn/ui-framework into bazel (#102908) --- .../monorepo-packages.asciidoc | 1 + package.json | 2 +- packages/BUILD.bazel | 1 + packages/kbn-ui-framework/BUILD.bazel | 47 +++++++++++++++++++ x-pack/package.json | 3 -- yarn.lock | 2 +- 6 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 packages/kbn-ui-framework/BUILD.bazel diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index 48d0d40d0abb06..e8b950a696f55d 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -104,6 +104,7 @@ yarn kbn watch-bazel - @kbn/storybook - @kbn/telemetry-utils - @kbn/tinymath +- @kbn/ui-framework - @kbn/ui-shared-deps - @kbn/utility-types - @kbn/utils diff --git a/package.json b/package.json index f654f5f62ba9ce..36fa086657adff 100644 --- a/package.json +++ b/package.json @@ -154,7 +154,7 @@ "@kbn/server-route-repository": "link:bazel-bin/packages/kbn-server-route-repository", "@kbn/std": "link:bazel-bin/packages/kbn-std", "@kbn/tinymath": "link:bazel-bin/packages/kbn-tinymath", - "@kbn/ui-framework": "link:packages/kbn-ui-framework", + "@kbn/ui-framework": "link:bazel-bin/packages/kbn-ui-framework", "@kbn/ui-shared-deps": "link:bazel-bin/packages/kbn-ui-shared-deps", "@kbn/utility-types": "link:bazel-bin/packages/kbn-utility-types", "@kbn/common-utils": "link:bazel-bin/packages/kbn-common-utils", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 70a3d1eacc7c58..b1c3f580c6baf8 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -48,6 +48,7 @@ filegroup( "//packages/kbn-storybook:build", "//packages/kbn-telemetry-tools:build", "//packages/kbn-tinymath:build", + "//packages/kbn-ui-framework:build", "//packages/kbn-ui-shared-deps:build", "//packages/kbn-utility-types:build", "//packages/kbn-utils:build", diff --git a/packages/kbn-ui-framework/BUILD.bazel b/packages/kbn-ui-framework/BUILD.bazel new file mode 100644 index 00000000000000..f8cf5035bdc5f3 --- /dev/null +++ b/packages/kbn-ui-framework/BUILD.bazel @@ -0,0 +1,47 @@ +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-ui-framework" +PKG_REQUIRE_NAME = "@kbn/ui-framework" + +SOURCE_FILES = glob([ + "dist/**/*", +]) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md", +] + +DEPS = [] + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES + [ + ":srcs", + ], + deps = DEPS, + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/x-pack/package.json b/x-pack/package.json index 01571cbb823fd6..1397a3da810722 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -28,8 +28,5 @@ "devDependencies": { "@kbn/plugin-helpers": "link:../packages/kbn-plugin-helpers", "@kbn/test": "link:../packages/kbn-test" - }, - "dependencies": { - "@kbn/ui-framework": "link:../packages/kbn-ui-framework" } } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 153309ad56f191..953e7907590e77 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2788,7 +2788,7 @@ version "0.0.0" uid "" -"@kbn/ui-framework@link:packages/kbn-ui-framework": +"@kbn/ui-framework@link:bazel-bin/packages/kbn-ui-framework": version "0.0.0" uid "" From c5e8df02c1ced111c26e607867cf4d73c940ad7b Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Tue, 22 Jun 2021 13:52:03 -0400 Subject: [PATCH 49/57] [Cases] RBAC Bugs (#101325) * Adding feature flag for auth * Hiding SOs and adding consumer field * First pass at adding security changes * Consumer as the app's plugin ID * Create addConsumerToSO migration helper * Fix mapping's SO consumer * Add test for CasesActions * Declare hidden types on SO client * Restructure integration tests * Init spaces_only integration tests * Implementing the cases security string * Adding security plugin tests for cases * Rough concept for authorization class * Adding comments * Fix merge * Get requiredPrivileges for classes * Check privillages * Ensure that all classes are available * Success if hasAllRequested is true * Failure if hasAllRequested is false * Adding schema updates for feature plugin * Seperate basic from trial * Enable SIR on integration tests * Starting the plumbing for authorization in plugin * Unit tests working * Move find route logic to case client * Create integration test helper functions * Adding auth to create call * Create getClassFilter helper * Add class attribute to find request * Create getFindAuthorizationFilter * Ensure savedObject is authorized in find method * Include fields for authorization * Combine authorization filter with cases & subcases filter * Fix isAuthorized flag * Fix merge issue * Create/delete spaces & users before and after tests * Add more user and roles * [Cases] Convert filters from strings to KueryNode (#95288) * [Cases] RBAC: Rename class to scope (#95535) * [Cases][RBAC] Rename scope to owner (#96035) * [Cases] RBAC: Create & Find integration tests (#95511) * [Cases] Cases client enchantment (#95923) * [Cases] Authorization and Client Audit Logger (#95477) * Starting audit logger * Finishing auth audit logger * Fixing tests and types * Adding audit event creator * Renaming class to scope * Adding audit logger messages to create and find * Adding comments and fixing import issue * Fixing type errors * Fixing tests and adding username to message * Addressing PR feedback * Removing unneccessary log and generating id * Fixing module issue and remove expect.anything * [Cases] Migrate sub cases routes to a client (#96461) * Adding sub cases client * Move sub case routes to case client * Throw when attempting to access the sub cases client * Fixing throw and removing user ans soclients * [Cases] RBAC: Migrate routes' unit tests to integration tests (#96374) Co-authored-by: Jonathan Buttner * [Cases] Move remaining HTTP functionality to client (#96507) * Moving deletes and find for attachments * Moving rest of comment apis * Migrating configuration routes to client * Finished moving routes, starting utils refactor * Refactoring utilites and fixing integration tests * Addressing PR feedback * Fixing mocks and types * Fixing integration tests * Renaming status_stats * Fixing test type errors * Adding plugins to kibana.json * Adding cases to required plugin * [Cases] Refactoring authorization (#97483) * Refactoring authorization * Wrapping auth calls in helper for try catch * Reverting name change * Hardcoding the saved object types * Switching ensure to owner array * [Cases] Add authorization to configuration & cases routes (#97228) * [Cases] Attachments RBAC (#97756) * Starting rbac for comments * Adding authorization to rest of comment apis * Starting the comment rbac tests * Fixing some of the rbac tests * Adding some integration tests * Starting patch tests * Working tests for comments * Working tests * Fixing some tests * Fixing type issues from pulling in master * Fixing connector tests that only work in trial license * Attempting to fix cypress * Mock return of array for configure * Fixing cypress test * Cleaning up * Addressing PR comments * Reducing operations * [Cases] Add RBAC to remaining Cases APIs (#98762) * Starting rbac for comments * Adding authorization to rest of comment apis * Starting the comment rbac tests * Fixing some of the rbac tests * Adding some integration tests * Starting patch tests * Working tests for comments * Working tests * Fixing some tests * Fixing type issues from pulling in master * Fixing connector tests that only work in trial license * Attempting to fix cypress * Mock return of array for configure * Fixing cypress test * Cleaning up * Working case update tests * Addressing PR comments * Reducing operations * Working rbac push case tests * Starting stats apis * Working status tests * User action tests and fixing migration errors * Fixing type errors * including error in message * Addressing pr feedback * Fixing some type errors * [Cases] Add space only tests (#99409) * Starting spaces tests * Finishing space only tests * Refactoring createCaseWithConnector * Fixing spelling * Addressing PR feedback and creating alert tests * Fixing mocks * [Cases] Add security only tests (#99679) * Starting spaces tests * Finishing space only tests * Refactoring createCaseWithConnector * Fixing spelling * Addressing PR feedback and creating alert tests * Fixing mocks * Starting security only tests * Adding remainder security only tests * Using helper objects * Fixing type error for null space * Renaming utility variables * Refactoring users and roles for security only tests * Adding sub feature * [Cases] Cleaning up the services and TODOs (#99723) * Cleaning up the service intialization * Fixing type errors * Adding comments for the api * Working test for cases client * Fix type error * Adding generated docs * Adding more docs and cleaning up types * Cleaning up readme * More clean up and links * Changing some file names * Renaming docs * Integration tests for cases privs and fixes (#100038) * [Cases] RBAC on UI (#99478) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> * Fixing case ids by alert id route call * [Cases] Fixing UI feature permissions and adding UI tests (#100074) * Integration tests for cases privs and fixes * Fixing ui cases permissions and adding tests * Adding test for collection failure and fixing jest * Renaming variables * Fixing type error * Adding some comments * Validate cases features * Fix new schema * Adding owner param for the status stats * Fix get case status tests * Adjusting permissions text and fixing status * Address PR feedback * Adding top level feature back * Fixing feature privileges * Renaming * Removing uneeded else * Fixing tests and adding cases merge tests * [Cases][Security Solution] Basic license security solution API tests (#100925) * Cleaning up the fixture plugins * Adding basic feature test * renaming to unsecuredSavedObjectsClient (#101215) * [Cases] RBAC Refactoring audit logging (#100952) * Refactoring audit logging * Adding unit tests for authorization classes * Addressing feedback and adding util tests * return undefined on empty array * fixing eslint * conditional rendering the recently created cases * Remove unnecessary Array.from * Cleaning up overview page for permissions * Fixing log message for attachments * hiding add to cases button * Disable the Cases app from the global nav * Hide the add to cases button from detections * Fixing merge * Making progress on removing icons * Hding edit icons on detail view * Trying to get connector error msg tests working * Removing test * Disable error callouts * Fixing spacing and removing cases tab one no read * Adding read only badge * Cleaning up and adding badge * Wrapping in use effect * Default toasting permissions errors * Removing actions icon on comments * Addressing feedback * Fixing type Co-authored-by: Christos Nasikas Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/add_comment/index.test.tsx | 26 ++- .../public/components/add_comment/index.tsx | 58 +++---- .../public/components/all_cases/header.tsx | 32 ++-- .../components/all_cases/nav_buttons.tsx | 5 +- .../public/components/all_cases/table.tsx | 26 +-- .../components/all_cases/translations.ts | 8 + .../public/components/callout/helpers.tsx | 11 -- .../public/components/callout/translations.ts | 9 - .../components/case_action_bar/actions.tsx | 5 +- .../components/case_action_bar/index.test.tsx | 1 + .../components/case_action_bar/index.tsx | 27 +-- .../public/components/case_view/index.tsx | 15 +- .../components/edit_connector/index.test.tsx | 55 +++++- .../components/edit_connector/index.tsx | 43 +++-- .../components/recent_cases/index.test.tsx | 1 + .../public/components/recent_cases/index.tsx | 3 + .../recent_cases/no_cases/index.test.tsx | 13 +- .../recent_cases/no_cases/index.tsx | 30 ++-- .../components/recent_cases/recent_cases.tsx | 4 +- .../components/recent_cases/translations.ts | 4 + .../cases/public/components/status/button.tsx | 9 +- .../public/components/status/status.test.tsx | 9 +- .../cases/public/components/status/status.tsx | 8 +- .../public/components/tag_list/index.test.tsx | 16 +- .../public/components/tag_list/index.tsx | 10 +- .../use_push_to_service/index.test.tsx | 153 +++++++++++++++++ .../components/use_push_to_service/index.tsx | 11 +- .../components/user_action_tree/index.tsx | 37 +++-- .../user_action_content_toolbar.test.tsx | 9 +- .../user_action_content_toolbar.tsx | 29 ++-- .../user_action_property_actions.tsx | 6 +- .../containers/configure/translations.ts | 8 + .../containers/configure/use_connectors.tsx | 46 ++++-- x-pack/plugins/cases/public/mocks.ts | 21 +++ .../server/authorization/audit_logger.test.ts | 8 +- x-pack/plugins/cases/server/plugin.ts | 2 +- .../components/app/cases/callout/helpers.tsx | 11 -- .../app/cases/callout/translations.ts | 15 -- .../components/app/cases/translations.ts | 14 ++ .../public/hooks/use_readonly_header.tsx | 40 +++++ .../public/pages/cases/all_cases.tsx | 25 +-- .../public/pages/cases/case_details.tsx | 30 ++-- .../public/pages/cases/configure_cases.tsx | 12 +- .../public/pages/cases/create_case.tsx | 12 +- .../security_solution/common/constants.ts | 3 + .../detection_alerts/attach_to_case.spec.ts | 2 +- .../cases/components/callout/helpers.tsx | 11 -- .../cases/components/callout/translations.ts | 15 -- .../add_to_case_action.test.tsx | 6 +- .../timeline_actions/add_to_case_action.tsx | 28 ++-- .../public/cases/pages/case.tsx | 7 - .../public/cases/pages/case_details.tsx | 18 +- .../public/cases/pages/configure_cases.tsx | 13 +- .../public/cases/pages/create_case.tsx | 16 +- .../public/cases/pages/index.test.tsx | 91 ++++++++++ .../public/cases/pages/index.tsx | 77 ++++++--- .../public/cases/pages/translations.ts | 21 +++ .../components/header_global/index.test.tsx | 51 ++++++ .../common/components/header_global/index.tsx | 23 ++- .../components/recent_cases/index.tsx | 5 +- .../components/sidebar/sidebar.test.tsx | 72 ++++++++ .../overview/components/sidebar/sidebar.tsx | 16 +- .../components/flyout/header/index.test.tsx | 156 +++++++++++------- .../components/flyout/header/index.tsx | 12 +- .../security_solution/server/plugin.ts | 20 ++- .../observability_security.ts | 10 +- .../page_objects/observability_page.ts | 16 +- 67 files changed, 1114 insertions(+), 492 deletions(-) create mode 100644 x-pack/plugins/cases/public/mocks.ts create mode 100644 x-pack/plugins/observability/public/hooks/use_readonly_header.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/pages/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/header_global/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.test.tsx diff --git a/x-pack/plugins/cases/public/components/add_comment/index.test.tsx b/x-pack/plugins/cases/public/components/add_comment/index.test.tsx index 19c303840fc1a6..078db1e6dbe6da 100644 --- a/x-pack/plugins/cases/public/components/add_comment/index.test.tsx +++ b/x-pack/plugins/cases/public/components/add_comment/index.test.tsx @@ -14,7 +14,7 @@ import { TestProviders } from '../../common/mock'; import { CommentRequest, CommentType, SECURITY_SOLUTION_OWNER } from '../../../common'; import { usePostComment } from '../../containers/use_post_comment'; -import { AddComment, AddCommentRefObject } from '.'; +import { AddComment, AddCommentProps, AddCommentRefObject } from '.'; import { CasesTimelineIntegrationProvider } from '../timeline_context'; import { timelineIntegrationMock } from '../__mock__/timeline'; @@ -25,10 +25,9 @@ const onCommentSaving = jest.fn(); const onCommentPosted = jest.fn(); const postComment = jest.fn(); -const addCommentProps = { +const addCommentProps: AddCommentProps = { caseId: '1234', - disabled: false, - insertQuote: null, + userCanCrud: true, onCommentSaving, onCommentPosted, showLoading: false, @@ -94,11 +93,11 @@ describe('AddComment ', () => { ).toBeTruthy(); }); - it('should disable submit button when disabled prop passed', () => { + it('should disable submit button when isLoading is true', () => { usePostCommentMock.mockImplementation(() => ({ ...defaultPostComment, isLoading: true })); const wrapper = mount( - + ); @@ -107,12 +106,23 @@ describe('AddComment ', () => { ).toBeTruthy(); }); + it('should hide the component when the user does not have crud permissions', () => { + usePostCommentMock.mockImplementation(() => ({ ...defaultPostComment, isLoading: true })); + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="add-comment"]`).exists()).toBeFalsy(); + }); + it('should insert a quote', async () => { const sampleQuote = 'what a cool quote'; const ref = React.createRef(); const wrapper = mount( - + ); @@ -143,7 +153,7 @@ describe('AddComment ', () => { const wrapper = mount( - + ); diff --git a/x-pack/plugins/cases/public/components/add_comment/index.tsx b/x-pack/plugins/cases/public/components/add_comment/index.tsx index 04104f0b9471d0..6604f3d2b8bc8f 100644 --- a/x-pack/plugins/cases/public/components/add_comment/index.tsx +++ b/x-pack/plugins/cases/public/components/add_comment/index.tsx @@ -33,9 +33,9 @@ export interface AddCommentRefObject { addQuote: (quote: string) => void; } -interface AddCommentProps { +export interface AddCommentProps { caseId: string; - disabled?: boolean; + userCanCrud?: boolean; onCommentSaving?: () => void; onCommentPosted: (newCase: Case) => void; showLoading?: boolean; @@ -45,7 +45,7 @@ interface AddCommentProps { export const AddComment = React.memo( forwardRef( ( - { caseId, disabled, onCommentPosted, onCommentSaving, showLoading = true, subCaseId }, + { caseId, userCanCrud, onCommentPosted, onCommentSaving, showLoading = true, subCaseId }, ref ) => { const owner = useOwnerContext(); @@ -91,31 +91,33 @@ export const AddComment = React.memo( return ( {isLoading && showLoading && } - - - {i18n.ADD_COMMENT} - - ), - }} - /> - - + {userCanCrud && ( +
+ + {i18n.ADD_COMMENT} + + ), + }} + /> + + + )}
); } diff --git a/x-pack/plugins/cases/public/components/all_cases/header.tsx b/x-pack/plugins/cases/public/components/all_cases/header.tsx index 7452fe7e44b3c4..73dcc18b971083 100644 --- a/x-pack/plugins/cases/public/components/all_cases/header.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/header.tsx @@ -52,17 +52,27 @@ export const CasesTableHeader: FunctionComponent = ({ wrap={true} data-test-subj="all-cases-header" > - - - - - - + {userCanCrud ? ( + <> + + + + + + + + + ) : ( + // doesn't include the horizontal bar that divides the buttons and other padding since we don't have any buttons + // to the right + + + + )} ); diff --git a/x-pack/plugins/cases/public/components/all_cases/nav_buttons.tsx b/x-pack/plugins/cases/public/components/all_cases/nav_buttons.tsx index e29551f43c2bd5..b8755d03e0b001 100644 --- a/x-pack/plugins/cases/public/components/all_cases/nav_buttons.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/nav_buttons.tsx @@ -17,7 +17,6 @@ interface OwnProps { actionsErrors: ErrorMessage[]; configureCasesNavigation: CasesNavigation; createCaseNavigation: CasesNavigation; - userCanCrud: boolean; } type Props = OwnProps; @@ -26,14 +25,13 @@ export const NavButtons: FunctionComponent = ({ actionsErrors, configureCasesNavigation, createCaseNavigation, - userCanCrud, }) => ( } titleTooltip={!isEmpty(actionsErrors) ? actionsErrors[0].title : ''} @@ -41,7 +39,6 @@ export const NavButtons: FunctionComponent = ({ = ({ {i18n.NO_CASES}} titleSize="xs" - body={i18n.NO_CASES_BODY} + body={userCanCrud ? i18n.NO_CASES_BODY : i18n.NO_CASES_BODY_READ_ONLY} actions={ - - {i18n.ADD_NEW_CASE} - + userCanCrud && ( + + {i18n.ADD_NEW_CASE} + + ) } /> } diff --git a/x-pack/plugins/cases/public/components/all_cases/translations.ts b/x-pack/plugins/cases/public/components/all_cases/translations.ts index 0f535b771ec8a3..8da90f32fabdf2 100644 --- a/x-pack/plugins/cases/public/components/all_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/all_cases/translations.ts @@ -12,11 +12,19 @@ export * from '../../common/translations'; export const NO_CASES = i18n.translate('xpack.cases.caseTable.noCases.title', { defaultMessage: 'No Cases', }); + export const NO_CASES_BODY = i18n.translate('xpack.cases.caseTable.noCases.body', { defaultMessage: 'There are no cases to display. Please create a new case or change your filter settings above.', }); +export const NO_CASES_BODY_READ_ONLY = i18n.translate( + 'xpack.cases.caseTable.noCases.readonly.body', + { + defaultMessage: 'There are no cases to display. Please change your filter settings above.', + } +); + export const ADD_NEW_CASE = i18n.translate('xpack.cases.caseTable.addNewCase', { defaultMessage: 'Add New Case', }); diff --git a/x-pack/plugins/cases/public/components/callout/helpers.tsx b/x-pack/plugins/cases/public/components/callout/helpers.tsx index 29b17cd426c58b..fdd49ad17168de 100644 --- a/x-pack/plugins/cases/public/components/callout/helpers.tsx +++ b/x-pack/plugins/cases/public/components/callout/helpers.tsx @@ -5,18 +5,7 @@ * 2.0. */ -import React from 'react'; import md5 from 'md5'; -import * as i18n from './translations'; -import { ErrorMessage } from './types'; - -export const permissionsReadOnlyErrorMessage: ErrorMessage = { - id: 'read-only-privileges-error', - title: i18n.READ_ONLY_FEATURE_TITLE, - description: <>{i18n.READ_ONLY_FEATURE_MSG}, - errorType: 'warning', -}; - export const createCalloutId = (ids: string[], delimiter: string = '|'): string => md5(ids.join(delimiter)); diff --git a/x-pack/plugins/cases/public/components/callout/translations.ts b/x-pack/plugins/cases/public/components/callout/translations.ts index dca622e60c863b..8b0ad31dba88e3 100644 --- a/x-pack/plugins/cases/public/components/callout/translations.ts +++ b/x-pack/plugins/cases/public/components/callout/translations.ts @@ -7,15 +7,6 @@ import { i18n } from '@kbn/i18n'; -export const READ_ONLY_FEATURE_TITLE = i18n.translate('xpack.cases.readOnlyFeatureTitle', { - defaultMessage: 'You cannot open new or update existing cases', -}); - -export const READ_ONLY_FEATURE_MSG = i18n.translate('xpack.cases.readOnlyFeatureDescription', { - defaultMessage: - 'You only have privileges to view cases. If you need to open and update cases, contact your Kibana administrator.', -}); - export const DISMISS_CALLOUT = i18n.translate('xpack.cases.dismissErrorsPushServiceCallOutTitle', { defaultMessage: 'Dismiss', }); diff --git a/x-pack/plugins/cases/public/components/case_action_bar/actions.tsx b/x-pack/plugins/cases/public/components/case_action_bar/actions.tsx index c2578dc3debdb6..6816575d649f75 100644 --- a/x-pack/plugins/cases/public/components/case_action_bar/actions.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/actions.tsx @@ -19,14 +19,12 @@ interface CaseViewActions { allCasesNavigation: CasesNavigation; caseData: Case; currentExternalIncident: CaseService | null; - disabled?: boolean; } const ActionsComponent: React.FC = ({ allCasesNavigation, caseData, currentExternalIncident, - disabled = false, }) => { // Delete case const { @@ -39,7 +37,6 @@ const ActionsComponent: React.FC = ({ const propertyActions = useMemo( () => [ { - disabled, iconType: 'trash', label: i18n.DELETE_CASE(), onClick: handleToggleModal, @@ -54,7 +51,7 @@ const ActionsComponent: React.FC = ({ ] : []), ], - [disabled, handleToggleModal, currentExternalIncident] + [handleToggleModal, currentExternalIncident] ); if (isDeleted) { diff --git a/x-pack/plugins/cases/public/components/case_action_bar/index.test.tsx b/x-pack/plugins/cases/public/components/case_action_bar/index.test.tsx index 724d35b20df535..3040b0fe47a470 100644 --- a/x-pack/plugins/cases/public/components/case_action_bar/index.test.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/index.test.tsx @@ -26,6 +26,7 @@ describe('CaseActionBar', () => { onRefresh, onUpdateField, currentExternalIncident: null, + userCanCrud: true, }; beforeEach(() => { diff --git a/x-pack/plugins/cases/public/components/case_action_bar/index.tsx b/x-pack/plugins/cases/public/components/case_action_bar/index.tsx index d8e012b0721065..3448d112dadd12 100644 --- a/x-pack/plugins/cases/public/components/case_action_bar/index.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/index.tsx @@ -40,7 +40,7 @@ interface CaseActionBarProps { allCasesNavigation: CasesNavigation; caseData: Case; currentExternalIncident: CaseService | null; - disabled?: boolean; + userCanCrud: boolean; disableAlerting: boolean; isLoading: boolean; onRefresh: () => void; @@ -50,8 +50,8 @@ const CaseActionBarComponent: React.FC = ({ allCasesNavigation, caseData, currentExternalIncident, - disabled = false, disableAlerting, + userCanCrud, isLoading, onRefresh, onUpdateField, @@ -87,7 +87,7 @@ const CaseActionBarComponent: React.FC = ({ @@ -108,7 +108,7 @@ const CaseActionBarComponent: React.FC = ({ - {!disableAlerting && ( + {userCanCrud && !disableAlerting && ( @@ -122,7 +122,7 @@ const CaseActionBarComponent: React.FC = ({ @@ -134,14 +134,15 @@ const CaseActionBarComponent: React.FC = ({ {i18n.CASE_REFRESH} - - - + {userCanCrud && ( + + + + )} diff --git a/x-pack/plugins/cases/public/components/case_view/index.tsx b/x-pack/plugins/cases/public/components/case_view/index.tsx index df57e49073a604..05f1c6727b1680 100644 --- a/x-pack/plugins/cases/public/components/case_view/index.tsx +++ b/x-pack/plugins/cases/public/components/case_view/index.tsx @@ -230,7 +230,9 @@ export const CaseComponent = React.memo( [updateCase, fetchCaseUserActions, caseId, subCaseId] ); - const { loading: isLoadingConnectors, connectors } = useConnectors(); + const { loading: isLoadingConnectors, connectors, permissionsError } = useConnectors({ + toastPermissionsErrors: false, + }); const [connectorName, isValidConnector] = useMemo(() => { const connector = connectors.find((c) => c.id === caseData.connector.id); @@ -363,7 +365,7 @@ export const CaseComponent = React.memo( allCasesNavigation={allCasesNavigation} caseData={caseData} currentExternalIncident={currentExternalIncident} - disabled={!userCanCrud} + userCanCrud={userCanCrud} disableAlerting={ruleDetailsNavigation == null} isLoading={isLoading && (updateKey === 'status' || updateKey === 'settings')} onRefresh={handleRefresh} @@ -406,7 +408,7 @@ export const CaseComponent = React.memo( useFetchAlertData={useFetchAlertData} userCanCrud={userCanCrud} /> - {(caseData.type !== CaseType.collection || hasDataToPush) && ( + {(caseData.type !== CaseType.collection || hasDataToPush) && userCanCrud && ( <> ( @@ -450,16 +451,15 @@ export const CaseComponent = React.memo( /> ( onSubmit={onSubmitConnector} selectedConnector={caseData.connector.id} userActions={caseUserActions} + permissionsError={permissionsError} />
diff --git a/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx b/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx index 1385e8e8664c37..33efb7e447583e 100644 --- a/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx +++ b/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { mount } from 'enzyme'; import { waitFor } from '@testing-library/react'; -import { EditConnector } from './index'; +import { EditConnector, EditConnectorProps } from './index'; import { getFormMock, useFormMock } from '../__mock__/form'; import { TestProviders } from '../../common/mock'; import { connectorsMock } from '../../containers/configure/mock'; @@ -21,9 +21,9 @@ jest.mock('../../common/lib/kibana'); const useKibanaMock = useKibana as jest.Mocked; const onSubmit = jest.fn(); -const defaultProps = { +const defaultProps: EditConnectorProps = { connectors: connectorsMock, - disabled: false, + userCanCrud: true, isLoading: false, onSubmit, selectedConnector: 'none', @@ -144,4 +144,53 @@ describe('EditConnector ', () => { expect(wrapper.find(`[data-test-subj="connector-loading"]`).last().exists()).toBeTruthy() ); }); + + it('does not allow the connector to be edited when the user does not have write permissions', async () => { + const props = { ...defaultProps, userCanCrud: false }; + const wrapper = mount( + + + + ); + await waitFor(() => + expect(wrapper.find(`[data-test-subj="connector-edit"]`).exists()).toBeFalsy() + ); + }); + + it('displays the permissions error message when one is provided', async () => { + const props = { ...defaultProps, permissionsError: 'error message' }; + const wrapper = mount( + + + + ); + + await waitFor(() => { + expect( + wrapper.find(`[data-test-subj="edit-connector-permissions-error-msg"]`).exists() + ).toBeTruthy(); + + expect( + wrapper.find(`[data-test-subj="edit-connector-no-connectors-msg"]`).exists() + ).toBeFalsy(); + }); + }); + + it('displays the default none connector message', async () => { + const props = { ...defaultProps }; + const wrapper = mount( + + + + ); + + await waitFor(() => { + expect( + wrapper.find(`[data-test-subj="edit-connector-permissions-error-msg"]`).exists() + ).toBeFalsy(); + expect( + wrapper.find(`[data-test-subj="edit-connector-no-connectors-msg"]`).exists() + ).toBeTruthy(); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/edit_connector/index.tsx b/x-pack/plugins/cases/public/components/edit_connector/index.tsx index ad6b5a5e7cddf5..570f6e34d25287 100644 --- a/x-pack/plugins/cases/public/components/edit_connector/index.tsx +++ b/x-pack/plugins/cases/public/components/edit_connector/index.tsx @@ -30,7 +30,7 @@ import { schema } from './schema'; import { getConnectorFieldsFromUserActions } from './helpers'; import * as i18n from './translations'; -interface EditConnectorProps { +export interface EditConnectorProps { caseFields: ConnectorTypeFields['fields']; connectors: ActionConnector[]; isLoading: boolean; @@ -42,8 +42,9 @@ interface EditConnectorProps { ) => void; selectedConnector: string; userActions: CaseUserActions[]; - disabled?: boolean; + userCanCrud?: boolean; hideConnectorServiceNowSir?: boolean; + permissionsError?: string; } const MyFlexGroup = styled(EuiFlexGroup)` @@ -104,12 +105,13 @@ export const EditConnector = React.memo( ({ caseFields, connectors, - disabled = false, + userCanCrud = true, hideConnectorServiceNowSir = false, isLoading, onSubmit, selectedConnector, userActions, + permissionsError, }: EditConnectorProps) => { const { form } = useForm({ defaultValue: { connectorId: selectedConnector }, @@ -203,6 +205,18 @@ export const EditConnector = React.memo( }); }, [dispatch]); + /** + * if this evaluates to true it means that the connector was likely deleted because the case connector was set to something + * other than none but we don't find it in the list of connectors returned from the actions plugin + */ + const connectorFromCaseMissing = currentConnector == null && selectedConnector !== 'none'; + + /** + * True if the chosen connector from the form was the "none" connector or no connector was in the case. The + * currentConnector will be null initially and after the form initializes if the case connector is "none" + */ + const connectorUndefinedOrNone = currentConnector == null || currentConnector?.id === 'none'; + return ( @@ -210,11 +224,10 @@ export const EditConnector = React.memo(

{i18n.CONNECTORS}

{isLoading && } - {!isLoading && !editConnector && ( + {!isLoading && !editConnector && userCanCrud && ( - {(currentConnector == null || currentConnector?.id === 'none') && // Connector is none or not defined. - !(currentConnector === null && selectedConnector !== 'none') && // Connector has not been deleted. - !editConnector && ( - + {!editConnector && permissionsError ? ( + + {permissionsError} + + ) : ( + // if we're not editing the connectors and the connector specified in the case was found and the connector + // is undefined or explicitly set to none + !editConnector && + !connectorFromCaseMissing && + connectorUndefinedOrNone && ( + {i18n.NO_CONNECTOR} - )} + ) + )} ; createCaseNavigation: CasesNavigation; + hasWritePermissions: boolean; maxCasesToShow: number; } @@ -29,6 +30,7 @@ const RecentCasesComponent = ({ caseDetailsNavigation, createCaseNavigation, maxCasesToShow, + hasWritePermissions, }: Omit) => { const currentUser = useCurrentUser(); const [recentCasesFilterBy, setRecentCasesFilterBy] = useState( @@ -77,6 +79,7 @@ const RecentCasesComponent = ({ createCaseNavigation={createCaseNavigation} filterOptions={recentCasesFilterOptions} maxCasesToShow={maxCasesToShow} + hasWritePermissions={hasWritePermissions} /> diff --git a/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.test.tsx b/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.test.tsx index 0295632cc137ae..10fef0bb82df94 100644 --- a/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.test.tsx @@ -16,11 +16,22 @@ describe('RecentCases', () => { const createCaseHref = '/create'; const wrapper = mount( - + ); expect(wrapper.find(`[data-test-subj="no-cases-create-case"]`).first().prop('href')).toEqual( createCaseHref ); }); + + it('displays a message without a link to create a case when the user does not have write permissions', () => { + const createCaseHref = '/create'; + const wrapper = mount( + + + + ); + expect(wrapper.find(`[data-test-subj="no-cases-create-case"]`).exists()).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="no-cases-readonly"]`).exists()).toBeTruthy(); + }); }); diff --git a/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.tsx b/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.tsx index df0efcec4552cc..a5b90943a219a7 100644 --- a/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.tsx @@ -10,16 +10,26 @@ import React from 'react'; import { EuiLink } from '@elastic/eui'; import * as i18n from '../translations'; -const NoCasesComponent = ({ createCaseHref }: { createCaseHref: string }) => ( - <> - {i18n.NO_CASES} - {` ${i18n.START_A_NEW_CASE}`} - {'!'} - -); +const NoCasesComponent = ({ + createCaseHref, + hasWritePermissions, +}: { + createCaseHref: string; + hasWritePermissions: boolean; +}) => { + return hasWritePermissions ? ( + <> + {i18n.NO_CASES} + {` ${i18n.START_A_NEW_CASE}`} + {'!'} + + ) : ( + {i18n.NO_CASES_READ_ONLY} + ); +}; NoCasesComponent.displayName = 'NoCasesComponent'; diff --git a/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx b/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx index 5b4313530e4904..bfe44dda6c6efc 100644 --- a/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx +++ b/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx @@ -31,6 +31,7 @@ export interface RecentCasesProps { caseDetailsNavigation: CasesNavigation; createCaseNavigation: CasesNavigation; maxCasesToShow: number; + hasWritePermissions: boolean; } const usePrevious = (value: Partial) => { @@ -45,6 +46,7 @@ export const RecentCasesComp = ({ createCaseNavigation, filterOptions, maxCasesToShow, + hasWritePermissions, }: RecentCasesProps) => { const previousFilterOptions = usePrevious(filterOptions); const { data, loading, setFilters } = useGetCases({ @@ -65,7 +67,7 @@ export const RecentCasesComp = ({ return isLoadingCases ? ( ) : !isLoadingCases && data.cases.length === 0 ? ( - + ) : ( <> {data.cases.map((c, i) => ( diff --git a/x-pack/plugins/cases/public/components/recent_cases/translations.ts b/x-pack/plugins/cases/public/components/recent_cases/translations.ts index c8f6c349d8f720..653bda4be2ebc0 100644 --- a/x-pack/plugins/cases/public/components/recent_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/recent_cases/translations.ts @@ -22,6 +22,10 @@ export const NO_CASES = i18n.translate('xpack.cases.recentCases.noCasesMessage', defaultMessage: 'No cases have been created yet. Put your detective hat on and', }); +export const NO_CASES_READ_ONLY = i18n.translate('xpack.cases.recentCases.noCasesMessageReadOnly', { + defaultMessage: 'No cases have been created yet.', +}); + export const RECENT_CASES = i18n.translate('xpack.cases.recentCases.recentCasesSidebarTitle', { defaultMessage: 'Recent cases', }); diff --git a/x-pack/plugins/cases/public/components/status/button.tsx b/x-pack/plugins/cases/public/components/status/button.tsx index 623afeb43c5965..675d83c759bc77 100644 --- a/x-pack/plugins/cases/public/components/status/button.tsx +++ b/x-pack/plugins/cases/public/components/status/button.tsx @@ -13,7 +13,6 @@ import { statuses } from './config'; interface Props { status: CaseStatuses; - disabled: boolean; isLoading: boolean; onStatusChanged: (status: CaseStatuses) => void; } @@ -21,12 +20,7 @@ interface Props { // Rotate over the statuses. open -> in-progress -> closes -> open... const getNextItem = (item: number) => (item + 1) % caseStatuses.length; -const StatusActionButtonComponent: React.FC = ({ - status, - onStatusChanged, - disabled, - isLoading, -}) => { +const StatusActionButtonComponent: React.FC = ({ status, onStatusChanged, isLoading }) => { const indexOfCurrentStatus = useMemo( () => caseStatuses.findIndex((caseStatus) => caseStatus === status), [status] @@ -41,7 +35,6 @@ const StatusActionButtonComponent: React.FC = ({ diff --git a/x-pack/plugins/cases/public/components/status/status.test.tsx b/x-pack/plugins/cases/public/components/status/status.test.tsx index 4d13e57fbdee71..a685256741c432 100644 --- a/x-pack/plugins/cases/public/components/status/status.test.tsx +++ b/x-pack/plugins/cases/public/components/status/status.test.tsx @@ -42,17 +42,14 @@ describe('Stats', () => { ).toBe(false); }); - it('it renders with the pop over disabled when initialized disabled', async () => { + it('renders without the arrow and is not clickable when initialized disabled', async () => { const wrapper = mount( ); expect( - wrapper - .find(`[data-test-subj="status-badge-open"] .euiBadge__iconButton`) - .first() - .prop('disabled') - ).toBe(true); + wrapper.find(`[data-test-subj="status-badge-open"] .euiBadge__iconButton`).exists() + ).toBeFalsy(); }); it('it calls onClick when pressing the badge', async () => { diff --git a/x-pack/plugins/cases/public/components/status/status.tsx b/x-pack/plugins/cases/public/components/status/status.tsx index 3b832ce155400c..3c186313a151a4 100644 --- a/x-pack/plugins/cases/public/components/status/status.tsx +++ b/x-pack/plugins/cases/public/components/status/status.tsx @@ -29,18 +29,18 @@ const StatusComponent: React.FC = ({ const props = useMemo( () => ({ color: type === StatusAll ? allCaseStatus[StatusAll].color : statuses[type].color, - ...(withArrow ? { iconType: 'arrowDown', iconSide: 'right' as const } : {}), + // if we are disabled, don't show the arrow and don't allow the user to click + ...(withArrow && !disabled ? { iconType: 'arrowDown', iconSide: 'right' as const } : {}), + ...(!disabled ? { iconOnClick: onClick } : { iconOnClick: noop }), }), - [withArrow, type] + [disabled, onClick, withArrow, type] ); return ( {type === StatusAll ? allCaseStatus[StatusAll].label : statuses[type].label} diff --git a/x-pack/plugins/cases/public/components/tag_list/index.test.tsx b/x-pack/plugins/cases/public/components/tag_list/index.test.tsx index b3fbcd30d4e978..2ced7502b3c3fe 100644 --- a/x-pack/plugins/cases/public/components/tag_list/index.test.tsx +++ b/x-pack/plugins/cases/public/components/tag_list/index.test.tsx @@ -8,13 +8,12 @@ import React from 'react'; import { mount } from 'enzyme'; -import { TagList } from '.'; +import { TagList, TagListProps } from '.'; import { getFormMock } from '../__mock__/form'; import { TestProviders } from '../../common/mock'; import { waitFor } from '@testing-library/react'; import { useForm } from '../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form'; import { useGetTags } from '../../containers/use_get_tags'; -import { SECURITY_SOLUTION_OWNER } from '../../../common'; jest.mock('../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form'); jest.mock('../../containers/use_get_tags'); @@ -33,12 +32,11 @@ jest.mock('@elastic/eui', () => { }; }); const onSubmit = jest.fn(); -const defaultProps = { - disabled: false, +const defaultProps: TagListProps = { + userCanCrud: true, isLoading: false, onSubmit, tags: [], - owner: [SECURITY_SOLUTION_OWNER], }; describe('TagList ', () => { @@ -110,15 +108,13 @@ describe('TagList ', () => { expect(wrapper.find(`[data-test-subj="tag-pepsi"]`).last().exists()).toBeTruthy(); }); - it('Renders disabled button', () => { - const props = { ...defaultProps, disabled: true }; + it('does not render when the user does not have write permissions', () => { + const props = { ...defaultProps, userCanCrud: false }; const wrapper = mount( ); - expect( - wrapper.find(`[data-test-subj="tag-list-edit-button"]`).last().prop('disabled') - ).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="tag-list-edit"]`).exists()).toBeFalsy(); }); }); diff --git a/x-pack/plugins/cases/public/components/tag_list/index.tsx b/x-pack/plugins/cases/public/components/tag_list/index.tsx index f2605933696796..4e8946a6589a33 100644 --- a/x-pack/plugins/cases/public/components/tag_list/index.tsx +++ b/x-pack/plugins/cases/public/components/tag_list/index.tsx @@ -27,12 +27,11 @@ import { Tags } from './tags'; const CommonUseField = getUseField({ component: Field }); -interface TagListProps { - disabled?: boolean; +export interface TagListProps { + userCanCrud?: boolean; isLoading: boolean; onSubmit: (a: string[]) => void; tags: string[]; - owner: string[]; } const MyFlexGroup = styled(EuiFlexGroup)` @@ -45,7 +44,7 @@ const MyFlexGroup = styled(EuiFlexGroup)` `; export const TagList = React.memo( - ({ disabled = false, isLoading, onSubmit, tags, owner }: TagListProps) => { + ({ userCanCrud = true, isLoading, onSubmit, tags }: TagListProps) => { const initialState = { tags }; const { form } = useForm({ defaultValue: initialState, @@ -86,11 +85,10 @@ export const TagList = React.memo(

{i18n.TAGS}

{isLoading && } - {!isLoading && ( + {!isLoading && userCanCrud && ( { expect(errorsMsg[0].id).toEqual('closed-case-push-error'); }); }); + + describe('user does not have write permissions', () => { + const noWriteProps = { ...defaultArgs, userCanCrud: false }; + + it('does not display a message when user does not have a premium license', async () => { + (useGetActionLicense as jest.Mock).mockImplementation(() => ({ + isLoading: false, + actionLicense: { + ...actionLicense, + enabledInLicense: false, + }, + })); + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => usePushToService(noWriteProps), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + expect(result.current.pushCallouts).toBeNull(); + }); + }); + + it('does not display a message when user does not have case enabled in config', async () => { + (useGetActionLicense as jest.Mock).mockImplementation(() => ({ + isLoading: false, + actionLicense: { + ...actionLicense, + enabledInConfig: false, + }, + })); + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => usePushToService(noWriteProps), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + expect(result.current.pushCallouts).toBeNull(); + }); + }); + + it('does not display a message when user does not have any connector configured', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => + usePushToService({ + ...noWriteProps, + connectors: [], + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + }), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + expect(result.current.pushCallouts).toBeNull(); + }); + }); + + it('does not display a message when user does have a connector but is configured to none', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => + usePushToService({ + ...noWriteProps, + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + }), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + expect(result.current.pushCallouts).toBeNull(); + }); + }); + + it('does not display a message when connector is deleted', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => + usePushToService({ + ...noWriteProps, + connector: { + id: 'not-exist', + name: 'not-exist', + type: ConnectorTypes.none, + fields: null, + }, + isValidConnector: false, + }), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + expect(result.current.pushCallouts).toBeNull(); + }); + }); + + it('does not display a message when connector is deleted with empty connectors', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => + usePushToService({ + ...noWriteProps, + connectors: [], + connector: { + id: 'not-exist', + name: 'not-exist', + type: ConnectorTypes.none, + fields: null, + }, + isValidConnector: false, + }), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + expect(result.current.pushCallouts).toBeNull(); + }); + }); + + it('does not display a message when case is closed', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => + usePushToService({ + ...noWriteProps, + caseStatus: CaseStatuses.closed, + }), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + expect(result.current.pushCallouts).toBeNull(); + }); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/use_push_to_service/index.tsx b/x-pack/plugins/cases/public/components/use_push_to_service/index.tsx index 00b88d372584b6..6f711150b77443 100644 --- a/x-pack/plugins/cases/public/components/use_push_to_service/index.tsx +++ b/x-pack/plugins/cases/public/components/use_push_to_service/index.tsx @@ -67,9 +67,17 @@ export const usePushToService = ({ const errorsMsg = useMemo(() => { let errors: ErrorMessage[] = []; + + // these message require that the user do some sort of write action as a result of the message, readonly users won't + // be able to perform such an action so let's not display the error to the user in that situation + if (!userCanCrud) { + return errors; + } + if (actionLicense != null && !actionLicense.enabledInLicense) { errors = [...errors, getLicenseError()]; } + if (connectors.length === 0 && connector.id === 'none' && !loadingLicense) { errors = [ ...errors, @@ -136,12 +144,13 @@ export const usePushToService = ({ }, ]; } + if (actionLicense != null && !actionLicense.enabledInConfig) { errors = [...errors, getKibanaConfigError()]; } return errors; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [actionLicense, caseStatus, connectors.length, connector, loadingLicense]); + }, [actionLicense, caseStatus, connectors.length, connector, loadingLicense, userCanCrud]); const pushToServiceButton = useMemo( () => ( diff --git a/x-pack/plugins/cases/public/components/user_action_tree/index.tsx b/x-pack/plugins/cases/public/components/user_action_tree/index.tsx index f9bd941547078e..c7cc71da929477 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/index.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/index.tsx @@ -241,7 +241,7 @@ export const UserActionTree = React.memo( () => ( ), }), @@ -363,10 +363,10 @@ export const UserActionTree = React.memo( id={comment.id} editLabel={i18n.EDIT_COMMENT} quoteLabel={i18n.QUOTE} - disabled={!userCanCrud} isLoading={isLoadingIds.includes(comment.id)} onEdit={handleManageMarkdownEditId.bind(null, comment.id)} onQuote={handleManageQuote.bind(null, comment.comment)} + userCanCrud={userCanCrud} /> ), }, @@ -571,19 +571,24 @@ export const UserActionTree = React.memo( ] ); - const bottomActions = [ - { - username: ( - - ), - 'data-test-subj': 'add-comment', - timelineIcon: ( - - ), - className: 'isEdit', - children: MarkdownNewComment, - }, - ]; + const bottomActions = userCanCrud + ? [ + { + username: ( + + ), + 'data-test-subj': 'add-comment', + timelineIcon: ( + + ), + className: 'isEdit', + children: MarkdownNewComment, + }, + ] + : []; const comments = [...userActions, ...bottomActions]; diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.test.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.test.tsx index a5244e14ad2432..155e9e2323e645 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.test.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.test.tsx @@ -7,7 +7,10 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; -import { UserActionContentToolbar } from './user_action_content_toolbar'; +import { + UserActionContentToolbar, + UserActionContentToolbarProps, +} from './user_action_content_toolbar'; jest.mock('react-router-dom', () => { const originalModule = jest.requireActual('react-router-dom'); @@ -28,12 +31,12 @@ jest.mock('../../common/lib/kibana', () => ({ }), })); -const props = { +const props: UserActionContentToolbarProps = { getCaseDetailHrefWithCommentId: jest.fn().mockReturnValue('case-detail-url-with-comment-id-1'), id: '1', editLabel: 'edit', quoteLabel: 'quote', - disabled: false, + userCanCrud: true, isLoading: false, onEdit: jest.fn(), onQuote: jest.fn(), diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.tsx index 7adaffce22c54a..5fa12b8cfa4344 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.tsx @@ -11,15 +11,15 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { UserActionCopyLink } from './user_action_copy_link'; import { UserActionPropertyActions } from './user_action_property_actions'; -interface UserActionContentToolbarProps { +export interface UserActionContentToolbarProps { id: string; getCaseDetailHrefWithCommentId: (commentId: string) => string; editLabel: string; quoteLabel: string; - disabled: boolean; isLoading: boolean; onEdit: (id: string) => void; onQuote: (id: string) => void; + userCanCrud: boolean; } const UserActionContentToolbarComponent = ({ @@ -27,26 +27,27 @@ const UserActionContentToolbarComponent = ({ getCaseDetailHrefWithCommentId, editLabel, quoteLabel, - disabled, isLoading, onEdit, onQuote, + userCanCrud, }: UserActionContentToolbarProps) => ( - - - + {userCanCrud && ( + + + + )} ); diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_property_actions.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_property_actions.tsx index 44b5baf3246cc7..ebc83de1ef36a1 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_property_actions.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/user_action_property_actions.tsx @@ -14,7 +14,6 @@ interface UserActionPropertyActionsProps { id: string; editLabel: string; quoteLabel: string; - disabled: boolean; isLoading: boolean; onEdit: (id: string) => void; onQuote: (id: string) => void; @@ -24,7 +23,6 @@ const UserActionPropertyActionsComponent = ({ id, editLabel, quoteLabel, - disabled, isLoading, onEdit, onQuote, @@ -35,19 +33,17 @@ const UserActionPropertyActionsComponent = ({ const propertyActions = useMemo( () => [ { - disabled, iconType: 'pencil', label: editLabel, onClick: onEditClick, }, { - disabled, iconType: 'quote', label: quoteLabel, onClick: onQuoteClick, }, ], - [disabled, editLabel, quoteLabel, onEditClick, onQuoteClick] + [editLabel, quoteLabel, onEditClick, onQuoteClick] ); return ( <> diff --git a/x-pack/plugins/cases/public/containers/configure/translations.ts b/x-pack/plugins/cases/public/containers/configure/translations.ts index e77b9f57c8f4c7..01900b8850c195 100644 --- a/x-pack/plugins/cases/public/containers/configure/translations.ts +++ b/x-pack/plugins/cases/public/containers/configure/translations.ts @@ -12,3 +12,11 @@ export * from '../translations'; export const SUCCESS_CONFIGURE = i18n.translate('xpack.cases.configure.successSaveToast', { defaultMessage: 'Saved external connection settings', }); + +export const READ_PERMISSIONS_ERROR_MSG = i18n.translate( + 'xpack.cases.configure.readPermissionsErrorDescription', + { + defaultMessage: + 'You do not have permissions to view connectors. If you would like to view the connectors associated with this case, contact your Kibana administrator.', + } +); diff --git a/x-pack/plugins/cases/public/containers/configure/use_connectors.tsx b/x-pack/plugins/cases/public/containers/configure/use_connectors.tsx index 3b91c77d0235a0..e350146c650ce3 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_connectors.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_connectors.tsx @@ -7,26 +7,40 @@ import { useState, useEffect, useCallback, useRef } from 'react'; -import * as i18n from '../translations'; import { fetchConnectors } from './api'; import { ActionConnector } from './types'; import { useToasts } from '../../common/lib/kibana'; +import * as i18n from './translations'; + +interface ConnectorsState { + loading: boolean; + connectors: ActionConnector[]; + permissionsError?: string; +} export interface UseConnectorsResponse { loading: boolean; connectors: ActionConnector[]; refetchConnectors: () => void; + permissionsError?: string; } -export const useConnectors = (): UseConnectorsResponse => { +/** + * Retrieves the configured case connectors + * + * @param toastPermissionsErrors boolean controlling whether 403 and 401 errors should be displayed in a toast error + */ +export const useConnectors = ({ + toastPermissionsErrors = true, +}: { + toastPermissionsErrors?: boolean; +} = {}): UseConnectorsResponse => { const toasts = useToasts(); - const [state, setState] = useState<{ - loading: boolean; - connectors: ActionConnector[]; - }>({ + const [state, setState] = useState({ loading: true, connectors: [], }); + const isCancelledRef = useRef(false); const abortCtrlRef = useRef(new AbortController()); @@ -49,15 +63,26 @@ export const useConnectors = (): UseConnectorsResponse => { } } catch (error) { if (!isCancelledRef.current) { + let permissionsError: string | undefined; if (error.name !== 'AbortError') { - toasts.addError( - error.body && error.body.message ? new Error(error.body.message) : error, - { title: i18n.ERROR_TITLE } - ); + // if the error was related to permissions then let's return a boilerplate error message describing the problem + if (error.body?.statusCode === 403 || error.body?.statusCode === 401) { + permissionsError = i18n.READ_PERMISSIONS_ERROR_MSG; + } + + // if the error was not permissions related then toast it + // if it was permissions related (permissionsError was defined) and the caller wants to toast, then create a toast + if (permissionsError === undefined || toastPermissionsErrors) { + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_TITLE } + ); + } } setState({ loading: false, connectors: [], + permissionsError, }); } } @@ -77,5 +102,6 @@ export const useConnectors = (): UseConnectorsResponse => { loading: state.loading, connectors: state.connectors, refetchConnectors, + permissionsError: state.permissionsError, }; }; diff --git a/x-pack/plugins/cases/public/mocks.ts b/x-pack/plugins/cases/public/mocks.ts new file mode 100644 index 00000000000000..c543baa4774756 --- /dev/null +++ b/x-pack/plugins/cases/public/mocks.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CasesUiStart } from './types'; + +const createStartContract = (): jest.Mocked => ({ + getAllCases: jest.fn(), + getAllCasesSelectorModal: jest.fn(), + getCaseView: jest.fn(), + getConfigureCases: jest.fn(), + getCreateCase: jest.fn(), + getRecentCases: jest.fn(), +}); + +export const casesPluginMock = { + createStartContract, +}; diff --git a/x-pack/plugins/cases/server/authorization/audit_logger.test.ts b/x-pack/plugins/cases/server/authorization/audit_logger.test.ts index d54b5164b10b94..48c6e9ebcd07a8 100644 --- a/x-pack/plugins/cases/server/authorization/audit_logger.test.ts +++ b/x-pack/plugins/cases/server/authorization/audit_logger.test.ts @@ -143,7 +143,7 @@ describe('audit_logger', () => { // for reference: https://github.com/facebook/jest/issues/9409#issuecomment-629272237 // This loops through all operation keys - it.each(Array.from(Object.keys(Operations)))( + it.each(Object.keys(Operations))( `creates the correct audit event for operation: "%s" without an error or entity`, (operationKey) => { // forcing the cast here because using a string throws a type error @@ -156,7 +156,7 @@ describe('audit_logger', () => { ); // This loops through all operation keys - it.each(Array.from(Object.keys(Operations)))( + it.each(Object.keys(Operations))( `creates the correct audit event for operation: "%s" with an error but no entity`, (operationKey) => { // forcing the cast here because using a string throws a type error @@ -170,7 +170,7 @@ describe('audit_logger', () => { ); // This loops through all operation keys - it.each(Array.from(Object.keys(Operations)))( + it.each(Object.keys(Operations))( `creates the correct audit event for operation: "%s" with an error and entity`, (operationKey) => { // forcing the cast here because using a string throws a type error @@ -188,7 +188,7 @@ describe('audit_logger', () => { ); // This loops through all operation keys - it.each(Array.from(Object.keys(Operations)))( + it.each(Object.keys(Operations))( `creates the correct audit event for operation: "%s" without an error but with an entity`, (operationKey) => { // forcing the cast here because using a string throws a type error diff --git a/x-pack/plugins/cases/server/plugin.ts b/x-pack/plugins/cases/server/plugin.ts index 28b9cf9e4e032a..b1e2f61a595eef 100644 --- a/x-pack/plugins/cases/server/plugin.ts +++ b/x-pack/plugins/cases/server/plugin.ts @@ -72,7 +72,7 @@ export class CasePlugin { this.clientFactory = new CasesClientFactory(this.log); } - public async setup(core: CoreSetup, plugins: PluginsSetup) { + public setup(core: CoreSetup, plugins: PluginsSetup) { const config = createConfig(this.initializerContext); if (!config.enabled) { diff --git a/x-pack/plugins/observability/public/components/app/cases/callout/helpers.tsx b/x-pack/plugins/observability/public/components/app/cases/callout/helpers.tsx index 29b17cd426c58b..fdd49ad17168de 100644 --- a/x-pack/plugins/observability/public/components/app/cases/callout/helpers.tsx +++ b/x-pack/plugins/observability/public/components/app/cases/callout/helpers.tsx @@ -5,18 +5,7 @@ * 2.0. */ -import React from 'react'; import md5 from 'md5'; -import * as i18n from './translations'; -import { ErrorMessage } from './types'; - -export const permissionsReadOnlyErrorMessage: ErrorMessage = { - id: 'read-only-privileges-error', - title: i18n.READ_ONLY_FEATURE_TITLE, - description: <>{i18n.READ_ONLY_FEATURE_MSG}, - errorType: 'warning', -}; - export const createCalloutId = (ids: string[], delimiter: string = '|'): string => md5(ids.join(delimiter)); diff --git a/x-pack/plugins/observability/public/components/app/cases/callout/translations.ts b/x-pack/plugins/observability/public/components/app/cases/callout/translations.ts index cb7236b445be12..20bb57daf5841b 100644 --- a/x-pack/plugins/observability/public/components/app/cases/callout/translations.ts +++ b/x-pack/plugins/observability/public/components/app/cases/callout/translations.ts @@ -7,21 +7,6 @@ import { i18n } from '@kbn/i18n'; -export const READ_ONLY_FEATURE_TITLE = i18n.translate( - 'xpack.observability.cases.readOnlyFeatureTitle', - { - defaultMessage: 'You cannot open new or update existing cases', - } -); - -export const READ_ONLY_FEATURE_MSG = i18n.translate( - 'xpack.observability.cases.readOnlyFeatureDescription', - { - defaultMessage: - 'You only have privileges to view cases. If you need to open and update cases, contact your Kibana administrator.', - } -); - export const DISMISS_CALLOUT = i18n.translate( 'xpack.observability.cases.dismissErrorsPushServiceCallOutTitle', { diff --git a/x-pack/plugins/observability/public/components/app/cases/translations.ts b/x-pack/plugins/observability/public/components/app/cases/translations.ts index 1a5abe218edf52..a85b0bc744e66a 100644 --- a/x-pack/plugins/observability/public/components/app/cases/translations.ts +++ b/x-pack/plugins/observability/public/components/app/cases/translations.ts @@ -201,3 +201,17 @@ export const CONNECTORS = i18n.translate('xpack.observability.cases.caseView.con export const EDIT_CONNECTOR = i18n.translate('xpack.observability.cases.caseView.editConnector', { defaultMessage: 'Change external incident management system', }); + +export const READ_ONLY_BADGE_TEXT = i18n.translate( + 'xpack.observability.cases.badge.readOnly.text', + { + defaultMessage: 'Read only', + } +); + +export const READ_ONLY_BADGE_TOOLTIP = i18n.translate( + 'xpack.observability.cases.badge.readOnly.tooltip', + { + defaultMessage: 'Unable to create or edit cases', + } +); diff --git a/x-pack/plugins/observability/public/hooks/use_readonly_header.tsx b/x-pack/plugins/observability/public/hooks/use_readonly_header.tsx new file mode 100644 index 00000000000000..4d8779e1ea150a --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_readonly_header.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useEffect } from 'react'; + +import * as i18n from '../components/app/cases/translations'; +import { useGetUserCasesPermissions } from '../hooks/use_get_user_cases_permissions'; +import { useKibana } from '../utils/kibana_react'; + +/** + * This component places a read-only icon badge in the header if user only has read permissions + */ +export function useReadonlyHeader() { + const userPermissions = useGetUserCasesPermissions(); + const chrome = useKibana().services.chrome; + + // if the user is read only then display the glasses badge in the global navigation header + const setBadge = useCallback(() => { + if (userPermissions != null && !userPermissions.crud && userPermissions.read) { + chrome.setBadge({ + text: i18n.READ_ONLY_BADGE_TEXT, + tooltip: i18n.READ_ONLY_BADGE_TOOLTIP, + iconType: 'glasses', + }); + } + }, [chrome, userPermissions]); + + useEffect(() => { + setBadge(); + + // remove the icon after the component unmounts + return () => { + chrome.setBadge(); + }; + }, [setBadge, chrome]); +} diff --git a/x-pack/plugins/observability/public/pages/cases/all_cases.tsx b/x-pack/plugins/observability/public/pages/cases/all_cases.tsx index f73f3b4cf57d75..442104a7106017 100644 --- a/x-pack/plugins/observability/public/pages/cases/all_cases.tsx +++ b/x-pack/plugins/observability/public/pages/cases/all_cases.tsx @@ -10,35 +10,28 @@ import React from 'react'; import { AllCases } from '../../components/app/cases/all_cases'; import * as i18n from '../../components/app/cases/translations'; -import { permissionsReadOnlyErrorMessage, CaseCallOut } from '../../components/app/cases/callout'; import { CaseFeatureNoPermissions } from './feature_no_permissions'; import { useGetUserCasesPermissions } from '../../hooks/use_get_user_cases_permissions'; import { usePluginContext } from '../../hooks/use_plugin_context'; +import { useReadonlyHeader } from '../../hooks/use_readonly_header'; import { casesBreadcrumbs } from './links'; import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; export const AllCasesPage = React.memo(() => { const userPermissions = useGetUserCasesPermissions(); const { ObservabilityPageTemplate } = usePluginContext(); + useReadonlyHeader(); useBreadcrumbs([casesBreadcrumbs.cases]); return userPermissions == null || userPermissions?.read ? ( - <> - {userPermissions != null && !userPermissions?.crud && userPermissions?.read && ( - - )} - {i18n.PAGE_TITLE}, - }} - > - - - + {i18n.PAGE_TITLE}, + }} + > + + ) : ( ); diff --git a/x-pack/plugins/observability/public/pages/cases/case_details.tsx b/x-pack/plugins/observability/public/pages/cases/case_details.tsx index 6adf5ad286808f..f93cb5c4e7919a 100644 --- a/x-pack/plugins/observability/public/pages/cases/case_details.tsx +++ b/x-pack/plugins/observability/public/pages/cases/case_details.tsx @@ -5,45 +5,35 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect } from 'react'; import { useParams } from 'react-router-dom'; import { CaseView } from '../../components/app/cases/case_view'; import { useGetUserCasesPermissions } from '../../hooks/use_get_user_cases_permissions'; import { useKibana } from '../../utils/kibana_react'; import { CASES_APP_ID } from '../../components/app/cases/constants'; -import { CaseCallOut, permissionsReadOnlyErrorMessage } from '../../components/app/cases/callout'; +import { useReadonlyHeader } from '../../hooks/use_readonly_header'; export const CaseDetailsPage = React.memo(() => { const { application: { getUrlForApp, navigateToUrl }, } = useKibana().services; + const casesUrl = getUrlForApp(CASES_APP_ID); const userPermissions = useGetUserCasesPermissions(); const { detailName: caseId, subCaseId } = useParams<{ detailName?: string; subCaseId?: string; }>(); + useReadonlyHeader(); - const casesUrl = getUrlForApp(CASES_APP_ID); - if (userPermissions != null && !userPermissions.read) { - navigateToUrl(casesUrl); - return null; - } + useEffect(() => { + if (userPermissions != null && !userPermissions.read) { + navigateToUrl(casesUrl); + } + }, [casesUrl, navigateToUrl, userPermissions]); return caseId != null ? ( - <> - {userPermissions != null && !userPermissions?.crud && userPermissions?.read && ( - - )} - - + ) : null; }); diff --git a/x-pack/plugins/observability/public/pages/cases/configure_cases.tsx b/x-pack/plugins/observability/public/pages/cases/configure_cases.tsx index a4df4855b0204d..9676eb7eba1470 100644 --- a/x-pack/plugins/observability/public/pages/cases/configure_cases.tsx +++ b/x-pack/plugins/observability/public/pages/cases/configure_cases.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect } from 'react'; import styled from 'styled-components'; import { EuiButtonEmpty } from '@elastic/eui'; @@ -38,10 +38,12 @@ function ConfigureCasesPageComponent() { const { formatUrl } = useFormatUrl(CASES_APP_ID); const href = formatUrl(getCaseUrl()); useBreadcrumbs([{ ...casesBreadcrumbs.cases, href }, casesBreadcrumbs.configure]); - if (userPermissions != null && !userPermissions.read) { - navigateToUrl(casesUrl); - return null; - } + + useEffect(() => { + if (userPermissions != null && !userPermissions.read) { + navigateToUrl(casesUrl); + } + }, [casesUrl, userPermissions, navigateToUrl]); return ( { const { formatUrl } = useFormatUrl(CASES_APP_ID); const href = formatUrl(getCaseUrl()); useBreadcrumbs([{ ...casesBreadcrumbs.cases, href }, casesBreadcrumbs.create]); - if (userPermissions != null && !userPermissions.crud) { - navigateToUrl(casesUrl); - return null; - } + + useEffect(() => { + if (userPermissions != null && !userPermissions.crud) { + navigateToUrl(casesUrl); + } + }, [casesUrl, navigateToUrl, userPermissions]); return ( { }); it('should not allow user with read only privileges to attach alerts to cases', () => { - cy.get(ATTACH_ALERT_TO_CASE_BUTTON).first().should('be.disabled'); + cy.get(ATTACH_ALERT_TO_CASE_BUTTON).should('not.exist'); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/callout/helpers.tsx b/x-pack/plugins/security_solution/public/cases/components/callout/helpers.tsx index 29b17cd426c58b..fdd49ad17168de 100644 --- a/x-pack/plugins/security_solution/public/cases/components/callout/helpers.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/callout/helpers.tsx @@ -5,18 +5,7 @@ * 2.0. */ -import React from 'react'; import md5 from 'md5'; -import * as i18n from './translations'; -import { ErrorMessage } from './types'; - -export const permissionsReadOnlyErrorMessage: ErrorMessage = { - id: 'read-only-privileges-error', - title: i18n.READ_ONLY_FEATURE_TITLE, - description: <>{i18n.READ_ONLY_FEATURE_MSG}, - errorType: 'warning', -}; - export const createCalloutId = (ids: string[], delimiter: string = '|'): string => md5(ids.join(delimiter)); diff --git a/x-pack/plugins/security_solution/public/cases/components/callout/translations.ts b/x-pack/plugins/security_solution/public/cases/components/callout/translations.ts index db4809126452f9..617995cc366b06 100644 --- a/x-pack/plugins/security_solution/public/cases/components/callout/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/callout/translations.ts @@ -7,21 +7,6 @@ import { i18n } from '@kbn/i18n'; -export const READ_ONLY_FEATURE_TITLE = i18n.translate( - 'xpack.securitySolution.cases.readOnlyFeatureTitle', - { - defaultMessage: 'You cannot open new or update existing cases', - } -); - -export const READ_ONLY_FEATURE_MSG = i18n.translate( - 'xpack.securitySolution.cases.readOnlyFeatureDescription', - { - defaultMessage: - 'You only have privileges to view cases. If you need to open and update cases, contact your Kibana administrator.', - } -); - export const DISMISS_CALLOUT = i18n.translate( 'xpack.securitySolution.cases.dismissErrorsPushServiceCallOutTitle', { diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx index 77fa9e8b3cc8c9..02047c774ca6f5 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx @@ -200,7 +200,7 @@ describe('AddToCaseAction', () => { ).toBeTruthy(); }); - it('disabled when user does not have crud permissions', () => { + it('hides the icon when user does not have crud permissions', () => { (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ crud: false, read: true, @@ -212,8 +212,6 @@ describe('AddToCaseAction', () => { ); - expect( - wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().prop('isDisabled') - ).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).exists()).toBeFalsy(); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx index eaad912a4dc51c..7025bff1ce49a6 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx @@ -208,19 +208,21 @@ const AddToCaseActionComponent: React.FC = ({ return ( <> - - - - - + {userCanCrud && ( + + + + + + )} {isCreateCaseFlyoutOpen && ( { return userPermissions == null || userPermissions?.read ? ( <> - {userPermissions != null && !userPermissions?.crud && userPermissions?.read && ( - - )} diff --git a/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx b/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx index 73077334268626..a086409e55df52 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect } from 'react'; import { useParams } from 'react-router-dom'; import { SecurityPageName } from '../../app/types'; @@ -16,7 +16,6 @@ import { useGetUserCasesPermissions, useKibana } from '../../common/lib/kibana'; import { getCaseUrl } from '../../common/components/link_to'; import { navTabs } from '../../app/home/home_navigations'; import { CaseView } from '../components/case_view'; -import { permissionsReadOnlyErrorMessage, CaseCallOut } from '../components/callout'; import { CASES_APP_ID } from '../../../common/constants'; export const CaseDetailsPage = React.memo(() => { @@ -30,20 +29,15 @@ export const CaseDetailsPage = React.memo(() => { }>(); const search = useGetUrlSearch(navTabs.case); - if (userPermissions != null && !userPermissions.read) { - navigateToApp(CASES_APP_ID, { path: getCaseUrl(search) }); - return null; - } + useEffect(() => { + if (userPermissions != null && !userPermissions.read) { + navigateToApp(CASES_APP_ID, { path: getCaseUrl(search) }); + } + }, [navigateToApp, userPermissions, search]); return caseId != null ? ( <> - {userPermissions != null && !userPermissions?.crud && userPermissions?.read && ( - - )} { [search] ); - if (userPermissions != null && !userPermissions.read) { - navigateToApp(CASES_APP_ID, { path: getCaseUrl(search) }); - return null; - } + useEffect(() => { + if (userPermissions != null && !userPermissions.read) { + navigateToApp(CASES_APP_ID, { + path: getCaseUrl(search), + }); + } + }, [navigateToApp, userPermissions, search]); const HeaderWrapper = styled.div` padding-top: ${({ theme }) => theme.eui.paddingSizes.l}; diff --git a/x-pack/plugins/security_solution/public/cases/pages/create_case.tsx b/x-pack/plugins/security_solution/public/cases/pages/create_case.tsx index 19f97bae60ebe9..3c5197f19eff12 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/create_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/create_case.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { SecurityPageName } from '../../app/types'; import { getCaseUrl } from '../../common/components/link_to'; @@ -25,6 +25,7 @@ export const CreateCasePage = React.memo(() => { const { application: { navigateToApp }, } = useKibana().services; + const backOptions = useMemo( () => ({ href: getCaseUrl(search), @@ -34,12 +35,13 @@ export const CreateCasePage = React.memo(() => { [search] ); - if (userPermissions != null && !userPermissions.crud) { - navigateToApp(CASES_APP_ID, { - path: getCaseUrl(search), - }); - return null; - } + useEffect(() => { + if (userPermissions != null && !userPermissions.crud) { + navigateToApp(CASES_APP_ID, { + path: getCaseUrl(search), + }); + } + }, [userPermissions, navigateToApp, search]); return ( <> diff --git a/x-pack/plugins/security_solution/public/cases/pages/index.test.tsx b/x-pack/plugins/security_solution/public/cases/pages/index.test.tsx new file mode 100644 index 00000000000000..0d12d63fdc244c --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/pages/index.test.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { BrowserRouter as Router } from 'react-router-dom'; + +import { useGetUserCasesPermissions, useKibana } from '../../common/lib/kibana'; +import { TestProviders } from '../../common/mock'; +import { Case } from '.'; + +const useKibanaMock = useKibana as jest.Mocked; +jest.mock('../../common/lib/kibana'); + +const mockedSetBadge = jest.fn(); + +describe('CaseContainerComponent', () => { + beforeEach(() => { + jest.clearAllMocks(); + useKibanaMock().services.chrome.setBadge = mockedSetBadge; + }); + + it('does not display the readonly glasses badge when the user has write permissions', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: true, + read: false, + }); + + mount( + + + + + + ); + + expect(mockedSetBadge).not.toBeCalled(); + }); + + it('does not display the readonly glasses badge when the user has neither write nor read permissions', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: false, + read: false, + }); + + mount( + + + + + + ); + + expect(mockedSetBadge).not.toBeCalled(); + }); + + it('does not display the readonly glasses badge when the user has null permissions', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue(null); + + mount( + + + + + + ); + + expect(mockedSetBadge).not.toBeCalled(); + }); + + it('displays the readonly glasses badge read permissions but not write', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: false, + read: true, + }); + + mount( + + + + + + ); + + expect(mockedSetBadge).toBeCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/pages/index.tsx b/x-pack/plugins/security_solution/public/cases/pages/index.tsx index 314bdc9bfd117f..fca19cf5c70a7e 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/index.tsx @@ -5,13 +5,15 @@ * 2.0. */ -import React from 'react'; - +import React, { useEffect } from 'react'; import { Route, Switch } from 'react-router-dom'; + +import * as i18n from './translations'; import { CaseDetailsPage } from './case_details'; import { CasesPage } from './case'; import { CreateCasePage } from './create_case'; import { ConfigureCasesPage } from './configure_cases'; +import { useGetUserCasesPermissions, useKibana } from '../../common/lib/kibana'; const casesPagePath = ''; const caseDetailsPagePath = `${casesPagePath}/:detailName`; @@ -21,30 +23,51 @@ const subCaseDetailsPagePathWithCommentId = `${subCaseDetailsPagePath}/:commentI const createCasePagePath = `${casesPagePath}/create`; const configureCasesPagePath = `${casesPagePath}/configure`; -const CaseContainerComponent: React.FC = () => ( - - - - - - - - - - - - - - - - - - - - - - - -); +const CaseContainerComponent: React.FC = () => { + const userPermissions = useGetUserCasesPermissions(); + const chrome = useKibana().services.chrome; + + useEffect(() => { + // if the user is read only then display the glasses badge in the global navigation header + if (userPermissions != null && !userPermissions.crud && userPermissions.read) { + chrome.setBadge({ + text: i18n.READ_ONLY_BADGE_TEXT, + tooltip: i18n.READ_ONLY_BADGE_TOOLTIP, + iconType: 'glasses', + }); + } + + // remove the icon after the component unmounts + return () => { + chrome.setBadge(); + }; + }, [userPermissions, chrome]); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +}; export const Case = React.memo(CaseContainerComponent); diff --git a/x-pack/plugins/security_solution/public/cases/pages/translations.ts b/x-pack/plugins/security_solution/public/cases/pages/translations.ts index 1a811a3fd7bbc3..6768401b3f608e 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/pages/translations.ts @@ -157,3 +157,24 @@ export const GO_TO_DOCUMENTATION = i18n.translate( export const CONNECTORS = i18n.translate('xpack.securitySolution.cases.caseView.connectors', { defaultMessage: 'External Incident Management System', }); + +export const EDIT_CONNECTOR = i18n.translate( + 'xpack.securitySolution.cases.caseView.editConnector', + { + defaultMessage: 'Change external incident management system', + } +); + +export const READ_ONLY_BADGE_TEXT = i18n.translate( + 'xpack.securitySolution.cases.badge.readOnly.text', + { + defaultMessage: 'Read only', + } +); + +export const READ_ONLY_BADGE_TOOLTIP = i18n.translate( + 'xpack.securitySolution.cases.badge.readOnly.tooltip', + { + defaultMessage: 'Unable to create or edit cases', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/header_global/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/header_global/index.test.tsx new file mode 100644 index 00000000000000..96a7eacb7fb08c --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/header_global/index.test.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; + +import { useGetUserCasesPermissions } from '../../../common/lib/kibana'; +import { TestProviders } from '../../../common/mock'; +import { HeaderGlobal } from '.'; + +jest.mock('../../../common/lib/kibana'); + +describe('HeaderGlobal', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not display the cases tab when the user does not have read permissions', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: false, + read: false, + }); + + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="navigation-case"]`).exists()).toBeFalsy(); + }); + + it('displays the cases tab when the user has read permissions', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: true, + read: true, + }); + + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="navigation-case"]`).exists()).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx b/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx index 4a7ac8a148f647..e91905183aab10 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx @@ -19,7 +19,7 @@ import { MlPopover } from '../ml_popover/ml_popover'; import { SiemNavigation } from '../navigation'; import * as i18n from './translations'; import { useGetUrlSearch } from '../navigation/use_get_url_search'; -import { useKibana } from '../../lib/kibana'; +import { useGetUserCasesPermissions, useKibana } from '../../lib/kibana'; import { APP_ID, ADD_DATA_PATH, APP_DETECTIONS_PATH } from '../../../../common/constants'; import { useGlobalHeaderPortal } from '../../hooks/use_global_header_portal'; import { LinkAnchor } from '../links'; @@ -91,6 +91,18 @@ export const HeaderGlobal = React.memo( }, [navigateToApp, search] ); + + const hasCasesReadPermissions = useGetUserCasesPermissions()?.read; + + // build a list of tabs to exclude + const tabsToExclude = new Set([ + ...(hideDetectionEngine ? [SecurityPageName.detections] : []), + ...(!hasCasesReadPermissions ? [SecurityPageName.case] : []), + ]); + + // include the tab if it is not in the set of excluded ones + const tabsToDisplay = pickBy((_, key) => !tabsToExclude.has(key), navTabs); + return ( @@ -109,14 +121,7 @@ export const HeaderGlobal = React.memo( - key !== SecurityPageName.detections, navTabs) - : navTabs - } - /> + diff --git a/x-pack/plugins/security_solution/public/overview/components/recent_cases/index.tsx b/x-pack/plugins/security_solution/public/overview/components/recent_cases/index.tsx index 996835296fcc4b..cb7733e3049850 100644 --- a/x-pack/plugins/security_solution/public/overview/components/recent_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/recent_cases/index.tsx @@ -13,7 +13,7 @@ import { getCreateCaseUrl, } from '../../../common/components/link_to/redirect_to_case'; import { useFormatUrl } from '../../../common/components/link_to'; -import { useKibana } from '../../../common/lib/kibana'; +import { useGetUserCasesPermissions, useKibana } from '../../../common/lib/kibana'; import { APP_ID, CASES_APP_ID } from '../../../../common/constants'; import { SecurityPageName } from '../../../app/types'; import { AllCasesNavProps } from '../../../cases/components/all_cases'; @@ -26,6 +26,8 @@ const RecentCasesComponent = () => { application: { navigateToApp }, } = useKibana().services; + const hasWritePermissions = useGetUserCasesPermissions()?.crud ?? false; + return casesUi.getRecentCases({ allCasesNavigation: { href: formatUrl(getCaseUrl()), @@ -60,6 +62,7 @@ const RecentCasesComponent = () => { }); }, }, + hasWritePermissions, maxCasesToShow: MAX_CASES_TO_SHOW, owner: [APP_ID], }); diff --git a/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.test.tsx b/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.test.tsx new file mode 100644 index 00000000000000..76c5663644a789 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.test.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { waitFor } from '@testing-library/react'; +import { TestProviders } from '../../../common/mock'; +import { Sidebar } from './sidebar'; +import { useGetUserCasesPermissions, useKibana } from '../../../common/lib/kibana'; +import { casesPluginMock } from '../../../../../cases/public/mocks'; +import { CasesUiStart } from '../../../../../cases/public'; + +jest.mock('../../../common/lib/kibana'); + +const useKibanaMock = useKibana as jest.MockedFunction; + +describe('Sidebar', () => { + let casesMock: jest.Mocked; + + beforeEach(() => { + casesMock = casesPluginMock.createStartContract(); + casesMock.getRecentCases.mockImplementation(() => <>{'test'}); + useKibanaMock.mockReturnValue(({ + services: { + cases: casesMock, + application: { + // these are needed by the RecentCases component if it is rendered. + navigateToApp: jest.fn(), + getUrlForApp: jest.fn(() => ''), + }, + }, + } as unknown) as ReturnType); + }); + + it('does not render the recently created cases section when the user does not have read permissions', async () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: false, + read: false, + }); + + await waitFor(() => + mount( + + {}} /> + + ) + ); + + expect(casesMock.getRecentCases).not.toHaveBeenCalled(); + }); + + it('does render the recently created cases section when the user has read permissions', async () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: false, + read: true, + }); + + await waitFor(() => + mount( + + {}} /> + + ) + ); + + expect(casesMock.getRecentCases).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.tsx b/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.tsx index 77cfa220f07220..b8701f3ef1639d 100644 --- a/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.tsx @@ -18,6 +18,7 @@ import { SidebarHeader } from '../../../common/components/sidebar_header'; import * as i18n from '../../pages/translations'; import { RecentCases } from '../recent_cases'; +import { useGetUserCasesPermissions } from '../../../common/lib/kibana'; const SidebarFlexGroup = styled(EuiFlexGroup)` width: 305px; @@ -46,13 +47,20 @@ export const Sidebar = React.memo<{ [recentTimelinesFilterBy, setRecentTimelinesFilterBy] ); + // only render the recently created cases view if the user has at least read permissions + const hasCasesReadPermissions = useGetUserCasesPermissions()?.read; + return ( - - - + {hasCasesReadPermissions && ( + <> + + + - + + + )} {recentTimelinesFilters} diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.test.tsx index 68b4f2e4a0c31c..206fcb2dc087ca 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useKibana, useGetUserCasesPermissions } from '../../../../common/lib/kibana'; import { TestProviders, mockIndexNames, mockIndexPattern } from '../../../../common/mock'; import { TimelineId } from '../../../../../common/types/timeline'; import { useTimelineKpis } from '../../../containers/kpis'; @@ -57,7 +57,7 @@ const defaultMocks = { loading: false, selectedPatterns: mockIndexNames, }; -describe('Timeline KPIs', () => { +describe('header', () => { const mount = useMountAppended(); beforeEach(() => { @@ -75,86 +75,124 @@ describe('Timeline KPIs', () => { jest.clearAllMocks(); }); - describe('when the data is not loading and the response contains data', () => { + describe('AddToCaseButton', () => { beforeEach(() => { mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineKpiResponse]); }); - it('renders the component, labels and values succesfully', async () => { + + it('renders the button when the user has write permissions', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: true, + read: false, + }); + const wrapper = mount( ); - expect(wrapper.find('[data-test-subj="siem-timeline-kpis"]').exists()).toEqual(true); - // label - expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( - expect.stringContaining('Processes') - ); - // value - expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( - expect.stringContaining('1') - ); - }); - }); - describe('when the data is loading', () => { - beforeEach(() => { - mockUseTimelineKpis.mockReturnValue([true, mockUseTimelineKpiResponse]); + expect(wrapper.find('[data-test-subj="attach-timeline-case-button"]').exists()).toBeTruthy(); }); - it('renders a loading indicator for values', async () => { + + it('does not render the button when the user does not have write permissions', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: false, + read: false, + }); + const wrapper = mount( ); - expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( - expect.stringContaining('--') - ); + + expect(wrapper.find('[data-test-subj="attach-timeline-case-button"]').exists()).toBeFalsy(); }); }); - describe('when the response is null and timeline is blank', () => { - beforeEach(() => { - mockUseTimelineKpis.mockReturnValue([false, null]); + describe('Timeline KPIs', () => { + describe('when the data is not loading and the response contains data', () => { + beforeEach(() => { + mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineKpiResponse]); + }); + it('renders the component, labels and values successfully', async () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="siem-timeline-kpis"]').exists()).toEqual(true); + // label + expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( + expect.stringContaining('Processes') + ); + // value + expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( + expect.stringContaining('1') + ); + }); }); - it('renders labels and the default empty string', async () => { - const wrapper = mount( - - - - ); - expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( - expect.stringContaining('Processes') - ); - expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( - expect.stringContaining(getEmptyValue()) - ); + describe('when the data is loading', () => { + beforeEach(() => { + mockUseTimelineKpis.mockReturnValue([true, mockUseTimelineKpiResponse]); + }); + it('renders a loading indicator for values', async () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( + expect.stringContaining('--') + ); + }); }); - }); - describe('when the response contains numbers larger than one thousand', () => { - beforeEach(() => { - mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineLargeKpiResponse]); + describe('when the response is null and timeline is blank', () => { + beforeEach(() => { + mockUseTimelineKpis.mockReturnValue([false, null]); + }); + it('renders labels and the default empty string', async () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( + expect.stringContaining('Processes') + ); + expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( + expect.stringContaining(getEmptyValue()) + ); + }); }); - it('formats the numbers correctly', async () => { - const wrapper = mount( - - - - ); - expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( - expect.stringContaining('1k') - ); - expect(wrapper.find('[data-test-subj="siem-timeline-user-kpi"]').first().text()).toEqual( - expect.stringContaining('1m') - ); - expect(wrapper.find('[data-test-subj="siem-timeline-source-ip-kpi"]').first().text()).toEqual( - expect.stringContaining('1b') - ); - expect(wrapper.find('[data-test-subj="siem-timeline-host-kpi"]').first().text()).toEqual( - expect.stringContaining('999') - ); + + describe('when the response contains numbers larger than one thousand', () => { + beforeEach(() => { + mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineLargeKpiResponse]); + }); + it('formats the numbers correctly', async () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( + expect.stringContaining('1k') + ); + expect(wrapper.find('[data-test-subj="siem-timeline-user-kpi"]').first().text()).toEqual( + expect.stringContaining('1m') + ); + expect( + wrapper.find('[data-test-subj="siem-timeline-source-ip-kpi"]').first().text() + ).toEqual(expect.stringContaining('1b')); + expect(wrapper.find('[data-test-subj="siem-timeline-host-kpi"]').first().text()).toEqual( + expect.stringContaining('999') + ); + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx index dd8cdb818cad75..216282b72920c5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx @@ -35,7 +35,7 @@ import { TimerangeInput } from '../../../../../common/search_strategy'; import { AddToCaseButton } from '../add_to_case_button'; import { AddTimelineButton } from '../add_timeline_button'; import { SaveTimelineButton } from '../../timeline/header/save_timeline_button'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useGetUserCasesPermissions, useKibana } from '../../../../common/lib/kibana'; import { InspectButton } from '../../../../common/components/inspect'; import { useTimelineKpis } from '../../../containers/kpis'; import { esQuery } from '../../../../../../../../src/plugins/data/public'; @@ -319,6 +319,8 @@ const FlyoutHeaderComponent: React.FC = ({ timelineId }) => { filterQuery: combinedQueries?.filterQuery ?? '', }); + const hasWritePermissions = useGetUserCasesPermissions()?.crud ?? false; + return ( @@ -350,9 +352,11 @@ const FlyoutHeaderComponent: React.FC = ({ timelineId }) => { - - - + {hasWritePermissions && ( + + + + )} diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 7e4d0989af413d..ac9d854f18211f 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -129,17 +129,23 @@ export interface PluginSetup {} // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface PluginStart {} -const securitySubPlugins = [ +const casesSubPlugin = `${APP_ID}:${SecurityPageName.case}`; + +/** + * Don't include cases here so that the sub feature can govern whether Cases is enabled in the navigation + */ +const securitySubPluginsNoCases = [ APP_ID, `${APP_ID}:${SecurityPageName.overview}`, `${APP_ID}:${SecurityPageName.detections}`, `${APP_ID}:${SecurityPageName.hosts}`, `${APP_ID}:${SecurityPageName.network}`, `${APP_ID}:${SecurityPageName.timelines}`, - `${APP_ID}:${SecurityPageName.case}`, `${APP_ID}:${SecurityPageName.administration}`, ]; +const allSecuritySubPlugins = [...securitySubPluginsNoCases, casesSubPlugin]; + export class Plugin implements IPlugin { private readonly logger: Logger; private readonly config: ConfigType; @@ -305,7 +311,7 @@ export class Plugin implements IPlugin { await PageObjects.common.navigateToActualUrl('observabilityCases'); - await PageObjects.observability.expectCreateCaseButtonDisabled(); + await PageObjects.observability.expectCreateCaseButtonMissing(); }); - it(`shows read-only callout`, async () => { - await PageObjects.observability.expectReadOnlyCallout(); + it(`shows read-only glasses badge`, async () => { + await PageObjects.observability.expectReadOnlyGlassesBadge(); }); it(`does not allow a case to be created`, async () => { @@ -151,7 +151,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); // expect redirection to observability cases landing - await PageObjects.observability.expectCreateCaseButtonDisabled(); + await PageObjects.observability.expectCreateCaseButtonMissing(); }); it(`does not allow a case to be edited`, async () => { @@ -162,7 +162,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { shouldUseHashForSubUrl: false, } ); - await PageObjects.observability.expectAddCommentButtonDisabled(); + await PageObjects.observability.expectAddCommentButtonMissing(); }); }); diff --git a/x-pack/test/functional/page_objects/observability_page.ts b/x-pack/test/functional/page_objects/observability_page.ts index 95016c31d10541..d9e413d473adf3 100644 --- a/x-pack/test/functional/page_objects/observability_page.ts +++ b/x-pack/test/functional/page_objects/observability_page.ts @@ -20,14 +20,12 @@ export function ObservabilityPageProvider({ getService, getPageObjects }: FtrPro expect(disabledAttr).to.be(null); }, - async expectCreateCaseButtonDisabled() { - const button = await testSubjects.find('createNewCaseBtn', 20000); - const disabledAttr = await button.getAttribute('disabled'); - expect(disabledAttr).to.be('true'); + async expectCreateCaseButtonMissing() { + await testSubjects.missingOrFail('createNewCaseBtn'); }, - async expectReadOnlyCallout() { - await testSubjects.existOrFail('case-callout-e41900b01c9ef0fa81dd6ff326083fb3'); + async expectReadOnlyGlassesBadge() { + await testSubjects.existOrFail('headerBadge'); }, async expectNoReadOnlyCallout() { @@ -44,10 +42,8 @@ export function ObservabilityPageProvider({ getService, getPageObjects }: FtrPro expect(disabledAttr).to.be(null); }, - async expectAddCommentButtonDisabled() { - const button = await testSubjects.find('submit-comment', 20000); - const disabledAttr = await button.getAttribute('disabled'); - expect(disabledAttr).to.be('true'); + async expectAddCommentButtonMissing() { + await testSubjects.missingOrFail('submit-comment'); }, async expectForbidden() { From bfbe6ab0b248b2160a4abb468483bbb15edf4e11 Mon Sep 17 00:00:00 2001 From: Kevin Logan <56395104+kevinlog@users.noreply.github.com> Date: Tue, 22 Jun 2021 14:01:24 -0400 Subject: [PATCH 50/57] [Security Solution] show case names in isolation success message (#102664) --- x-pack/plugins/cases/common/api/cases/case.ts | 25 ++++++++ .../plugins/cases/common/api/cases/comment.ts | 16 ----- .../classes/client.casesclient.md | 28 ++++----- .../interfaces/attachments_add.addargs.md | 4 +- ...attachments_client.attachmentssubclient.md | 16 ++--- .../attachments_delete.deleteallargs.md | 4 +- .../attachments_delete.deleteargs.md | 6 +- .../interfaces/attachments_get.findargs.md | 4 +- ...ttachments_get.getallalertsattachtocase.md | 2 +- .../interfaces/attachments_get.getallargs.md | 6 +- .../interfaces/attachments_get.getargs.md | 4 +- .../attachments_update.updateargs.md | 6 +- .../interfaces/cases_client.casessubclient.md | 30 +++++----- .../cases_get.caseidsbyalertidparams.md | 40 ------------- .../cases_get.casesbyalertidparams.md | 40 +++++++++++++ .../interfaces/cases_get.getparams.md | 6 +- .../interfaces/cases_push.pushparams.md | 4 +- .../configure_client.configuresubclient.md | 8 +-- .../interfaces/stats_client.statssubclient.md | 2 +- .../sub_cases_client.subcasesclient.md | 8 +-- .../user_actions_client.useractionget.md | 4 +- ...ser_actions_client.useractionssubclient.md | 2 +- .../docs/cases_client/modules/cases_get.md | 6 +- .../cases/server/client/cases/client.ts | 12 ++-- .../plugins/cases/server/client/cases/get.ts | 47 +++++++++++++-- x-pack/plugins/cases/server/client/mocks.ts | 2 +- .../routes/api/cases/alerts/get_cases.ts | 4 +- .../plugins/cases/server/routes/api/index.ts | 4 +- .../components/host_isolation/index.tsx | 20 +++---- .../components/host_isolation/isolate.tsx | 11 +++- .../components/host_isolation/unisolate.tsx | 11 +++- .../detection_engine/alerts/mock.ts | 4 +- .../detection_engine/alerts/types.ts | 2 +- .../alerts/use_cases_from_alerts.test.tsx | 2 +- .../alerts/use_cases_from_alerts.tsx | 4 +- .../endpoint/routes/actions/isolation.ts | 14 +++-- .../case_api_integration/common/lib/utils.ts | 5 +- .../common/lib/validation.ts | 27 +++++++++ .../tests/common/alerts/get_cases.ts | 58 +++++++++---------- .../tests/common/alerts/get_cases.ts | 38 ++++++------ .../tests/common/alerts/get_cases.ts | 17 +++--- 41 files changed, 318 insertions(+), 235 deletions(-) delete mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.caseidsbyalertidparams.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.casesbyalertidparams.md create mode 100644 x-pack/test/case_api_integration/common/lib/validation.ts diff --git a/x-pack/plugins/cases/common/api/cases/case.ts b/x-pack/plugins/cases/common/api/cases/case.ts index b3f7952a61ee7f..a72eda5bb1207e 100644 --- a/x-pack/plugins/cases/common/api/cases/case.ts +++ b/x-pack/plugins/cases/common/api/cases/case.ts @@ -14,6 +14,28 @@ import { CasesStatusResponseRt, CaseStatusRt } from './status'; import { CaseConnectorRt, ESCaseConnector } from '../connectors'; import { SubCaseResponseRt } from './sub_case'; +const BucketsAggs = rt.array( + rt.type({ + key: rt.string, + }) +); + +export const GetCaseIdsByAlertIdAggsRt = rt.type({ + references: rt.type({ + doc_count: rt.number, + caseIds: rt.type({ + buckets: BucketsAggs, + }), + }), +}); + +export const CasesByAlertIdRt = rt.array( + rt.type({ + id: rt.string, + title: rt.string, + }) +); + export enum CaseType { collection = 'collection', individual = 'individual', @@ -311,3 +333,6 @@ export type ESCasePatchRequest = Omit & { export type AllTagsFindRequest = rt.TypeOf; export type AllReportersFindRequest = AllTagsFindRequest; + +export type GetCaseIdsByAlertIdAggs = rt.TypeOf; +export type CasesByAlertId = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/api/cases/comment.ts b/x-pack/plugins/cases/common/api/cases/comment.ts index 5bc8da95639c85..746c28f9942392 100644 --- a/x-pack/plugins/cases/common/api/cases/comment.ts +++ b/x-pack/plugins/cases/common/api/cases/comment.ts @@ -10,21 +10,6 @@ import { SavedObjectFindOptionsRt } from '../saved_object'; import { UserRT } from '../user'; -const BucketsAggs = rt.array( - rt.type({ - key: rt.string, - }) -); - -export const GetCaseIdsByAlertIdAggsRt = rt.type({ - references: rt.type({ - doc_count: rt.number, - caseIds: rt.type({ - buckets: BucketsAggs, - }), - }), -}); - /** * this is used to differentiate between an alert attached to a top-level case and a group of alerts that should only * be attached to a sub case. The reason we need this is because an alert group comment will have references to both a case and @@ -152,4 +137,3 @@ export type CommentPatchRequest = rt.TypeOf; export type CommentPatchAttributes = rt.TypeOf; export type CommentRequestUserType = rt.TypeOf; export type CommentRequestAlertType = rt.TypeOf; -export type GetCaseIdsByAlertIdAggs = rt.TypeOf; diff --git a/x-pack/plugins/cases/docs/cases_client/classes/client.casesclient.md b/x-pack/plugins/cases/docs/cases_client/classes/client.casesclient.md index a20f018cffeb8d..bd07a44a2bfdf3 100644 --- a/x-pack/plugins/cases/docs/cases_client/classes/client.casesclient.md +++ b/x-pack/plugins/cases/docs/cases_client/classes/client.casesclient.md @@ -45,7 +45,7 @@ Client wrapper that contains accessor methods for individual entities within the **Returns:** [*CasesClient*](client.casesclient.md) -Defined in: [client.ts:28](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/client.ts#L28) +Defined in: [client.ts:28](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/client.ts#L28) ## Properties @@ -53,7 +53,7 @@ Defined in: [client.ts:28](https://github.com/jonathan-buttner/kibana/blob/0e98e • `Private` `Readonly` **\_attachments**: [*AttachmentsSubClient*](../interfaces/attachments_client.attachmentssubclient.md) -Defined in: [client.ts:24](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/client.ts#L24) +Defined in: [client.ts:24](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/client.ts#L24) ___ @@ -61,7 +61,7 @@ ___ • `Private` `Readonly` **\_cases**: [*CasesSubClient*](../interfaces/cases_client.casessubclient.md) -Defined in: [client.ts:23](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/client.ts#L23) +Defined in: [client.ts:23](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/client.ts#L23) ___ @@ -69,7 +69,7 @@ ___ • `Private` `Readonly` **\_casesClientInternal**: *CasesClientInternal* -Defined in: [client.ts:22](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/client.ts#L22) +Defined in: [client.ts:22](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/client.ts#L22) ___ @@ -77,7 +77,7 @@ ___ • `Private` `Readonly` **\_configure**: [*ConfigureSubClient*](../interfaces/configure_client.configuresubclient.md) -Defined in: [client.ts:27](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/client.ts#L27) +Defined in: [client.ts:27](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/client.ts#L27) ___ @@ -85,7 +85,7 @@ ___ • `Private` `Readonly` **\_stats**: [*StatsSubClient*](../interfaces/stats_client.statssubclient.md) -Defined in: [client.ts:28](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/client.ts#L28) +Defined in: [client.ts:28](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/client.ts#L28) ___ @@ -93,7 +93,7 @@ ___ • `Private` `Readonly` **\_subCases**: [*SubCasesClient*](../interfaces/sub_cases_client.subcasesclient.md) -Defined in: [client.ts:26](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/client.ts#L26) +Defined in: [client.ts:26](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/client.ts#L26) ___ @@ -101,7 +101,7 @@ ___ • `Private` `Readonly` **\_userActions**: [*UserActionsSubClient*](../interfaces/user_actions_client.useractionssubclient.md) -Defined in: [client.ts:25](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/client.ts#L25) +Defined in: [client.ts:25](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/client.ts#L25) ## Accessors @@ -113,7 +113,7 @@ Retrieves an interface for interacting with attachments (comments) entities. **Returns:** [*AttachmentsSubClient*](../interfaces/attachments_client.attachmentssubclient.md) -Defined in: [client.ts:50](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/client.ts#L50) +Defined in: [client.ts:50](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/client.ts#L50) ___ @@ -125,7 +125,7 @@ Retrieves an interface for interacting with cases entities. **Returns:** [*CasesSubClient*](../interfaces/cases_client.casessubclient.md) -Defined in: [client.ts:43](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/client.ts#L43) +Defined in: [client.ts:43](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/client.ts#L43) ___ @@ -137,7 +137,7 @@ Retrieves an interface for interacting with the configuration of external connec **Returns:** [*ConfigureSubClient*](../interfaces/configure_client.configuresubclient.md) -Defined in: [client.ts:76](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/client.ts#L76) +Defined in: [client.ts:76](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/client.ts#L76) ___ @@ -149,7 +149,7 @@ Retrieves an interface for retrieving statistics related to the cases entities. **Returns:** [*StatsSubClient*](../interfaces/stats_client.statssubclient.md) -Defined in: [client.ts:83](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/client.ts#L83) +Defined in: [client.ts:83](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/client.ts#L83) ___ @@ -163,7 +163,7 @@ Currently this functionality is disabled and will throw an error if this functio **Returns:** [*SubCasesClient*](../interfaces/sub_cases_client.subcasesclient.md) -Defined in: [client.ts:66](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/client.ts#L66) +Defined in: [client.ts:66](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/client.ts#L66) ___ @@ -175,4 +175,4 @@ Retrieves an interface for interacting with the user actions associated with the **Returns:** [*UserActionsSubClient*](../interfaces/user_actions_client.useractionssubclient.md) -Defined in: [client.ts:57](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/client.ts#L57) +Defined in: [client.ts:57](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/client.ts#L57) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_add.addargs.md b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_add.addargs.md index d5233ab6d8cb4f..f8f7babd15b909 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_add.addargs.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_add.addargs.md @@ -21,7 +21,7 @@ The arguments needed for creating a new attachment to a case. The case ID that this attachment will be associated with -Defined in: [attachments/add.ts:305](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/add.ts#L305) +Defined in: [attachments/add.ts:305](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/add.ts#L305) ___ @@ -31,4 +31,4 @@ ___ The attachment values. -Defined in: [attachments/add.ts:309](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/add.ts#L309) +Defined in: [attachments/add.ts:309](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/add.ts#L309) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_client.attachmentssubclient.md b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_client.attachmentssubclient.md index 1a9a687aa812b1..57141796f6f673 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_client.attachmentssubclient.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_client.attachmentssubclient.md @@ -35,7 +35,7 @@ Adds an attachment to a case. **Returns:** *Promise*<[*ICaseResponse*](typedoc_interfaces.icaseresponse.md)\> -Defined in: [attachments/client.ts:35](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/client.ts#L35) +Defined in: [attachments/client.ts:35](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/client.ts#L35) ___ @@ -53,7 +53,7 @@ Deletes a single attachment for a specific case. **Returns:** *Promise* -Defined in: [attachments/client.ts:43](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/client.ts#L43) +Defined in: [attachments/client.ts:43](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/client.ts#L43) ___ @@ -71,7 +71,7 @@ Deletes all attachments associated with a single case. **Returns:** *Promise* -Defined in: [attachments/client.ts:39](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/client.ts#L39) +Defined in: [attachments/client.ts:39](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/client.ts#L39) ___ @@ -89,7 +89,7 @@ Retrieves all comments matching the search criteria. **Returns:** *Promise*<[*ICommentsResponse*](typedoc_interfaces.icommentsresponse.md)\> -Defined in: [attachments/client.ts:47](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/client.ts#L47) +Defined in: [attachments/client.ts:47](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/client.ts#L47) ___ @@ -107,7 +107,7 @@ Retrieves a single attachment for a case. **Returns:** *Promise*<{ `comment`: *string* ; `owner`: *string* ; `type`: user } & { `associationType`: AssociationType ; `created_at`: *string* ; `created_by`: { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `owner`: *string* ; `pushed_at`: ``null`` \| *string* ; `pushed_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `updated_at`: ``null`` \| *string* ; `updated_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } } & { `id`: *string* ; `version`: *string* } & { `alertId`: *string* \| *string*[] ; `index`: *string* \| *string*[] ; `owner`: *string* ; `rule`: { id: string \| null; name: string \| null; } ; `type`: alert \| generatedAlert } & { `associationType`: AssociationType ; `created_at`: *string* ; `created_by`: { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `owner`: *string* ; `pushed_at`: ``null`` \| *string* ; `pushed_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `updated_at`: ``null`` \| *string* ; `updated_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } } & { `id`: *string* ; `version`: *string* }\> -Defined in: [attachments/client.ts:59](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/client.ts#L59) +Defined in: [attachments/client.ts:59](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/client.ts#L59) ___ @@ -125,7 +125,7 @@ Gets all attachments for a single case. **Returns:** *Promise*<[*IAllCommentsResponse*](typedoc_interfaces.iallcommentsresponse.md)\> -Defined in: [attachments/client.ts:55](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/client.ts#L55) +Defined in: [attachments/client.ts:55](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/client.ts#L55) ___ @@ -143,7 +143,7 @@ Retrieves all alerts attach to a case given a single case ID **Returns:** *Promise*<{ `attached_at`: *string* ; `id`: *string* ; `index`: *string* }[]\> -Defined in: [attachments/client.ts:51](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/client.ts#L51) +Defined in: [attachments/client.ts:51](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/client.ts#L51) ___ @@ -163,4 +163,4 @@ The request must include all fields for the attachment. Even the fields that are **Returns:** *Promise*<[*ICaseResponse*](typedoc_interfaces.icaseresponse.md)\> -Defined in: [attachments/client.ts:65](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/client.ts#L65) +Defined in: [attachments/client.ts:65](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/client.ts#L65) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_delete.deleteallargs.md b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_delete.deleteallargs.md index 437758a0147f28..d134c92e282a38 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_delete.deleteallargs.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_delete.deleteallargs.md @@ -21,7 +21,7 @@ Parameters for deleting all comments of a case or sub case. The case ID to delete all attachments for -Defined in: [attachments/delete.ts:31](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/delete.ts#L31) +Defined in: [attachments/delete.ts:31](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/delete.ts#L31) ___ @@ -31,4 +31,4 @@ ___ If specified the caseID will be ignored and this value will be used to find a sub case for deleting all the attachments -Defined in: [attachments/delete.ts:35](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/delete.ts#L35) +Defined in: [attachments/delete.ts:35](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/delete.ts#L35) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_delete.deleteargs.md b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_delete.deleteargs.md index 1afa5679161d99..a1c177bad8a09c 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_delete.deleteargs.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_delete.deleteargs.md @@ -22,7 +22,7 @@ Parameters for deleting a single attachment of a case or sub case. The attachment ID to delete -Defined in: [attachments/delete.ts:49](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/delete.ts#L49) +Defined in: [attachments/delete.ts:49](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/delete.ts#L49) ___ @@ -32,7 +32,7 @@ ___ The case ID to delete an attachment from -Defined in: [attachments/delete.ts:45](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/delete.ts#L45) +Defined in: [attachments/delete.ts:45](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/delete.ts#L45) ___ @@ -42,4 +42,4 @@ ___ If specified the caseID will be ignored and this value will be used to find a sub case for deleting the attachment -Defined in: [attachments/delete.ts:53](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/delete.ts#L53) +Defined in: [attachments/delete.ts:53](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/delete.ts#L53) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.findargs.md b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.findargs.md index dc0da295b26d20..dcd4deb28b687e 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.findargs.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.findargs.md @@ -21,7 +21,7 @@ Parameters for finding attachments of a case The case ID for finding associated attachments -Defined in: [attachments/get.ts:47](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/get.ts#L47) +Defined in: [attachments/get.ts:47](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/get.ts#L47) ___ @@ -48,4 +48,4 @@ Optional parameters for filtering the returned attachments | `sortOrder` | *undefined* \| ``"desc"`` \| ``"asc"`` | | `subCaseId` | *undefined* \| *string* | -Defined in: [attachments/get.ts:51](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/get.ts#L51) +Defined in: [attachments/get.ts:51](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/get.ts#L51) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getallalertsattachtocase.md b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getallalertsattachtocase.md index 541d1cf8f1d803..d935823054b037 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getallalertsattachtocase.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getallalertsattachtocase.md @@ -18,4 +18,4 @@ The ID of the case to retrieve the alerts from -Defined in: [attachments/get.ts:87](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/get.ts#L87) +Defined in: [attachments/get.ts:87](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/get.ts#L87) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getallargs.md b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getallargs.md index ae67f85e96fc02..9577e89b460741 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getallargs.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getallargs.md @@ -22,7 +22,7 @@ Parameters for retrieving all attachments of a case The case ID to retrieve all attachments for -Defined in: [attachments/get.ts:61](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/get.ts#L61) +Defined in: [attachments/get.ts:61](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/get.ts#L61) ___ @@ -32,7 +32,7 @@ ___ Optionally include the attachments associated with a sub case -Defined in: [attachments/get.ts:65](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/get.ts#L65) +Defined in: [attachments/get.ts:65](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/get.ts#L65) ___ @@ -42,4 +42,4 @@ ___ If included the case ID will be ignored and the attachments will be retrieved from the specified ID of the sub case -Defined in: [attachments/get.ts:69](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/get.ts#L69) +Defined in: [attachments/get.ts:69](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/get.ts#L69) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getargs.md b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getargs.md index 2fc569985f9802..5530ad8bd936e6 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getargs.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getargs.md @@ -19,7 +19,7 @@ The ID of the attachment to retrieve -Defined in: [attachments/get.ts:80](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/get.ts#L80) +Defined in: [attachments/get.ts:80](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/get.ts#L80) ___ @@ -29,4 +29,4 @@ ___ The ID of the case to retrieve an attachment from -Defined in: [attachments/get.ts:76](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/get.ts#L76) +Defined in: [attachments/get.ts:76](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/get.ts#L76) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_update.updateargs.md b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_update.updateargs.md index 4b2dd7b404e7a1..ce586a6bfdfbdf 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_update.updateargs.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_update.updateargs.md @@ -22,7 +22,7 @@ Parameters for updating a single attachment The ID of the case that is associated with this attachment -Defined in: [attachments/update.ts:32](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/update.ts#L32) +Defined in: [attachments/update.ts:32](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/update.ts#L32) ___ @@ -32,7 +32,7 @@ ___ The ID of a sub case, if specified a sub case will be searched for to perform the attachment update instead of on a case -Defined in: [attachments/update.ts:40](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/update.ts#L40) +Defined in: [attachments/update.ts:40](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/update.ts#L40) ___ @@ -42,4 +42,4 @@ ___ The full attachment request with the fields updated with appropriate values -Defined in: [attachments/update.ts:36](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/update.ts#L36) +Defined in: [attachments/update.ts:36](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/update.ts#L36) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/cases_client.casessubclient.md b/x-pack/plugins/cases/docs/cases_client/interfaces/cases_client.casessubclient.md index d86308720cb95b..52cf2fbaf1ef12 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/cases_client.casessubclient.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/cases_client.casessubclient.md @@ -14,7 +14,7 @@ API for interacting with the cases entities. - [delete](cases_client.casessubclient.md#delete) - [find](cases_client.casessubclient.md#find) - [get](cases_client.casessubclient.md#get) -- [getCaseIDsByAlertID](cases_client.casessubclient.md#getcaseidsbyalertid) +- [getCasesByAlertID](cases_client.casessubclient.md#getcasesbyalertid) - [getReporters](cases_client.casessubclient.md#getreporters) - [getTags](cases_client.casessubclient.md#gettags) - [push](cases_client.casessubclient.md#push) @@ -36,7 +36,7 @@ Creates a case. **Returns:** *Promise*<[*ICaseResponse*](typedoc_interfaces.icaseresponse.md)\> -Defined in: [cases/client.ts:48](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/client.ts#L48) +Defined in: [cases/client.ts:49](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/client.ts#L49) ___ @@ -56,7 +56,7 @@ Delete a case and all its comments. **Returns:** *Promise* -Defined in: [cases/client.ts:72](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/client.ts#L72) +Defined in: [cases/client.ts:73](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/client.ts#L73) ___ @@ -76,7 +76,7 @@ If the `owner` field is left empty then all the cases that the user has access t **Returns:** *Promise*<[*ICasesFindResponse*](typedoc_interfaces.icasesfindresponse.md)\> -Defined in: [cases/client.ts:54](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/client.ts#L54) +Defined in: [cases/client.ts:55](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/client.ts#L55) ___ @@ -94,25 +94,25 @@ Retrieves a single case with the specified ID. **Returns:** *Promise*<[*ICaseResponse*](typedoc_interfaces.icaseresponse.md)\> -Defined in: [cases/client.ts:58](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/client.ts#L58) +Defined in: [cases/client.ts:59](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/client.ts#L59) ___ -### getCaseIDsByAlertID +### getCasesByAlertID -▸ **getCaseIDsByAlertID**(`params`: [*CaseIDsByAlertIDParams*](cases_get.caseidsbyalertidparams.md)): *Promise* +▸ **getCasesByAlertID**(`params`: [*CasesByAlertIDParams*](cases_get.casesbyalertidparams.md)): *Promise*<{ `id`: *string* ; `title`: *string* }[]\> -Retrieves the case IDs given a single alert ID +Retrieves the cases ID and title that have the requested alert attached to them #### Parameters | Name | Type | | :------ | :------ | -| `params` | [*CaseIDsByAlertIDParams*](cases_get.caseidsbyalertidparams.md) | +| `params` | [*CasesByAlertIDParams*](cases_get.casesbyalertidparams.md) | -**Returns:** *Promise* +**Returns:** *Promise*<{ `id`: *string* ; `title`: *string* }[]\> -Defined in: [cases/client.ts:84](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/client.ts#L84) +Defined in: [cases/client.ts:85](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/client.ts#L85) ___ @@ -131,7 +131,7 @@ Retrieves all the reporters across all accessible cases. **Returns:** *Promise*<{ `email`: *undefined* \| ``null`` \| *string* ; `full_name`: *undefined* \| ``null`` \| *string* ; `username`: *undefined* \| ``null`` \| *string* }[]\> -Defined in: [cases/client.ts:80](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/client.ts#L80) +Defined in: [cases/client.ts:81](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/client.ts#L81) ___ @@ -150,7 +150,7 @@ Retrieves all the tags across all cases the user making the request has access t **Returns:** *Promise* -Defined in: [cases/client.ts:76](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/client.ts#L76) +Defined in: [cases/client.ts:77](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/client.ts#L77) ___ @@ -168,7 +168,7 @@ Pushes a specific case to an external system. **Returns:** *Promise*<[*ICaseResponse*](typedoc_interfaces.icaseresponse.md)\> -Defined in: [cases/client.ts:62](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/client.ts#L62) +Defined in: [cases/client.ts:63](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/client.ts#L63) ___ @@ -186,4 +186,4 @@ Update the specified cases with the passed in values. **Returns:** *Promise*<[*ICasesResponse*](typedoc_interfaces.icasesresponse.md)\> -Defined in: [cases/client.ts:66](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/client.ts#L66) +Defined in: [cases/client.ts:67](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/client.ts#L67) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.caseidsbyalertidparams.md b/x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.caseidsbyalertidparams.md deleted file mode 100644 index 274b7a8f2d4314..00000000000000 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.caseidsbyalertidparams.md +++ /dev/null @@ -1,40 +0,0 @@ -[Cases Client API Interface](../cases_client_api.md) / [cases/get](../modules/cases_get.md) / CaseIDsByAlertIDParams - -# Interface: CaseIDsByAlertIDParams - -[cases/get](../modules/cases_get.md).CaseIDsByAlertIDParams - -Parameters for finding cases IDs using an alert ID - -## Table of contents - -### Properties - -- [alertID](cases_get.caseidsbyalertidparams.md#alertid) -- [options](cases_get.caseidsbyalertidparams.md#options) - -## Properties - -### alertID - -• **alertID**: *string* - -The alert ID to search for - -Defined in: [cases/get.ts:42](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/get.ts#L42) - -___ - -### options - -• **options**: *object* - -The filtering options when searching for associated cases. - -#### Type declaration - -| Name | Type | -| :------ | :------ | -| `owner` | *undefined* \| *string* \| *string*[] | - -Defined in: [cases/get.ts:46](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/get.ts#L46) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.casesbyalertidparams.md b/x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.casesbyalertidparams.md new file mode 100644 index 00000000000000..4992ed035721be --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.casesbyalertidparams.md @@ -0,0 +1,40 @@ +[Cases Client API Interface](../cases_client_api.md) / [cases/get](../modules/cases_get.md) / CasesByAlertIDParams + +# Interface: CasesByAlertIDParams + +[cases/get](../modules/cases_get.md).CasesByAlertIDParams + +Parameters for finding cases IDs using an alert ID + +## Table of contents + +### Properties + +- [alertID](cases_get.casesbyalertidparams.md#alertid) +- [options](cases_get.casesbyalertidparams.md#options) + +## Properties + +### alertID + +• **alertID**: *string* + +The alert ID to search for + +Defined in: [cases/get.ts:44](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/get.ts#L44) + +___ + +### options + +• **options**: *object* + +The filtering options when searching for associated cases. + +#### Type declaration + +| Name | Type | +| :------ | :------ | +| `owner` | *undefined* \| *string* \| *string*[] | + +Defined in: [cases/get.ts:48](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/get.ts#L48) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.getparams.md b/x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.getparams.md index a528b7ce6256d1..a4dfc7301e5434 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.getparams.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.getparams.md @@ -22,7 +22,7 @@ The parameters for retrieving a case Case ID -Defined in: [cases/get.ts:110](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/get.ts#L110) +Defined in: [cases/get.ts:145](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/get.ts#L145) ___ @@ -32,7 +32,7 @@ ___ Whether to include the attachments for a case in the response -Defined in: [cases/get.ts:114](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/get.ts#L114) +Defined in: [cases/get.ts:149](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/get.ts#L149) ___ @@ -42,4 +42,4 @@ ___ Whether to include the attachments for all children of a case in the response -Defined in: [cases/get.ts:118](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/get.ts#L118) +Defined in: [cases/get.ts:153](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/get.ts#L153) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/cases_push.pushparams.md b/x-pack/plugins/cases/docs/cases_client/interfaces/cases_push.pushparams.md index 979e30cb31d3f1..0ed510700af8af 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/cases_push.pushparams.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/cases_push.pushparams.md @@ -21,7 +21,7 @@ Parameters for pushing a case to an external system The ID of a case -Defined in: [cases/push.ts:53](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/push.ts#L53) +Defined in: [cases/push.ts:53](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/push.ts#L53) ___ @@ -31,4 +31,4 @@ ___ The ID of an external system to push to -Defined in: [cases/push.ts:57](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/push.ts#L57) +Defined in: [cases/push.ts:57](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/push.ts#L57) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/configure_client.configuresubclient.md b/x-pack/plugins/cases/docs/cases_client/interfaces/configure_client.configuresubclient.md index cf69b101ce2bce..98a6c3a2fcbbf3 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/configure_client.configuresubclient.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/configure_client.configuresubclient.md @@ -31,7 +31,7 @@ Creates a configuration if one does not already exist. If one exists it is delet **Returns:** *Promise*<[*ICasesConfigureResponse*](typedoc_interfaces.icasesconfigureresponse.md)\> -Defined in: [configure/client.ts:98](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/configure/client.ts#L98) +Defined in: [configure/client.ts:98](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/configure/client.ts#L98) ___ @@ -50,7 +50,7 @@ Retrieves the external connector configuration for a particular case owner. **Returns:** *Promise*<{} \| [*ICasesConfigureResponse*](typedoc_interfaces.icasesconfigureresponse.md)\> -Defined in: [configure/client.ts:80](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/configure/client.ts#L80) +Defined in: [configure/client.ts:80](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/configure/client.ts#L80) ___ @@ -62,7 +62,7 @@ Retrieves the valid external connectors supported by the cases plugin. **Returns:** *Promise* -Defined in: [configure/client.ts:84](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/configure/client.ts#L84) +Defined in: [configure/client.ts:84](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/configure/client.ts#L84) ___ @@ -81,4 +81,4 @@ Updates a particular configuration with new values. **Returns:** *Promise*<[*ICasesConfigureResponse*](typedoc_interfaces.icasesconfigureresponse.md)\> -Defined in: [configure/client.ts:91](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/configure/client.ts#L91) +Defined in: [configure/client.ts:91](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/configure/client.ts#L91) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/stats_client.statssubclient.md b/x-pack/plugins/cases/docs/cases_client/interfaces/stats_client.statssubclient.md index 761b34b5205ecd..cc0f30055597d2 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/stats_client.statssubclient.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/stats_client.statssubclient.md @@ -29,4 +29,4 @@ Retrieves the total number of open, closed, and in-progress cases. **Returns:** *Promise*<{ `count_closed_cases`: *number* ; `count_in_progress_cases`: *number* ; `count_open_cases`: *number* }\> -Defined in: [stats/client.ts:34](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/stats/client.ts#L34) +Defined in: [stats/client.ts:34](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/stats/client.ts#L34) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/sub_cases_client.subcasesclient.md b/x-pack/plugins/cases/docs/cases_client/interfaces/sub_cases_client.subcasesclient.md index c83c68620e8acd..5c0369709c0f0d 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/sub_cases_client.subcasesclient.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/sub_cases_client.subcasesclient.md @@ -31,7 +31,7 @@ Deletes the specified entities and their attachments. **Returns:** *Promise* -Defined in: [sub_cases/client.ts:68](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/sub_cases/client.ts#L68) +Defined in: [sub_cases/client.ts:68](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/sub_cases/client.ts#L68) ___ @@ -49,7 +49,7 @@ Retrieves the sub cases matching the search criteria. **Returns:** *Promise*<[*ISubCasesFindResponse*](typedoc_interfaces.isubcasesfindresponse.md)\> -Defined in: [sub_cases/client.ts:72](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/sub_cases/client.ts#L72) +Defined in: [sub_cases/client.ts:72](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/sub_cases/client.ts#L72) ___ @@ -67,7 +67,7 @@ Retrieves a single sub case. **Returns:** *Promise*<[*ISubCaseResponse*](typedoc_interfaces.isubcaseresponse.md)\> -Defined in: [sub_cases/client.ts:76](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/sub_cases/client.ts#L76) +Defined in: [sub_cases/client.ts:76](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/sub_cases/client.ts#L76) ___ @@ -86,4 +86,4 @@ Updates the specified sub cases to the new values included in the request. **Returns:** *Promise*<[*ISubCasesResponse*](typedoc_interfaces.isubcasesresponse.md)\> -Defined in: [sub_cases/client.ts:80](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/sub_cases/client.ts#L80) +Defined in: [sub_cases/client.ts:80](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/sub_cases/client.ts#L80) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/user_actions_client.useractionget.md b/x-pack/plugins/cases/docs/cases_client/interfaces/user_actions_client.useractionget.md index f992a4116c800a..5f0cc89239fd8a 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/user_actions_client.useractionget.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/user_actions_client.useractionget.md @@ -21,7 +21,7 @@ Parameters for retrieving user actions for a particular case The ID of the case -Defined in: [user_actions/client.ts:19](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/user_actions/client.ts#L19) +Defined in: [user_actions/client.ts:19](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/user_actions/client.ts#L19) ___ @@ -31,4 +31,4 @@ ___ If specified then a sub case will be used for finding all the user actions -Defined in: [user_actions/client.ts:23](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/user_actions/client.ts#L23) +Defined in: [user_actions/client.ts:23](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/user_actions/client.ts#L23) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/user_actions_client.useractionssubclient.md b/x-pack/plugins/cases/docs/cases_client/interfaces/user_actions_client.useractionssubclient.md index e838a72159befa..df2641adf5a8c4 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/user_actions_client.useractionssubclient.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/user_actions_client.useractionssubclient.md @@ -28,4 +28,4 @@ Retrieves all user actions for a particular case. **Returns:** *Promise*<[*ICaseUserActionsResponse*](typedoc_interfaces.icaseuseractionsresponse.md)\> -Defined in: [user_actions/client.ts:33](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/user_actions/client.ts#L33) +Defined in: [user_actions/client.ts:33](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/user_actions/client.ts#L33) diff --git a/x-pack/plugins/cases/docs/cases_client/modules/cases_get.md b/x-pack/plugins/cases/docs/cases_client/modules/cases_get.md index acfa0b918aa9a4..d4ca13501294a1 100644 --- a/x-pack/plugins/cases/docs/cases_client/modules/cases_get.md +++ b/x-pack/plugins/cases/docs/cases_client/modules/cases_get.md @@ -6,7 +6,7 @@ ### Interfaces -- [CaseIDsByAlertIDParams](../interfaces/cases_get.caseidsbyalertidparams.md) +- [CasesByAlertIDParams](../interfaces/cases_get.casesbyalertidparams.md) - [GetParams](../interfaces/cases_get.getparams.md) ### Functions @@ -31,7 +31,7 @@ Retrieves the reporters from all the cases. **Returns:** *Promise* -Defined in: [cases/get.ts:255](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/get.ts#L255) +Defined in: [cases/get.ts:290](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/get.ts#L290) ___ @@ -50,4 +50,4 @@ Retrieves the tags from all the cases. **Returns:** *Promise* -Defined in: [cases/get.ts:205](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/get.ts#L205) +Defined in: [cases/get.ts:240](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/get.ts#L240) diff --git a/x-pack/plugins/cases/server/client/cases/client.ts b/x-pack/plugins/cases/server/client/cases/client.ts index 8a17ff9bd0ec11..0932308c2e269a 100644 --- a/x-pack/plugins/cases/server/client/cases/client.ts +++ b/x-pack/plugins/cases/server/client/cases/client.ts @@ -12,6 +12,7 @@ import { User, AllTagsFindRequest, AllReportersFindRequest, + CasesByAlertId, } from '../../../common'; import { CasesClient } from '../client'; import { CasesClientInternal } from '../client_internal'; @@ -28,9 +29,9 @@ import { create } from './create'; import { deleteCases } from './delete'; import { find } from './find'; import { - CaseIDsByAlertIDParams, + CasesByAlertIDParams, get, - getCaseIDsByAlertID, + getCasesByAlertID, GetParams, getReporters, getTags, @@ -79,9 +80,9 @@ export interface CasesSubClient { */ getReporters(params: AllReportersFindRequest): Promise; /** - * Retrieves the case IDs given a single alert ID + * Retrieves the cases ID and title that have the requested alert attached to them */ - getCaseIDsByAlertID(params: CaseIDsByAlertIDParams): Promise; + getCasesByAlertID(params: CasesByAlertIDParams): Promise; } /** @@ -103,8 +104,7 @@ export const createCasesSubClient = ( delete: (ids: string[]) => deleteCases(ids, clientArgs), getTags: (params: AllTagsFindRequest) => getTags(params, clientArgs), getReporters: (params: AllReportersFindRequest) => getReporters(params, clientArgs), - getCaseIDsByAlertID: (params: CaseIDsByAlertIDParams) => - getCaseIDsByAlertID(params, clientArgs), + getCasesByAlertID: (params: CasesByAlertIDParams) => getCasesByAlertID(params, clientArgs), }; return Object.freeze(casesSubClient); diff --git a/x-pack/plugins/cases/server/client/cases/get.ts b/x-pack/plugins/cases/server/client/cases/get.ts index f908a8f091ef3b..3df1891391c75e 100644 --- a/x-pack/plugins/cases/server/client/cases/get.ts +++ b/x-pack/plugins/cases/server/client/cases/get.ts @@ -25,6 +25,8 @@ import { CasesByAlertIDRequest, CasesByAlertIDRequestRt, ENABLE_CASE_CONNECTOR, + CasesByAlertId, + CasesByAlertIdRt, } from '../../../common'; import { countAlertsForID, createCaseError, flattenCaseSavedObject } from '../../common'; import { CasesClientArgs } from '..'; @@ -35,7 +37,7 @@ import { CasesService } from '../../services'; /** * Parameters for finding cases IDs using an alert ID */ -export interface CaseIDsByAlertIDParams { +export interface CasesByAlertIDParams { /** * The alert ID to search for */ @@ -47,15 +49,15 @@ export interface CaseIDsByAlertIDParams { } /** - * Case Client wrapper function for retrieving the case IDs that have a particular alert ID + * Case Client wrapper function for retrieving the case IDs and titles that have a particular alert ID * attached to them. This handles RBAC before calling the saved object API. * * @ignore */ -export const getCaseIDsByAlertID = async ( - { alertID, options }: CaseIDsByAlertIDParams, +export const getCasesByAlertID = async ( + { alertID, options }: CasesByAlertIDParams, clientArgs: CasesClientArgs -): Promise => { +): Promise => { const { unsecuredSavedObjectsClient, caseService, logger, authorization } = clientArgs; try { @@ -75,12 +77,15 @@ export const getCaseIDsByAlertID = async ( Operations.getCaseIDsByAlertID.savedObjectType ); + // This will likely only return one comment saved object, the response aggregation will contain + // the keys we need to retrieve the cases const commentsWithAlert = await caseService.getCaseIdsByAlertId({ unsecuredSavedObjectsClient, alertId: alertID, filter, }); + // make sure the comments returned have the right owner ensureSavedObjectsAreAuthorized( commentsWithAlert.saved_objects.map((comment) => ({ owner: comment.attributes.owner, @@ -88,7 +93,37 @@ export const getCaseIDsByAlertID = async ( })) ); - return CasesService.getCaseIDsFromAlertAggs(commentsWithAlert); + const caseIds = CasesService.getCaseIDsFromAlertAggs(commentsWithAlert); + + // if we didn't find any case IDs then let's return early because there's nothing to request + if (caseIds.length <= 0) { + return []; + } + + const casesInfo = await caseService.getCases({ + unsecuredSavedObjectsClient, + caseIds, + }); + + // if there was an error retrieving one of the cases (maybe it was deleted, but the alert comment still existed) + // just ignore it + const validCasesInfo = casesInfo.saved_objects.filter( + (caseInfo) => caseInfo.error === undefined + ); + + ensureSavedObjectsAreAuthorized( + validCasesInfo.map((caseInfo) => ({ + owner: caseInfo.attributes.owner, + id: caseInfo.id, + })) + ); + + return CasesByAlertIdRt.encode( + validCasesInfo.map((caseInfo) => ({ + id: caseInfo.id, + title: caseInfo.attributes.title, + })) + ); } catch (error) { throw createCaseError({ message: `Failed to get case IDs using alert ID: ${alertID} options: ${JSON.stringify( diff --git a/x-pack/plugins/cases/server/client/mocks.ts b/x-pack/plugins/cases/server/client/mocks.ts index f6a36369c0b033..f7c27166ee9104 100644 --- a/x-pack/plugins/cases/server/client/mocks.ts +++ b/x-pack/plugins/cases/server/client/mocks.ts @@ -28,7 +28,7 @@ const createCasesSubClientMock = (): CasesSubClientMock => { delete: jest.fn(), getTags: jest.fn(), getReporters: jest.fn(), - getCaseIDsByAlertID: jest.fn(), + getCasesByAlertID: jest.fn(), }; }; diff --git a/x-pack/plugins/cases/server/routes/api/cases/alerts/get_cases.ts b/x-pack/plugins/cases/server/routes/api/cases/alerts/get_cases.ts index f4b53a921ef881..3471c1dec62088 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/alerts/get_cases.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/alerts/get_cases.ts @@ -12,7 +12,7 @@ import { RouteDeps } from '../../types'; import { escapeHatch, wrapError } from '../../utils'; import { CASE_ALERTS_URL, CasesByAlertIDRequest } from '../../../../../common'; -export function initGetCaseIdsByAlertIdApi({ router, logger }: RouteDeps) { +export function initGetCasesByAlertIdApi({ router, logger }: RouteDeps) { router.get( { path: CASE_ALERTS_URL, @@ -33,7 +33,7 @@ export function initGetCaseIdsByAlertIdApi({ router, logger }: RouteDeps) { const options = request.query as CasesByAlertIDRequest; return response.ok({ - body: await casesClient.cases.getCaseIDsByAlertID({ alertID, options }), + body: await casesClient.cases.getCasesByAlertID({ alertID, options }), }); } catch (error) { logger.error( diff --git a/x-pack/plugins/cases/server/routes/api/index.ts b/x-pack/plugins/cases/server/routes/api/index.ts index 011464a73396f2..266ea9ddb0f18c 100644 --- a/x-pack/plugins/cases/server/routes/api/index.ts +++ b/x-pack/plugins/cases/server/routes/api/index.ts @@ -38,7 +38,7 @@ import { initPatchSubCasesApi } from './sub_case/patch_sub_cases'; import { initFindSubCasesApi } from './sub_case/find_sub_cases'; import { initDeleteSubCasesApi } from './sub_case/delete_sub_cases'; import { ENABLE_CASE_CONNECTOR } from '../../../common'; -import { initGetCaseIdsByAlertIdApi } from './cases/alerts/get_cases'; +import { initGetCasesByAlertIdApi } from './cases/alerts/get_cases'; import { initGetAllAlertsAttachToCaseApi } from './comments/get_alerts'; /** @@ -89,6 +89,6 @@ export function initCaseApi(deps: RouteDeps) { // Tags initGetTagsApi(deps); // Alerts - initGetCaseIdsByAlertIdApi(deps); + initGetCasesByAlertIdApi(deps); initGetAllAlertsAttachToCaseApi(deps); } diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx index 42d53f97d478b4..ef311a7ca43b17 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx @@ -41,27 +41,27 @@ export const HostIsolationPanel = React.memo( return findAlertId ? findAlertId[0] : ''; }, [details]); - const { caseIds } = useCasesFromAlerts({ alertId }); + const { casesInfo } = useCasesFromAlerts({ alertId }); // Cases related components to be used in both isolate and unisolate actions from the alert details flyout entry point - const caseCount: number = useMemo(() => caseIds.length, [caseIds]); + const caseCount: number = useMemo(() => casesInfo.length, [casesInfo]); const casesList = useMemo( () => - caseIds.map((id, index) => { + casesInfo.map((caseInfo, index) => { return ( -
  • - +
  • +
  • ); }), - [caseIds] + [casesInfo] ); const associatedCases = useMemo(() => { @@ -90,7 +90,7 @@ export const HostIsolationPanel = React.memo( endpointId={endpointId} hostName={hostName} cases={associatedCases} - caseIds={caseIds} + casesInfo={casesInfo} cancelCallback={cancelCallback} /> ) : ( @@ -98,7 +98,7 @@ export const HostIsolationPanel = React.memo( endpointId={endpointId} hostName={hostName} cases={associatedCases} - caseIds={caseIds} + casesInfo={casesInfo} cancelCallback={cancelCallback} /> ); diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/isolate.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/isolate.tsx index afc2951e26e1fe..b209c2f9c6e24e 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/isolate.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/isolate.tsx @@ -15,24 +15,29 @@ import { EndpointIsolateForm, EndpointIsolateSuccess, } from '../../../common/components/endpoint/host_isolation'; +import { CasesFromAlertsResponse } from '../../containers/detection_engine/alerts/types'; export const IsolateHost = React.memo( ({ endpointId, hostName, cases, - caseIds, + casesInfo, cancelCallback, }: { endpointId: string; hostName: string; cases: ReactNode; - caseIds: string[]; + casesInfo: CasesFromAlertsResponse; cancelCallback: () => void; }) => { const [comment, setComment] = useState(''); const [isIsolated, setIsIsolated] = useState(false); + const caseIds: string[] = casesInfo.map((caseInfo): string => { + return caseInfo.id; + }); + const { loading, isolateHost } = useHostIsolation({ endpointId, comment, caseIds }); const confirmHostIsolation = useCallback(async () => { @@ -47,7 +52,7 @@ export const IsolateHost = React.memo( [] ); - const caseCount: number = useMemo(() => caseIds.length, [caseIds]); + const caseCount: number = useMemo(() => casesInfo.length, [casesInfo]); const hostIsolatedSuccess = useMemo(() => { return ( diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx index 71f7cadda2f68c..ad8e8eaddb39e3 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx @@ -15,24 +15,29 @@ import { EndpointUnisolateForm, } from '../../../common/components/endpoint/host_isolation'; import { useHostUnisolation } from '../../containers/detection_engine/alerts/use_host_unisolation'; +import { CasesFromAlertsResponse } from '../../containers/detection_engine/alerts/types'; export const UnisolateHost = React.memo( ({ endpointId, hostName, cases, - caseIds, + casesInfo, cancelCallback, }: { endpointId: string; hostName: string; cases: ReactNode; - caseIds: string[]; + casesInfo: CasesFromAlertsResponse; cancelCallback: () => void; }) => { const [comment, setComment] = useState(''); const [isUnIsolated, setIsUnIsolated] = useState(false); + const caseIds: string[] = casesInfo.map((caseInfo): string => { + return caseInfo.id; + }); + const { loading, unIsolateHost } = useHostUnisolation({ endpointId, comment, caseIds }); const confirmHostUnIsolation = useCallback(async () => { @@ -47,7 +52,7 @@ export const UnisolateHost = React.memo( [] ); - const caseCount: number = useMemo(() => caseIds.length, [caseIds]); + const caseCount: number = useMemo(() => casesInfo.length, [casesInfo]); const hostUnisolatedSuccess = useMemo(() => { return ( diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts index 69358958a395cd..e4bddfba8278bb 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts @@ -1046,6 +1046,6 @@ export const mockHostIsolation: HostIsolationResponse = { }; export const mockCaseIdsFromAlertId: CasesFromAlertsResponse = [ - '818601a0-b26b-11eb-8759-6b318e8cf4bc', - '8a774850-b26b-11eb-8759-6b318e8cf4bc', + { id: '818601a0-b26b-11eb-8759-6b318e8cf4bc', title: 'Case 1' }, + { id: '8a774850-b26b-11eb-8759-6b318e8cf4bc', title: 'Case 2' }, ]; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts index 52b477d95076b6..54d4b6fdcbafdb 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts @@ -48,7 +48,7 @@ export interface AlertsIndex { index_mapping_outdated: boolean; } -export type CasesFromAlertsResponse = string[]; +export type CasesFromAlertsResponse = Array<{ id: string; title: string }>; export interface Privilege { username: string; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.test.tsx index 0867fb001051a1..00aa7c9baa9aca 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.test.tsx @@ -35,7 +35,7 @@ describe('useCasesFromAlerts hook', () => { expect(spyOnCases).toHaveBeenCalledTimes(1); expect(result.current).toEqual({ loading: false, - caseIds: mockCaseIdsFromAlertId, + casesInfo: mockCaseIdsFromAlertId, }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.tsx index 85b80a588e88d2..eeb7968d6b2f27 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.tsx @@ -15,7 +15,7 @@ import { CasesFromAlertsResponse } from './types'; interface CasesFromAlertsStatus { loading: boolean; - caseIds: CasesFromAlertsResponse; + casesInfo: CasesFromAlertsResponse; } export const useCasesFromAlerts = ({ alertId }: { alertId: string }): CasesFromAlertsStatus => { @@ -48,5 +48,5 @@ export const useCasesFromAlerts = ({ alertId }: { alertId: string }): CasesFromA isMounted = false; }; }, [alertId, addError]); - return { loading, caseIds: cases }; + return { loading, casesInfo: cases }; }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts index c54c12981c7710..50fe2ffe2cea99 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts @@ -10,6 +10,7 @@ import { RequestHandler } from 'src/core/server'; import uuid from 'uuid'; import { TypeOf } from '@kbn/config-schema'; import { CommentType } from '../../../../../cases/common'; +import { CasesByAlertId } from '../../../../../cases/common/api/cases/case'; import { HostIsolationRequestSchema } from '../../../../common/endpoint/schema/actions'; import { ISOLATE_HOST_ROUTE, UNISOLATE_HOST_ROUTE } from '../../../../common/endpoint/constants'; import { AGENT_ACTIONS_INDEX } from '../../../../../fleet/common'; @@ -103,12 +104,17 @@ export const isolationRequestHandler = function ( let caseIDs: string[] = req.body.case_ids?.slice() || []; if (req.body.alert_ids && req.body.alert_ids.length > 0) { const newIDs: string[][] = await Promise.all( - req.body.alert_ids.map(async (a: string) => - (await endpointContext.service.getCasesClient(req)).cases.getCaseIDsByAlertID({ + req.body.alert_ids.map(async (a: string) => { + const cases: CasesByAlertId = await ( + await endpointContext.service.getCasesClient(req) + ).cases.getCasesByAlertID({ alertID: a, options: { owner: APP_ID }, - }) - ) + }); + return cases.map((caseInfo): string => { + return caseInfo.id; + }); + }) ); caseIDs = caseIDs.concat(...newIDs); } diff --git a/x-pack/test/case_api_integration/common/lib/utils.ts b/x-pack/test/case_api_integration/common/lib/utils.ts index 63be1736405fc1..921589b2341dd8 100644 --- a/x-pack/test/case_api_integration/common/lib/utils.ts +++ b/x-pack/test/case_api_integration/common/lib/utils.ts @@ -46,6 +46,7 @@ import { CasesConfigurationsResponse, CaseUserActionsResponse, AlertResponse, + CasesByAlertId, } from '../../../../plugins/cases/common/api'; import { getPostCaseRequest, postCollectionReq, postCommentGenAlertReq } from './mock'; import { getCaseUserActionUrl, getSubCasesUrl } from '../../../../plugins/cases/common/api/helpers'; @@ -1017,7 +1018,7 @@ export const findCases = async ({ return res; }; -export const getCaseIDsByAlert = async ({ +export const getCasesByAlert = async ({ supertest, alertID, query = {}, @@ -1029,7 +1030,7 @@ export const getCaseIDsByAlert = async ({ query?: Record; expectedHttpCode?: number; auth?: { user: User; space: string | null }; -}): Promise => { +}): Promise => { const { body: res } = await supertest .get(`${getSpaceUrlPrefix(auth.space)}${CASES_URL}/alerts/${alertID}`) .auth(auth.user.username, auth.user.password) diff --git a/x-pack/test/case_api_integration/common/lib/validation.ts b/x-pack/test/case_api_integration/common/lib/validation.ts new file mode 100644 index 00000000000000..8b1c8ca1241493 --- /dev/null +++ b/x-pack/test/case_api_integration/common/lib/validation.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; + +import { CaseResponse, CasesByAlertId } from '../../../../plugins/cases/common'; + +/** + * Ensure that the result of the alerts API request matches with the cases created for the test. + */ +export function validateCasesFromAlertIDResponse( + casesFromAPIResponse: CasesByAlertId, + createdCasesForTest: CaseResponse[] +) { + const idToTitle = new Map( + createdCasesForTest.map((caseInfo) => [caseInfo.id, caseInfo.title]) + ); + + for (const apiResCase of casesFromAPIResponse) { + // check that the title in the api response matches the title in the map from the created cases + expect(apiResCase.title).to.be(idToTitle.get(apiResCase.id)); + } +} diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/alerts/get_cases.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/alerts/get_cases.ts index e34f879e3aff84..136e52d08f46ab 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/alerts/get_cases.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/alerts/get_cases.ts @@ -13,9 +13,10 @@ import { getPostCaseRequest, postCommentAlertReq } from '../../../../common/lib/ import { createCase, createComment, - getCaseIDsByAlert, + getCasesByAlert, deleteAllCaseItems, } from '../../../../common/lib/utils'; +import { validateCasesFromAlertIDResponse } from '../../../../common/lib/validation'; import { CaseResponse } from '../../../../../../plugins/cases/common'; import { globalRead, @@ -41,9 +42,9 @@ export default ({ getService }: FtrProviderContext): void => { it('should return all cases with the same alert ID attached to them', async () => { const [case1, case2, case3] = await Promise.all([ - createCase(supertest, getPostCaseRequest()), - createCase(supertest, getPostCaseRequest()), - createCase(supertest, getPostCaseRequest()), + createCase(supertest, getPostCaseRequest({ title: 'a' })), + createCase(supertest, getPostCaseRequest({ title: 'b' })), + createCase(supertest, getPostCaseRequest({ title: 'c' })), ]); await Promise.all([ @@ -52,12 +53,10 @@ export default ({ getService }: FtrProviderContext): void => { createComment({ supertest, caseId: case3.id, params: postCommentAlertReq }), ]); - const caseIDsWithAlert = await getCaseIDsByAlert({ supertest, alertID: 'test-id' }); + const caseIDsWithAlert = await getCasesByAlert({ supertest, alertID: 'test-id' }); expect(caseIDsWithAlert.length).to.eql(3); - expect(caseIDsWithAlert).to.contain(case1.id); - expect(caseIDsWithAlert).to.contain(case2.id); - expect(caseIDsWithAlert).to.contain(case3.id); + validateCasesFromAlertIDResponse(caseIDsWithAlert, [case1, case2, case3]); }); it('should return all cases with the same alert ID when more than 100 cases', async () => { @@ -80,13 +79,11 @@ export default ({ getService }: FtrProviderContext): void => { await Promise.all(commentPromises); - const caseIDsWithAlert = await getCaseIDsByAlert({ supertest, alertID: 'test-id' }); + const caseIDsWithAlert = await getCasesByAlert({ supertest, alertID: 'test-id' }); expect(caseIDsWithAlert.length).to.eql(numCases); - for (const caseInfo of cases) { - expect(caseIDsWithAlert).to.contain(caseInfo.id); - } + validateCasesFromAlertIDResponse(caseIDsWithAlert, cases); }); it('should return no cases when the alert ID is not found', async () => { @@ -102,7 +99,7 @@ export default ({ getService }: FtrProviderContext): void => { createComment({ supertest, caseId: case3.id, params: postCommentAlertReq }), ]); - const caseIDsWithAlert = await getCaseIDsByAlert({ supertest, alertID: 'test-id100' }); + const caseIDsWithAlert = await getCasesByAlert({ supertest, alertID: 'test-id100' }); expect(caseIDsWithAlert.length).to.eql(0); }); @@ -120,7 +117,7 @@ export default ({ getService }: FtrProviderContext): void => { createComment({ supertest, caseId: case3.id, params: postCommentAlertReq }), ]); - const caseIDsWithAlert = await getCaseIDsByAlert({ + const caseIDsWithAlert = await getCasesByAlert({ supertest, alertID: 'test-id', query: { owner: 'not-real' }, @@ -137,7 +134,7 @@ export default ({ getService }: FtrProviderContext): void => { describe('rbac', () => { const supertestWithoutAuth = getService('supertestWithoutAuth'); - it('should return the correct case IDs', async () => { + it('should return the correct cases info', async () => { const secOnlyAuth = { user: secOnly, space: 'space1' }; const obsOnlyAuth = { user: obsOnly, space: 'space1' }; @@ -176,20 +173,20 @@ export default ({ getService }: FtrProviderContext): void => { for (const scenario of [ { user: globalRead, - caseIDs: [case1.id, case2.id, case3.id], + cases: [case1, case2, case3], }, { user: superUser, - caseIDs: [case1.id, case2.id, case3.id], + cases: [case1, case2, case3], }, - { user: secOnlyRead, caseIDs: [case1.id, case2.id] }, - { user: obsOnlyRead, caseIDs: [case3.id] }, + { user: secOnlyRead, cases: [case1, case2] }, + { user: obsOnlyRead, cases: [case3] }, { user: obsSecRead, - caseIDs: [case1.id, case2.id, case3.id], + cases: [case1, case2, case3], }, ]) { - const res = await getCaseIDsByAlert({ + const res = await getCasesByAlert({ supertest: supertestWithoutAuth, // cast because the official type is string | string[] but the ids will always be a single value in the tests alertID: postCommentAlertReq.alertId as string, @@ -198,10 +195,9 @@ export default ({ getService }: FtrProviderContext): void => { space: 'space1', }, }); - expect(res.length).to.eql(scenario.caseIDs.length); - for (const caseID of scenario.caseIDs) { - expect(res).to.contain(caseID); - } + expect(res.length).to.eql(scenario.cases.length); + + validateCasesFromAlertIDResponse(res, scenario.cases); } }); @@ -224,7 +220,7 @@ export default ({ getService }: FtrProviderContext): void => { auth: { user: superUser, space: scenario.space }, }); - await getCaseIDsByAlert({ + await getCasesByAlert({ supertest: supertestWithoutAuth, alertID: postCommentAlertReq.alertId as string, auth: { user: scenario.user, space: scenario.space }, @@ -260,17 +256,17 @@ export default ({ getService }: FtrProviderContext): void => { }), ]); - const res = await getCaseIDsByAlert({ + const res = await getCasesByAlert({ supertest: supertestWithoutAuth, alertID: postCommentAlertReq.alertId as string, auth, query: { owner: 'securitySolutionFixture' }, }); - expect(res).to.eql([case1.id]); + expect(res).to.eql([{ id: case1.id, title: case1.title }]); }); - it('should return the correct case IDs when the owner query parameter contains unprivileged values', async () => { + it('should return the correct cases info when the owner query parameter contains unprivileged values', async () => { const auth = { user: obsSec, space: 'space1' }; const [case1, case2] = await Promise.all([ createCase(supertestWithoutAuth, getPostCaseRequest(), 200, auth), @@ -297,7 +293,7 @@ export default ({ getService }: FtrProviderContext): void => { }), ]); - const res = await getCaseIDsByAlert({ + const res = await getCasesByAlert({ supertest: supertestWithoutAuth, alertID: postCommentAlertReq.alertId as string, auth: { user: secOnly, space: 'space1' }, @@ -305,7 +301,7 @@ export default ({ getService }: FtrProviderContext): void => { query: { owner: ['securitySolutionFixture', 'observabilityFixture'] }, }); - expect(res).to.eql([case1.id]); + expect(res).to.eql([{ id: case1.id, title: case1.title }]); }); }); }); diff --git a/x-pack/test/case_api_integration/security_only/tests/common/alerts/get_cases.ts b/x-pack/test/case_api_integration/security_only/tests/common/alerts/get_cases.ts index 9575bd99112f6b..f55427d13b32bf 100644 --- a/x-pack/test/case_api_integration/security_only/tests/common/alerts/get_cases.ts +++ b/x-pack/test/case_api_integration/security_only/tests/common/alerts/get_cases.ts @@ -12,7 +12,7 @@ import { getPostCaseRequest, postCommentAlertReq } from '../../../../common/lib/ import { createCase, createComment, - getCaseIDsByAlert, + getCasesByAlert, deleteAllCaseItems, } from '../../../../common/lib/utils'; import { @@ -30,6 +30,7 @@ import { superUserDefaultSpaceAuth, obsSecDefaultSpaceAuth, } from '../../../utils'; +import { validateCasesFromAlertIDResponse } from '../../../../common/lib/validation'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -43,7 +44,7 @@ export default ({ getService }: FtrProviderContext): void => { const supertestWithoutAuth = getService('supertestWithoutAuth'); - it('should return the correct case IDs', async () => { + it('should return the correct cases info', async () => { const [case1, case2, case3] = await Promise.all([ createCase(supertestWithoutAuth, getPostCaseRequest(), 200, secOnlyDefaultSpaceAuth), createCase(supertestWithoutAuth, getPostCaseRequest(), 200, secOnlyDefaultSpaceAuth), @@ -79,20 +80,20 @@ export default ({ getService }: FtrProviderContext): void => { for (const scenario of [ { user: globalRead, - caseIDs: [case1.id, case2.id, case3.id], + cases: [case1, case2, case3], }, { user: superUser, - caseIDs: [case1.id, case2.id, case3.id], + cases: [case1, case2, case3], }, - { user: secOnlyReadSpacesAll, caseIDs: [case1.id, case2.id] }, - { user: obsOnlyReadSpacesAll, caseIDs: [case3.id] }, + { user: secOnlyReadSpacesAll, cases: [case1, case2] }, + { user: obsOnlyReadSpacesAll, cases: [case3] }, { user: obsSecReadSpacesAll, - caseIDs: [case1.id, case2.id, case3.id], + cases: [case1, case2, case3], }, ]) { - const res = await getCaseIDsByAlert({ + const cases = await getCasesByAlert({ supertest: supertestWithoutAuth, // cast because the official type is string | string[] but the ids will always be a single value in the tests alertID: postCommentAlertReq.alertId as string, @@ -101,10 +102,9 @@ export default ({ getService }: FtrProviderContext): void => { space: null, }, }); - expect(res.length).to.eql(scenario.caseIDs.length); - for (const caseID of scenario.caseIDs) { - expect(res).to.contain(caseID); - } + + expect(cases.length).to.eql(scenario.cases.length); + validateCasesFromAlertIDResponse(cases, scenario.cases); } }); @@ -123,7 +123,7 @@ export default ({ getService }: FtrProviderContext): void => { auth: superUserDefaultSpaceAuth, }); - await getCaseIDsByAlert({ + await getCasesByAlert({ supertest: supertestWithoutAuth, alertID: postCommentAlertReq.alertId as string, auth: { user: noKibanaPrivileges, space: null }, @@ -157,7 +157,7 @@ export default ({ getService }: FtrProviderContext): void => { }), ]); - await getCaseIDsByAlert({ + await getCasesByAlert({ supertest: supertestWithoutAuth, alertID: postCommentAlertReq.alertId as string, auth: { user: obsSecSpacesAll, space: 'space1' }, @@ -192,17 +192,17 @@ export default ({ getService }: FtrProviderContext): void => { }), ]); - const res = await getCaseIDsByAlert({ + const cases = await getCasesByAlert({ supertest: supertestWithoutAuth, alertID: postCommentAlertReq.alertId as string, auth: obsSecDefaultSpaceAuth, query: { owner: 'securitySolutionFixture' }, }); - expect(res).to.eql([case1.id]); + expect(cases).to.eql([{ id: case1.id, title: case1.title }]); }); - it('should return the correct case IDs when the owner query parameter contains unprivileged values', async () => { + it('should return the correct cases info when the owner query parameter contains unprivileged values', async () => { const [case1, case2] = await Promise.all([ createCase(supertestWithoutAuth, getPostCaseRequest(), 200, obsSecDefaultSpaceAuth), createCase( @@ -228,7 +228,7 @@ export default ({ getService }: FtrProviderContext): void => { }), ]); - const res = await getCaseIDsByAlert({ + const cases = await getCasesByAlert({ supertest: supertestWithoutAuth, alertID: postCommentAlertReq.alertId as string, auth: secOnlyDefaultSpaceAuth, @@ -236,7 +236,7 @@ export default ({ getService }: FtrProviderContext): void => { query: { owner: ['securitySolutionFixture', 'observabilityFixture'] }, }); - expect(res).to.eql([case1.id]); + expect(cases).to.eql([{ id: case1.id, title: case1.title }]); }); }); }; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/common/alerts/get_cases.ts b/x-pack/test/case_api_integration/spaces_only/tests/common/alerts/get_cases.ts index 9587502fb642ce..739f8e5ec08926 100644 --- a/x-pack/test/case_api_integration/spaces_only/tests/common/alerts/get_cases.ts +++ b/x-pack/test/case_api_integration/spaces_only/tests/common/alerts/get_cases.ts @@ -12,10 +12,11 @@ import { getPostCaseRequest, postCommentAlertReq } from '../../../../common/lib/ import { createCase, createComment, - getCaseIDsByAlert, + getCasesByAlert, deleteAllCaseItems, getAuthWithSuperUser, } from '../../../../common/lib/utils'; +import { validateCasesFromAlertIDResponse } from '../../../../common/lib/validation'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -57,16 +58,14 @@ export default ({ getService }: FtrProviderContext): void => { }), ]); - const caseIDsWithAlert = await getCaseIDsByAlert({ + const cases = await getCasesByAlert({ supertest, alertID: 'test-id', auth: authSpace1, }); - expect(caseIDsWithAlert.length).to.eql(3); - expect(caseIDsWithAlert).to.contain(case1.id); - expect(caseIDsWithAlert).to.contain(case2.id); - expect(caseIDsWithAlert).to.contain(case3.id); + expect(cases.length).to.eql(3); + validateCasesFromAlertIDResponse(cases, [case1, case2, case3]); }); it('should return 1 case in space2 when 2 cases were created in space1 and 1 in space2', async () => { @@ -97,14 +96,14 @@ export default ({ getService }: FtrProviderContext): void => { }), ]); - const caseIDsWithAlert = await getCaseIDsByAlert({ + const casesByAlert = await getCasesByAlert({ supertest, alertID: 'test-id', auth: authSpace2, }); - expect(caseIDsWithAlert.length).to.eql(1); - expect(caseIDsWithAlert).to.eql([case3.id]); + expect(casesByAlert.length).to.eql(1); + expect(casesByAlert).to.eql([{ id: case3.id, title: case3.title }]); }); }); }; From c33138e5cb2bec40830537e4009ddf4a75ab8bb1 Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Tue, 22 Jun 2021 14:13:48 -0400 Subject: [PATCH 51/57] [Rollups] Migrate to new page layout (#102268) --- ...-plugin-core-public.doclinksstart.links.md | 1 + ...kibana-plugin-core-public.doclinksstart.md | 2 +- .../public/doc_links/doc_links_service.ts | 2 + src/core/public/public.api.md | 1 + .../rollup/public/crud_app/_crud_app.scss | 8 - .../sections/job_create/job_create.js | 57 ++--- .../job_list/detail_panel/detail_panel.js | 2 +- .../detail_panel/detail_panel.test.js | 2 +- .../crud_app/sections/job_list/job_list.js | 214 +++++++++--------- .../sections/job_list/job_list.test.js | 26 ++- .../sections/job_list/job_table/job_table.js | 23 +- .../job_list/job_table/job_table.test.js | 8 + .../crud_app/store/actions/load_jobs.js | 13 +- .../plugins/rollup/public/shared_imports.ts | 6 +- .../test/client_integration/job_list.test.js | 5 +- .../client_integration/job_list_clone.test.js | 9 + .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 18 files changed, 198 insertions(+), 183 deletions(-) diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md index d3d76079cdc2a1..ae433e3db14c68 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md @@ -116,6 +116,7 @@ readonly links: { readonly addData: string; readonly kibana: string; readonly upgradeAssistant: string; + readonly rollupJobs: string; readonly elasticsearch: Record; readonly siem: { readonly guide: string; diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md index 34279cef198bfb..b0800c7dfc65ea 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md @@ -17,5 +17,5 @@ export interface DocLinksStart | --- | --- | --- | | [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | string | | | [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | string | | -| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
    readonly canvas: {
    readonly guide: string;
    };
    readonly dashboard: {
    readonly guide: string;
    readonly drilldowns: string;
    readonly drilldownsTriggerPicker: string;
    readonly urlDrilldownTemplateSyntax: string;
    readonly urlDrilldownVariables: string;
    };
    readonly discover: Record<string, string>;
    readonly filebeat: {
    readonly base: string;
    readonly installation: string;
    readonly configuration: string;
    readonly elasticsearchOutput: string;
    readonly elasticsearchModule: string;
    readonly startup: string;
    readonly exportedFields: string;
    };
    readonly auditbeat: {
    readonly base: string;
    };
    readonly metricbeat: {
    readonly base: string;
    readonly configure: string;
    readonly httpEndpoint: string;
    readonly install: string;
    readonly start: string;
    };
    readonly enterpriseSearch: {
    readonly base: string;
    readonly appSearchBase: string;
    readonly workplaceSearchBase: string;
    };
    readonly heartbeat: {
    readonly base: string;
    };
    readonly logstash: {
    readonly base: string;
    };
    readonly functionbeat: {
    readonly base: string;
    };
    readonly winlogbeat: {
    readonly base: string;
    };
    readonly aggs: {
    readonly composite: string;
    readonly composite_missing_bucket: string;
    readonly date_histogram: string;
    readonly date_range: string;
    readonly date_format_pattern: string;
    readonly filter: string;
    readonly filters: string;
    readonly geohash_grid: string;
    readonly histogram: string;
    readonly ip_range: string;
    readonly range: string;
    readonly significant_terms: string;
    readonly terms: string;
    readonly avg: string;
    readonly avg_bucket: string;
    readonly max_bucket: string;
    readonly min_bucket: string;
    readonly sum_bucket: string;
    readonly cardinality: string;
    readonly count: string;
    readonly cumulative_sum: string;
    readonly derivative: string;
    readonly geo_bounds: string;
    readonly geo_centroid: string;
    readonly max: string;
    readonly median: string;
    readonly min: string;
    readonly moving_avg: string;
    readonly percentile_ranks: string;
    readonly serial_diff: string;
    readonly std_dev: string;
    readonly sum: string;
    readonly top_hits: string;
    };
    readonly runtimeFields: {
    readonly overview: string;
    readonly mapping: string;
    };
    readonly scriptedFields: {
    readonly scriptFields: string;
    readonly scriptAggs: string;
    readonly painless: string;
    readonly painlessApi: string;
    readonly painlessLangSpec: string;
    readonly painlessSyntax: string;
    readonly painlessWalkthrough: string;
    readonly luceneExpressions: string;
    };
    readonly search: {
    readonly sessions: string;
    };
    readonly indexPatterns: {
    readonly introduction: string;
    readonly fieldFormattersNumber: string;
    readonly fieldFormattersString: string;
    readonly runtimeFields: string;
    };
    readonly addData: string;
    readonly kibana: string;
    readonly upgradeAssistant: string;
    readonly elasticsearch: Record<string, string>;
    readonly siem: {
    readonly guide: string;
    readonly gettingStarted: string;
    };
    readonly query: {
    readonly eql: string;
    readonly kueryQuerySyntax: string;
    readonly luceneQuerySyntax: string;
    readonly percolate: string;
    readonly queryDsl: string;
    };
    readonly date: {
    readonly dateMath: string;
    readonly dateMathIndexNames: string;
    };
    readonly management: Record<string, string>;
    readonly ml: Record<string, string>;
    readonly transforms: Record<string, string>;
    readonly visualize: Record<string, string>;
    readonly apis: Readonly<{
    bulkIndexAlias: string;
    byteSizeUnits: string;
    createAutoFollowPattern: string;
    createFollower: string;
    createIndex: string;
    createSnapshotLifecyclePolicy: string;
    createRoleMapping: string;
    createRoleMappingTemplates: string;
    createRollupJobsRequest: string;
    createApiKey: string;
    createPipeline: string;
    createTransformRequest: string;
    cronExpressions: string;
    executeWatchActionModes: string;
    indexExists: string;
    openIndex: string;
    putComponentTemplate: string;
    painlessExecute: string;
    painlessExecuteAPIContexts: string;
    putComponentTemplateMetadata: string;
    putSnapshotLifecyclePolicy: string;
    putIndexTemplateV1: string;
    putWatch: string;
    simulatePipeline: string;
    timeUnits: string;
    updateTransform: string;
    }>;
    readonly observability: Record<string, string>;
    readonly alerting: Record<string, string>;
    readonly maps: Record<string, string>;
    readonly monitoring: Record<string, string>;
    readonly security: Readonly<{
    apiKeyServiceSettings: string;
    clusterPrivileges: string;
    elasticsearchSettings: string;
    elasticsearchEnableSecurity: string;
    indicesPrivileges: string;
    kibanaTLS: string;
    kibanaPrivileges: string;
    mappingRoles: string;
    mappingRolesFieldRules: string;
    runAsPrivilege: string;
    }>;
    readonly watcher: Record<string, string>;
    readonly ccs: Record<string, string>;
    readonly plugins: Record<string, string>;
    readonly snapshotRestore: Record<string, string>;
    readonly ingest: Record<string, string>;
    readonly fleet: Readonly<{
    guide: string;
    fleetServer: string;
    fleetServerAddFleetServer: string;
    settings: string;
    settingsFleetServerHostSettings: string;
    troubleshooting: string;
    elasticAgent: string;
    datastreams: string;
    datastreamsNamingScheme: string;
    upgradeElasticAgent: string;
    upgradeElasticAgent712lower: string;
    }>;
    } | | +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
    readonly canvas: {
    readonly guide: string;
    };
    readonly dashboard: {
    readonly guide: string;
    readonly drilldowns: string;
    readonly drilldownsTriggerPicker: string;
    readonly urlDrilldownTemplateSyntax: string;
    readonly urlDrilldownVariables: string;
    };
    readonly discover: Record<string, string>;
    readonly filebeat: {
    readonly base: string;
    readonly installation: string;
    readonly configuration: string;
    readonly elasticsearchOutput: string;
    readonly elasticsearchModule: string;
    readonly startup: string;
    readonly exportedFields: string;
    };
    readonly auditbeat: {
    readonly base: string;
    };
    readonly metricbeat: {
    readonly base: string;
    readonly configure: string;
    readonly httpEndpoint: string;
    readonly install: string;
    readonly start: string;
    };
    readonly enterpriseSearch: {
    readonly base: string;
    readonly appSearchBase: string;
    readonly workplaceSearchBase: string;
    };
    readonly heartbeat: {
    readonly base: string;
    };
    readonly logstash: {
    readonly base: string;
    };
    readonly functionbeat: {
    readonly base: string;
    };
    readonly winlogbeat: {
    readonly base: string;
    };
    readonly aggs: {
    readonly composite: string;
    readonly composite_missing_bucket: string;
    readonly date_histogram: string;
    readonly date_range: string;
    readonly date_format_pattern: string;
    readonly filter: string;
    readonly filters: string;
    readonly geohash_grid: string;
    readonly histogram: string;
    readonly ip_range: string;
    readonly range: string;
    readonly significant_terms: string;
    readonly terms: string;
    readonly avg: string;
    readonly avg_bucket: string;
    readonly max_bucket: string;
    readonly min_bucket: string;
    readonly sum_bucket: string;
    readonly cardinality: string;
    readonly count: string;
    readonly cumulative_sum: string;
    readonly derivative: string;
    readonly geo_bounds: string;
    readonly geo_centroid: string;
    readonly max: string;
    readonly median: string;
    readonly min: string;
    readonly moving_avg: string;
    readonly percentile_ranks: string;
    readonly serial_diff: string;
    readonly std_dev: string;
    readonly sum: string;
    readonly top_hits: string;
    };
    readonly runtimeFields: {
    readonly overview: string;
    readonly mapping: string;
    };
    readonly scriptedFields: {
    readonly scriptFields: string;
    readonly scriptAggs: string;
    readonly painless: string;
    readonly painlessApi: string;
    readonly painlessLangSpec: string;
    readonly painlessSyntax: string;
    readonly painlessWalkthrough: string;
    readonly luceneExpressions: string;
    };
    readonly search: {
    readonly sessions: string;
    };
    readonly indexPatterns: {
    readonly introduction: string;
    readonly fieldFormattersNumber: string;
    readonly fieldFormattersString: string;
    readonly runtimeFields: string;
    };
    readonly addData: string;
    readonly kibana: string;
    readonly upgradeAssistant: string;
    readonly rollupJobs: string;
    readonly elasticsearch: Record<string, string>;
    readonly siem: {
    readonly guide: string;
    readonly gettingStarted: string;
    };
    readonly query: {
    readonly eql: string;
    readonly kueryQuerySyntax: string;
    readonly luceneQuerySyntax: string;
    readonly percolate: string;
    readonly queryDsl: string;
    };
    readonly date: {
    readonly dateMath: string;
    readonly dateMathIndexNames: string;
    };
    readonly management: Record<string, string>;
    readonly ml: Record<string, string>;
    readonly transforms: Record<string, string>;
    readonly visualize: Record<string, string>;
    readonly apis: Readonly<{
    bulkIndexAlias: string;
    byteSizeUnits: string;
    createAutoFollowPattern: string;
    createFollower: string;
    createIndex: string;
    createSnapshotLifecyclePolicy: string;
    createRoleMapping: string;
    createRoleMappingTemplates: string;
    createRollupJobsRequest: string;
    createApiKey: string;
    createPipeline: string;
    createTransformRequest: string;
    cronExpressions: string;
    executeWatchActionModes: string;
    indexExists: string;
    openIndex: string;
    putComponentTemplate: string;
    painlessExecute: string;
    painlessExecuteAPIContexts: string;
    putComponentTemplateMetadata: string;
    putSnapshotLifecyclePolicy: string;
    putIndexTemplateV1: string;
    putWatch: string;
    simulatePipeline: string;
    timeUnits: string;
    updateTransform: string;
    }>;
    readonly observability: Record<string, string>;
    readonly alerting: Record<string, string>;
    readonly maps: Record<string, string>;
    readonly monitoring: Record<string, string>;
    readonly security: Readonly<{
    apiKeyServiceSettings: string;
    clusterPrivileges: string;
    elasticsearchSettings: string;
    elasticsearchEnableSecurity: string;
    indicesPrivileges: string;
    kibanaTLS: string;
    kibanaPrivileges: string;
    mappingRoles: string;
    mappingRolesFieldRules: string;
    runAsPrivilege: string;
    }>;
    readonly watcher: Record<string, string>;
    readonly ccs: Record<string, string>;
    readonly plugins: Record<string, string>;
    readonly snapshotRestore: Record<string, string>;
    readonly ingest: Record<string, string>;
    readonly fleet: Readonly<{
    guide: string;
    fleetServer: string;
    fleetServerAddFleetServer: string;
    settings: string;
    settingsFleetServerHostSettings: string;
    troubleshooting: string;
    elasticAgent: string;
    datastreams: string;
    datastreamsNamingScheme: string;
    upgradeElasticAgent: string;
    upgradeElasticAgent712lower: string;
    }>;
    } | | diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 95091a761639b6..8c52d09f821595 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -137,6 +137,7 @@ export class DocLinksService { addData: `${KIBANA_DOCS}connect-to-elasticsearch.html`, kibana: `${KIBANA_DOCS}index.html`, upgradeAssistant: `${KIBANA_DOCS}upgrade-assistant.html`, + rollupJobs: `${KIBANA_DOCS}data-rollups.html`, elasticsearch: { docsBase: `${ELASTICSEARCH_DOCS}`, asyncSearch: `${ELASTICSEARCH_DOCS}async-search-intro.html`, @@ -532,6 +533,7 @@ export interface DocLinksStart { readonly addData: string; readonly kibana: string; readonly upgradeAssistant: string; + readonly rollupJobs: string; readonly elasticsearch: Record; readonly siem: { readonly guide: string; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 6cc2b3f321fb7c..27569935bcc65f 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -595,6 +595,7 @@ export interface DocLinksStart { readonly addData: string; readonly kibana: string; readonly upgradeAssistant: string; + readonly rollupJobs: string; readonly elasticsearch: Record; readonly siem: { readonly guide: string; diff --git a/x-pack/plugins/rollup/public/crud_app/_crud_app.scss b/x-pack/plugins/rollup/public/crud_app/_crud_app.scss index 9e3bd491115ced..ddf69167145f14 100644 --- a/x-pack/plugins/rollup/public/crud_app/_crud_app.scss +++ b/x-pack/plugins/rollup/public/crud_app/_crud_app.scss @@ -4,11 +4,3 @@ .rollupJobWizardStepActions { align-items: flex-end; /* 1 */ } - -/** - * 1. Ensure panel fills width of parent when search input yields no matching rollup jobs. - */ -.rollupJobsListPanel { - // sass-lint:disable-block no-important - flex-grow: 1 !important; /* 1 */ -} diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_create/job_create.js b/x-pack/plugins/rollup/public/crud_app/sections/job_create/job_create.js index fa3ce260424f27..6f22345dc1cec5 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_create/job_create.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_create/job_create.js @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Component, Fragment } from 'react'; +import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { cloneDeep, debounce, first, mapValues } from 'lodash'; @@ -18,11 +18,10 @@ import { EuiCallOut, EuiLoadingKibana, EuiOverlayMask, - EuiPageContent, - EuiPageContentHeader, + EuiPageContentBody, + EuiPageHeader, EuiSpacer, EuiStepsHorizontal, - EuiTitle, } from '@elastic/eui'; import { @@ -522,44 +521,46 @@ export class JobCreateUi extends Component { } saveErrorFeedback = ( - + <> + + {errorBody} - + ); } return ( - - - - -

    - -

    -
    -
    - - {saveErrorFeedback} - - + + + } + /> - + + + + + {saveErrorFeedback} + + + + {this.renderCurrentStep()} - {this.renderCurrentStep()} + - + {this.renderNavigation()} - {this.renderNavigation()} -
    {savingFeedback} -
    + ); } diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.js b/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.js index 4fe1674e8c6436..5e97ff5e2980d3 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.js @@ -195,7 +195,7 @@ export class DetailPanel extends Component {
    diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.test.js b/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.test.js index 16919b8388e2e4..e1f9ec2b3a315c 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.test.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.test.js @@ -70,7 +70,7 @@ describe('', () => { ({ component, find, exists } = initTestBed({ isLoading: true })); const loading = find('rollupJobDetailLoading'); expect(loading.length).toBeTruthy(); - expect(loading.text()).toEqual('Loading rollup job...'); + expect(loading.text()).toEqual('Loading rollup job…'); // Make sure the title and the tabs are visible expect(exists('detailPanelTabSelected')).toBeTruthy(); diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.js b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.js index 589546a11ef38e..b2448eb6107742 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.js @@ -12,24 +12,19 @@ import { i18n } from '@kbn/i18n'; import { EuiButton, + EuiButtonEmpty, EuiEmptyPrompt, - EuiFlexGroup, - EuiFlexItem, - EuiLoadingSpinner, + EuiPageHeader, EuiPageContent, - EuiPageContentHeader, - EuiPageContentHeaderSection, EuiSpacer, - EuiText, - EuiTextColor, - EuiTitle, - EuiCallOut, } from '@elastic/eui'; import { withKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { extractQueryParams } from '../../../shared_imports'; +import { extractQueryParams, SectionLoading } from '../../../shared_imports'; import { getRouterLinkProps, listBreadcrumb } from '../../services'; +import { documentationLinks } from '../../services/documentation_links'; + import { JobTable } from './job_table'; import { DetailPanel } from './detail_panel'; @@ -87,38 +82,26 @@ export class JobListUi extends Component { this.props.closeDetailPanel(); } - getHeaderSection() { - return ( - - -

    - -

    -
    -
    - ); - } - renderNoPermission() { const title = i18n.translate('xpack.rollupJobs.jobList.noPermissionTitle', { defaultMessage: 'Permission error', }); return ( - - {this.getHeaderSection()} - - + - - - + iconType="alert" + title={

    {title}

    } + body={ +

    + +

    + } + /> + ); } @@ -130,101 +113,110 @@ export class JobListUi extends Component { const title = i18n.translate('xpack.rollupJobs.jobList.loadingErrorTitle', { defaultMessage: 'Error loading rollup jobs', }); + return ( - - {this.getHeaderSection()} - - - {statusCode} {errorString} - - + + {title}} + body={ +

    + {statusCode} {errorString} +

    + } + /> +
    ); } renderEmpty() { return ( - - - - } - body={ - -

    + + + + } + body={ + +

    + +

    +
    + } + actions={ + + -

    - - } - actions={ - - - - } - /> +
    + } + /> + ); } renderLoading() { return ( - - - - - - - - - - - - - + + + + + ); } renderList() { - const { isLoading } = this.props; - return ( - - - {this.getHeaderSection()} - - - + <> + + + + } + rightSideItems={[ + - - - + , + ]} + /> - {isLoading ? this.renderLoading() : } + + + - + ); } @@ -241,15 +233,13 @@ export class JobListUi extends Component { } } else if (!isLoading && !hasJobs) { content = this.renderEmpty(); + } else if (isLoading) { + content = this.renderLoading(); } else { content = this.renderList(); } - return ( - - {content} - - ); + return content; } } diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.test.js b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.test.js index 3283f4f521fc0e..b2c738a033b3cb 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.test.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.test.js @@ -22,6 +22,15 @@ jest.mock('../../services', () => { }; }); +jest.mock('../../services/documentation_links', () => { + const coreMocks = jest.requireActual('../../../../../../../src/core/public/mocks'); + + return { + init: jest.fn(), + documentationLinks: coreMocks.docLinksServiceMock.createStartContract().links, + }; +}); + const defaultProps = { history: { location: {} }, loadJobs: () => {}, @@ -52,14 +61,14 @@ describe('', () => { it('should display a loading message when loading the jobs', () => { const { component, exists } = initTestBed({ isLoading: true }); - expect(exists('jobListLoading')).toBeTruthy(); + expect(exists('sectionLoading')).toBeTruthy(); expect(component.find('JobTable').length).toBeFalsy(); }); it('should display the when there are jobs', () => { const { component, exists } = initTestBed({ hasJobs: true }); - expect(exists('jobListLoading')).toBeFalsy(); + expect(exists('sectionLoading')).toBeFalsy(); expect(component.find('JobTable').length).toBeTruthy(); }); @@ -71,21 +80,20 @@ describe('', () => { }, }); - it('should display a callout with the status and the message', () => { + it('should display an error with the status and the message', () => { expect(exists('jobListError')).toBeTruthy(); expect(find('jobListError').find('EuiText').text()).toEqual('400 Houston we got a problem.'); }); }); describe('when the user does not have the permission to access it', () => { - const { exists } = initTestBed({ jobLoadError: { status: 403 } }); + const { exists, find } = initTestBed({ jobLoadError: { status: 403 } }); - it('should render a callout message', () => { + it('should render an error message', () => { expect(exists('jobListNoPermission')).toBeTruthy(); - }); - - it('should display the page header', () => { - expect(exists('jobListPageHeader')).toBeTruthy(); + expect(find('jobListNoPermission').find('EuiText').text()).toEqual( + 'You do not have permission to view or add rollup jobs.' + ); }); }); }); diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_table/job_table.js b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_table/job_table.js index fe3d2cbd4cbe0d..83135cf219f350 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_table/job_table.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_table/job_table.js @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Component, Fragment } from 'react'; +import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -28,10 +28,11 @@ import { EuiTableRowCellCheckbox, EuiText, EuiToolTip, + EuiButton, } from '@elastic/eui'; import { UIM_SHOW_DETAILS_CLICK } from '../../../../../common'; -import { METRIC_TYPE } from '../../../services'; +import { METRIC_TYPE, getRouterLinkProps } from '../../../services'; import { trackUiMetric } from '../../../../kibana_services'; import { JobActionMenu, JobStatus } from '../../components'; @@ -346,9 +347,9 @@ export class JobTable extends Component { const atLeastOneItemSelected = Object.keys(idToSelectedJobMap).length > 0; return ( - - - {atLeastOneItemSelected ? ( +
    + + {atLeastOneItemSelected && ( - ) : null} + )} + + + + + @@ -409,7 +418,7 @@ export class JobTable extends Component { {jobs.length > 0 ? this.renderPager() : null} - +
    ); } } diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_table/job_table.test.js b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_table/job_table.test.js index 3fa879923c40ab..d52f3fa35a5441 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_table/job_table.test.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_table/job_table.test.js @@ -20,6 +20,14 @@ jest.mock('../../../../kibana_services', () => { }; }); +jest.mock('../../../services', () => { + const services = jest.requireActual('../../../services'); + return { + ...services, + getRouterLinkProps: (link) => ({ href: link }), + }; +}); + const defaultProps = { jobs: [], pager: new Pager(20, 10, 1), diff --git a/x-pack/plugins/rollup/public/crud_app/store/actions/load_jobs.js b/x-pack/plugins/rollup/public/crud_app/store/actions/load_jobs.js index 0dc3a02d3c0779..c63d01f3c200d5 100644 --- a/x-pack/plugins/rollup/public/crud_app/store/actions/load_jobs.js +++ b/x-pack/plugins/rollup/public/crud_app/store/actions/load_jobs.js @@ -5,9 +5,7 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; - -import { loadJobs as sendLoadJobsRequest, deserializeJobs, showApiError } from '../../services'; +import { loadJobs as sendLoadJobsRequest, deserializeJobs } from '../../services'; import { LOAD_JOBS_START, LOAD_JOBS_SUCCESS, LOAD_JOBS_FAILURE } from '../action_types'; export const loadJobs = () => async (dispatch) => { @@ -19,17 +17,10 @@ export const loadJobs = () => async (dispatch) => { try { jobs = await sendLoadJobsRequest(); } catch (error) { - dispatch({ + return dispatch({ type: LOAD_JOBS_FAILURE, payload: { error }, }); - - return showApiError( - error, - i18n.translate('xpack.rollupJobs.loadAction.errorTitle', { - defaultMessage: 'Error loading rollup jobs', - }) - ); } dispatch({ diff --git a/x-pack/plugins/rollup/public/shared_imports.ts b/x-pack/plugins/rollup/public/shared_imports.ts index fd281753186665..c8d7f1d9f13f3d 100644 --- a/x-pack/plugins/rollup/public/shared_imports.ts +++ b/x-pack/plugins/rollup/public/shared_imports.ts @@ -5,4 +5,8 @@ * 2.0. */ -export { extractQueryParams, indices } from '../../../../src/plugins/es_ui_shared/public'; +export { + extractQueryParams, + indices, + SectionLoading, +} from '../../../../src/plugins/es_ui_shared/public'; diff --git a/x-pack/plugins/rollup/public/test/client_integration/job_list.test.js b/x-pack/plugins/rollup/public/test/client_integration/job_list.test.js index fa1a786bc8a71d..46ddfbcfc2de55 100644 --- a/x-pack/plugins/rollup/public/test/client_integration/job_list.test.js +++ b/x-pack/plugins/rollup/public/test/client_integration/job_list.test.js @@ -5,10 +5,10 @@ * 2.0. */ -import { getRouter, setHttp } from '../../crud_app/services'; +import { getRouter, setHttp, init as initDocumentation } from '../../crud_app/services'; import { mockHttpRequest, pageHelpers, nextTick } from './helpers'; import { JOBS } from './helpers/constants'; -import { coreMock } from '../../../../../../src/core/public/mocks'; +import { coreMock, docLinksServiceMock } from '../../../../../../src/core/public/mocks'; jest.mock('../../crud_app/services', () => { const services = jest.requireActual('../../crud_app/services'); @@ -38,6 +38,7 @@ describe('', () => { beforeAll(() => { startMock = coreMock.createStart(); setHttp(startMock.http); + initDocumentation(docLinksServiceMock.createStartContract()); }); beforeEach(async () => { diff --git a/x-pack/plugins/rollup/public/test/client_integration/job_list_clone.test.js b/x-pack/plugins/rollup/public/test/client_integration/job_list_clone.test.js index cfb63893ee423a..3987e18538e577 100644 --- a/x-pack/plugins/rollup/public/test/client_integration/job_list_clone.test.js +++ b/x-pack/plugins/rollup/public/test/client_integration/job_list_clone.test.js @@ -24,6 +24,15 @@ jest.mock('../../kibana_services', () => { }; }); +jest.mock('../../crud_app/services/documentation_links', () => { + const coreMocks = jest.requireActual('../../../../../../src/core/public/mocks'); + + return { + init: jest.fn(), + documentationLinks: coreMocks.docLinksServiceMock.createStartContract().links, + }; +}); + const { setup } = pageHelpers.jobList; describe('Smoke test cloning an existing rollup job from job list', () => { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 9520c1ad0d9c1d..91277403d9e058 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -18006,7 +18006,6 @@ "xpack.rollupJobs.jobTable.selectRow": "この行 {id} を選択", "xpack.rollupJobs.licenseCheckErrorMessage": "ライセンス確認失敗", "xpack.rollupJobs.listBreadcrumbTitle": "ロールアップジョブ", - "xpack.rollupJobs.loadAction.errorTitle": "ロールアップジョブを読み込み中にエラーが発生", "xpack.rollupJobs.refreshAction.errorTitle": "ロールアップジョブの更新中にエラーが発生", "xpack.rollupJobs.rollupIndexPatternsDescription": "ロールアップインデックスを捕捉するインデックスパターンの作成を有効にします。\n それによりロールアップデータに基づくビジュアライゼーションが可能になります。", "xpack.rollupJobs.rollupIndexPatternsTitle": "ロールアップインデックスパターンを有効にする", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index f74d27eb8b2142..632c502d4ef555 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -18250,7 +18250,6 @@ "xpack.rollupJobs.jobTable.selectRow": "选择行 {id}", "xpack.rollupJobs.licenseCheckErrorMessage": "许可证检查失败", "xpack.rollupJobs.listBreadcrumbTitle": "汇总/打包作业", - "xpack.rollupJobs.loadAction.errorTitle": "加载汇总/打包作业时出错", "xpack.rollupJobs.refreshAction.errorTitle": "刷新汇总/打包作业时出错", "xpack.rollupJobs.rollupIndexPatternsDescription": "启用用于捕获汇总/打包索引的索引模式的创建,\n 汇总/打包索引反过来基于汇总/打包数据启用可视化。", "xpack.rollupJobs.rollupIndexPatternsTitle": "启用汇总索引模式", From 953a464e94ad1791c228fc1705430d11964da909 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Tue, 22 Jun 2021 11:21:19 -0700 Subject: [PATCH 52/57] [Monitoring] Update Kibana rules/alerts language in setup mode (#102441) --- x-pack/plugins/monitoring/public/alerts/badge.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/monitoring/public/alerts/badge.tsx b/x-pack/plugins/monitoring/public/alerts/badge.tsx index 8b4075ba67cdc7..44af8b33279753 100644 --- a/x-pack/plugins/monitoring/public/alerts/badge.tsx +++ b/x-pack/plugins/monitoring/public/alerts/badge.tsx @@ -19,13 +19,18 @@ import { getAlertPanelsByCategory } from './lib/get_alert_panels_by_category'; import { getAlertPanelsByNode } from './lib/get_alert_panels_by_node'; export const numberOfAlertsLabel = (count: number) => `${count} alert${count > 1 ? 's' : ''}`; +export const numberOfRulesLabel = (count: number) => `${count} rule${count > 1 ? 's' : ''}`; const MAX_TO_SHOW_BY_CATEGORY = 8; -const PANEL_TITLE = i18n.translate('xpack.monitoring.alerts.badge.panelTitle', { +const PANEL_TITLE_ALERTS = i18n.translate('xpack.monitoring.alerts.badge.panelTitle', { defaultMessage: 'Alerts', }); +const PANEL_TITLE_RULES = i18n.translate('xpack.monitoring.rules.badge.panelTitle', { + defaultMessage: 'Rules', +}); + const GROUP_BY_NODE = i18n.translate('xpack.monitoring.alerts.badge.groupByNode', { defaultMessage: 'Group by node', }); @@ -54,6 +59,7 @@ export const AlertsBadge: React.FC = (props: Props) => { const [showByNode, setShowByNode] = React.useState( !inSetupMode && alertCount > MAX_TO_SHOW_BY_CATEGORY ); + const PANEL_TITLE = inSetupMode ? PANEL_TITLE_RULES : PANEL_TITLE_ALERTS; React.useEffect(() => { if (inSetupMode && showByNode) { @@ -93,10 +99,12 @@ export const AlertsBadge: React.FC = (props: Props) => { setShowPopover(true)} > - {numberOfAlertsLabel(alertCount)} + {inSetupMode ? numberOfRulesLabel(alertCount) : numberOfAlertsLabel(alertCount)} ); From 00a6bdd4010b2419229e3b9e98c738b10659df52 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Tue, 22 Jun 2021 14:36:04 -0400 Subject: [PATCH 53/57] Allow initialNamespaces to be used for isolated types (#102585) --- docs/api/saved-objects/bulk_create.asciidoc | 5 + docs/api/saved-objects/create.asciidoc | 5 + ...jectsbulkcreateobject.initialnamespaces.md | 2 +- ...ore-server.savedobjectsbulkcreateobject.md | 2 +- ...dobjectscreateoptions.initialnamespaces.md | 2 +- ...n-core-server.savedobjectscreateoptions.md | 2 +- .../service/lib/repository.test.js | 145 +++++++++++++----- .../saved_objects/service/lib/repository.ts | 74 +++++---- .../service/saved_objects_client.ts | 12 +- src/core/server/server.api.md | 2 +- .../common/lib/saved_object_test_utils.ts | 6 +- .../common/suites/bulk_create.ts | 22 ++- .../common/suites/create.ts | 22 ++- .../security_and_spaces/apis/bulk_create.ts | 19 ++- .../security_and_spaces/apis/create.ts | 19 ++- .../security_only/apis/bulk_create.ts | 18 ++- .../security_only/apis/create.ts | 18 ++- .../spaces_only/apis/bulk_create.ts | 18 ++- .../spaces_only/apis/create.ts | 18 ++- 19 files changed, 307 insertions(+), 104 deletions(-) diff --git a/docs/api/saved-objects/bulk_create.asciidoc b/docs/api/saved-objects/bulk_create.asciidoc index 267ab3891d7000..5bd3a7587dde9e 100644 --- a/docs/api/saved-objects/bulk_create.asciidoc +++ b/docs/api/saved-objects/bulk_create.asciidoc @@ -45,6 +45,11 @@ experimental[] Create multiple {kib} saved objects. (Optional, string array) Identifiers for the <> in which this object is created. If this is provided, the object is created only in the explicitly defined spaces. If this is not provided, the object is created in the current space (default behavior). +* For shareable object types (registered with `namespaceType: 'multiple'`): this option can be used to specify one or more spaces, including +the "All spaces" identifier (`'*'`). +* For isolated object types (registered with `namespaceType: 'single'` or `namespaceType: 'multiple-isolated'`): this option can only be +used to specify a single space, and the "All spaces" identifier (`'*'`) is not allowed. +* For global object types (registered with `namespaceType: 'agnostic'`): this option cannot be used. `version`:: (Optional, number) Specifies the version. diff --git a/docs/api/saved-objects/create.asciidoc b/docs/api/saved-objects/create.asciidoc index d7a368034ef07f..e7e25c7d3bba6d 100644 --- a/docs/api/saved-objects/create.asciidoc +++ b/docs/api/saved-objects/create.asciidoc @@ -52,6 +52,11 @@ any data that you send to the API is properly formed. (Optional, string array) Identifiers for the <> in which this object is created. If this is provided, the object is created only in the explicitly defined spaces. If this is not provided, the object is created in the current space (default behavior). +* For shareable object types (registered with `namespaceType: 'multiple'`): this option can be used to specify one or more spaces, including +the "All spaces" identifier (`'*'`). +* For isolated object types (registered with `namespaceType: 'single'` or `namespaceType: 'multiple-isolated'`): this option can only be +used to specify a single space, and the "All spaces" identifier (`'*'`) is not allowed. +* For global object types (registered with `namespaceType: 'agnostic'): this option cannot be used. [[saved-objects-api-create-request-codes]] ==== Response code diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md index 3db8bbadfbd6bf..4d094ecde7a96a 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md @@ -6,7 +6,7 @@ Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md). -Note: this can only be used for multi-namespace object types. +\* For shareable object types (registered with `namespaceType: 'multiple'`): this option can be used to specify one or more spaces, including the "All spaces" identifier (`'*'`). \* For isolated object types (registered with `namespaceType: 'single'` or `namespaceType: 'multiple-isolated'`): this option can only be used to specify a single space, and the "All spaces" identifier (`'*'`) is not allowed. \* For global object types (registered with `namespaceType: 'agnostic'`): this option cannot be used. Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md index 6fc01212a2e41a..463c3fe81b7029 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md @@ -18,7 +18,7 @@ export interface SavedObjectsBulkCreateObject | [attributes](./kibana-plugin-core-server.savedobjectsbulkcreateobject.attributes.md) | T | | | [coreMigrationVersion](./kibana-plugin-core-server.savedobjectsbulkcreateobject.coremigrationversion.md) | string | A semver value that is used when upgrading objects between Kibana versions. If undefined, this will be automatically set to the current Kibana version when the object is created. If this is set to a non-semver value, or it is set to a semver value greater than the current Kibana version, it will result in an error. | | [id](./kibana-plugin-core-server.savedobjectsbulkcreateobject.id.md) | string | | -| [initialNamespaces](./kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md) | string[] | Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md).Note: this can only be used for multi-namespace object types. | +| [initialNamespaces](./kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md) | string[] | Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md).\* For shareable object types (registered with namespaceType: 'multiple'): this option can be used to specify one or more spaces, including the "All spaces" identifier ('*'). \* For isolated object types (registered with namespaceType: 'single' or namespaceType: 'multiple-isolated'): this option can only be used to specify a single space, and the "All spaces" identifier ('*') is not allowed. \* For global object types (registered with namespaceType: 'agnostic'): this option cannot be used. | | [migrationVersion](./kibana-plugin-core-server.savedobjectsbulkcreateobject.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | | [originId](./kibana-plugin-core-server.savedobjectsbulkcreateobject.originid.md) | string | Optional ID of the original saved object, if this object's id was regenerated | | [references](./kibana-plugin-core-server.savedobjectsbulkcreateobject.references.md) | SavedObjectReference[] | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md index 262b0997cb9050..43489b8d2e8a27 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md @@ -6,7 +6,7 @@ Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md). -Note: this can only be used for multi-namespace object types. +\* For shareable object types (registered with `namespaceType: 'multiple'`): this option can be used to specify one or more spaces, including the "All spaces" identifier (`'*'`). \* For isolated object types (registered with `namespaceType: 'single'` or `namespaceType: 'multiple-isolated'`): this option can only be used to specify a single space, and the "All spaces" identifier (`'*'`) is not allowed. \* For global object types (registered with `namespaceType: 'agnostic'`): this option cannot be used. Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md index 1805f389d4e7f3..7eaa9c51f5c82c 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md @@ -17,7 +17,7 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions | --- | --- | --- | | [coreMigrationVersion](./kibana-plugin-core-server.savedobjectscreateoptions.coremigrationversion.md) | string | A semver value that is used when upgrading objects between Kibana versions. If undefined, this will be automatically set to the current Kibana version when the object is created. If this is set to a non-semver value, or it is set to a semver value greater than the current Kibana version, it will result in an error. | | [id](./kibana-plugin-core-server.savedobjectscreateoptions.id.md) | string | (not recommended) Specify an id for the document | -| [initialNamespaces](./kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md) | string[] | Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md).Note: this can only be used for multi-namespace object types. | +| [initialNamespaces](./kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md) | string[] | Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md).\* For shareable object types (registered with namespaceType: 'multiple'): this option can be used to specify one or more spaces, including the "All spaces" identifier ('*'). \* For isolated object types (registered with namespaceType: 'single' or namespaceType: 'multiple-isolated'): this option can only be used to specify a single space, and the "All spaces" identifier ('*') is not allowed. \* For global object types (registered with namespaceType: 'agnostic'): this option cannot be used. | | [migrationVersion](./kibana-plugin-core-server.savedobjectscreateoptions.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | | [originId](./kibana-plugin-core-server.savedobjectscreateoptions.originid.md) | string | Optional ID of the original saved object, if this object's id was regenerated | | [overwrite](./kibana-plugin-core-server.savedobjectscreateoptions.overwrite.md) | boolean | Overwrite existing documents (defaults to false) | diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 22c40a547f419a..4456784fdbc0b4 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -525,15 +525,22 @@ describe('SavedObjectsRepository', () => { const ns2 = 'bar-namespace'; const ns3 = 'baz-namespace'; const objects = [ - { ...obj1, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [ns2] }, - { ...obj2, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [ns3] }, + { ...obj1, type: 'dashboard', initialNamespaces: [ns2] }, + { ...obj1, type: MULTI_NAMESPACE_ISOLATED_TYPE, initialNamespaces: [ns2] }, + { ...obj1, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [ns2, ns3] }, ]; await bulkCreateSuccess(objects, { namespace, overwrite: true }); const body = [ - expect.any(Object), + { index: expect.objectContaining({ _id: `${ns2}:dashboard:${obj1.id}` }) }, + expect.objectContaining({ namespace: ns2 }), + { + index: expect.objectContaining({ + _id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${obj1.id}`, + }), + }, expect.objectContaining({ namespaces: [ns2] }), - expect.any(Object), - expect.objectContaining({ namespaces: [ns3] }), + { index: expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${obj1.id}` }) }, + expect.objectContaining({ namespaces: [ns2, ns3] }), ]; expect(client.bulk).toHaveBeenCalledWith( expect.objectContaining({ body }), @@ -649,24 +656,19 @@ describe('SavedObjectsRepository', () => { ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); }); - it(`returns error when initialNamespaces is used with a non-shareable object`, async () => { - const test = async (objType) => { - const obj = { ...obj3, type: objType, initialNamespaces: [] }; - await bulkCreateError( + it(`returns error when initialNamespaces is used with a space-agnostic object`, async () => { + const obj = { ...obj3, type: NAMESPACE_AGNOSTIC_TYPE, initialNamespaces: [] }; + await bulkCreateError( + obj, + undefined, + expectErrorResult( obj, - undefined, - expectErrorResult( - obj, - createBadRequestError('"initialNamespaces" can only be used on multi-namespace types') - ) - ); - }; - await test('dashboard'); - await test(NAMESPACE_AGNOSTIC_TYPE); - await test(MULTI_NAMESPACE_ISOLATED_TYPE); + createBadRequestError('"initialNamespaces" cannot be used on space-agnostic types') + ) + ); }); - it(`throws when options.initialNamespaces is used with a shareable type and is empty`, async () => { + it(`returns error when initialNamespaces is empty`, async () => { const obj = { ...obj3, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [] }; await bulkCreateError( obj, @@ -678,6 +680,26 @@ describe('SavedObjectsRepository', () => { ); }); + it(`returns error when initialNamespaces is used with a space-isolated object and does not specify a single space`, async () => { + const doTest = async (objType, initialNamespaces) => { + const obj = { ...obj3, type: objType, initialNamespaces }; + await bulkCreateError( + obj, + undefined, + expectErrorResult( + obj, + createBadRequestError( + '"initialNamespaces" can only specify a single space when used with space-isolated types' + ) + ) + ); + }; + await doTest('dashboard', ['spacex', 'spacey']); + await doTest('dashboard', ['*']); + await doTest(MULTI_NAMESPACE_ISOLATED_TYPE, ['spacex', 'spacey']); + await doTest(MULTI_NAMESPACE_ISOLATED_TYPE, ['*']); + }); + it(`returns error when type is invalid`, async () => { const obj = { ...obj3, type: 'unknownType' }; await bulkCreateError(obj, undefined, expectErrorInvalidType(obj)); @@ -1865,12 +1887,46 @@ describe('SavedObjectsRepository', () => { }); it(`adds initialNamespaces instead of namespace`, async () => { - const options = { id, namespace, initialNamespaces: ['bar-namespace', 'baz-namespace'] }; - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, options); - expect(client.create).toHaveBeenCalledWith( + const ns2 = 'bar-namespace'; + const ns3 = 'baz-namespace'; + await savedObjectsRepository.create('dashboard', attributes, { + id, + namespace, + initialNamespaces: [ns2], + }); + await savedObjectsRepository.create(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { + id, + namespace, + initialNamespaces: [ns2], + }); + await savedObjectsRepository.create(MULTI_NAMESPACE_TYPE, attributes, { + id, + namespace, + initialNamespaces: [ns2, ns3], + }); + + expect(client.create).toHaveBeenCalledTimes(3); + expect(client.create).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + id: `${ns2}:dashboard:${id}`, + body: expect.objectContaining({ namespace: ns2 }), + }), + expect.anything() + ); + expect(client.create).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}`, + body: expect.objectContaining({ namespaces: [ns2] }), + }), + expect.anything() + ); + expect(client.create).toHaveBeenNthCalledWith( + 3, expect.objectContaining({ id: `${MULTI_NAMESPACE_TYPE}:${id}`, - body: expect.objectContaining({ namespaces: options.initialNamespaces }), + body: expect.objectContaining({ namespaces: [ns2, ns3] }), }), expect.anything() ); @@ -1892,29 +1948,40 @@ describe('SavedObjectsRepository', () => { }); describe('errors', () => { - it(`throws when options.initialNamespaces is used with a non-shareable object`, async () => { - const test = async (objType) => { - await expect( - savedObjectsRepository.create(objType, attributes, { initialNamespaces: [namespace] }) - ).rejects.toThrowError( - createBadRequestError( - '"options.initialNamespaces" can only be used on multi-namespace types' - ) - ); - }; - await test('dashboard'); - await test(MULTI_NAMESPACE_ISOLATED_TYPE); - await test(NAMESPACE_AGNOSTIC_TYPE); + it(`throws when options.initialNamespaces is used with a space-agnostic object`, async () => { + await expect( + savedObjectsRepository.create(NAMESPACE_AGNOSTIC_TYPE, attributes, { + initialNamespaces: [namespace], + }) + ).rejects.toThrowError( + createBadRequestError('"initialNamespaces" cannot be used on space-agnostic types') + ); }); - it(`throws when options.initialNamespaces is used with a shareable type and is empty`, async () => { + it(`throws when options.initialNamespaces is empty`, async () => { await expect( savedObjectsRepository.create(MULTI_NAMESPACE_TYPE, attributes, { initialNamespaces: [] }) ).rejects.toThrowError( - createBadRequestError('"options.initialNamespaces" must be a non-empty array of strings') + createBadRequestError('"initialNamespaces" must be a non-empty array of strings') ); }); + it(`throws when options.initialNamespaces is used with a space-isolated object and does not specify a single space`, async () => { + const doTest = async (objType, initialNamespaces) => { + await expect( + savedObjectsRepository.create(objType, attributes, { initialNamespaces }) + ).rejects.toThrowError( + createBadRequestError( + '"initialNamespaces" can only specify a single space when used with space-isolated types' + ) + ); + }; + await doTest('dashboard', ['spacex', 'spacey']); + await doTest('dashboard', ['*']); + await doTest(MULTI_NAMESPACE_ISOLATED_TYPE, ['spacex', 'spacey']); + await doTest(MULTI_NAMESPACE_ISOLATED_TYPE, ['*']); + }); + it(`throws when options.namespace is '*'`, async () => { await expect( savedObjectsRepository.create(type, attributes, { namespace: ALL_NAMESPACES_STRING }) diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 1577f773434b9d..c9fa50da55df10 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -283,28 +283,18 @@ export class SavedObjectsRepository { } = options; const namespace = normalizeNamespace(options.namespace); - if (initialNamespaces) { - if (!this._registry.isShareable(type)) { - throw SavedObjectsErrorHelpers.createBadRequestError( - '"options.initialNamespaces" can only be used on multi-namespace types' - ); - } else if (!initialNamespaces.length) { - throw SavedObjectsErrorHelpers.createBadRequestError( - '"options.initialNamespaces" must be a non-empty array of strings' - ); - } - } + this.validateInitialNamespaces(type, initialNamespaces); if (!this._allowedTypes.includes(type)) { throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type); } const time = this._getCurrentTime(); - let savedObjectNamespace; + let savedObjectNamespace: string | undefined; let savedObjectNamespaces: string[] | undefined; - if (this._registry.isSingleNamespace(type) && namespace) { - savedObjectNamespace = namespace; + if (this._registry.isSingleNamespace(type)) { + savedObjectNamespace = initialNamespaces ? initialNamespaces[0] : namespace; } else if (this._registry.isMultiNamespace(type)) { if (id && overwrite) { // we will overwrite a multi-namespace saved object if it exists; if that happens, ensure we preserve its included namespaces @@ -369,32 +359,29 @@ export class SavedObjectsRepository { let bulkGetRequestIndexCounter = 0; const expectedResults: Either[] = objects.map((object) => { + const { type, id, initialNamespaces } = object; let error: DecoratedError | undefined; - if (!this._allowedTypes.includes(object.type)) { - error = SavedObjectsErrorHelpers.createUnsupportedTypeError(object.type); - } else if (object.initialNamespaces) { - if (!this._registry.isShareable(object.type)) { - error = SavedObjectsErrorHelpers.createBadRequestError( - '"initialNamespaces" can only be used on multi-namespace types' - ); - } else if (!object.initialNamespaces.length) { - error = SavedObjectsErrorHelpers.createBadRequestError( - '"initialNamespaces" must be a non-empty array of strings' - ); + if (!this._allowedTypes.includes(type)) { + error = SavedObjectsErrorHelpers.createUnsupportedTypeError(type); + } else { + try { + this.validateInitialNamespaces(type, initialNamespaces); + } catch (e) { + error = e; } } if (error) { return { tag: 'Left' as 'Left', - error: { id: object.id, type: object.type, error: errorContent(error) }, + error: { id, type, error: errorContent(error) }, }; } - const method = object.id && overwrite ? 'index' : 'create'; - const requiresNamespacesCheck = object.id && this._registry.isMultiNamespace(object.type); + const method = id && overwrite ? 'index' : 'create'; + const requiresNamespacesCheck = id && this._registry.isMultiNamespace(type); - if (object.id == null) { + if (id == null) { object.id = SavedObjectsUtils.generateId(); } @@ -434,8 +421,8 @@ export class SavedObjectsRepository { return expectedBulkGetResult; } - let savedObjectNamespace; - let savedObjectNamespaces; + let savedObjectNamespace: string | undefined; + let savedObjectNamespaces: string[] | undefined; let versionProperties; const { esRequestIndex, @@ -469,7 +456,7 @@ export class SavedObjectsRepository { versionProperties = getExpectedVersionProperties(version, actualResult); } else { if (this._registry.isSingleNamespace(object.type)) { - savedObjectNamespace = namespace; + savedObjectNamespace = initialNamespaces ? initialNamespaces[0] : namespace; } else if (this._registry.isMultiNamespace(object.type)) { savedObjectNamespaces = initialNamespaces || getSavedObjectNamespaces(namespace); } @@ -2080,6 +2067,29 @@ export class SavedObjectsRepository { const object = await this.get(type, id, options); return { saved_object: object, outcome: 'exactMatch' }; } + + private validateInitialNamespaces(type: string, initialNamespaces: string[] | undefined) { + if (!initialNamespaces) { + return; + } + + if (this._registry.isNamespaceAgnostic(type)) { + throw SavedObjectsErrorHelpers.createBadRequestError( + '"initialNamespaces" cannot be used on space-agnostic types' + ); + } else if (!initialNamespaces.length) { + throw SavedObjectsErrorHelpers.createBadRequestError( + '"initialNamespaces" must be a non-empty array of strings' + ); + } else if ( + !this._registry.isShareable(type) && + (initialNamespaces.length > 1 || initialNamespaces.includes(ALL_NAMESPACES_STRING)) + ) { + throw SavedObjectsErrorHelpers.createBadRequestError( + '"initialNamespaces" can only specify a single space when used with space-isolated types' + ); + } + } } /** diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index af682cfb81296e..1423050145695f 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -63,7 +63,11 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { * Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in * {@link SavedObjectsCreateOptions}. * - * Note: this can only be used for multi-namespace object types. + * * For shareable object types (registered with `namespaceType: 'multiple'`): this option can be used to specify one or more spaces, + * including the "All spaces" identifier (`'*'`). + * * For isolated object types (registered with `namespaceType: 'single'` or `namespaceType: 'multiple-isolated'`): this option can only + * be used to specify a single space, and the "All spaces" identifier (`'*'`) is not allowed. + * * For global object types (registered with `namespaceType: 'agnostic'`): this option cannot be used. */ initialNamespaces?: string[]; } @@ -96,7 +100,11 @@ export interface SavedObjectsBulkCreateObject { * Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in * {@link SavedObjectsCreateOptions}. * - * Note: this can only be used for multi-namespace object types. + * * For shareable object types (registered with `namespaceType: 'multiple'`): this option can be used to specify one or more spaces, + * including the "All spaces" identifier (`'*'`). + * * For isolated object types (registered with `namespaceType: 'single'` or `namespaceType: 'multiple-isolated'`): this option can only + * be used to specify a single space, and the "All spaces" identifier (`'*'`) is not allowed. + * * For global object types (registered with `namespaceType: 'agnostic'`): this option cannot be used. */ initialNamespaces?: string[]; } diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 9e7721fde90e7d..fcecf39f7e53a9 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2901,7 +2901,7 @@ export class SavedObjectsRepository { resolve(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; update(type: string, id: string, attributes: Partial, options?: SavedObjectsUpdateOptions): Promise>; updateObjectsSpaces(objects: SavedObjectsUpdateObjectsSpacesObject[], spacesToAdd: string[], spacesToRemove: string[], options?: SavedObjectsUpdateObjectsSpacesOptions): Promise; -} + } // @public export interface SavedObjectsRepositoryFactory { diff --git a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts index b712c2882ee0f9..eb0c161049cf05 100644 --- a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts +++ b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts @@ -154,12 +154,14 @@ export const expectResponses = { // bulk request error expect(object.type).to.eql(type); expect(object.id).to.eql(id); - expect(object.error).to.eql(error.output.payload); + expect(object.error.error).to.eql(error.output.payload.error); + expect(object.error.statusCode).to.eql(error.output.payload.statusCode); + // ignore the error.message, because it can vary for decorated errors } else { // non-bulk request error expect(object.error).to.eql(error.output.payload.error); expect(object.statusCode).to.eql(error.output.payload.statusCode); - // ignore the error.message, because it can vary for decorated non-bulk errors (e.g., conflict) + // ignore the error.message, because it can vary for decorated errors } } else { // fall back to default behavior of testing the success outcome diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts index 5860ec1f193b27..06758da1ebad27 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts @@ -41,13 +41,25 @@ const EACH_SPACE = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; const NEW_SINGLE_NAMESPACE_OBJ = Object.freeze({ type: 'dashboard', id: 'new-dashboard-id' }); const NEW_MULTI_NAMESPACE_OBJ = Object.freeze({ type: 'sharedtype', id: 'new-sharedtype-id' }); -const NEW_EACH_SPACE_OBJ = Object.freeze({ +const INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE = Object.freeze({ + type: 'isolatedtype', + id: 'new-other-space-id', + expectedNamespaces: ['other-space'], // expected namespaces of resulting object + initialNamespaces: ['other-space'], // args passed to the bulkCreate method +}); +const INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE = Object.freeze({ + type: 'sharecapabletype', + id: 'new-other-space-id', + expectedNamespaces: ['other-space'], // expected namespaces of resulting object + initialNamespaces: ['other-space'], // args passed to the bulkCreate method +}); +const INITIAL_NS_MULTI_NAMESPACE_OBJ_EACH_SPACE = Object.freeze({ type: 'sharedtype', id: 'new-each-space-id', expectedNamespaces: EACH_SPACE, // expected namespaces of resulting object initialNamespaces: EACH_SPACE, // args passed to the bulkCreate method }); -const NEW_ALL_SPACES_OBJ = Object.freeze({ +const INITIAL_NS_MULTI_NAMESPACE_OBJ_ALL_SPACES = Object.freeze({ type: 'sharedtype', id: 'new-all-spaces-id', expectedNamespaces: [ALL_SPACES_ID], // expected namespaces of resulting object @@ -58,8 +70,10 @@ export const TEST_CASES: Record = Object.freeze({ ...CASES, NEW_SINGLE_NAMESPACE_OBJ, NEW_MULTI_NAMESPACE_OBJ, - NEW_EACH_SPACE_OBJ, - NEW_ALL_SPACES_OBJ, + INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, + INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, + INITIAL_NS_MULTI_NAMESPACE_OBJ_EACH_SPACE, + INITIAL_NS_MULTI_NAMESPACE_OBJ_ALL_SPACES, NEW_NAMESPACE_AGNOSTIC_OBJ, }); diff --git a/x-pack/test/saved_object_api_integration/common/suites/create.ts b/x-pack/test/saved_object_api_integration/common/suites/create.ts index ff2bfdefb4c087..298e1a98071754 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/create.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/create.ts @@ -41,13 +41,25 @@ const EACH_SPACE = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; // we could create six separate test cases to test every permutation, but there's no real value in doing so const NEW_SINGLE_NAMESPACE_OBJ = Object.freeze({ type: 'dashboard', id: '' }); const NEW_MULTI_NAMESPACE_OBJ = Object.freeze({ type: 'sharedtype', id: 'new-sharedtype-id' }); -const NEW_EACH_SPACE_OBJ = Object.freeze({ +const INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE = Object.freeze({ + type: 'isolatedtype', + id: 'new-other-space-id', + expectedNamespaces: ['other-space'], // expected namespaces of resulting object + initialNamespaces: ['other-space'], // args passed to the bulkCreate method +}); +const INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE = Object.freeze({ + type: 'sharecapabletype', + id: 'new-other-space-id', + expectedNamespaces: ['other-space'], // expected namespaces of resulting object + initialNamespaces: ['other-space'], // args passed to the bulkCreate method +}); +const INITIAL_NS_MULTI_NAMESPACE_OBJ_EACH_SPACE = Object.freeze({ type: 'sharedtype', id: 'new-each-space-id', expectedNamespaces: EACH_SPACE, // expected namespaces of resulting object initialNamespaces: EACH_SPACE, // args passed to the bulkCreate method }); -const NEW_ALL_SPACES_OBJ = Object.freeze({ +const INITIAL_NS_MULTI_NAMESPACE_OBJ_ALL_SPACES = Object.freeze({ type: 'sharedtype', id: 'new-all-spaces-id', expectedNamespaces: [ALL_SPACES_ID], // expected namespaces of resulting object @@ -58,8 +70,10 @@ export const TEST_CASES: Record = Object.freeze({ ...CASES, NEW_SINGLE_NAMESPACE_OBJ, NEW_MULTI_NAMESPACE_OBJ, - NEW_EACH_SPACE_OBJ, - NEW_ALL_SPACES_OBJ, + INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, + INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, + INITIAL_NS_MULTI_NAMESPACE_OBJ_EACH_SPACE, + INITIAL_NS_MULTI_NAMESPACE_OBJ_ALL_SPACES, NEW_NAMESPACE_AGNOSTIC_OBJ, }); diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts index 1fa24c6d6e2d6f..e048a4abc8ccc5 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { SPACES } from '../../common/lib/spaces'; +import { SPACES, ALL_SPACES_ID } from '../../common/lib/spaces'; import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -75,7 +75,22 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, ]; - const crossNamespace = [CASES.NEW_EACH_SPACE_OBJ, CASES.NEW_ALL_SPACES_OBJ]; + const crossNamespace = [ + { + ...CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, + initialNamespaces: ['x', 'y'], + ...fail400(), // cannot be created in multiple spaces + }, + CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid + { + ...CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, + initialNamespaces: [ALL_SPACES_ID], + ...fail400(), // cannot be created in multiple spaces + }, + CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid + CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_EACH_SPACE, + CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_ALL_SPACES, + ]; const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; const allTypes = normalTypes.concat(hiddenType); return { normalTypes, crossNamespace, hiddenType, allTypes }; diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts index 3553ae0e5b5387..8215c991a9287d 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { SPACES } from '../../common/lib/spaces'; +import { SPACES, ALL_SPACES_ID } from '../../common/lib/spaces'; import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -62,7 +62,22 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, ]; - const crossNamespace = [CASES.NEW_EACH_SPACE_OBJ, CASES.NEW_ALL_SPACES_OBJ]; + const crossNamespace = [ + { + ...CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, + initialNamespaces: ['x', 'y'], + ...fail400(), // cannot be created in multiple spaces + }, + CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid + { + ...CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, + initialNamespaces: [ALL_SPACES_ID], + ...fail400(), // cannot be created in multiple spaces + }, + CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid + CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_EACH_SPACE, + CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_ALL_SPACES, + ]; const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; const allTypes = normalTypes.concat(crossNamespace, hiddenType); return { normalTypes, crossNamespace, hiddenType, allTypes }; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts index 7487466f4b38c0..f9423d77c5bb56 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { SPACES } from '../../common/lib/spaces'; +import { SPACES, ALL_SPACES_ID } from '../../common/lib/spaces'; import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -39,8 +39,20 @@ const createTestCases = (overwrite: boolean) => { { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, - CASES.NEW_EACH_SPACE_OBJ, - CASES.NEW_ALL_SPACES_OBJ, + { + ...CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, + initialNamespaces: ['x', 'y'], + ...fail400(), // cannot be created in multiple spaces + }, + CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid + { + ...CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, + initialNamespaces: [ALL_SPACES_ID], + ...fail400(), // cannot be created in multiple spaces + }, + CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid + CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_EACH_SPACE, + CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_ALL_SPACES, ]; const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; const allTypes = normalTypes.concat(hiddenType); diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/create.ts b/x-pack/test/saved_object_api_integration/security_only/apis/create.ts index 7eda7f52834480..67195637f0c0ac 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/create.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { SPACES } from '../../common/lib/spaces'; +import { SPACES, ALL_SPACES_ID } from '../../common/lib/spaces'; import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -38,8 +38,20 @@ const createTestCases = (overwrite: boolean) => { { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, - CASES.NEW_EACH_SPACE_OBJ, - CASES.NEW_ALL_SPACES_OBJ, + { + ...CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, + initialNamespaces: ['x', 'y'], + ...fail400(), // cannot be created in multiple spaces + }, + CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid + { + ...CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, + initialNamespaces: [ALL_SPACES_ID], + ...fail400(), // cannot be created in multiple spaces + }, + CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid + CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_EACH_SPACE, + CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_ALL_SPACES, ]; const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; const allTypes = normalTypes.concat(hiddenType); diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts index 5812aaf43060d3..c448d73ce7bf83 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { SPACES } from '../../common/lib/spaces'; +import { SPACES, ALL_SPACES_ID } from '../../common/lib/spaces'; import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { bulkCreateTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/bulk_create'; @@ -70,8 +70,20 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, - CASES.NEW_EACH_SPACE_OBJ, - CASES.NEW_ALL_SPACES_OBJ, + { + ...CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, + initialNamespaces: ['x', 'y'], + ...fail400(), // cannot be created in multiple spaces + }, + CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid + { + ...CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, + initialNamespaces: [ALL_SPACES_ID], + ...fail400(), // cannot be created in multiple spaces + }, + CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid + CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_EACH_SPACE, + CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_ALL_SPACES, ]; }; diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts index 4c91781b6ab2c0..7c8726896c18a1 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { SPACES } from '../../common/lib/spaces'; +import { SPACES, ALL_SPACES_ID } from '../../common/lib/spaces'; import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { createTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/create'; @@ -57,8 +57,20 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, - CASES.NEW_EACH_SPACE_OBJ, - CASES.NEW_ALL_SPACES_OBJ, + { + ...CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, + initialNamespaces: ['x', 'y'], + ...fail400(), // cannot be created in multiple spaces + }, + CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid + { + ...CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, + initialNamespaces: [ALL_SPACES_ID], + ...fail400(), // cannot be created in multiple spaces + }, + CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid + CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_EACH_SPACE, + CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_ALL_SPACES, ]; }; From f422cbdcf17f5e598e715e88bd9bdd52d2f1d72b Mon Sep 17 00:00:00 2001 From: Constance Date: Tue, 22 Jun 2021 11:40:10 -0700 Subject: [PATCH 54/57] [App Search] Convert API Logs page to new page template + empty state polish (#102820) * Convert API Logs noItemsMessage to its own empty state prompt - Will be used by new page template * Convert API Logs view to new page template + use new empty state + add tests clarifying loading UX * Update router * Fix i18n ID --- .../components/api_logs/api_logs.test.tsx | 24 +++--- .../components/api_logs/api_logs.tsx | 73 ++++++++----------- .../components/api_logs_table.test.tsx | 10 +-- .../api_logs/components/api_logs_table.tsx | 20 ----- .../api_logs/components/empty_state.test.tsx | 27 +++++++ .../api_logs/components/empty_state.tsx | 45 ++++++++++++ .../components/api_logs/components/index.ts | 1 + .../components/engine/engine_router.tsx | 10 +-- 8 files changed, 124 insertions(+), 86 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.test.tsx index c2a11ec06fa6a4..5b082ce8d26ba5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.test.tsx @@ -13,10 +13,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiPageHeader } from '@elastic/eui'; - -import { Loading } from '../../../shared/loading'; -import { rerender } from '../../../test_helpers'; +import { rerender, getPageTitle } from '../../../test_helpers'; import { LogRetentionCallout, LogRetentionTooltip } from '../log_retention'; import { ApiLogsTable, NewApiEventsPrompt } from './components'; @@ -42,7 +39,7 @@ describe('ApiLogs', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find(EuiPageHeader).prop('pageTitle')).toEqual('API Logs'); + expect(getPageTitle(wrapper)).toEqual('API Logs'); expect(wrapper.find(ApiLogsTable)).toHaveLength(1); expect(wrapper.find(NewApiEventsPrompt)).toHaveLength(1); @@ -50,11 +47,20 @@ describe('ApiLogs', () => { expect(wrapper.find(LogRetentionTooltip).prop('type')).toEqual('api'); }); - it('renders a loading screen', () => { - setMockValues({ ...values, dataLoading: true, apiLogs: [] }); - const wrapper = shallow(); + describe('loading state', () => { + it('renders a full-page loading state on initial page load (no logs exist yet)', () => { + setMockValues({ ...values, dataLoading: true, apiLogs: [] }); + const wrapper = shallow(); + + expect(wrapper.prop('isLoading')).toEqual(true); + }); + + it('does not re-render a full-page loading state after initial page load (uses component-level loading state instead)', () => { + setMockValues({ ...values, dataLoading: true, apiLogs: [{}] }); + const wrapper = shallow(); - expect(wrapper.find(Loading)).toHaveLength(1); + expect(wrapper.prop('isLoading')).toEqual(false); + }); }); describe('effects', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.tsx index b8179163c93f94..d3eef77db21f04 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.tsx @@ -9,25 +9,14 @@ import React, { useEffect } from 'react'; import { useValues, useActions } from 'kea'; -import { - EuiPageHeader, - EuiTitle, - EuiPageContent, - EuiPageContentBody, - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, -} from '@elastic/eui'; - -import { FlashMessages } from '../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { Loading } from '../../../shared/loading'; +import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer } from '@elastic/eui'; import { getEngineBreadcrumbs } from '../engine'; +import { AppSearchPageTemplate } from '../layout'; import { LogRetentionCallout, LogRetentionTooltip, LogRetentionOptions } from '../log_retention'; import { ApiLogFlyout } from './api_log'; -import { ApiLogsTable, NewApiEventsPrompt } from './components'; +import { ApiLogsTable, NewApiEventsPrompt, EmptyState } from './components'; import { API_LOGS_TITLE, RECENT_API_EVENTS } from './constants'; import { ApiLogsLogic } from './'; @@ -44,38 +33,36 @@ export const ApiLogs: React.FC = () => { pollForApiLogs(); }, []); - if (dataLoading && !apiLogs.length) return ; - return ( - <> - - - - + } + > - - - - - -

    {RECENT_API_EVENTS}

    -
    -
    - - - - - - - -
    - + + + + +

    {RECENT_API_EVENTS}

    +
    +
    + + + + + + + +
    + - - -
    -
    - + + + +
    ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/api_logs_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/api_logs_table.test.tsx index 2a00cc6eb42bb3..82d3d4715cbc56 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/api_logs_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/api_logs_table.test.tsx @@ -18,7 +18,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiBasicTable, EuiBadge, EuiHealth, EuiButtonEmpty, EuiEmptyPrompt } from '@elastic/eui'; +import { EuiBasicTable, EuiBadge, EuiHealth, EuiButtonEmpty } from '@elastic/eui'; import { DEFAULT_META } from '../../../../shared/constants'; import { mountWithIntl } from '../../../../test_helpers'; @@ -91,14 +91,6 @@ describe('ApiLogsTable', () => { expect(actions.openFlyout).toHaveBeenCalled(); }); - it('renders an empty prompt if no items are passed', () => { - setMockValues({ ...values, apiLogs: [] }); - const wrapper = mountWithIntl(); - const promptContent = wrapper.find(EuiEmptyPrompt).text(); - - expect(promptContent).toContain('Perform your first API call'); - }); - describe('hasPagination', () => { it('does not render with pagination by default', () => { const wrapper = shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/api_logs_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/api_logs_table.tsx index bb1327ce2da30b..1b5a8084f5b594 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/api_logs_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/api_logs_table.tsx @@ -15,7 +15,6 @@ import { EuiBadge, EuiHealth, EuiButtonEmpty, - EuiEmptyPrompt, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedRelative } from '@kbn/i18n/react'; @@ -109,25 +108,6 @@ export const ApiLogsTable: React.FC = ({ hasPagination }) => { items={apiLogs} responsive loading={dataLoading} - noItemsMessage={ - - {i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.emptyTitle', { - defaultMessage: 'Perform your first API call', - })} - - } - body={ -

    - {i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.emptyDescription', { - defaultMessage: "Check back after you've performed some API calls.", - })} -

    - } - /> - } {...paginationProps} /> ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.test.tsx new file mode 100644 index 00000000000000..3ad22ceac5840d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.test.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiEmptyPrompt, EuiButton } from '@elastic/eui'; + +import { EmptyState } from './'; + +describe('EmptyState', () => { + it('renders', () => { + const wrapper = shallow() + .find(EuiEmptyPrompt) + .dive(); + + expect(wrapper.find('h2').text()).toEqual('Perform your first API call'); + expect(wrapper.find(EuiButton).prop('href')).toEqual( + expect.stringContaining('/api-reference.html') + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.tsx new file mode 100644 index 00000000000000..3f6f44adefc71d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { DOCS_PREFIX } from '../../../routes'; + +export const EmptyState: React.FC = () => ( + + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.emptyTitle', { + defaultMessage: 'Perform your first API call', + })} + + } + body={ +

    + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.emptyDescription', { + defaultMessage: "Check back after you've performed some API calls.", + })} +

    + } + actions={ + + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.empty.buttonLabel', { + defaultMessage: 'View the API reference', + })} + + } + /> +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/index.ts index c0edc51d062283..863216554a540b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/index.ts @@ -7,3 +7,4 @@ export { ApiLogsTable } from './api_logs_table'; export { NewApiEventsPrompt } from './new_api_events_prompt'; +export { EmptyState } from './empty_state'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx index 3e18c9e680de22..fc057858426d2f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx @@ -114,6 +114,11 @@ export const EngineRouter: React.FC = () => { )} + {canViewEngineApiLogs && ( + + + + )} {/* TODO: Remove layout once page template migration is over */} }> {canViewEngineSchema && ( @@ -141,11 +146,6 @@ export const EngineRouter: React.FC = () => { )} - {canViewEngineApiLogs && ( - - - - )} {canViewMetaEngineSourceEngines && ( From 2b0f1256ddd2e65db8f29c198000db4d7cb3af61 Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Tue, 22 Jun 2021 14:11:15 -0500 Subject: [PATCH 55/57] [canvas] New Home Page (#102446) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/canvas/i18n/components.ts | 221 ------- x-pack/plugins/canvas/i18n/errors.ts | 53 +- .../public/components/home/home.component.tsx | 67 +++ .../public/components/home/home.stories.tsx | 30 + .../canvas/public/components/home/home.tsx | 33 + .../public/components/home/hooks/index.ts | 15 + .../home/hooks/use_clone_workpad.ts | 60 ++ .../home/hooks/use_create_from_template.ts | 32 + .../home/hooks/use_create_workpad.ts | 46 ++ .../home/hooks/use_delete_workpad.ts | 63 ++ .../home/hooks/use_download_workpad.ts | 12 + .../home/hooks/use_find_templates.ts | 38 ++ .../components/home/hooks/use_find_workpad.ts | 57 ++ .../home/hooks/use_upload_workpad.ts | 100 ++++ .../index.js => home/index.ts} | 2 +- .../home/my_workpads/empty_prompt.stories.tsx | 19 + .../home/my_workpads/empty_prompt.tsx | 65 ++ .../components/home/my_workpads/index.ts | 10 + .../components/home/my_workpads/loading.tsx | 17 + .../my_workpads/my_workpads.component.tsx | 38 ++ .../home/my_workpads/my_workpads.stories.tsx | 56 ++ .../home/my_workpads/my_workpads.tsx | 42 ++ .../my_workpads/upload_dropzone.component.tsx | 30 + .../home/my_workpads/upload_dropzone.scss | 8 + .../home/my_workpads/upload_dropzone.tsx | 55 ++ .../my_workpads/workpad_import.component.tsx | 40 ++ .../home/my_workpads/workpad_import.tsx | 35 ++ .../my_workpads/workpad_table.component.tsx | 203 +++++++ .../my_workpads/workpad_table.stories.tsx | 83 +++ .../home/my_workpads/workpad_table.tsx | 38 ++ .../workpad_table_tools.component.tsx | 160 +++++ .../home/my_workpads/workpad_table_tools.tsx | 51 ++ .../home/workpad_create.component.tsx | 37 ++ .../public/components/home/workpad_create.tsx | 31 + .../home/workpad_templates/index.ts | 10 + .../workpad_templates.component.tsx | 157 +++++ .../workpad_templates.stories.tsx | 62 ++ .../workpad_templates/workpad_templates.tsx | 35 ++ .../home_app/home_app.component.tsx | 18 +- .../toolbar/__stories__/toolbar.stories.tsx | 2 - .../components/toolbar/toolbar.component.tsx | 36 +- .../components/workpad_loader/index.tsx | 173 ------ .../workpad_loader/upload_workpad.js | 52 -- .../workpad_loader/workpad_create.js | 31 - .../workpad_loader/workpad_dropzone/index.js | 31 - .../workpad_dropzone/workpad_dropzone.js | 31 - .../workpad_dropzone/workpad_dropzone.scss | 22 - .../workpad_loader/workpad_loader.js | 426 ------------- .../workpad_loader/workpad_loader.scss | 25 - .../workpad_loader/workpad_search.js | 44 -- .../workpad_manager/workpad_manager.js | 69 --- .../workpad_templates.stories.storyshot | 564 ------------------ .../examples/workpad_templates.stories.tsx | 45 -- .../components/workpad_templates/index.tsx | 86 --- .../workpad_templates/workpad_templates.tsx | 215 ------- .../canvas/public/lib/get_tags_filter.tsx | 39 -- .../plugins/canvas/public/services/index.ts | 2 +- .../canvas/public/services/stubs/platform.ts | 8 +- .../canvas/public/services/stubs/workpad.ts | 96 ++- .../plugins/canvas/public/services/workpad.ts | 21 +- x-pack/plugins/canvas/public/style/index.scss | 2 - .../canvas/storybook/decorators/index.ts | 3 +- .../storybook/decorators/redux_decorator.tsx | 2 +- .../decorators/services_decorator.tsx | 40 +- x-pack/plugins/canvas/storybook/index.ts | 5 + x-pack/plugins/canvas/storybook/main.ts | 5 + .../empty_prompt.stories.storyshot | 65 ++ .../canvas/storybook/storyshots.test.tsx | 7 +- .../translations/translations/ja-JP.json | 81 ++- .../translations/translations/zh-CN.json | 81 ++- x-pack/test/accessibility/apps/canvas.ts | 2 +- .../test/functional/apps/canvas/smoke_test.js | 2 +- .../functional/page_objects/canvas_page.ts | 2 +- 73 files changed, 2156 insertions(+), 2288 deletions(-) create mode 100644 x-pack/plugins/canvas/public/components/home/home.component.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/home.stories.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/home.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/hooks/index.ts create mode 100644 x-pack/plugins/canvas/public/components/home/hooks/use_clone_workpad.ts create mode 100644 x-pack/plugins/canvas/public/components/home/hooks/use_create_from_template.ts create mode 100644 x-pack/plugins/canvas/public/components/home/hooks/use_create_workpad.ts create mode 100644 x-pack/plugins/canvas/public/components/home/hooks/use_delete_workpad.ts create mode 100644 x-pack/plugins/canvas/public/components/home/hooks/use_download_workpad.ts create mode 100644 x-pack/plugins/canvas/public/components/home/hooks/use_find_templates.ts create mode 100644 x-pack/plugins/canvas/public/components/home/hooks/use_find_workpad.ts create mode 100644 x-pack/plugins/canvas/public/components/home/hooks/use_upload_workpad.ts rename x-pack/plugins/canvas/public/components/{workpad_manager/index.js => home/index.ts} (83%) create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/empty_prompt.stories.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/empty_prompt.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/index.ts create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/loading.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/my_workpads.component.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/my_workpads.stories.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/my_workpads.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/upload_dropzone.component.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/upload_dropzone.scss create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/upload_dropzone.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/workpad_import.component.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/workpad_import.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.component.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.stories.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.component.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/workpad_create.component.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/workpad_create.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/workpad_templates/index.ts create mode 100644 x-pack/plugins/canvas/public/components/home/workpad_templates/workpad_templates.component.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/workpad_templates/workpad_templates.stories.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/workpad_templates/workpad_templates.tsx delete mode 100644 x-pack/plugins/canvas/public/components/workpad_loader/index.tsx delete mode 100644 x-pack/plugins/canvas/public/components/workpad_loader/upload_workpad.js delete mode 100644 x-pack/plugins/canvas/public/components/workpad_loader/workpad_create.js delete mode 100644 x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/index.js delete mode 100644 x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/workpad_dropzone.js delete mode 100644 x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/workpad_dropzone.scss delete mode 100644 x-pack/plugins/canvas/public/components/workpad_loader/workpad_loader.js delete mode 100644 x-pack/plugins/canvas/public/components/workpad_loader/workpad_loader.scss delete mode 100644 x-pack/plugins/canvas/public/components/workpad_loader/workpad_search.js delete mode 100644 x-pack/plugins/canvas/public/components/workpad_manager/workpad_manager.js delete mode 100644 x-pack/plugins/canvas/public/components/workpad_templates/examples/__snapshots__/workpad_templates.stories.storyshot delete mode 100644 x-pack/plugins/canvas/public/components/workpad_templates/examples/workpad_templates.stories.tsx delete mode 100644 x-pack/plugins/canvas/public/components/workpad_templates/index.tsx delete mode 100644 x-pack/plugins/canvas/public/components/workpad_templates/workpad_templates.tsx delete mode 100644 x-pack/plugins/canvas/public/lib/get_tags_filter.tsx create mode 100644 x-pack/plugins/canvas/storybook/public/components/home/my_workpads/__snapshots__/empty_prompt.stories.storyshot diff --git a/x-pack/plugins/canvas/i18n/components.ts b/x-pack/plugins/canvas/i18n/components.ts index 7a23137e7ef60e..6f011bb73e3b0d 100644 --- a/x-pack/plugins/canvas/i18n/components.ts +++ b/x-pack/plugins/canvas/i18n/components.ts @@ -1166,12 +1166,6 @@ export const ComponentStrings = { description: 'This is referring to the dimensions of U.S. standard letter paper.', }), }, - WorkpadCreate: { - getWorkpadCreateButtonLabel: () => - i18n.translate('xpack.canvas.workpadCreate.createButtonLabel', { - defaultMessage: 'Create workpad', - }), - }, WorkpadHeader: { getAddElementButtonLabel: () => i18n.translate('xpack.canvas.workpadHeader.addElementButtonLabel', { @@ -1546,219 +1540,4 @@ export const ComponentStrings = { defaultMessage: 'Reset', }), }, - WorkpadLoader: { - getClonedWorkpadName: (workpadName: string) => - i18n.translate('xpack.canvas.workpadLoader.clonedWorkpadName', { - defaultMessage: 'Copy of {workpadName}', - values: { - workpadName, - }, - description: - 'This suffix is added to the end of the name of a cloned workpad to indicate that this ' + - 'new workpad is a copy of the original workpad. Example: "Copy of Sales Pitch"', - }), - getCloneToolTip: () => - i18n.translate('xpack.canvas.workpadLoader.cloneTooltip', { - defaultMessage: 'Clone workpad', - }), - getCreateWorkpadLoadingDescription: () => - i18n.translate('xpack.canvas.workpadLoader.createWorkpadLoadingDescription', { - defaultMessage: 'Creating workpad...', - description: - 'This message appears while the user is waiting for a new workpad to be created', - }), - getDeleteButtonAriaLabel: (numberOfWorkpads: number) => - i18n.translate('xpack.canvas.workpadLoader.deleteButtonAriaLabel', { - defaultMessage: 'Delete {numberOfWorkpads} workpads', - values: { - numberOfWorkpads, - }, - }), - getDeleteButtonLabel: (numberOfWorkpads: number) => - i18n.translate('xpack.canvas.workpadLoader.deleteButtonLabel', { - defaultMessage: 'Delete ({numberOfWorkpads})', - values: { - numberOfWorkpads, - }, - }), - getDeleteModalConfirmButtonLabel: () => - i18n.translate('xpack.canvas.workpadLoader.deleteModalConfirmButtonLabel', { - defaultMessage: 'Delete', - }), - getDeleteModalDescription: () => - i18n.translate('xpack.canvas.workpadLoader.deleteModalDescription', { - defaultMessage: `You can't recover deleted workpads.`, - }), - getDeleteMultipleWorkpadModalTitle: (numberOfWorkpads: string) => - i18n.translate('xpack.canvas.workpadLoader.deleteMultipleWorkpadsModalTitle', { - defaultMessage: 'Delete {numberOfWorkpads} workpads?', - values: { - numberOfWorkpads, - }, - }), - getDeleteSingleWorkpadModalTitle: (workpadName: string) => - i18n.translate('xpack.canvas.workpadLoader.deleteSingleWorkpadModalTitle', { - defaultMessage: `Delete workpad '{workpadName}'?`, - values: { - workpadName, - }, - }), - getEmptyPromptGettingStartedDescription: () => - i18n.translate('xpack.canvas.workpadLoader.emptyPromptGettingStartedDescription', { - defaultMessage: - 'Create a new workpad, start from a template, or import a workpad {JSON} file by dropping it here.', - values: { - JSON, - }, - }), - getEmptyPromptNewUserDescription: () => - i18n.translate('xpack.canvas.workpadLoader.emptyPromptNewUserDescription', { - defaultMessage: 'New to {CANVAS}?', - values: { - CANVAS, - }, - }), - getEmptyPromptTitle: () => - i18n.translate('xpack.canvas.workpadLoader.emptyPromptTitle', { - defaultMessage: 'Add your first workpad', - }), - getExportButtonAriaLabel: (numberOfWorkpads: number) => - i18n.translate('xpack.canvas.workpadLoader.exportButtonAriaLabel', { - defaultMessage: 'Export {numberOfWorkpads} workpads', - values: { - numberOfWorkpads, - }, - }), - getExportButtonLabel: (numberOfWorkpads: number) => - i18n.translate('xpack.canvas.workpadLoader.exportButtonLabel', { - defaultMessage: 'Export ({numberOfWorkpads})', - values: { - numberOfWorkpads, - }, - }), - getExportToolTip: () => - i18n.translate('xpack.canvas.workpadLoader.exportTooltip', { - defaultMessage: 'Export workpad', - }), - getFetchLoadingDescription: () => - i18n.translate('xpack.canvas.workpadLoader.fetchLoadingDescription', { - defaultMessage: 'Fetching workpads...', - description: - 'This message appears while the user is waiting for their list of workpads to load', - }), - getFilePickerPlaceholder: () => - i18n.translate('xpack.canvas.workpadLoader.filePickerPlaceholder', { - defaultMessage: 'Import workpad {JSON} file', - values: { - JSON, - }, - }), - getLoadWorkpadArialLabel: (workpadName: string) => - i18n.translate('xpack.canvas.workpadLoader.loadWorkpadArialLabel', { - defaultMessage: `Load workpad '{workpadName}'`, - values: { - workpadName, - }, - }), - getNoPermissionToCloneToolTip: () => - i18n.translate('xpack.canvas.workpadLoader.noPermissionToCloneToolTip', { - defaultMessage: `You don't have permission to clone workpads`, - }), - getNoPermissionToCreateToolTip: () => - i18n.translate('xpack.canvas.workpadLoader.noPermissionToCreateToolTip', { - defaultMessage: `You don't have permission to create workpads`, - }), - getNoPermissionToDeleteToolTip: () => - i18n.translate('xpack.canvas.workpadLoader.noPermissionToDeleteToolTip', { - defaultMessage: `You don't have permission to delete workpads`, - }), - getNoPermissionToUploadToolTip: () => - i18n.translate('xpack.canvas.workpadLoader.noPermissionToUploadToolTip', { - defaultMessage: `You don't have permission to upload workpads`, - }), - getSampleDataLinkLabel: () => - i18n.translate('xpack.canvas.workpadLoader.sampleDataLinkLabel', { - defaultMessage: 'Add your first workpad', - }), - getTableCreatedColumnTitle: () => - i18n.translate('xpack.canvas.workpadLoader.table.createdColumnTitle', { - defaultMessage: 'Created', - description: 'This column in the table contains the date/time the workpad was created.', - }), - getTableNameColumnTitle: () => - i18n.translate('xpack.canvas.workpadLoader.table.nameColumnTitle', { - defaultMessage: 'Workpad name', - }), - getTableUpdatedColumnTitle: () => - i18n.translate('xpack.canvas.workpadLoader.table.updatedColumnTitle', { - defaultMessage: 'Updated', - description: - 'This column in the table contains the date/time the workpad was last updated.', - }), - getTableActionsColumnTitle: () => - i18n.translate('xpack.canvas.workpadLoader.table.actionsColumnTitle', { - defaultMessage: 'Actions', - description: - 'This column in the table contains the actions that can be taken on a workpad.', - }), - }, - WorkpadManager: { - getModalTitle: () => - i18n.translate('xpack.canvas.workpadManager.modalTitle', { - defaultMessage: '{CANVAS} workpads', - values: { - CANVAS, - }, - }), - getMyWorkpadsTabLabel: () => - i18n.translate('xpack.canvas.workpadManager.myWorkpadsTabLabel', { - defaultMessage: 'My workpads', - }), - getWorkpadTemplatesTabLabel: () => - i18n.translate('xpack.canvas.workpadManager.workpadTemplatesTabLabel', { - defaultMessage: 'Templates', - description: 'The label for the tab that displays a list of designed workpad templates.', - }), - }, - WorkpadSearch: { - getWorkpadSearchPlaceholder: () => - i18n.translate('xpack.canvas.workpadSearch.searchPlaceholder', { - defaultMessage: 'Find workpad', - }), - }, - WorkpadTemplates: { - getCloneTemplateLinkAriaLabel: (templateName: string) => - i18n.translate('xpack.canvas.workpadTemplate.cloneTemplateLinkAriaLabel', { - defaultMessage: `Clone workpad template '{templateName}'`, - values: { - templateName, - }, - }), - getTableDescriptionColumnTitle: () => - i18n.translate('xpack.canvas.workpadTemplates.table.descriptionColumnTitle', { - defaultMessage: 'Description', - }), - getTableNameColumnTitle: () => - i18n.translate('xpack.canvas.workpadTemplates.table.nameColumnTitle', { - defaultMessage: 'Template name', - }), - getTableTagsColumnTitle: () => - i18n.translate('xpack.canvas.workpadTemplates.table.tagsColumnTitle', { - defaultMessage: 'Tags', - description: - 'This column contains relevant tags that indicate what type of template ' + - 'is displayed. For example: "report", "presentation", etc.', - }), - getTemplateSearchPlaceholder: () => - i18n.translate('xpack.canvas.workpadTemplate.searchPlaceholder', { - defaultMessage: 'Find template', - }), - getCreatingTemplateLabel: (templateName: string) => - i18n.translate('xpack.canvas.workpadTemplate.creatingTemplateLabel', { - defaultMessage: `Creating from template '{templateName}'`, - values: { - templateName, - }, - }), - }, }; diff --git a/x-pack/plugins/canvas/i18n/errors.ts b/x-pack/plugins/canvas/i18n/errors.ts index 09280451192345..a55762dce2d204 100644 --- a/x-pack/plugins/canvas/i18n/errors.ts +++ b/x-pack/plugins/canvas/i18n/errors.ts @@ -6,7 +6,6 @@ */ import { i18n } from '@kbn/i18n'; -import { CANVAS, JSON } from './constants'; export const ErrorStrings = { actionsElements: { @@ -93,54 +92,10 @@ export const ErrorStrings = { }, }), }, - WorkpadFileUpload: { - getAcceptJSONOnlyErrorMessage: () => - i18n.translate('xpack.canvas.error.workpadUpload.acceptJSONOnlyErrorMessage', { - defaultMessage: 'Only {JSON} files are accepted', - values: { - JSON, - }, - }), - getFileUploadFailureWithFileNameErrorMessage: (fileName: string) => - i18n.translate('xpack.canvas.errors.workpadUpload.fileUploadFileWithFileNameErrorMessage', { - defaultMessage: `Couldn't upload '{fileName}'`, - values: { - fileName, - }, - }), - getFileUploadFailureWithoutFileNameErrorMessage: () => - i18n.translate( - 'xpack.canvas.error.workpadUpload.fileUploadFailureWithoutFileNameErrorMessage', - { - defaultMessage: `Couldn't upload file`, - } - ), - getMissingPropertiesErrorMessage: () => - i18n.translate('xpack.canvas.error.workpadUpload.missingPropertiesErrorMessage', { - defaultMessage: - 'Some properties required for a {CANVAS} workpad are missing. Edit your {JSON} file to provide the correct property values, and try again.', - values: { - CANVAS, - JSON, - }, - }), - }, - WorkpadLoader: { - getCloneFailureErrorMessage: () => - i18n.translate('xpack.canvas.error.workpadLoader.cloneFailureErrorMessage', { - defaultMessage: `Couldn't clone workpad`, - }), - getDeleteFailureErrorMessage: () => - i18n.translate('xpack.canvas.error.workpadLoader.deleteFailureErrorMessage', { - defaultMessage: `Couldn't delete all workpads`, - }), - getFindFailureErrorMessage: () => - i18n.translate('xpack.canvas.error.workpadLoader.findFailureErrorMessage', { - defaultMessage: `Couldn't find workpad`, - }), - getUploadFailureErrorMessage: () => - i18n.translate('xpack.canvas.error.workpadLoader.uploadFailureErrorMessage', { - defaultMessage: `Couldn't upload workpad`, + WorkpadDropzone: { + getTooManyFilesErrorMessage: () => + i18n.translate('xpack.canvas.error.workpadDropzone.tooManyFilesErrorMessage', { + defaultMessage: 'One one file can be uploaded at a time', }), }, workpadRoutes: { diff --git a/x-pack/plugins/canvas/public/components/home/home.component.tsx b/x-pack/plugins/canvas/public/components/home/home.component.tsx new file mode 100644 index 00000000000000..96a773186da2b9 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/home.component.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { KibanaPageTemplate } from '../../../../../../src/plugins/kibana_react/public'; +import { withSuspense } from '../../../../../../src/plugins/presentation_util/public'; + +import { WorkpadCreate } from './workpad_create'; +import { LazyWorkpadTemplates } from './workpad_templates'; +import { LazyMyWorkpads } from './my_workpads'; + +export type HomePageTab = 'workpads' | 'templates'; + +export interface Props { + activeTab?: HomePageTab; +} + +const WorkpadTemplates = withSuspense(LazyWorkpadTemplates); +const MyWorkpads = withSuspense(LazyMyWorkpads); + +export const Home = ({ activeTab = 'workpads' }: Props) => { + const [tab, setTab] = useState(activeTab); + + return ( + ], + bottomBorder: true, + tabs: [ + { + label: strings.getMyWorkpadsTabLabel(), + id: 'myWorkpads', + isSelected: tab === 'workpads', + onClick: () => setTab('workpads'), + }, + { + label: strings.getWorkpadTemplatesTabLabel(), + id: 'workpadTemplates', + 'data-test-subj': 'workpadTemplates', + isSelected: tab === 'templates', + onClick: () => setTab('templates'), + }, + ], + }} + > + {tab === 'workpads' ? : } + + ); +}; + +const strings = { + getMyWorkpadsTabLabel: () => + i18n.translate('xpack.canvas.home.myWorkpadsTabLabel', { + defaultMessage: 'My workpads', + }), + getWorkpadTemplatesTabLabel: () => + i18n.translate('xpack.canvas.home.workpadTemplatesTabLabel', { + defaultMessage: 'Templates', + description: 'The label for the tab that displays a list of designed workpad templates.', + }), +}; diff --git a/x-pack/plugins/canvas/public/components/home/home.stories.tsx b/x-pack/plugins/canvas/public/components/home/home.stories.tsx new file mode 100644 index 00000000000000..186b916afa0032 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/home.stories.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { + reduxDecorator, + getAddonPanelParameters, + servicesContextDecorator, + getDisableStoryshotsParameter, +} from '../../../storybook'; + +import { Home } from './home.component'; + +export default { + title: 'Home/Home Page', + argTypes: {}, + decorators: [reduxDecorator()], + parameters: { ...getAddonPanelParameters(), ...getDisableStoryshotsParameter() }, +}; + +export const NoContent = () => ; +export const HasContent = () => ; + +NoContent.decorators = [servicesContextDecorator()]; +HasContent.decorators = [servicesContextDecorator({ findWorkpads: 5, findTemplates: true })]; diff --git a/x-pack/plugins/canvas/public/components/home/home.tsx b/x-pack/plugins/canvas/public/components/home/home.tsx new file mode 100644 index 00000000000000..6b356ada8681ec --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/home.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; + +import { getBaseBreadcrumb } from '../../lib/breadcrumbs'; +import { resetWorkpad } from '../../state/actions/workpad'; +import { Home as Component } from './home.component'; +import { usePlatformService } from '../../services'; + +export const Home = () => { + const { setBreadcrumbs } = usePlatformService(); + const [isMounted, setIsMounted] = useState(false); + const dispatch = useDispatch(); + + useEffect(() => { + if (!isMounted) { + dispatch(resetWorkpad()); + setIsMounted(true); + } + }, [dispatch, isMounted, setIsMounted]); + + useEffect(() => { + setBreadcrumbs([getBaseBreadcrumb()]); + }, [setBreadcrumbs]); + + return ; +}; diff --git a/x-pack/plugins/canvas/public/components/home/hooks/index.ts b/x-pack/plugins/canvas/public/components/home/hooks/index.ts new file mode 100644 index 00000000000000..91e52948a7ba6b --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/hooks/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { useCloneWorkpad } from './use_clone_workpad'; +export { useCreateWorkpad } from './use_create_workpad'; +export { useDeleteWorkpads } from './use_delete_workpad'; +export { useDownloadWorkpad } from './use_download_workpad'; +export { useFindTemplates, useFindTemplatesOnMount } from './use_find_templates'; +export { useFindWorkpads, useFindWorkpadsOnMount } from './use_find_workpad'; +export { useImportWorkpad } from './use_upload_workpad'; +export { useCreateFromTemplate } from './use_create_from_template'; diff --git a/x-pack/plugins/canvas/public/components/home/hooks/use_clone_workpad.ts b/x-pack/plugins/canvas/public/components/home/hooks/use_clone_workpad.ts new file mode 100644 index 00000000000000..001a711a58a726 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/hooks/use_clone_workpad.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback } from 'react'; +import { useHistory } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; + +import { useNotifyService, useWorkpadService } from '../../../services'; +import { getId } from '../../../lib/get_id'; + +export const useCloneWorkpad = () => { + const workpadService = useWorkpadService(); + const notifyService = useNotifyService(); + const history = useHistory(); + + return useCallback( + async (workpadId: string) => { + try { + let workpad = await workpadService.get(workpadId); + + workpad = { + ...workpad, + name: strings.getClonedWorkpadName(workpad.name), + id: getId('workpad'), + }; + + await workpadService.create(workpad); + + history.push(`/workpad/${workpad.id}/page/1`); + } catch (err) { + notifyService.error(err, { title: errors.getCloneFailureErrorMessage() }); + } + }, + [notifyService, workpadService, history] + ); +}; + +const strings = { + getClonedWorkpadName: (workpadName: string) => + i18n.translate('xpack.canvas.useCloneWorkpad.clonedWorkpadName', { + defaultMessage: 'Copy of {workpadName}', + values: { + workpadName, + }, + description: + 'This suffix is added to the end of the name of a cloned workpad to indicate that this ' + + 'new workpad is a copy of the original workpad. Example: "Copy of Sales Pitch"', + }), +}; + +const errors = { + getCloneFailureErrorMessage: () => + i18n.translate('xpack.canvas.error.useCloneWorkpad.cloneFailureErrorMessage', { + defaultMessage: `Couldn't clone workpad`, + }), +}; diff --git a/x-pack/plugins/canvas/public/components/home/hooks/use_create_from_template.ts b/x-pack/plugins/canvas/public/components/home/hooks/use_create_from_template.ts new file mode 100644 index 00000000000000..968f9398ba8577 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/hooks/use_create_from_template.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback } from 'react'; +import { useHistory } from 'react-router-dom'; + +import { CanvasTemplate } from '../../../../types'; +import { useNotifyService, useWorkpadService } from '../../../services'; + +export const useCreateFromTemplate = () => { + const workpadService = useWorkpadService(); + const notifyService = useNotifyService(); + const history = useHistory(); + + return useCallback( + async (template: CanvasTemplate) => { + try { + const result = await workpadService.createFromTemplate(template.id); + history.push(`/workpad/${result.id}/page/1`); + } catch (e) { + notifyService.error(e, { + title: `Couldn't create workpad from template`, + }); + } + }, + [workpadService, notifyService, history] + ); +}; diff --git a/x-pack/plugins/canvas/public/components/home/hooks/use_create_workpad.ts b/x-pack/plugins/canvas/public/components/home/hooks/use_create_workpad.ts new file mode 100644 index 00000000000000..eb87f4720deec9 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/hooks/use_create_workpad.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback } from 'react'; +import { useHistory } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; + +// @ts-expect-error +import { getDefaultWorkpad } from '../../../state/defaults'; +import { useNotifyService, useWorkpadService } from '../../../services'; + +import type { CanvasWorkpad } from '../../../../types'; + +export const useCreateWorkpad = () => { + const workpadService = useWorkpadService(); + const notifyService = useNotifyService(); + const history = useHistory(); + + return useCallback( + async (_workpad?: CanvasWorkpad | null) => { + const workpad = _workpad || (getDefaultWorkpad() as CanvasWorkpad); + + try { + await workpadService.create(workpad); + history.push(`/workpad/${workpad.id}/page/1`); + } catch (err) { + notifyService.error(err, { + title: errors.getUploadFailureErrorMessage(), + }); + } + return; + }, + [notifyService, history, workpadService] + ); +}; + +const errors = { + getUploadFailureErrorMessage: () => + i18n.translate('xpack.canvas.error.useCreateWorkpad.uploadFailureErrorMessage', { + defaultMessage: `Couldn't upload workpad`, + }), +}; diff --git a/x-pack/plugins/canvas/public/components/home/hooks/use_delete_workpad.ts b/x-pack/plugins/canvas/public/components/home/hooks/use_delete_workpad.ts new file mode 100644 index 00000000000000..722ddae7411c92 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/hooks/use_delete_workpad.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { useNotifyService, useWorkpadService } from '../../../services'; + +export const useDeleteWorkpads = () => { + const workpadService = useWorkpadService(); + const notifyService = useNotifyService(); + + return useCallback( + async (workpadIds: string[]) => { + const removedWorkpads = workpadIds.map(async (id) => { + try { + await workpadService.remove(id); + return { id, err: null }; + } catch (err) { + return { id, err }; + } + }); + + return Promise.all(removedWorkpads).then((results) => { + const [passes, errored] = results.reduce<[string[], string[]]>( + ([passesArr, errorsArr], result) => { + if (result.err) { + errorsArr.push(result.id); + } else { + passesArr.push(result.id); + } + + return [passesArr, errorsArr]; + }, + [[], []] + ); + + const removedIds = workpadIds.filter((id) => passes.includes(id)); + + if (errored.length > 0) { + notifyService.error(errors.getDeleteFailureErrorMessage()); + } + + return { + removedIds, + errored, + }; + }); + }, + [workpadService, notifyService] + ); +}; + +const errors = { + getDeleteFailureErrorMessage: () => + i18n.translate('xpack.canvas.error.useDeleteWorkpads.deleteFailureErrorMessage', { + defaultMessage: `Couldn't delete all workpads`, + }), +}; diff --git a/x-pack/plugins/canvas/public/components/home/hooks/use_download_workpad.ts b/x-pack/plugins/canvas/public/components/home/hooks/use_download_workpad.ts new file mode 100644 index 00000000000000..b875e08c2a230e --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/hooks/use_download_workpad.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback } from 'react'; +import { downloadWorkpad as downloadWorkpadFn } from '../../../lib/download_workpad'; + +export const useDownloadWorkpad = () => + useCallback((workpadId: string) => downloadWorkpadFn(workpadId), []); diff --git a/x-pack/plugins/canvas/public/components/home/hooks/use_find_templates.ts b/x-pack/plugins/canvas/public/components/home/hooks/use_find_templates.ts new file mode 100644 index 00000000000000..13ee289fe98676 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/hooks/use_find_templates.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useCallback } from 'react'; +import useMount from 'react-use/lib/useMount'; + +import { useWorkpadService } from '../../../services'; +import { TemplateFindResponse } from '../../../services/workpad'; + +const emptyResponse = { templates: [] }; + +export const useFindTemplates = () => { + const workpadService = useWorkpadService(); + return useCallback(async () => await workpadService.findTemplates(), [workpadService]); +}; + +export const useFindTemplatesOnMount = (): [boolean, TemplateFindResponse] => { + const [isMounted, setIsMounted] = useState(false); + const findTemplates = useFindTemplates(); + const [templateResponse, setTemplateResponse] = useState(emptyResponse); + + const fetchTemplates = useCallback(async () => { + const foundTemplates = await findTemplates(); + setTemplateResponse(foundTemplates || emptyResponse); + setIsMounted(true); + }, [findTemplates]); + + useMount(() => { + fetchTemplates(); + return () => setIsMounted(false); + }); + + return [isMounted, templateResponse]; +}; diff --git a/x-pack/plugins/canvas/public/components/home/hooks/use_find_workpad.ts b/x-pack/plugins/canvas/public/components/home/hooks/use_find_workpad.ts new file mode 100644 index 00000000000000..3f8b0e6f630f5a --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/hooks/use_find_workpad.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useCallback } from 'react'; +import useMount from 'react-use/lib/useMount'; +import { i18n } from '@kbn/i18n'; + +import { WorkpadFindResponse } from '../../../services/workpad'; + +import { useNotifyService, useWorkpadService } from '../../../services'; +const emptyResponse = { total: 0, workpads: [] }; + +export const useFindWorkpads = () => { + const workpadService = useWorkpadService(); + const notifyService = useNotifyService(); + + return useCallback( + async (text = '') => { + try { + return await workpadService.find(text); + } catch (err) { + notifyService.error(err, { title: errors.getFindFailureErrorMessage() }); + } + }, + [notifyService, workpadService] + ); +}; + +export const useFindWorkpadsOnMount = (): [boolean, WorkpadFindResponse] => { + const [isMounted, setIsMounted] = useState(false); + const findWorkpads = useFindWorkpads(); + const [workpadResponse, setWorkpadResponse] = useState(emptyResponse); + + const fetchWorkpads = useCallback(async () => { + const foundWorkpads = await findWorkpads(); + setWorkpadResponse(foundWorkpads || emptyResponse); + setIsMounted(true); + }, [findWorkpads]); + + useMount(() => { + fetchWorkpads(); + return () => setIsMounted(false); + }); + + return [isMounted, workpadResponse]; +}; + +const errors = { + getFindFailureErrorMessage: () => + i18n.translate('xpack.canvas.error.useFindWorkpads.findFailureErrorMessage', { + defaultMessage: `Couldn't find workpad`, + }), +}; diff --git a/x-pack/plugins/canvas/public/components/home/hooks/use_upload_workpad.ts b/x-pack/plugins/canvas/public/components/home/hooks/use_upload_workpad.ts new file mode 100644 index 00000000000000..7934a469bb7a2c --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/hooks/use_upload_workpad.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback } from 'react'; +import { get } from 'lodash'; +import { i18n } from '@kbn/i18n'; + +import { CANVAS, JSON as JSONString } from '../../../../i18n/constants'; +import { useNotifyService } from '../../../services'; +import { getId } from '../../../lib/get_id'; +import type { CanvasWorkpad } from '../../../../types'; + +export const useImportWorkpad = () => { + const notifyService = useNotifyService(); + + return useCallback( + (file?: File, onComplete: (workpad?: CanvasWorkpad) => void = () => {}) => { + if (!file) { + onComplete(); + return; + } + + if (get(file, 'type') !== 'application/json') { + notifyService.warning(errors.getAcceptJSONOnlyErrorMessage(), { + title: file.name + ? errors.getFileUploadFailureWithFileNameErrorMessage(file.name) + : errors.getFileUploadFailureWithoutFileNameErrorMessage(), + }); + onComplete(); + } + + // TODO: Clean up this file, this loading stuff can, and should be, abstracted + const reader = new FileReader(); + + // handle reading the uploaded file + reader.onload = () => { + try { + const workpad = JSON.parse(reader.result as string); // Type-casting because we catch below. + workpad.id = getId('workpad'); + + // sanity check for workpad object + if (!Array.isArray(workpad.pages) || workpad.pages.length === 0 || !workpad.assets) { + onComplete(); + throw new Error(errors.getMissingPropertiesErrorMessage()); + } + + onComplete(workpad); + } catch (e) { + notifyService.error(e, { + title: file.name + ? errors.getFileUploadFailureWithFileNameErrorMessage(file.name) + : errors.getFileUploadFailureWithoutFileNameErrorMessage(), + }); + onComplete(); + } + }; + + // read the uploaded file + reader.readAsText(file); + }, + [notifyService] + ); +}; + +const errors = { + getFileUploadFailureWithoutFileNameErrorMessage: () => + i18n.translate( + 'xpack.canvas.error.useImportWorkpad.fileUploadFailureWithoutFileNameErrorMessage', + { + defaultMessage: `Couldn't upload file`, + } + ), + getFileUploadFailureWithFileNameErrorMessage: (fileName: string) => + i18n.translate('xpack.canvas.errors.useImportWorkpad.fileUploadFileWithFileNameErrorMessage', { + defaultMessage: `Couldn't upload '{fileName}'`, + values: { + fileName, + }, + }), + getMissingPropertiesErrorMessage: () => + i18n.translate('xpack.canvas.error.useImportWorkpad.missingPropertiesErrorMessage', { + defaultMessage: + 'Some properties required for a {CANVAS} workpad are missing. Edit your {JSON} file to provide the correct property values, and try again.', + values: { + CANVAS, + JSON: JSONString, + }, + }), + getAcceptJSONOnlyErrorMessage: () => + i18n.translate('xpack.canvas.error.useImportWorkpad.acceptJSONOnlyErrorMessage', { + defaultMessage: 'Only {JSON} files are accepted', + values: { + JSON: JSONString, + }, + }), +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_manager/index.js b/x-pack/plugins/canvas/public/components/home/index.ts similarity index 83% rename from x-pack/plugins/canvas/public/components/workpad_manager/index.js rename to x-pack/plugins/canvas/public/components/home/index.ts index e1f5855e762af1..aeb62c3a8de78a 100644 --- a/x-pack/plugins/canvas/public/components/workpad_manager/index.js +++ b/x-pack/plugins/canvas/public/components/home/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { WorkpadManager } from './workpad_manager'; +export { Home } from './home'; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/empty_prompt.stories.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/empty_prompt.stories.tsx new file mode 100644 index 00000000000000..aef1b0625b5858 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/empty_prompt.stories.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { HomeEmptyPrompt } from './empty_prompt'; +import { getDisableStoryshotsParameter } from '../../../../storybook'; + +export default { + title: 'Home/Empty Prompt', + argTypes: {}, + parameters: { ...getDisableStoryshotsParameter() }, +}; + +export const EmptyPrompt = () => ; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/empty_prompt.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/empty_prompt.tsx new file mode 100644 index 00000000000000..797f50ac112d0c --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/empty_prompt.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Fragment } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiEmptyPrompt, EuiLink, EuiPanel, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { CANVAS, JSON } from '../../../../i18n/constants'; + +export const HomeEmptyPrompt = () => ( + + + + {strings.getEmptyPromptTitle()}} + titleSize="m" + body={ + +

    {strings.getEmptyPromptGettingStartedDescription()}

    +

    + {strings.getEmptyPromptNewUserDescription()}{' '} + + {strings.getSampleDataLinkLabel()} + + . +

    +
    + } + /> +
    +
    +
    +); + +const strings = { + getEmptyPromptGettingStartedDescription: () => + i18n.translate('xpack.canvas.homeEmptyPrompt.emptyPromptGettingStartedDescription', { + defaultMessage: + 'Create a new workpad, start from a template, or import a workpad {JSON} file by dropping it here.', + values: { + JSON, + }, + }), + getEmptyPromptNewUserDescription: () => + i18n.translate('xpack.canvas.homeEmptyPrompt.emptyPromptNewUserDescription', { + defaultMessage: 'New to {CANVAS}?', + values: { + CANVAS, + }, + }), + getEmptyPromptTitle: () => + i18n.translate('xpack.canvas.homeEmptyPrompt.emptyPromptTitle', { + defaultMessage: 'Add your first workpad', + }), + getSampleDataLinkLabel: () => + i18n.translate('xpack.canvas.homeEmptyPrompt.sampleDataLinkLabel', { + defaultMessage: 'Add your first workpad', + }), +}; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/index.ts b/x-pack/plugins/canvas/public/components/home/my_workpads/index.ts new file mode 100644 index 00000000000000..79b1519df90fe2 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +export const LazyMyWorkpads = React.lazy(() => import('./my_workpads')); diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/loading.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/loading.tsx new file mode 100644 index 00000000000000..28edfea7c36caf --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/loading.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +export const Loading = () => ( + + + + + +); diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/my_workpads.component.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/my_workpads.component.tsx new file mode 100644 index 00000000000000..d9e3f0e4e2c999 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/my_workpads.component.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { FoundWorkpad } from '../../../services/workpad'; +import { UploadDropzone } from './upload_dropzone'; +import { HomeEmptyPrompt } from './empty_prompt'; +import { WorkpadTable } from './workpad_table'; + +export interface Props { + workpads: FoundWorkpad[]; +} + +export const MyWorkpads = ({ workpads }: Props) => { + if (workpads.length === 0) { + return ( + + + + + + + + ); + } + + return ( + + + + ); +}; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/my_workpads.stories.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/my_workpads.stories.tsx new file mode 100644 index 00000000000000..0d5d6ca16f614d --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/my_workpads.stories.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { EuiPanel } from '@elastic/eui'; + +import { + reduxDecorator, + getAddonPanelParameters, + servicesContextDecorator, + getDisableStoryshotsParameter, +} from '../../../../storybook'; +import { getSomeWorkpads } from '../../../services/stubs/workpad'; + +import { MyWorkpads, WorkpadsContext } from './my_workpads'; +import { MyWorkpads as MyWorkpadsComponent } from './my_workpads.component'; + +export default { + title: 'Home/My Workpads', + argTypes: {}, + decorators: [reduxDecorator()], + parameters: { ...getAddonPanelParameters(), ...getDisableStoryshotsParameter() }, +}; + +export const NoWorkpads = () => { + return ; +}; + +export const HasWorkpads = () => { + return ( + + + + ); +}; + +NoWorkpads.decorators = [servicesContextDecorator()]; +HasWorkpads.decorators = [servicesContextDecorator({ findWorkpads: 5 })]; + +export const Component = ({ workpadCount }: { workpadCount: number }) => { + const [workpads, setWorkpads] = useState(getSomeWorkpads(workpadCount)); + + return ( + + + + + + ); +}; + +Component.args = { workpadCount: 5 }; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/my_workpads.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/my_workpads.tsx new file mode 100644 index 00000000000000..4242e2e9d130f7 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/my_workpads.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useEffect, createContext, Dispatch, SetStateAction } from 'react'; +import { useFindWorkpadsOnMount } from './../hooks'; +import { FoundWorkpad } from '../../../services/workpad'; +import { Loading } from './loading'; +import { MyWorkpads as Component } from './my_workpads.component'; + +interface Context { + workpads: FoundWorkpad[]; + setWorkpads: Dispatch>; +} + +export const WorkpadsContext = createContext(null); + +export const MyWorkpads = () => { + const [isMounted, workpadResponse] = useFindWorkpadsOnMount(); + const [workpads, setWorkpads] = useState(workpadResponse.workpads); + + useEffect(() => { + setWorkpads(workpadResponse.workpads); + }, [workpadResponse]); + + if (!isMounted) { + return ; + } + + return ( + + + + ); +}; + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default MyWorkpads; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/upload_dropzone.component.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/upload_dropzone.component.tsx new file mode 100644 index 00000000000000..603f4679a9e95f --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/upload_dropzone.component.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +// @ts-expect-error untyped library +import Dropzone from 'react-dropzone'; + +import './upload_dropzone.scss'; + +export interface Props { + disabled?: boolean; + onDrop?: (files: FileList) => void; +} + +export const UploadDropzone: FC = ({ onDrop = () => {}, disabled, children }) => { + return ( + + {children} + + ); +}; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/upload_dropzone.scss b/x-pack/plugins/canvas/public/components/home/my_workpads/upload_dropzone.scss new file mode 100644 index 00000000000000..e4ee284c72deeb --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/upload_dropzone.scss @@ -0,0 +1,8 @@ +.canvasWorkpad__dropzone { + border: 2px dashed transparent; +} + +.canvasWorkpad__dropzone--active { + background-color: $euiColorLightestShade; + border-color: $euiColorLightShade; +} diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/upload_dropzone.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/upload_dropzone.tsx new file mode 100644 index 00000000000000..8ee0ae108392e2 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/upload_dropzone.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useState } from 'react'; +// @ts-expect-error untyped library +import Dropzone from 'react-dropzone'; + +import { useNotifyService } from '../../../services'; +import { ErrorStrings } from '../../../../i18n'; +import { useImportWorkpad, useCreateWorkpad } from '../hooks'; +import { CanvasWorkpad } from '../../../../types'; + +import { UploadDropzone as Component } from './upload_dropzone.component'; + +const { WorkpadDropzone: errors } = ErrorStrings; + +export const UploadDropzone: FC = ({ children }) => { + const notify = useNotifyService(); + const uploadWorkpad = useImportWorkpad(); + const createWorkpad = useCreateWorkpad(); + const [isDisabled, setIsDisabled] = useState(false); + + const onComplete = async (workpad?: CanvasWorkpad) => { + if (!workpad) { + setIsDisabled(false); + return; + } + + await createWorkpad(workpad); + }; + + const onDrop = (files: FileList) => { + if (!files) { + return; + } + + if (files.length > 1) { + notify.warning(errors.getTooManyFilesErrorMessage()); + return; + } + + setIsDisabled(true); + uploadWorkpad(files[0], onComplete); + }; + + return ( + + {children} + + ); +}; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_import.component.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_import.component.tsx new file mode 100644 index 00000000000000..28e2aa0449d46d --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_import.component.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFilePicker, EuiFilePickerProps } from '@elastic/eui'; + +import { JSON } from '../../../../i18n/constants'; +export interface Props { + canUserWrite: boolean; + onImportWorkpad?: EuiFilePickerProps['onChange']; + uniqueKey?: string | number; +} + +export const WorkpadImport = ({ uniqueKey, canUserWrite, onImportWorkpad = () => {} }: Props) => ( + +); + +const strings = { + getFilePickerPlaceholder: () => + i18n.translate('xpack.canvas.workpadImport.filePickerPlaceholder', { + defaultMessage: 'Import workpad {JSON} file', + values: { + JSON, + }, + }), +}; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_import.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_import.tsx new file mode 100644 index 00000000000000..0f1ba621e14d72 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_import.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { useSelector } from 'react-redux'; + +import { canUserWrite as canUserWriteSelector } from '../../../state/selectors/app'; +import type { State } from '../../../../types'; + +import { useImportWorkpad } from '../hooks'; +import { WorkpadImport as Component, Props as ComponentProps } from './workpad_import.component'; + +type Props = Omit; + +export const WorkpadImport = (props: Props) => { + const importWorkpad = useImportWorkpad(); + const [uniqueKey, setUniqueKey] = useState(Date.now()); + + const { canUserWrite } = useSelector((state: State) => ({ + canUserWrite: canUserWriteSelector(state), + })); + + const onImportWorkpad: ComponentProps['onImportWorkpad'] = (files) => { + if (files) { + importWorkpad(files[0]); + } + setUniqueKey(Date.now()); + }; + + return ; +}; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.component.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.component.tsx new file mode 100644 index 00000000000000..5301a88844369e --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.component.tsx @@ -0,0 +1,203 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiInMemoryTable, + EuiInMemoryTableProps, + EuiTableActionsColumnType, + EuiBasicTableColumn, + EuiToolTip, + EuiButtonIcon, + EuiTableSelectionType, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import moment from 'moment'; + +import { RoutingLink } from '../../routing'; +import { FoundWorkpad } from '../../../services/workpad'; +import { WorkpadTableTools } from './workpad_table_tools'; +import { WorkpadImport } from './workpad_import'; + +export interface Props { + workpads: FoundWorkpad[]; + canUserWrite: boolean; + dateFormat: string; + onExportWorkpad: (ids: string) => void; + onCloneWorkpad: (id: string) => void; +} + +const getDisplayName = (name: string, workpadId: string, loadedWorkpadId?: string) => { + const workpadName = name.length ? {name} : {workpadId}; + return workpadId === loadedWorkpadId ? {workpadName} : workpadName; +}; + +export const WorkpadTable = ({ + workpads, + canUserWrite, + dateFormat, + onExportWorkpad: onExport, + onCloneWorkpad, +}: Props) => { + const [selectedIds, setSelectedIds] = useState([]); + const formatDate = (date: string) => date && moment(date).format(dateFormat); + + const selection: EuiTableSelectionType = { + onSelectionChange: (selectedWorkpads) => { + setSelectedIds(selectedWorkpads.map((workpad) => workpad.id).filter((id) => !!id)); + }, + }; + + const actions: EuiTableActionsColumnType['actions'] = [ + { + render: (workpad: FoundWorkpad) => ( + + + + onExport(workpad.id)} + aria-label={strings.getExportToolTip()} + /> + + + + + onCloneWorkpad(workpad.id)} + aria-label={strings.getCloneToolTip()} + disabled={!canUserWrite} + /> + + + + ), + }, + ]; + + const search: EuiInMemoryTableProps['search'] = { + toolsLeft: + selectedIds.length > 0 ? : undefined, + toolsRight: , + box: { + schema: true, + incremental: true, + placeholder: strings.getWorkpadSearchPlaceholder(), + 'data-test-subj': 'tableListSearchBox', + }, + }; + + const columns: Array> = [ + { + field: 'name', + name: strings.getTableNameColumnTitle(), + sortable: true, + dataType: 'string', + render: (name, workpad) => ( + + {getDisplayName(name, workpad.id)} + + ), + }, + { + field: '@created', + name: strings.getTableCreatedColumnTitle(), + sortable: true, + dataType: 'date', + width: '20%', + render: (date: string) => formatDate(date), + }, + { + field: '@timestamp', + name: strings.getTableUpdatedColumnTitle(), + sortable: true, + dataType: 'date', + width: '20%', + render: (date: string) => formatDate(date), + }, + { name: strings.getTableActionsColumnTitle(), actions, width: '100px' }, + ]; + + return ( + + ); +}; + +const strings = { + getCloneToolTip: () => + i18n.translate('xpack.canvas.workpadTable.cloneTooltip', { + defaultMessage: 'Clone workpad', + }), + getExportToolTip: () => + i18n.translate('xpack.canvas.workpadTable.exportTooltip', { + defaultMessage: 'Export workpad', + }), + getLoadWorkpadArialLabel: (workpadName: string) => + i18n.translate('xpack.canvas.workpadTable.loadWorkpadArialLabel', { + defaultMessage: `Load workpad '{workpadName}'`, + values: { + workpadName, + }, + }), + getNoPermissionToCloneToolTip: () => + i18n.translate('xpack.canvas.workpadTable.noPermissionToCloneToolTip', { + defaultMessage: `You don't have permission to clone workpads`, + }), + getNoWorkpadsFoundMessage: () => + i18n.translate('xpack.canvas.workpadTable.noWorkpadsFoundMessage', { + defaultMessage: 'No workpads matched your search.', + }), + getWorkpadSearchPlaceholder: () => + i18n.translate('xpack.canvas.workpadTable.searchPlaceholder', { + defaultMessage: 'Find workpad', + }), + getTableCreatedColumnTitle: () => + i18n.translate('xpack.canvas.workpadTable.table.createdColumnTitle', { + defaultMessage: 'Created', + description: 'This column in the table contains the date/time the workpad was created.', + }), + getTableNameColumnTitle: () => + i18n.translate('xpack.canvas.workpadTable.table.nameColumnTitle', { + defaultMessage: 'Workpad name', + }), + getTableUpdatedColumnTitle: () => + i18n.translate('xpack.canvas.workpadTable.table.updatedColumnTitle', { + defaultMessage: 'Updated', + description: 'This column in the table contains the date/time the workpad was last updated.', + }), + getTableActionsColumnTitle: () => + i18n.translate('xpack.canvas.workpadTable.table.actionsColumnTitle', { + defaultMessage: 'Actions', + description: 'This column in the table contains the actions that can be taken on a workpad.', + }), +}; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.stories.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.stories.tsx new file mode 100644 index 00000000000000..501a0a76a85893 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.stories.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useEffect } from 'react'; +import { EuiPanel } from '@elastic/eui'; + +import { action } from '@storybook/addon-actions'; +import { + reduxDecorator, + getAddonPanelParameters, + getDisableStoryshotsParameter, +} from '../../../../storybook'; +import { getSomeWorkpads } from '../../../services/stubs/workpad'; + +import { WorkpadTable } from './workpad_table'; +import { WorkpadTable as WorkpadTableComponent } from './workpad_table.component'; +import { WorkpadsContext } from './my_workpads'; + +export default { + title: 'Home/Workpad Table', + argTypes: {}, + decorators: [reduxDecorator()], + parameters: { ...getAddonPanelParameters(), ...getDisableStoryshotsParameter() }, +}; + +export const NoWorkpads = () => { + const [workpads, setWorkpads] = useState(getSomeWorkpads(0)); + + return ( + + + + + + ); +}; + +export const HasWorkpads = () => { + const [workpads, setWorkpads] = useState(getSomeWorkpads(5)); + + return ( + + + + + + ); +}; + +export const Component = ({ + workpadCount, + canUserWrite, + dateFormat, +}: { + workpadCount: number; + canUserWrite: boolean; + dateFormat: string; +}) => { + const [workpads, setWorkpads] = useState(getSomeWorkpads(workpadCount)); + + useEffect(() => { + setWorkpads(getSomeWorkpads(workpadCount)); + }, [workpadCount]); + + return ( + + + + + + ); +}; + +Component.args = { workpadCount: 5, canUserWrite: true, dateFormat: 'MMM D, YYYY @ HH:mm:ss.SSS' }; +Component.argTypes = {}; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.tsx new file mode 100644 index 00000000000000..e5d83039a87ebe --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useContext } from 'react'; +import { useSelector } from 'react-redux'; + +import { canUserWrite as canUserWriteSelector } from '../../../state/selectors/app'; +import type { State } from '../../../../types'; +import { usePlatformService } from '../../../services'; +import { useCloneWorkpad, useDownloadWorkpad } from '../hooks'; + +import { WorkpadTable as Component } from './workpad_table.component'; +import { WorkpadsContext } from './my_workpads'; + +export const WorkpadTable = () => { + const platformService = usePlatformService(); + const onCloneWorkpad = useCloneWorkpad(); + const onExportWorkpad = useDownloadWorkpad(); + const context = useContext(WorkpadsContext); + + const { canUserWrite } = useSelector((state: State) => ({ + canUserWrite: canUserWriteSelector(state), + })); + + if (!context) { + return null; + } + + const { workpads } = context; + + const dateFormat = platformService.getUISetting('dateFormat'); + + return ; +}; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.component.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.component.tsx new file mode 100644 index 00000000000000..ae6ff9c3cc9104 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.component.tsx @@ -0,0 +1,160 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, Fragment } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiButton, EuiToolTip, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; + +import { ConfirmModal } from '../../confirm_modal'; +import { FoundWorkpad } from '../../../services/workpad'; + +export interface Props { + workpads: FoundWorkpad[]; + canUserWrite: boolean; + selectedWorkpadIds: string[]; + onDeleteWorkpads: (ids: string[]) => void; + onExportWorkpads: (ids: string[]) => void; +} + +export const WorkpadTableTools = ({ + workpads, + canUserWrite, + selectedWorkpadIds, + onDeleteWorkpads, + onExportWorkpads, +}: Props) => { + const [isDeletePending, setIsDeletePending] = useState(false); + + const openRemoveConfirm = () => setIsDeletePending(true); + const closeRemoveConfirm = () => setIsDeletePending(false); + + let deleteButton = ( + + {strings.getDeleteButtonLabel(selectedWorkpadIds.length)} + + ); + + const downloadButton = ( + onExportWorkpads(selectedWorkpadIds)} + iconType="exportAction" + aria-label={strings.getExportButtonAriaLabel(selectedWorkpadIds.length)} + > + {strings.getExportButtonLabel(selectedWorkpadIds.length)} + + ); + + if (!canUserWrite) { + deleteButton = ( + {deleteButton} + ); + } + + const modalTitle = + selectedWorkpadIds.length === 1 + ? strings.getDeleteSingleWorkpadModalTitle( + workpads.find((workpad) => workpad.id === selectedWorkpadIds[0])?.name || '' + ) + : strings.getDeleteMultipleWorkpadModalTitle(selectedWorkpadIds.length + ''); + + const confirmModal = ( + { + onDeleteWorkpads(selectedWorkpadIds); + closeRemoveConfirm(); + }} + onCancel={closeRemoveConfirm} + /> + ); + + return ( + + + {downloadButton} + {deleteButton} + + {confirmModal} + + ); +}; + +const strings = { + getDeleteButtonAriaLabel: (numberOfWorkpads: number) => + i18n.translate('xpack.canvas.workpadTableTools.deleteButtonAriaLabel', { + defaultMessage: 'Delete {numberOfWorkpads} workpads', + values: { + numberOfWorkpads, + }, + }), + getDeleteButtonLabel: (numberOfWorkpads: number) => + i18n.translate('xpack.canvas.workpadTableTools.deleteButtonLabel', { + defaultMessage: 'Delete ({numberOfWorkpads})', + values: { + numberOfWorkpads, + }, + }), + getDeleteModalConfirmButtonLabel: () => + i18n.translate('xpack.canvas.workpadTableTools.deleteModalConfirmButtonLabel', { + defaultMessage: 'Delete', + }), + getDeleteModalDescription: () => + i18n.translate('xpack.canvas.workpadTableTools.deleteModalDescription', { + defaultMessage: `You can't recover deleted workpads.`, + }), + getDeleteMultipleWorkpadModalTitle: (numberOfWorkpads: string) => + i18n.translate('xpack.canvas.workpadTableTools.deleteMultipleWorkpadsModalTitle', { + defaultMessage: 'Delete {numberOfWorkpads} workpads?', + values: { + numberOfWorkpads, + }, + }), + getDeleteSingleWorkpadModalTitle: (workpadName: string) => + i18n.translate('xpack.canvas.workpadTableTools.deleteSingleWorkpadModalTitle', { + defaultMessage: `Delete workpad '{workpadName}'?`, + values: { + workpadName, + }, + }), + getExportButtonAriaLabel: (numberOfWorkpads: number) => + i18n.translate('xpack.canvas.workpadTableTools.exportButtonAriaLabel', { + defaultMessage: 'Export {numberOfWorkpads} workpads', + values: { + numberOfWorkpads, + }, + }), + getExportButtonLabel: (numberOfWorkpads: number) => + i18n.translate('xpack.canvas.workpadTableTools.exportButtonLabel', { + defaultMessage: 'Export ({numberOfWorkpads})', + values: { + numberOfWorkpads, + }, + }), + getNoPermissionToCreateToolTip: () => + i18n.translate('xpack.canvas.workpadTableTools.noPermissionToCreateToolTip', { + defaultMessage: `You don't have permission to create workpads`, + }), + getNoPermissionToDeleteToolTip: () => + i18n.translate('xpack.canvas.workpadTableTools.noPermissionToDeleteToolTip', { + defaultMessage: `You don't have permission to delete workpads`, + }), + getNoPermissionToUploadToolTip: () => + i18n.translate('xpack.canvas.workpadTableTools.noPermissionToUploadToolTip', { + defaultMessage: `You don't have permission to upload workpads`, + }), +}; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.tsx new file mode 100644 index 00000000000000..62d84adfc2649d --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useContext } from 'react'; +import { useSelector } from 'react-redux'; + +import { canUserWrite as canUserWriteSelector } from '../../../state/selectors/app'; +import type { State } from '../../../../types'; +import { useDeleteWorkpads, useDownloadWorkpad } from '../hooks'; + +import { + WorkpadTableTools as Component, + Props as ComponentProps, +} from './workpad_table_tools.component'; +import { WorkpadsContext } from './my_workpads'; + +export type Props = Pick; + +export const WorkpadTableTools = ({ selectedWorkpadIds }: Props) => { + const deleteWorkpads = useDeleteWorkpads(); + const downloadWorkpad = useDownloadWorkpad(); + const context = useContext(WorkpadsContext); + + const { canUserWrite } = useSelector((state: State) => ({ + canUserWrite: canUserWriteSelector(state), + })); + + if (context === null || selectedWorkpadIds.length <= 0) { + return null; + } + + const { workpads, setWorkpads } = context; + + const onExport = () => selectedWorkpadIds.map((id) => downloadWorkpad(id)); + const onDelete = async () => { + const { removedIds } = await deleteWorkpads(selectedWorkpadIds); + setWorkpads(workpads.filter((workpad) => !removedIds.includes(workpad.id))); + }; + + return ( + + ); +}; diff --git a/x-pack/plugins/canvas/public/components/home/workpad_create.component.tsx b/x-pack/plugins/canvas/public/components/home/workpad_create.component.tsx new file mode 100644 index 00000000000000..18bdb976831948 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/workpad_create.component.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiButton } from '@elastic/eui'; +import { EuiButtonPropsForButton } from '@elastic/eui/src/components/button/button'; + +export interface Props + extends Omit { + canUserWrite: boolean; +} + +export const WorkpadCreate = ({ canUserWrite, disabled, ...rest }: Props) => { + return ( + + {strings.getWorkpadCreateButtonLabel()} + + ); +}; + +const strings = { + getWorkpadCreateButtonLabel: () => + i18n.translate('xpack.canvas.workpadCreate.createButtonLabel', { + defaultMessage: 'Create workpad', + }), +}; diff --git a/x-pack/plugins/canvas/public/components/home/workpad_create.tsx b/x-pack/plugins/canvas/public/components/home/workpad_create.tsx new file mode 100644 index 00000000000000..adb73a6bb88967 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/workpad_create.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { useSelector } from 'react-redux'; + +import { canUserWrite as canUserWriteSelector } from '../../state/selectors/app'; +import type { State } from '../../../types'; + +import { useCreateWorkpad } from './hooks'; +import { WorkpadCreate as Component, Props as ComponentProps } from './workpad_create.component'; + +type Props = Omit; + +export const WorkpadCreate = (props: Props) => { + const createWorkpad = useCreateWorkpad(); + + const { canUserWrite } = useSelector((state: State) => ({ + canUserWrite: canUserWriteSelector(state), + })); + + const onClick: ComponentProps['onClick'] = async () => { + await createWorkpad(); + }; + + return ; +}; diff --git a/x-pack/plugins/canvas/public/components/home/workpad_templates/index.ts b/x-pack/plugins/canvas/public/components/home/workpad_templates/index.ts new file mode 100644 index 00000000000000..4c45dbff383778 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/workpad_templates/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +export const LazyWorkpadTemplates = React.lazy(() => import('./workpad_templates')); diff --git a/x-pack/plugins/canvas/public/components/home/workpad_templates/workpad_templates.component.tsx b/x-pack/plugins/canvas/public/components/home/workpad_templates/workpad_templates.component.tsx new file mode 100644 index 00000000000000..d974c70b05cf21 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/workpad_templates/workpad_templates.component.tsx @@ -0,0 +1,157 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { uniq } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { + EuiInMemoryTable, + EuiBasicTableColumn, + EuiButtonEmpty, + EuiSearchBarProps, + SearchFilterConfig, +} from '@elastic/eui'; + +import { CanvasTemplate } from '../../../../types'; +import { tagsRegistry } from '../../../lib/tags_registry'; +import { TagList } from '../../tag_list'; + +export interface Props { + templates: CanvasTemplate[]; + onCreateWorkpad: (template: CanvasTemplate) => void; +} + +export const WorkpadTemplates = ({ templates, onCreateWorkpad }: Props) => { + const columns: Array> = [ + { + field: 'name', + name: strings.getTableNameColumnTitle(), + sortable: true, + width: '30%', + dataType: 'string', + render: (name: string, template) => { + const templateName = name.length ? name : 'Unnamed Template'; + + return ( + onCreateWorkpad(template)} + aria-label={strings.getCloneTemplateLinkAriaLabel(templateName)} + type="button" + > + {templateName} + + ); + }, + }, + { + field: 'help', + name: strings.getTableDescriptionColumnTitle(), + sortable: false, + dataType: 'string', + width: '30%', + }, + { + field: 'tags', + name: strings.getTableTagsColumnTitle(), + sortable: false, + dataType: 'string', + width: '30%', + render: (tags: string[]) => , + }, + ]; + + let uniqueTagNames: string[] = []; + + templates.forEach((template) => { + const { tags } = template; + tags.forEach((tag) => uniqueTagNames.push(tag)); + uniqueTagNames = uniq(uniqueTagNames); + }); + + const uniqueTags = uniqueTagNames.map( + (name) => + tagsRegistry.get(name) || { + color: undefined, + name, + } + ); + + const filters: SearchFilterConfig[] = [ + { + type: 'field_value_selection', + field: 'tags', + name: 'Tags', + multiSelect: true, + options: uniqueTags.map((tag) => ({ + value: tag.name, + name: tag.name, + view: , + })), + }, + ]; + + const search: EuiSearchBarProps = { + box: { + incremental: true, + schema: true, + }, + filters, + }; + + return ( + + ); +}; + +const strings = { + getCloneTemplateLinkAriaLabel: (templateName: string) => + i18n.translate('xpack.canvas.workpadTemplates.cloneTemplateLinkAriaLabel', { + defaultMessage: `Clone workpad template '{templateName}'`, + values: { + templateName, + }, + }), + getTableDescriptionColumnTitle: () => + i18n.translate('xpack.canvas.workpadTemplates.table.descriptionColumnTitle', { + defaultMessage: 'Description', + }), + getTableNameColumnTitle: () => + i18n.translate('xpack.canvas.workpadTemplates.table.nameColumnTitle', { + defaultMessage: 'Template name', + }), + getTableTagsColumnTitle: () => + i18n.translate('xpack.canvas.workpadTemplates.table.tagsColumnTitle', { + defaultMessage: 'Tags', + description: + 'This column contains relevant tags that indicate what type of template ' + + 'is displayed. For example: "report", "presentation", etc.', + }), + getTemplateSearchPlaceholder: () => + i18n.translate('xpack.canvas.workpadTemplates.searchPlaceholder', { + defaultMessage: 'Find template', + }), + getCreatingTemplateLabel: (templateName: string) => + i18n.translate('xpack.canvas.workpadTemplates.creatingTemplateLabel', { + defaultMessage: `Creating from template '{templateName}'`, + values: { + templateName, + }, + }), +}; diff --git a/x-pack/plugins/canvas/public/components/home/workpad_templates/workpad_templates.stories.tsx b/x-pack/plugins/canvas/public/components/home/workpad_templates/workpad_templates.stories.tsx new file mode 100644 index 00000000000000..cb2b872ea15f96 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/workpad_templates/workpad_templates.stories.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiPanel } from '@elastic/eui'; +import { action } from '@storybook/addon-actions'; +import React from 'react'; + +import { + reduxDecorator, + getAddonPanelParameters, + servicesContextDecorator, + getDisableStoryshotsParameter, +} from '../../../../storybook'; +import { getSomeTemplates } from '../../../services/stubs/workpad'; + +import { WorkpadTemplates } from './workpad_templates'; +import { WorkpadTemplates as WorkpadTemplatesComponent } from './workpad_templates.component'; + +export default { + title: 'Home/Workpad Templates', + argTypes: {}, + decorators: [reduxDecorator()], + parameters: { ...getAddonPanelParameters(), ...getDisableStoryshotsParameter() }, +}; + +export const NoTemplates = () => { + return ( + + + + ); +}; + +export const HasTemplates = () => { + return ( + + + + ); +}; + +NoTemplates.decorators = [servicesContextDecorator()]; +HasTemplates.decorators = [servicesContextDecorator({ findTemplates: true })]; + +export const Component = ({ hasTemplates }: { hasTemplates: boolean }) => { + return ( + + + + ); +}; + +Component.args = { + hasTemplates: true, +}; diff --git a/x-pack/plugins/canvas/public/components/home/workpad_templates/workpad_templates.tsx b/x-pack/plugins/canvas/public/components/home/workpad_templates/workpad_templates.tsx new file mode 100644 index 00000000000000..352285e66424b7 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/workpad_templates/workpad_templates.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; + +import { useCreateFromTemplate, useFindTemplatesOnMount } from '../hooks'; + +import { WorkpadTemplates as Component } from './workpad_templates.component'; + +export const WorkpadTemplates = () => { + const [isMounted, templateResponse] = useFindTemplatesOnMount(); + const onCreateWorkpad = useCreateFromTemplate(); + + if (!isMounted) { + return ( + + + + + + ); + } + const { templates } = templateResponse; + + return ; +}; + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default WorkpadTemplates; diff --git a/x-pack/plugins/canvas/public/components/home_app/home_app.component.tsx b/x-pack/plugins/canvas/public/components/home_app/home_app.component.tsx index 712b06cb39299d..2e3e826cc32b5d 100644 --- a/x-pack/plugins/canvas/public/components/home_app/home_app.component.tsx +++ b/x-pack/plugins/canvas/public/components/home_app/home_app.component.tsx @@ -6,9 +6,7 @@ */ import React, { FC } from 'react'; -import { EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui'; -// @ts-expect-error untyped local -import { WorkpadManager } from '../workpad_manager'; +import { Home } from '../home'; // @ts-expect-error untyped local import { setDocTitle } from '../../lib/doc_title'; @@ -19,17 +17,5 @@ export interface Props { export const HomeApp: FC = ({ onLoad = () => {} }) => { onLoad(); setDocTitle('Canvas'); - return ( - - - - {}} /> - - - - ); + return ; }; diff --git a/x-pack/plugins/canvas/public/components/toolbar/__stories__/toolbar.stories.tsx b/x-pack/plugins/canvas/public/components/toolbar/__stories__/toolbar.stories.tsx index e4f297446701c9..bd47bb52e00308 100644 --- a/x-pack/plugins/canvas/public/components/toolbar/__stories__/toolbar.stories.tsx +++ b/x-pack/plugins/canvas/public/components/toolbar/__stories__/toolbar.stories.tsx @@ -18,7 +18,6 @@ storiesOf('components/Toolbar', module) isWriteable={true} selectedPageNumber={1} totalPages={1} - workpadId={'abc'} workpadName={'My Canvas Workpad'} /> )) @@ -28,7 +27,6 @@ storiesOf('components/Toolbar', module) selectedElement={getDefaultElement()} selectedPageNumber={1} totalPages={1} - workpadId={'abc'} workpadName={'My Canvas Workpad'} /> )); diff --git a/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx b/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx index baafbdafcc549c..9e89ad4c4f27b3 100644 --- a/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx +++ b/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx @@ -7,17 +7,8 @@ import React, { FC, useState, useContext, useEffect } from 'react'; import PropTypes from 'prop-types'; -import { - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiModal, - EuiModalFooter, - EuiButton, -} from '@elastic/eui'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -// @ts-expect-error untyped local -import { WorkpadManager } from '../workpad_manager'; import { PageManager } from '../page_manager'; import { Expression } from '../expression'; import { Tray } from './tray'; @@ -37,7 +28,6 @@ export interface Props { selectedElement?: CanvasElement; selectedPageNumber: number; totalPages: number; - workpadId: string; workpadName: string; } @@ -46,11 +36,9 @@ export const Toolbar: FC = ({ selectedElement, selectedPageNumber, totalPages, - workpadId, workpadName, }) => { const [activeTray, setActiveTray] = useState(null); - const [showWorkpadManager, setShowWorkpadManager] = useState(false); const { getUrl, previousPage } = useContext(WorkpadRoutingContext); // While the tray doesn't get activated if the workpad isn't writeable, @@ -75,20 +63,6 @@ export const Toolbar: FC = ({ } }; - const closeWorkpadManager = () => setShowWorkpadManager(false); - const openWorkpadManager = () => setShowWorkpadManager(true); - - const workpadManager = ( - - - - - {strings.getWorkpadManagerCloseButtonLabel()} - - - - ); - const trays = { pageManager: , expression: !elementIsSelected ? null : setActiveTray(null)} />, @@ -99,12 +73,6 @@ export const Toolbar: FC = ({ {activeTray !== null && setActiveTray(null)}>{trays[activeTray]}}
    - - openWorkpadManager()}> - {workpadName} - - - = ({ )}
    - {showWorkpadManager && workpadManager}
    ); }; @@ -153,6 +120,5 @@ Toolbar.propTypes = { selectedElement: PropTypes.object, selectedPageNumber: PropTypes.number.isRequired, totalPages: PropTypes.number.isRequired, - workpadId: PropTypes.string.isRequired, workpadName: PropTypes.string.isRequired, }; diff --git a/x-pack/plugins/canvas/public/components/workpad_loader/index.tsx b/x-pack/plugins/canvas/public/components/workpad_loader/index.tsx deleted file mode 100644 index 2afd5fe70abe12..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_loader/index.tsx +++ /dev/null @@ -1,173 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { FC, useState, useCallback } from 'react'; -import { useHistory } from 'react-router-dom'; -import { useSelector } from 'react-redux'; -import moment from 'moment'; -// @ts-expect-error -import { getDefaultWorkpad } from '../../state/defaults'; -import { canUserWrite as canUserWriteSelector } from '../../state/selectors/app'; -import { getWorkpad } from '../../state/selectors/workpad'; -import { getId } from '../../lib/get_id'; -import { downloadWorkpad } from '../../lib/download_workpad'; -import { ComponentStrings, ErrorStrings } from '../../../i18n'; -import { State, CanvasWorkpad } from '../../../types'; -import { useNotifyService, useWorkpadService, usePlatformService } from '../../services'; -// @ts-expect-error -import { WorkpadLoader as Component } from './workpad_loader'; - -const { WorkpadLoader: strings } = ComponentStrings; -const { WorkpadLoader: errors } = ErrorStrings; - -type WorkpadStatePromise = ReturnType['find']>; -type WorkpadState = WorkpadStatePromise extends PromiseLike ? U : never; - -export const WorkpadLoader: FC<{ onClose: () => void }> = ({ onClose }) => { - const fromState = useSelector((state: State) => ({ - workpadId: getWorkpad(state).id, - canUserWrite: canUserWriteSelector(state), - })); - - const [workpadsState, setWorkpadsState] = useState(null); - const workpadService = useWorkpadService(); - const notifyService = useNotifyService(); - const platformService = usePlatformService(); - const history = useHistory(); - - const createWorkpad = useCallback( - async (_workpad: CanvasWorkpad | null | undefined) => { - const workpad = _workpad || getDefaultWorkpad(); - if (workpad != null) { - try { - await workpadService.create(workpad); - history.push(`/workpad/${workpad.id}/page/1`); - } catch (err) { - notifyService.error(err, { - title: errors.getUploadFailureErrorMessage(), - }); - } - return; - } - }, - [workpadService, notifyService, history] - ); - - const findWorkpads = useCallback( - async (text) => { - try { - const fetchedWorkpads = await workpadService.find(text); - setWorkpadsState(fetchedWorkpads); - } catch (err) { - notifyService.error(err, { title: errors.getFindFailureErrorMessage() }); - } - }, - [notifyService, workpadService] - ); - - const onDownloadWorkpad = useCallback((workpadId: string) => downloadWorkpad(workpadId), []); - - const cloneWorkpad = useCallback( - async (workpadId: string) => { - try { - const workpad = await workpadService.get(workpadId); - workpad.name = strings.getClonedWorkpadName(workpad.name); - workpad.id = getId('workpad'); - await workpadService.create(workpad); - history.push(`/workpad/${workpad.id}/page/1`); - } catch (err) { - notifyService.error(err, { title: errors.getCloneFailureErrorMessage() }); - } - }, - [notifyService, workpadService, history] - ); - - const removeWorkpads = useCallback( - (workpadIds: string[]) => { - if (workpadsState === null) { - return; - } - - const removedWorkpads = workpadIds.map(async (id) => { - try { - await workpadService.remove(id); - return { id, err: null }; - } catch (err) { - return { id, err }; - } - }); - - return Promise.all(removedWorkpads).then((results) => { - let redirectHome = false; - - const [passes, errored] = results.reduce<[string[], string[]]>( - ([passesArr, errorsArr], result) => { - if (result.id === fromState.workpadId && !result.err) { - redirectHome = true; - } - - if (result.err) { - errorsArr.push(result.id); - } else { - passesArr.push(result.id); - } - - return [passesArr, errorsArr]; - }, - [[], []] - ); - - const remainingWorkpads = workpadsState.workpads.filter(({ id }) => !passes.includes(id)); - - const workpadState = { - total: remainingWorkpads.length, - workpads: remainingWorkpads, - }; - - if (errored.length > 0) { - notifyService.error(errors.getDeleteFailureErrorMessage()); - } - - setWorkpadsState(workpadState); - - if (redirectHome) { - history.push('/'); - } - - return errored; - }); - }, - [history, workpadService, fromState.workpadId, workpadsState, notifyService] - ); - - const formatDate = useCallback( - (date: any) => { - const dateFormat = platformService.getUISetting('dateFormat'); - return date && moment(date).format(dateFormat); - }, - [platformService] - ); - - const { workpadId, canUserWrite } = fromState; - - return ( - - ); -}; diff --git a/x-pack/plugins/canvas/public/components/workpad_loader/upload_workpad.js b/x-pack/plugins/canvas/public/components/workpad_loader/upload_workpad.js deleted file mode 100644 index 24a694268e4eef..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_loader/upload_workpad.js +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { get } from 'lodash'; -import { getId } from '../../lib/get_id'; -import { ErrorStrings } from '../../../i18n'; - -const { WorkpadFileUpload: errors } = ErrorStrings; - -export const uploadWorkpad = (file, onUpload, notify) => { - if (!file) { - return; - } - - if (get(file, 'type') !== 'application/json') { - return notify.warning(errors.getAcceptJSONOnlyErrorMessage(), { - title: file.name - ? errors.getFileUploadFailureWithFileNameErrorMessage(file.name) - : errors.getFileUploadFailureWithoutFileNameErrorMessage(), - }); - } - // TODO: Clean up this file, this loading stuff can, and should be, abstracted - const reader = new FileReader(); - - // handle reading the uploaded file - reader.onload = () => { - try { - const workpad = JSON.parse(reader.result); - workpad.id = getId('workpad'); - - // sanity check for workpad object - if (!Array.isArray(workpad.pages) || workpad.pages.length === 0 || !workpad.assets) { - throw new Error(errors.getMissingPropertiesErrorMessage()); - } - - onUpload(workpad); - } catch (e) { - notify.error(e, { - title: file.name - ? errors.getFileUploadFailureWithFileNameErrorMessage(file.name) - : errors.getFileUploadFailureWithoutFileNameErrorMessage(), - }); - } - }; - - // read the uploaded file - reader.readAsText(file); -}; diff --git a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_create.js b/x-pack/plugins/canvas/public/components/workpad_loader/workpad_create.js deleted file mode 100644 index 51733dad5b3773..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_create.js +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import PropTypes from 'prop-types'; -import { EuiButton } from '@elastic/eui'; -import { ComponentStrings } from '../../../i18n'; - -const { WorkpadCreate: strings } = ComponentStrings; - -export const WorkpadCreate = ({ createPending, onCreate, ...rest }) => ( - - {strings.getWorkpadCreateButtonLabel()} - -); - -WorkpadCreate.propTypes = { - onCreate: PropTypes.func.isRequired, - createPending: PropTypes.bool, -}; diff --git a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/index.js b/x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/index.js deleted file mode 100644 index 7c34837771c6f5..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/index.js +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import PropTypes from 'prop-types'; -import { compose, withHandlers } from 'recompose'; -import { uploadWorkpad } from '../upload_workpad'; -import { ErrorStrings } from '../../../../i18n'; -import { WorkpadDropzone as Component } from './workpad_dropzone'; - -const { WorkpadFileUpload: errors } = ErrorStrings; - -export const WorkpadDropzone = compose( - withHandlers(({ notify }) => ({ - onDropAccepted: ({ onUpload }) => ([file]) => uploadWorkpad(file, onUpload), - onDropRejected: () => ([file]) => { - notify.warning(errors.getAcceptJSONOnlyErrorMessage(), { - title: file.name - ? errors.getFileUploadFailureWithFileNameErrorMessage(file.name) - : errors.getFileUploadFailureWithoutFileNameErrorMessage(), - }); - }, - })) -)(Component); - -WorkpadDropzone.propTypes = { - onUpload: PropTypes.func.isRequired, -}; diff --git a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/workpad_dropzone.js b/x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/workpad_dropzone.js deleted file mode 100644 index f77929e1feb761..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/workpad_dropzone.js +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import PropTypes from 'prop-types'; -import Dropzone from 'react-dropzone'; - -export const WorkpadDropzone = ({ onDropAccepted, onDropRejected, disabled, children }) => ( - - {children} - -); - -WorkpadDropzone.propTypes = { - onDropAccepted: PropTypes.func.isRequired, - onDropRejected: PropTypes.func.isRequired, - disabled: PropTypes.bool.isRequired, - children: PropTypes.node.isRequired, -}; diff --git a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/workpad_dropzone.scss b/x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/workpad_dropzone.scss deleted file mode 100644 index ac6838da97fbd1..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/workpad_dropzone.scss +++ /dev/null @@ -1,22 +0,0 @@ -.canvasWorkpad__dropzone { - border: 2px dashed transparent; -} - -.canvasWorkpad__dropzone--active { - background-color: $euiColorLightestShade; - border-color: $euiColorLightShade; -} - -.canvasWorkpad__dropzoneTable .euiTable { - background-color: transparent; -} - -.canvasWorkpad__dropzoneTable--tags { - .euiTableCellContent { - flex-wrap: wrap; - } - - .euiHealth { - width: 100%; - } -} \ No newline at end of file diff --git a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_loader.js b/x-pack/plugins/canvas/public/components/workpad_loader/workpad_loader.js deleted file mode 100644 index 9c232ab43ec8d0..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_loader.js +++ /dev/null @@ -1,426 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { Fragment } from 'react'; -import PropTypes from 'prop-types'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiBasicTable, - EuiButtonIcon, - EuiPagination, - EuiSpacer, - EuiButton, - EuiToolTip, - EuiEmptyPrompt, - EuiFilePicker, - EuiLink, -} from '@elastic/eui'; -import { orderBy } from 'lodash'; -import { ConfirmModal } from '../confirm_modal'; -import { RoutingLink } from '../routing'; -import { Paginate } from '../paginate'; -import { ComponentStrings } from '../../../i18n'; -import { WorkpadDropzone } from './workpad_dropzone'; -import { WorkpadCreate } from './workpad_create'; -import { WorkpadSearch } from './workpad_search'; -import { uploadWorkpad } from './upload_workpad'; - -const { WorkpadLoader: strings } = ComponentStrings; - -const getDisplayName = (name, workpad, loadedWorkpad) => { - const workpadName = name.length ? name : {workpad.id}; - return workpad.id === loadedWorkpad ? {workpadName} : workpadName; -}; - -export class WorkpadLoader extends React.PureComponent { - static propTypes = { - workpadId: PropTypes.string.isRequired, - canUserWrite: PropTypes.bool.isRequired, - createWorkpad: PropTypes.func.isRequired, - findWorkpads: PropTypes.func.isRequired, - downloadWorkpad: PropTypes.func.isRequired, - cloneWorkpad: PropTypes.func.isRequired, - removeWorkpads: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, - workpads: PropTypes.object, - formatDate: PropTypes.func.isRequired, - }; - - state = { - createPending: false, - deletingWorkpad: false, - sortField: '@timestamp', - sortDirection: 'desc', - selectedWorkpads: [], - pageSize: 10, - }; - - async componentDidMount() { - // on component load, kick off the workpad search - this.props.findWorkpads(); - - // keep track of whether or not the component is mounted, to prevent rogue setState calls - this._isMounted = true; - } - - UNSAFE_componentWillReceiveProps(newProps) { - // the workpadId prop will change when a is created or loaded, close the toolbar when it does - const { workpadId, onClose } = this.props; - if (workpadId !== newProps.workpadId) { - onClose(); - } - } - - componentWillUnmount() { - this._isMounted = false; - } - - // create new empty workpad - createWorkpad = async () => { - this.setState({ createPending: true }); - await this.props.createWorkpad(); - this._isMounted && this.setState({ createPending: false }); - }; - - // create new workpad from uploaded JSON - onUpload = async (workpad) => { - this.setState({ createPending: true }); - await this.props.createWorkpad(workpad); - this._isMounted && this.setState({ createPending: false }); - }; - - // clone existing workpad - cloneWorkpad = async (workpad) => { - this.setState({ createPending: true }); - await this.props.cloneWorkpad(workpad.id); - this._isMounted && this.setState({ createPending: false }); - }; - - // Workpad remove methods - openRemoveConfirm = () => this.setState({ deletingWorkpad: true }); - - closeRemoveConfirm = () => this.setState({ deletingWorkpad: false }); - - removeWorkpads = () => { - const { selectedWorkpads } = this.state; - - this.props.removeWorkpads(selectedWorkpads.map(({ id }) => id)).then((remainingIds) => { - const remainingWorkpads = - remainingIds.length > 0 - ? selectedWorkpads.filter(({ id }) => remainingIds.includes(id)) - : []; - - this._isMounted && - this.setState({ - deletingWorkpad: false, - selectedWorkpads: remainingWorkpads, - }); - }); - }; - - // downloads selected workpads as JSON files - downloadWorkpads = () => { - this.state.selectedWorkpads.forEach(({ id }) => this.props.downloadWorkpad(id)); - }; - - onSelectionChange = (selectedWorkpads) => { - this.setState({ selectedWorkpads }); - }; - - onTableChange = ({ sort = {} }) => { - const { field: sortField, direction: sortDirection } = sort; - this.setState({ - sortField, - sortDirection, - }); - }; - - renderWorkpadTable = ({ rows, pageNumber, totalPages, setPage }) => { - const { sortField, sortDirection } = this.state; - const { canUserWrite, createPending, workpadId: loadedWorkpad } = this.props; - - const actions = [ - { - render: (workpad) => ( - - - - this.props.downloadWorkpad(workpad.id)} - aria-label={strings.getExportToolTip()} - /> - - - - - this.cloneWorkpad(workpad)} - aria-label={strings.getCloneToolTip()} - disabled={!canUserWrite} - /> - - - - ), - }, - ]; - - const columns = [ - { - field: 'name', - name: strings.getTableNameColumnTitle(), - sortable: true, - dataType: 'string', - render: (name, workpad) => { - const workpadName = getDisplayName(name, workpad, loadedWorkpad); - - return ( - - {workpadName} - - ); - }, - }, - { - field: '@created', - name: strings.getTableCreatedColumnTitle(), - sortable: true, - dataType: 'date', - width: '20%', - render: (date) => this.props.formatDate(date), - }, - { - field: '@timestamp', - name: strings.getTableUpdatedColumnTitle(), - sortable: true, - dataType: 'date', - width: '20%', - render: (date) => this.props.formatDate(date), - }, - { name: strings.getTableActionsColumnTitle(), actions, width: '100px' }, - ]; - - const sorting = { - sort: { - field: sortField, - direction: sortDirection, - }, - }; - - const selection = { - itemId: 'id', - onSelectionChange: this.onSelectionChange, - }; - - const emptyTable = ( - {strings.getEmptyPromptTitle()}} - titleSize="s" - body={ - -

    {strings.getEmptyPromptGettingStartedDescription()}

    -

    - {strings.getEmptyPromptNewUserDescription()}{' '} - - {strings.getSampleDataLinkLabel()} - - . -

    -
    - } - /> - ); - - return ( - - - - - {rows.length > 0 && ( - - - - - - )} - - - ); - }; - - render() { - const { - deletingWorkpad, - createPending, - selectedWorkpads, - sortField, - sortDirection, - } = this.state; - const { canUserWrite } = this.props; - const isLoading = this.props.workpads == null; - - let createButton = ( - - ); - - let deleteButton = ( - - {strings.getDeleteButtonLabel(selectedWorkpads.length)} - - ); - - const downloadButton = ( - - {strings.getExportButtonLabel(selectedWorkpads.length)} - - ); - - let uploadButton = ( - uploadWorkpad(file, this.onUpload, this.props.notify)} - accept="application/json" - disabled={createPending || !canUserWrite} - /> - ); - - if (!canUserWrite) { - createButton = ( - {createButton} - ); - deleteButton = ( - {deleteButton} - ); - uploadButton = ( - {uploadButton} - ); - } - - const modalTitle = - selectedWorkpads.length === 1 - ? strings.getDeleteSingleWorkpadModalTitle(selectedWorkpads[0].name) - : strings.getDeleteMultipleWorkpadModalTitle(selectedWorkpads.length); - - const confirmModal = ( - - ); - - let sortedWorkpads = []; - - if (!createPending && !isLoading) { - const { workpads } = this.props.workpads; - sortedWorkpads = orderBy(workpads, [sortField, '@timestamp'], [sortDirection, 'desc']); - } - - return ( - - {(pagination) => ( - - - - - {selectedWorkpads.length > 0 && ( - - {downloadButton} - {deleteButton} - - )} - - { - pagination.setPage(0); - this.props.findWorkpads(text); - }} - /> - - - - - - {uploadButton} - {createButton} - - - - - - - {createPending && ( -
    {strings.getCreateWorkpadLoadingDescription()}
    - )} - - {!createPending && isLoading && ( -
    {strings.getFetchLoadingDescription()}
    - )} - - {!createPending && !isLoading && this.renderWorkpadTable(pagination)} - - {confirmModal} -
    - )} -
    - ); - } -} diff --git a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_loader.scss b/x-pack/plugins/canvas/public/components/workpad_loader/workpad_loader.scss deleted file mode 100644 index 3b2c8eae9e5420..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_loader.scss +++ /dev/null @@ -1,25 +0,0 @@ -.canvasWorkpad__upload--compressed { - - &.euiFilePicker--compressed.euiFilePicker { - .euiFilePicker__prompt { - height: $euiSizeXXL; - padding: $euiSizeM; - padding-left: $euiSizeXXL; - } - - .euiFilePicker__icon { - top: $euiSizeM; - } - } - - // The file picker input is being used moreso as a button, outside of a form, - // and thus the need to override the default max-width of form inputs. - // An issue has been opened in EUI to consider creating a button - // version of the file picker - https://github.com/elastic/eui/issues/1987 - - .euiFilePicker__wrap { - @include euiBreakpoint('xs', 's') { - max-width: none; - } - } -} \ No newline at end of file diff --git a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_search.js b/x-pack/plugins/canvas/public/components/workpad_loader/workpad_search.js deleted file mode 100644 index 8bf8bbae8ced40..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_search.js +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import PropTypes from 'prop-types'; -import { EuiFieldSearch } from '@elastic/eui'; -import { debounce } from 'lodash'; -import { ComponentStrings } from '../../../i18n'; - -const { WorkpadSearch: strings } = ComponentStrings; -export class WorkpadSearch extends React.PureComponent { - static propTypes = { - onChange: PropTypes.func.isRequired, - initialText: PropTypes.string, - }; - - state = { - searchText: this.props.initialText || '', - }; - - triggerChange = debounce(this.props.onChange, 150); - - setSearchText = (ev) => { - const text = ev.target.value; - this.setState({ searchText: text }); - this.triggerChange(text); - }; - - render() { - return ( - - ); - } -} diff --git a/x-pack/plugins/canvas/public/components/workpad_manager/workpad_manager.js b/x-pack/plugins/canvas/public/components/workpad_manager/workpad_manager.js deleted file mode 100644 index 8055be32ac481a..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_manager/workpad_manager.js +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { Fragment } from 'react'; -import PropTypes from 'prop-types'; -import { - EuiTabbedContent, - EuiModalHeader, - EuiModalHeaderTitle, - EuiModalBody, - EuiSpacer, - EuiFlexGroup, - EuiFlexItem, -} from '@elastic/eui'; -import { WorkpadLoader } from '../workpad_loader'; -import { WorkpadTemplates } from '../workpad_templates'; -import { ComponentStrings } from '../../../i18n'; - -const { WorkpadManager: strings } = ComponentStrings; - -export const WorkpadManager = ({ onClose }) => { - const tabs = [ - { - id: 'workpadLoader', - name: strings.getMyWorkpadsTabLabel(), - content: ( - - - - - ), - }, - { - id: 'workpadTemplates', - name: strings.getWorkpadTemplatesTabLabel(), - 'data-test-subj': 'workpadTemplates', - content: ( - - - - - ), - }, - ]; - return ( - - - - - -

    {strings.getModalTitle()}

    -
    -
    -
    -
    - - - -
    - ); -}; - -WorkpadManager.propTypes = { - onClose: PropTypes.func, -}; diff --git a/x-pack/plugins/canvas/public/components/workpad_templates/examples/__snapshots__/workpad_templates.stories.storyshot b/x-pack/plugins/canvas/public/components/workpad_templates/examples/__snapshots__/workpad_templates.stories.storyshot deleted file mode 100644 index cab6e8fd9b5f53..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_templates/examples/__snapshots__/workpad_templates.stories.storyshot +++ /dev/null @@ -1,564 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Storyshots components/WorkpadTemplates default 1`] = ` -
    -
    -
    -
    -
    - -
    - - -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    - - - - - - - - - - - - - - - - - - - - -
    -
    - - - - - Description - - - - - - Tags - - -
    -
    - Template name -
    -
    - -
    -
    -
    - Description -
    -
    - - This is a test template - -
    -
    -
    - Tags -
    -
    -
    -
    -
    - -
    -
    - tag1 -
    -
    -
    -
    -
    -
    - -
    -
    - tag2 -
    -
    -
    -
    -
    -
    - Template name -
    -
    - -
    -
    -
    - Description -
    -
    - - This is a second test template - -
    -
    -
    - Tags -
    -
    -
    -
    -
    - -
    -
    - tag2 -
    -
    -
    -
    -
    -
    - -
    -
    - tag3 -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -`; diff --git a/x-pack/plugins/canvas/public/components/workpad_templates/examples/workpad_templates.stories.tsx b/x-pack/plugins/canvas/public/components/workpad_templates/examples/workpad_templates.stories.tsx deleted file mode 100644 index 8e6c055478ca2b..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_templates/examples/workpad_templates.stories.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { storiesOf } from '@storybook/react'; -import { action } from '@storybook/addon-actions'; -import { WorkpadTemplates } from '../workpad_templates'; -import { CanvasTemplate } from '../../../../types'; - -const templates: Record = { - test1: { - id: 'test1-id', - name: 'test1', - help: 'This is a test template', - tags: ['tag1', 'tag2'], - template_key: 'test1-key', - }, - test2: { - id: 'test2-id', - name: 'test2', - help: 'This is a second test template', - tags: ['tag2', 'tag3'], - template_key: 'test2-key', - }, -}; - -storiesOf('components/WorkpadTemplates', module) - .addDecorator((story) =>
    {story()}
    ) - .add('default', () => { - const onCreateFromTemplateAction = action('onCreateFromTemplate'); - return ( - { - onCreateFromTemplateAction(template); - return Promise.resolve(); - }} - /> - ); - }); diff --git a/x-pack/plugins/canvas/public/components/workpad_templates/index.tsx b/x-pack/plugins/canvas/public/components/workpad_templates/index.tsx deleted file mode 100644 index 7e007b1253464e..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_templates/index.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useCallback, useState, useEffect, FunctionComponent } from 'react'; -import { EuiLoadingSpinner } from '@elastic/eui'; -import { useHistory } from 'react-router-dom'; - -import { ComponentStrings } from '../../../i18n/components'; -// @ts-expect-error -import * as workpadService from '../../lib/workpad_service'; -import { WorkpadTemplates as Component } from './workpad_templates'; -import { CanvasTemplate } from '../../../types'; -import { list } from '../../lib/template_service'; -import { applyTemplateStrings } from '../../../i18n/templates/apply_strings'; -import { useNotifyService, useServices } from '../../services'; - -interface WorkpadTemplatesProps { - onClose: () => void; -} - -const Creating: FunctionComponent<{ name: string }> = ({ name }) => ( -
    - {' '} - {ComponentStrings.WorkpadTemplates.getCreatingTemplateLabel(name)} -
    -); -export const WorkpadTemplates: FunctionComponent = ({ onClose }) => { - const history = useHistory(); - const services = useServices(); - - const [templates, setTemplates] = useState(undefined); - const [creatingFromTemplateName, setCreatingFromTemplateName] = useState( - undefined - ); - const { error } = useNotifyService(); - - useEffect(() => { - if (!templates) { - (async () => { - const fetchedTemplates = await list(); - setTemplates(applyTemplateStrings(fetchedTemplates)); - })(); - } - }, [templates]); - - let templateProp: Record = {}; - - if (templates) { - templateProp = templates.reduce>((reduction, template) => { - reduction[template.name] = template; - return reduction; - }, {}); - } - - const createFromTemplate = useCallback( - async (template: CanvasTemplate) => { - setCreatingFromTemplateName(template.name); - try { - const result = await services.workpad.createFromTemplate(template.id); - history.push(`/workpad/${result.id}/page/1`); - } catch (e) { - setCreatingFromTemplateName(undefined); - error(e, { - title: `Couldn't create workpad from template`, - }); - } - }, - [services.workpad, error, history] - ); - - if (creatingFromTemplateName) { - return ; - } - - return ( - - ); -}; diff --git a/x-pack/plugins/canvas/public/components/workpad_templates/workpad_templates.tsx b/x-pack/plugins/canvas/public/components/workpad_templates/workpad_templates.tsx deleted file mode 100644 index 72871b93c1735b..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_templates/workpad_templates.tsx +++ /dev/null @@ -1,215 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { Fragment } from 'react'; -import PropTypes from 'prop-types'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiBasicTable, - EuiPagination, - EuiSpacer, - EuiButtonEmpty, - EuiSearchBar, - EuiTableSortingType, - Direction, - SortDirection, -} from '@elastic/eui'; -import { orderBy } from 'lodash'; -// @ts-ignore untyped local -import { EuiBasicTableColumn } from '@elastic/eui'; -import { Paginate, PaginateChildProps } from '../paginate'; -import { TagList } from '../tag_list'; -import { getTagsFilter } from '../../lib/get_tags_filter'; -// @ts-expect-error -import { extractSearch } from '../../lib/extract_search'; -import { ComponentStrings } from '../../../i18n'; -import { CanvasTemplate } from '../../../types'; - -interface TableChange { - page?: { - index: number; - size: number; - }; - sort?: { - field: keyof T; - direction: Direction; - }; -} - -const { WorkpadTemplates: strings } = ComponentStrings; - -interface WorkpadTemplatesProps { - onCreateFromTemplate: (template: CanvasTemplate) => Promise; - onClose: () => void; - templates: Record; -} - -interface WorkpadTemplatesState { - sortField: string; - sortDirection: Direction; - pageSize: number; - searchTerm: string; - filterTags: string[]; -} - -export class WorkpadTemplates extends React.PureComponent< - WorkpadTemplatesProps, - WorkpadTemplatesState -> { - static propTypes = { - onCreateFromTemplate: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, - templates: PropTypes.object, - }; - - state = { - sortField: 'name', - sortDirection: SortDirection.ASC, - pageSize: 10, - searchTerm: '', - filterTags: [], - }; - - tagType: 'health' = 'health'; - - onTableChange = (tableChange: TableChange) => { - if (tableChange.sort) { - const { field: sortField, direction: sortDirection } = tableChange.sort; - this.setState({ - sortField, - sortDirection, - }); - } - }; - - onSearch = ({ queryText = '' }) => this.setState(extractSearch(queryText)); - - cloneTemplate = (template: CanvasTemplate) => - this.props.onCreateFromTemplate(template).then(() => this.props.onClose()); - - renderWorkpadTable = ({ rows, pageNumber, totalPages, setPage }: PaginateChildProps) => { - const { sortField, sortDirection } = this.state; - - const columns: Array> = [ - { - field: 'name', - name: strings.getTableNameColumnTitle(), - sortable: true, - width: '30%', - dataType: 'string', - render: (name: string, template) => { - const templateName = name.length ? name : 'Unnamed Template'; - - return ( - this.cloneTemplate(template)} - aria-label={strings.getCloneTemplateLinkAriaLabel(templateName)} - type="button" - > - {templateName} - - ); - }, - }, - { - field: 'help', - name: strings.getTableDescriptionColumnTitle(), - sortable: false, - dataType: 'string', - width: '30%', - }, - { - field: 'tags', - name: strings.getTableTagsColumnTitle(), - sortable: false, - dataType: 'string', - width: '30%', - render: (tags: string[]) => , - }, - ]; - - const sorting: EuiTableSortingType = { - sort: { - field: sortField, - direction: sortDirection, - }, - }; - - return ( - - - - {rows.length > 0 && ( - - - - - - )} - - ); - }; - - renderSearch = () => { - const { searchTerm } = this.state; - const filters = [getTagsFilter(this.tagType)]; - - return ( - - ); - }; - - render() { - const { templates } = this.props; - const { sortField, sortDirection, searchTerm, filterTags } = this.state; - const sortedTemplates = orderBy(templates, [sortField, 'name'], [sortDirection, 'asc']); - - const filteredTemplates = sortedTemplates.filter(({ name = '', help = '', tags = [] }) => { - const tagMatch = filterTags.length - ? filterTags.every((filterTag) => tags.indexOf(filterTag) > -1) - : true; - - const lowercaseSearch = searchTerm.toLowerCase(); - const textMatch = lowercaseSearch - ? name.toLowerCase().indexOf(lowercaseSearch) > -1 || - help.toLowerCase().indexOf(lowercaseSearch) > -1 - : true; - - return tagMatch && textMatch; - }); - - return ( - - {(pagination: PaginateChildProps) => ( - - {this.renderSearch()} - - {this.renderWorkpadTable(pagination)} - - )} - - ); - } -} diff --git a/x-pack/plugins/canvas/public/lib/get_tags_filter.tsx b/x-pack/plugins/canvas/public/lib/get_tags_filter.tsx deleted file mode 100644 index 12d77c9c7f0c0a..00000000000000 --- a/x-pack/plugins/canvas/public/lib/get_tags_filter.tsx +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { sortBy } from 'lodash'; -import { SearchFilterConfig } from '@elastic/eui'; -import { Tag } from '../components/tag'; -import { getId } from './get_id'; -import { tagsRegistry } from './tags_registry'; -import { ComponentStrings } from '../../i18n'; - -const { WorkpadTemplates: strings } = ComponentStrings; - -// EUI helper function -// generates the FieldValueSelectionFilter object for EuiSearchBar for tag filtering -export const getTagsFilter = (type: 'health' | 'badge'): SearchFilterConfig => { - const uniqueTags = sortBy(Object.values(tagsRegistry.toJS()), 'name'); - const filterType = 'field_value_selection'; - - return { - type: filterType, - field: 'tag', - name: strings.getTableTagsColumnTitle(), - multiSelect: true, - options: uniqueTags.map(({ name, color }) => ({ - value: name, - name, - view: ( -
    - -
    - ), - })), - }; -}; diff --git a/x-pack/plugins/canvas/public/services/index.ts b/x-pack/plugins/canvas/public/services/index.ts index 6c039660c64c7a..3f8f58367171a8 100644 --- a/x-pack/plugins/canvas/public/services/index.ts +++ b/x-pack/plugins/canvas/public/services/index.ts @@ -34,7 +34,7 @@ export type CanvasServiceFactory = ( appUpdater: BehaviorSubject ) => Service | Promise; -class CanvasServiceProvider { +export class CanvasServiceProvider { private factory: CanvasServiceFactory; private service: Service | undefined; diff --git a/x-pack/plugins/canvas/public/services/stubs/platform.ts b/x-pack/plugins/canvas/public/services/stubs/platform.ts index ea80a5a7c26b99..5776a1d0d69834 100644 --- a/x-pack/plugins/canvas/public/services/stubs/platform.ts +++ b/x-pack/plugins/canvas/public/services/stubs/platform.ts @@ -9,13 +9,19 @@ import { PlatformService } from '../platform'; const noop = (..._args: any[]): any => {}; +const uiSettings: Record = { + dateFormat: 'MMM D, YYYY @ HH:mm:ss.SSS', +}; + +const getUISetting = (setting: string) => uiSettings[setting]; + export const platformService: PlatformService = { getBasePath: () => '/base/path', getBasePathInterface: noop, getDocLinkVersion: () => 'dockLinkVersion', getElasticWebsiteUrl: () => 'https://elastic.co', getHasWriteAccess: () => true, - getUISetting: noop, + getUISetting, setBreadcrumbs: noop, setRecentlyAccessed: noop, getSavedObjects: noop, diff --git a/x-pack/plugins/canvas/public/services/stubs/workpad.ts b/x-pack/plugins/canvas/public/services/stubs/workpad.ts index 857831c92a8a61..4e3612feb67c8d 100644 --- a/x-pack/plugins/canvas/public/services/stubs/workpad.ts +++ b/x-pack/plugins/canvas/public/services/stubs/workpad.ts @@ -5,17 +5,95 @@ * 2.0. */ +import moment from 'moment'; + +// @ts-expect-error +import { getDefaultWorkpad } from '../../state/defaults'; import { WorkpadService } from '../workpad'; -import { CanvasWorkpad } from '../../../types'; +import { getId } from '../../lib/get_id'; +import { CanvasTemplate } from '../../../types'; -export const workpadService: WorkpadService = { - get: (id: string) => Promise.resolve({} as CanvasWorkpad), - create: (workpad) => Promise.resolve({} as CanvasWorkpad), - createFromTemplate: (templateId: string) => Promise.resolve({} as CanvasWorkpad), - find: (term: string) => - Promise.resolve({ +const TIMEOUT = 500; + +const promiseTimeout = (time: number) => () => new Promise((resolve) => setTimeout(resolve, time)); +const getName = () => { + const lorem = 'Lorem ipsum dolor sit amet consectetur adipiscing elit Fusce lobortis aliquet arcu ut turpis duis'.split( + ' ' + ); + return [1, 2, 3].map(() => lorem[Math.floor(Math.random() * lorem.length)]).join(' '); +}; + +const randomDate = ( + start: Date = moment().toDate(), + end: Date = moment().subtract(7, 'days').toDate() +) => new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())).toISOString(); + +const templates: CanvasTemplate[] = [ + { + id: 'test1-id', + name: 'test1', + help: 'This is a test template', + tags: ['tag1', 'tag2'], + template_key: 'test1-key', + }, + { + id: 'test2-id', + name: 'test2', + help: 'This is a second test template', + tags: ['tag2', 'tag3'], + template_key: 'test2-key', + }, +]; + +export const getSomeWorkpads = (count = 3) => + Array.from({ length: count }, () => ({ + '@created': randomDate( + moment().subtract(3, 'days').toDate(), + moment().subtract(10, 'days').toDate() + ), + '@timestamp': randomDate(), + id: getId('workpad'), + name: getName(), + })); + +export const findSomeWorkpads = (count = 3, timeout = TIMEOUT) => (_term: string) => { + return Promise.resolve() + .then(promiseTimeout(timeout)) + .then(() => ({ + total: count, + workpads: getSomeWorkpads(count), + })); +}; + +export const findNoWorkpads = (timeout = TIMEOUT) => (_term: string) => { + return Promise.resolve() + .then(promiseTimeout(timeout)) + .then(() => ({ total: 0, workpads: [], - }), - remove: (id: string) => Promise.resolve(undefined), + })); +}; + +export const findSomeTemplates = (timeout = TIMEOUT) => () => { + return Promise.resolve() + .then(promiseTimeout(timeout)) + .then(() => getSomeTemplates()); +}; + +export const findNoTemplates = (timeout = TIMEOUT) => () => { + return Promise.resolve() + .then(promiseTimeout(timeout)) + .then(() => getNoTemplates()); +}; + +export const getNoTemplates = () => ({ templates: [] }); +export const getSomeTemplates = () => ({ templates }); + +export const workpadService: WorkpadService = { + get: (id: string) => Promise.resolve({ ...getDefaultWorkpad(), id }), + findTemplates: findNoTemplates(), + create: (workpad) => Promise.resolve(workpad), + createFromTemplate: (_templateId: string) => Promise.resolve(getDefaultWorkpad()), + find: findNoWorkpads(), + remove: (id: string) => Promise.resolve(), }; diff --git a/x-pack/plugins/canvas/public/services/workpad.ts b/x-pack/plugins/canvas/public/services/workpad.ts index 11690ca4c0c450..7d2f1550a312fc 100644 --- a/x-pack/plugins/canvas/public/services/workpad.ts +++ b/x-pack/plugins/canvas/public/services/workpad.ts @@ -5,8 +5,12 @@ * 2.0. */ -import { API_ROUTE_WORKPAD, DEFAULT_WORKPAD_CSS } from '../../common/lib/constants'; -import { CanvasWorkpad } from '../../types'; +import { + API_ROUTE_WORKPAD, + DEFAULT_WORKPAD_CSS, + API_ROUTE_TEMPLATES, +} from '../../common/lib/constants'; +import { CanvasWorkpad, CanvasTemplate } from '../../types'; import { CanvasServiceFactory } from './'; /* @@ -40,9 +44,15 @@ const sanitizeWorkpad = function (workpad: CanvasWorkpad) { return workpad; }; -interface WorkpadFindResponse { +export type FoundWorkpads = Array>; +export type FoundWorkpad = FoundWorkpads[number]; +export interface WorkpadFindResponse { total: number; - workpads: Array>; + workpads: FoundWorkpads; +} + +export interface TemplateFindResponse { + templates: CanvasTemplate[]; } export interface WorkpadService { @@ -51,6 +61,7 @@ export interface WorkpadService { createFromTemplate: (templateId: string) => Promise; find: (term: string) => Promise; remove: (id: string) => Promise; + findTemplates: () => Promise; } export const workpadServiceFactory: CanvasServiceFactory = ( @@ -82,7 +93,9 @@ export const workpadServiceFactory: CanvasServiceFactory = ( body: JSON.stringify({ templateId }), }); }, + findTemplates: async () => coreStart.http.get(API_ROUTE_TEMPLATES), find: (searchTerm: string) => { + // TODO: this shouldn't be necessary. Check for usage. const validSearchTerm = typeof searchTerm === 'string' && searchTerm.length > 0; return coreStart.http.get(`${getApiPath()}/find`, { diff --git a/x-pack/plugins/canvas/public/style/index.scss b/x-pack/plugins/canvas/public/style/index.scss index a79e07a7d00168..d9592d5c0be5f7 100644 --- a/x-pack/plugins/canvas/public/style/index.scss +++ b/x-pack/plugins/canvas/public/style/index.scss @@ -40,8 +40,6 @@ @import '../components/workpad_header/element_menu/element_menu'; @import '../components/workpad_header/share_menu/share_menu'; @import '../components/workpad_header/view_menu/view_menu'; -@import '../components/workpad_loader/workpad_loader'; -@import '../components/workpad_loader/workpad_dropzone/workpad_dropzone'; @import '../components/workpad_page/workpad_page'; @import '../components/workpad_page/workpad_interactive_page/workpad_interactive_page'; @import '../components/workpad_page/workpad_static_page/workpad_static_page'; diff --git a/x-pack/plugins/canvas/storybook/decorators/index.ts b/x-pack/plugins/canvas/storybook/decorators/index.ts index a674eaad576a72..598a2333be5541 100644 --- a/x-pack/plugins/canvas/storybook/decorators/index.ts +++ b/x-pack/plugins/canvas/storybook/decorators/index.ts @@ -11,6 +11,7 @@ import { kibanaContextDecorator } from './kibana_decorator'; import { servicesContextDecorator } from './services_decorator'; export { reduxDecorator } from './redux_decorator'; +export { servicesContextDecorator } from './services_decorator'; export const addDecorators = () => { if (process.env.NODE_ENV === 'test') { @@ -20,5 +21,5 @@ export const addDecorators = () => { addDecorator(kibanaContextDecorator); addDecorator(routerContextDecorator); - addDecorator(servicesContextDecorator); + addDecorator(servicesContextDecorator()); }; diff --git a/x-pack/plugins/canvas/storybook/decorators/redux_decorator.tsx b/x-pack/plugins/canvas/storybook/decorators/redux_decorator.tsx index 01d96cb0c70e62..289171f136ab5a 100644 --- a/x-pack/plugins/canvas/storybook/decorators/redux_decorator.tsx +++ b/x-pack/plugins/canvas/storybook/decorators/redux_decorator.tsx @@ -25,7 +25,7 @@ elementsRegistry.register(image); import { getInitialState, getReducer, getMiddleware, patchDispatch } from '../addon/src/state'; export { ADDON_ID, ACTIONS_PANEL_ID } from '../addon/src/constants'; -interface Params { +export interface Params { workpad?: CanvasWorkpad; elements?: CanvasElement[]; assets?: CanvasAsset[]; diff --git a/x-pack/plugins/canvas/storybook/decorators/services_decorator.tsx b/x-pack/plugins/canvas/storybook/decorators/services_decorator.tsx index a11492387ea7fe..def5a5681a8c4e 100644 --- a/x-pack/plugins/canvas/storybook/decorators/services_decorator.tsx +++ b/x-pack/plugins/canvas/storybook/decorators/services_decorator.tsx @@ -7,8 +7,40 @@ import React from 'react'; -import { ServicesProvider } from '../../public/services'; +import { + CanvasServiceFactory, + CanvasServiceProvider, + ServicesProvider, +} from '../../public/services'; +import { + findNoWorkpads, + findSomeWorkpads, + workpadService, + findSomeTemplates, + findNoTemplates, +} from '../../public/services/stubs/workpad'; +import { WorkpadService } from '../../public/services/workpad'; -export const servicesContextDecorator = (story: Function) => ( - {story()} -); +interface Params { + findWorkpads?: number; + findTemplates?: boolean; +} + +export const servicesContextDecorator = ({ + findWorkpads = 0, + findTemplates: findTemplatesOption = false, +}: Params = {}) => { + const workpadServiceFactory: CanvasServiceFactory = (): WorkpadService => ({ + ...workpadService, + find: findWorkpads > 0 ? findSomeWorkpads(findWorkpads) : findNoWorkpads(), + findTemplates: findTemplatesOption ? findSomeTemplates() : findNoTemplates(), + }); + + const workpad = new CanvasServiceProvider(workpadServiceFactory); + // @ts-expect-error This is a hack at the moment, until we can get Canvas moved over to the new services architecture. + workpad.start(); + + return (story: Function) => ( + {story()} + ); +}; diff --git a/x-pack/plugins/canvas/storybook/index.ts b/x-pack/plugins/canvas/storybook/index.ts index 148af337d7720e..ff60b84c88a696 100644 --- a/x-pack/plugins/canvas/storybook/index.ts +++ b/x-pack/plugins/canvas/storybook/index.ts @@ -10,3 +10,8 @@ import { ACTIONS_PANEL_ID } from './addon/src/constants'; export * from './decorators'; export { ACTIONS_PANEL_ID } from './addon/src/constants'; export const getAddonPanelParameters = () => ({ options: { selectedPanel: ACTIONS_PANEL_ID } }); +export const getDisableStoryshotsParameter = () => ({ + storyshots: { + disable: true, + }, +}); diff --git a/x-pack/plugins/canvas/storybook/main.ts b/x-pack/plugins/canvas/storybook/main.ts index 80a8aeb14a804e..69c05322cf3f07 100644 --- a/x-pack/plugins/canvas/storybook/main.ts +++ b/x-pack/plugins/canvas/storybook/main.ts @@ -53,6 +53,11 @@ const canvasWebpack = { }, ], }, + resolve: { + alias: { + 'src/plugins': resolve(KIBANA_ROOT, 'src/plugins'), + }, + }, }; module.exports = { diff --git a/x-pack/plugins/canvas/storybook/public/components/home/my_workpads/__snapshots__/empty_prompt.stories.storyshot b/x-pack/plugins/canvas/storybook/public/components/home/my_workpads/__snapshots__/empty_prompt.stories.storyshot new file mode 100644 index 00000000000000..39ec1e234ead54 --- /dev/null +++ b/x-pack/plugins/canvas/storybook/public/components/home/my_workpads/__snapshots__/empty_prompt.stories.storyshot @@ -0,0 +1,65 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots Home/Empty Prompt Empty Prompt 1`] = ` +
    +
    +
    +
    + +
    + +

    + Add your first workpad +

    +
    +
    +

    + Create a new workpad, start from a template, or import a workpad JSON file by dropping it here. +

    +

    + New to Canvas? + + + Add your first workpad + + . +

    +
    + +
    +
    +
    +
    +`; diff --git a/x-pack/plugins/canvas/storybook/storyshots.test.tsx b/x-pack/plugins/canvas/storybook/storyshots.test.tsx index 0c3765812066e7..7f0ea077c75698 100644 --- a/x-pack/plugins/canvas/storybook/storyshots.test.tsx +++ b/x-pack/plugins/canvas/storybook/storyshots.test.tsx @@ -90,6 +90,11 @@ import { EuiObserver } from '@elastic/eui/test-env/components/observer/observer' jest.mock('@elastic/eui/test-env/components/observer/observer'); EuiObserver.mockImplementation(() => 'EuiObserver'); +// @ts-expect-error untyped library +import Dropzone from 'react-dropzone'; +jest.mock('react-dropzone'); +Dropzone.mockImplementation(() => 'Dropzone'); + // This element uses a `ref` and cannot be rendered by Jest snapshots. import { RenderedElement } from '../shareable_runtime/components/rendered_element'; jest.mock('../shareable_runtime/components/rendered_element'); @@ -111,7 +116,7 @@ addSerializer(styleSheetSerializer); // Initialize Storyshots and build the Jest Snapshots initStoryshots({ - configPath: path.resolve(__dirname, './../storybook'), + configPath: path.resolve(__dirname), framework: 'react', test: multiSnapshotWithOptions({}), // Don't snapshot tests that start with 'redux' diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 91277403d9e058..bc8318e803c8f5 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -6107,18 +6107,18 @@ "xpack.canvas.error.esService.indicesFetchErrorMessage": "Elasticsearch インデックスを取得できませんでした", "xpack.canvas.error.RenderWithFn.renderErrorMessage": "「{functionName}」のレンダリングが失敗しました", "xpack.canvas.error.repeatImage.missingMaxArgument": "{emptyImageArgument} を指定する場合は、{maxArgument} を設定する必要があります", - "xpack.canvas.error.workpadLoader.cloneFailureErrorMessage": "ワークパッドのクローンを作成できませんでした", - "xpack.canvas.error.workpadLoader.deleteFailureErrorMessage": "すべてのワークパッドを削除できませんでした", - "xpack.canvas.error.workpadLoader.findFailureErrorMessage": "ワークパッドが見つかりませんでした", - "xpack.canvas.error.workpadLoader.uploadFailureErrorMessage": "ワークパッドをアップロードできませんでした", + "xpack.canvas.error.useCloneWorkpad.cloneFailureErrorMessage": "ワークパッドのクローンを作成できませんでした", + "xpack.canvas.error.useCreateWorkpad.uploadFailureErrorMessage": "ワークパッドをアップロードできませんでした", + "xpack.canvas.error.useDeleteWorkpads.deleteFailureErrorMessage": "すべてのワークパッドを削除できませんでした", + "xpack.canvas.error.useFindWorkpads.findFailureErrorMessage": "ワークパッドが見つかりませんでした", + "xpack.canvas.error.useImportWorkpad.acceptJSONOnlyErrorMessage": "{JSON} 個のファイルしか受け付けられませんでした", + "xpack.canvas.error.useImportWorkpad.fileUploadFailureWithoutFileNameErrorMessage": "ファイルをアップロードできませんでした", + "xpack.canvas.error.useImportWorkpad.missingPropertiesErrorMessage": "{CANVAS} ワークパッドに必要なプロパティの一部が欠けています。 {JSON} ファイルを編集して正しいプロパティ値を入力し、再試行してください。", "xpack.canvas.error.workpadRoutes.createFailureErrorMessage": "ワークパッドを作成できませんでした", "xpack.canvas.error.workpadRoutes.loadFailureErrorMessage": "ID でワークパッドを読み込めませんでした", - "xpack.canvas.error.workpadUpload.acceptJSONOnlyErrorMessage": "{JSON} 個のファイルしか受け付けられませんでした", - "xpack.canvas.error.workpadUpload.fileUploadFailureWithoutFileNameErrorMessage": "ファイルをアップロードできませんでした", - "xpack.canvas.error.workpadUpload.missingPropertiesErrorMessage": "{CANVAS} ワークパッドに必要なプロパティの一部が欠けています。 {JSON} ファイルを編集して正しいプロパティ値を入力し、再試行してください。", "xpack.canvas.errorComponent.description": "表現が失敗し次のメッセージが返されました:", "xpack.canvas.errorComponent.title": "おっと!表現が失敗しました", - "xpack.canvas.errors.workpadUpload.fileUploadFileWithFileNameErrorMessage": "「{fileName}」をアップロードできませんでした", + "xpack.canvas.errors.useImportWorkpad.fileUploadFileWithFileNameErrorMessage": "「{fileName}」をアップロードできませんでした", "xpack.canvas.expression.cancelButtonLabel": "キャンセル", "xpack.canvas.expression.closeButtonLabel": "閉じる", "xpack.canvas.expression.learnLinkText": "表現構文の詳細", @@ -6452,6 +6452,12 @@ "xpack.canvas.helpMenu.description": "{CANVAS} に関する情報", "xpack.canvas.helpMenu.documentationLinkLabel": "{CANVAS} ドキュメント", "xpack.canvas.helpMenu.keyboardShortcutsLinkLabel": "キーボードショートカット", + "xpack.canvas.home.myWorkpadsTabLabel": "マイワークパッド", + "xpack.canvas.home.workpadTemplatesTabLabel": "テンプレート", + "xpack.canvas.homeEmptyPrompt.emptyPromptGettingStartedDescription": "新規ワークパッドを作成、テンプレートで開始、またはワークパッド {JSON} ファイルをここにドロップしてインポートします。", + "xpack.canvas.homeEmptyPrompt.emptyPromptNewUserDescription": "{CANVAS} を初めて使用する場合", + "xpack.canvas.homeEmptyPrompt.emptyPromptTitle": "初の’ワークパッドを追加しましょう", + "xpack.canvas.homeEmptyPrompt.sampleDataLinkLabel": "初の’ワークパッドを追加しましょう", "xpack.canvas.keyboardShortcuts.bringFowardShortcutHelpText": "前に移動", "xpack.canvas.keyboardShortcuts.bringToFrontShortcutHelpText": "表面に移動", "xpack.canvas.keyboardShortcuts.cloneShortcutHelpText": "クローンを作成", @@ -6898,6 +6904,7 @@ "xpack.canvas.units.quickRange.last90Days": "過去90日間", "xpack.canvas.units.quickRange.today": "今日", "xpack.canvas.units.quickRange.yesterday": "昨日", + "xpack.canvas.useCloneWorkpad.clonedWorkpadName": "{workpadName} のコピー", "xpack.canvas.varConfig.addButtonLabel": "変数の追加", "xpack.canvas.varConfig.addTooltipLabel": "変数の追加", "xpack.canvas.varConfig.copyActionButtonLabel": "スニペットをコピー", @@ -7024,40 +7031,30 @@ "xpack.canvas.workpadHeaderViewMenu.zoomPanelTitle": "ズーム", "xpack.canvas.workpadHeaderViewMenu.zoomPrecentageValue": "リセット", "xpack.canvas.workpadHeaderViewMenu.zoomResetText": "{scalePercentage}%", - "xpack.canvas.workpadLoader.clonedWorkpadName": "{workpadName} のコピー", - "xpack.canvas.workpadLoader.cloneTooltip": "ワークパッドのクローンを作成します", - "xpack.canvas.workpadLoader.createWorkpadLoadingDescription": "ワークパッドを作成中...", - "xpack.canvas.workpadLoader.deleteButtonAriaLabel": "{numberOfWorkpads} 個のワークパッドを削除", - "xpack.canvas.workpadLoader.deleteButtonLabel": " ({numberOfWorkpads}) ワークパッドを削除", - "xpack.canvas.workpadLoader.deleteModalConfirmButtonLabel": "削除", - "xpack.canvas.workpadLoader.deleteModalDescription": "削除されたワークパッドは復元できません。", - "xpack.canvas.workpadLoader.deleteMultipleWorkpadsModalTitle": "{numberOfWorkpads} 個のワークパッドを削除しますか?", - "xpack.canvas.workpadLoader.deleteSingleWorkpadModalTitle": "ワークパッド「{workpadName}」削除しますか?", - "xpack.canvas.workpadLoader.emptyPromptGettingStartedDescription": "新規ワークパッドを作成、テンプレートで開始、またはワークパッド {JSON} ファイルをここにドロップしてインポートします。", - "xpack.canvas.workpadLoader.emptyPromptNewUserDescription": "{CANVAS} を初めて使用する場合", - "xpack.canvas.workpadLoader.emptyPromptTitle": "初の’ワークパッドを追加しましょう", - "xpack.canvas.workpadLoader.exportButtonAriaLabel": "{numberOfWorkpads} 個のワークパッドをエクスポート", - "xpack.canvas.workpadLoader.exportButtonLabel": "エクスポート ({numberOfWorkpads}) ", - "xpack.canvas.workpadLoader.exportTooltip": "ワークパッドをエクスポート", - "xpack.canvas.workpadLoader.fetchLoadingDescription": "ワークパッドを取得中...", - "xpack.canvas.workpadLoader.filePickerPlaceholder": "ワークパッド {JSON} ファイルをインポート", - "xpack.canvas.workpadLoader.loadWorkpadArialLabel": "ワークパッド「{workpadName}」を読み込む", - "xpack.canvas.workpadLoader.noPermissionToCloneToolTip": "ワークパッドのクローンを作成するパーミッションがありません", - "xpack.canvas.workpadLoader.noPermissionToCreateToolTip": "ワークパッドを作成するパーミッションがありません", - "xpack.canvas.workpadLoader.noPermissionToDeleteToolTip": "ワークパッドを削除するパーミッションがありません", - "xpack.canvas.workpadLoader.noPermissionToUploadToolTip": "ワークパッドを更新するパーミッションがありません", - "xpack.canvas.workpadLoader.sampleDataLinkLabel": "初の’ワークパッドを追加しましょう", - "xpack.canvas.workpadLoader.table.actionsColumnTitle": "アクション", - "xpack.canvas.workpadLoader.table.createdColumnTitle": "作成済み", - "xpack.canvas.workpadLoader.table.nameColumnTitle": "ワークパッド名", - "xpack.canvas.workpadLoader.table.updatedColumnTitle": "更新しました", - "xpack.canvas.workpadManager.modalTitle": "{CANVAS} ワークパッド", - "xpack.canvas.workpadManager.myWorkpadsTabLabel": "マイワークパッド", - "xpack.canvas.workpadManager.workpadTemplatesTabLabel": "テンプレート", - "xpack.canvas.workpadSearch.searchPlaceholder": "ワークパッドを検索", - "xpack.canvas.workpadTemplate.cloneTemplateLinkAriaLabel": "ワークパッドテンプレート「{templateName}」のクローンを作成", - "xpack.canvas.workpadTemplate.creatingTemplateLabel": "テンプレート「{templateName}」から作成しています", - "xpack.canvas.workpadTemplate.searchPlaceholder": "テンプレートを検索", + "xpack.canvas.workpadImport.filePickerPlaceholder": "ワークパッド {JSON} ファイルをインポート", + "xpack.canvas.workpadTable.cloneTooltip": "ワークパッドのクローンを作成します", + "xpack.canvas.workpadTable.exportTooltip": "ワークパッドをエクスポート", + "xpack.canvas.workpadTable.loadWorkpadArialLabel": "ワークパッド「{workpadName}」を読み込む", + "xpack.canvas.workpadTable.noPermissionToCloneToolTip": "ワークパッドのクローンを作成するパーミッションがありません", + "xpack.canvas.workpadTable.searchPlaceholder": "ワークパッドを検索", + "xpack.canvas.workpadTable.table.actionsColumnTitle": "アクション", + "xpack.canvas.workpadTable.table.createdColumnTitle": "作成済み", + "xpack.canvas.workpadTable.table.nameColumnTitle": "ワークパッド名", + "xpack.canvas.workpadTable.table.updatedColumnTitle": "更新しました", + "xpack.canvas.workpadTableTools.deleteButtonAriaLabel": "{numberOfWorkpads} 個のワークパッドを削除", + "xpack.canvas.workpadTableTools.deleteButtonLabel": " ({numberOfWorkpads}) ワークパッドを削除", + "xpack.canvas.workpadTableTools.deleteModalConfirmButtonLabel": "削除", + "xpack.canvas.workpadTableTools.deleteModalDescription": "削除されたワークパッドは復元できません。", + "xpack.canvas.workpadTableTools.deleteMultipleWorkpadsModalTitle": "{numberOfWorkpads} 個のワークパッドを削除しますか?", + "xpack.canvas.workpadTableTools.deleteSingleWorkpadModalTitle": "ワークパッド「{workpadName}」削除しますか?", + "xpack.canvas.workpadTableTools.exportButtonAriaLabel": "{numberOfWorkpads} 個のワークパッドをエクスポート", + "xpack.canvas.workpadTableTools.exportButtonLabel": "エクスポート ({numberOfWorkpads}) ", + "xpack.canvas.workpadTableTools.noPermissionToCreateToolTip": "ワークパッドを作成するパーミッションがありません", + "xpack.canvas.workpadTableTools.noPermissionToDeleteToolTip": "ワークパッドを削除するパーミッションがありません", + "xpack.canvas.workpadTableTools.noPermissionToUploadToolTip": "ワークパッドを更新するパーミッションがありません", + "xpack.canvas.workpadTemplates.cloneTemplateLinkAriaLabel": "ワークパッドテンプレート「{templateName}」のクローンを作成", + "xpack.canvas.workpadTemplates.creatingTemplateLabel": "テンプレート「{templateName}」から作成しています", + "xpack.canvas.workpadTemplates.searchPlaceholder": "テンプレートを検索", "xpack.canvas.workpadTemplates.table.descriptionColumnTitle": "説明", "xpack.canvas.workpadTemplates.table.nameColumnTitle": "テンプレート名", "xpack.canvas.workpadTemplates.table.tagsColumnTitle": "タグ", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 632c502d4ef555..f867407ff2d9b8 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6146,18 +6146,18 @@ "xpack.canvas.error.esService.indicesFetchErrorMessage": "无法提取 Elasticsearch 索引", "xpack.canvas.error.RenderWithFn.renderErrorMessage": "呈现“{functionName}”失败。", "xpack.canvas.error.repeatImage.missingMaxArgument": "如果提供 {emptyImageArgument},则必须设置 {maxArgument}", - "xpack.canvas.error.workpadLoader.cloneFailureErrorMessage": "无法克隆 Workpad", - "xpack.canvas.error.workpadLoader.deleteFailureErrorMessage": "无法删除所有 Workpad", - "xpack.canvas.error.workpadLoader.findFailureErrorMessage": "无法查找 Workpad", - "xpack.canvas.error.workpadLoader.uploadFailureErrorMessage": "无法上传 Workpad", + "xpack.canvas.error.useCloneWorkpad.cloneFailureErrorMessage": "无法克隆 Workpad", + "xpack.canvas.error.useCreateWorkpad.uploadFailureErrorMessage": "无法上传 Workpad", + "xpack.canvas.error.useDeleteWorkpads.deleteFailureErrorMessage": "无法删除所有 Workpad", + "xpack.canvas.error.useFindWorkpads.findFailureErrorMessage": "无法查找 Workpad", + "xpack.canvas.error.useImportWorkpad.acceptJSONOnlyErrorMessage": "仅接受 {JSON} 文件", + "xpack.canvas.error.useImportWorkpad.fileUploadFailureWithoutFileNameErrorMessage": "无法上传文件", + "xpack.canvas.error.useImportWorkpad.missingPropertiesErrorMessage": "{CANVAS} Workpad 所需的某些属性缺失。 编辑 {JSON} 文件以提供正确的属性值,然后重试。", "xpack.canvas.error.workpadRoutes.createFailureErrorMessage": "无法创建 Workpad", "xpack.canvas.error.workpadRoutes.loadFailureErrorMessage": "无法加载具有以下 ID 的 Workpad", - "xpack.canvas.error.workpadUpload.acceptJSONOnlyErrorMessage": "仅接受 {JSON} 文件", - "xpack.canvas.error.workpadUpload.fileUploadFailureWithoutFileNameErrorMessage": "无法上传文件", - "xpack.canvas.error.workpadUpload.missingPropertiesErrorMessage": "{CANVAS} Workpad 所需的某些属性缺失。 编辑 {JSON} 文件以提供正确的属性值,然后重试。", "xpack.canvas.errorComponent.description": "表达式失败,并显示消息:", "xpack.canvas.errorComponent.title": "哎哟!表达式失败", - "xpack.canvas.errors.workpadUpload.fileUploadFileWithFileNameErrorMessage": "无法上传“{fileName}”", + "xpack.canvas.errors.useImportWorkpad.fileUploadFileWithFileNameErrorMessage": "无法上传“{fileName}”", "xpack.canvas.expression.cancelButtonLabel": "取消", "xpack.canvas.expression.closeButtonLabel": "关闭", "xpack.canvas.expression.learnLinkText": "学习表达式语法", @@ -6492,6 +6492,12 @@ "xpack.canvas.helpMenu.description": "有关 {CANVAS} 特定信息", "xpack.canvas.helpMenu.documentationLinkLabel": "{CANVAS} 文档", "xpack.canvas.helpMenu.keyboardShortcutsLinkLabel": "快捷键", + "xpack.canvas.home.myWorkpadsTabLabel": "我的 Workpad", + "xpack.canvas.home.workpadTemplatesTabLabel": "模板", + "xpack.canvas.homeEmptyPrompt.emptyPromptGettingStartedDescription": "创建新的 Workpad、从模板入手或通过将 Workpad {JSON} 文件拖放到此处来导入。", + "xpack.canvas.homeEmptyPrompt.emptyPromptNewUserDescription": "{CANVAS} 新手?", + "xpack.canvas.homeEmptyPrompt.emptyPromptTitle": "添加您的首个 Workpad", + "xpack.canvas.homeEmptyPrompt.sampleDataLinkLabel": "添加您的首个 Workpad", "xpack.canvas.keyboardShortcuts.bringFowardShortcutHelpText": "前移", "xpack.canvas.keyboardShortcuts.bringToFrontShortcutHelpText": "置前", "xpack.canvas.keyboardShortcuts.cloneShortcutHelpText": "克隆", @@ -6942,6 +6948,7 @@ "xpack.canvas.units.time.hours": "{hours, plural, other {# 小时}}", "xpack.canvas.units.time.minutes": "{minutes, plural, other {# 分钟}}", "xpack.canvas.units.time.seconds": "{seconds, plural, other {# 秒}}", + "xpack.canvas.useCloneWorkpad.clonedWorkpadName": "{workpadName} 副本", "xpack.canvas.varConfig.addButtonLabel": "添加变量", "xpack.canvas.varConfig.addTooltipLabel": "添加变量", "xpack.canvas.varConfig.copyActionButtonLabel": "复制代码片段", @@ -7072,40 +7079,30 @@ "xpack.canvas.workpadHeaderViewMenu.zoomPanelTitle": "缩放", "xpack.canvas.workpadHeaderViewMenu.zoomPrecentageValue": "重置", "xpack.canvas.workpadHeaderViewMenu.zoomResetText": "{scalePercentage}%", - "xpack.canvas.workpadLoader.clonedWorkpadName": "{workpadName} 副本", - "xpack.canvas.workpadLoader.cloneTooltip": "克隆 Workpad", - "xpack.canvas.workpadLoader.createWorkpadLoadingDescription": "正在创建 Workpad......", - "xpack.canvas.workpadLoader.deleteButtonAriaLabel": "删除 {numberOfWorkpads} 个 Workpad", - "xpack.canvas.workpadLoader.deleteButtonLabel": "删除 ({numberOfWorkpads})", - "xpack.canvas.workpadLoader.deleteModalConfirmButtonLabel": "删除", - "xpack.canvas.workpadLoader.deleteModalDescription": "您无法恢复删除的 Workpad。", - "xpack.canvas.workpadLoader.deleteMultipleWorkpadsModalTitle": "删除 {numberOfWorkpads} 个 Workpad?", - "xpack.canvas.workpadLoader.deleteSingleWorkpadModalTitle": "删除 Workpad“{workpadName}”?", - "xpack.canvas.workpadLoader.emptyPromptGettingStartedDescription": "创建新的 Workpad、从模板入手或通过将 Workpad {JSON} 文件拖放到此处来导入。", - "xpack.canvas.workpadLoader.emptyPromptNewUserDescription": "{CANVAS} 新手?", - "xpack.canvas.workpadLoader.emptyPromptTitle": "添加您的首个 Workpad", - "xpack.canvas.workpadLoader.exportButtonAriaLabel": "导出 {numberOfWorkpads} 个 Workpad", - "xpack.canvas.workpadLoader.exportButtonLabel": "导出 ({numberOfWorkpads})", - "xpack.canvas.workpadLoader.exportTooltip": "导出 Workpad", - "xpack.canvas.workpadLoader.fetchLoadingDescription": "正在获取 Workpad......", - "xpack.canvas.workpadLoader.filePickerPlaceholder": "导入 Workpad {JSON} 文件", - "xpack.canvas.workpadLoader.loadWorkpadArialLabel": "加载 Workpad“{workpadName}”", - "xpack.canvas.workpadLoader.noPermissionToCloneToolTip": "您无权克隆 Workpad", - "xpack.canvas.workpadLoader.noPermissionToCreateToolTip": "您无权创建 Workpad", - "xpack.canvas.workpadLoader.noPermissionToDeleteToolTip": "您无权删除 Workpad", - "xpack.canvas.workpadLoader.noPermissionToUploadToolTip": "您无权上传 Workpad", - "xpack.canvas.workpadLoader.sampleDataLinkLabel": "添加您的首个 Workpad", - "xpack.canvas.workpadLoader.table.actionsColumnTitle": "操作", - "xpack.canvas.workpadLoader.table.createdColumnTitle": "创建时间", - "xpack.canvas.workpadLoader.table.nameColumnTitle": "Workpad 名称", - "xpack.canvas.workpadLoader.table.updatedColumnTitle": "更新时间", - "xpack.canvas.workpadManager.modalTitle": "{CANVAS} Workpad", - "xpack.canvas.workpadManager.myWorkpadsTabLabel": "我的 Workpad", - "xpack.canvas.workpadManager.workpadTemplatesTabLabel": "模板", - "xpack.canvas.workpadSearch.searchPlaceholder": "查找 Workpad", - "xpack.canvas.workpadTemplate.cloneTemplateLinkAriaLabel": "克隆 Workpad 模板“{templateName}”", - "xpack.canvas.workpadTemplate.creatingTemplateLabel": "正在从模板“{templateName}”创建", - "xpack.canvas.workpadTemplate.searchPlaceholder": "查找模板", + "xpack.canvas.workpadImport.filePickerPlaceholder": "导入 Workpad {JSON} 文件", + "xpack.canvas.workpadTable.searchPlaceholder": "查找 Workpad", + "xpack.canvas.workpadTable.cloneTooltip": "克隆 Workpad", + "xpack.canvas.workpadTable.exportTooltip": "导出 Workpad", + "xpack.canvas.workpadTable.loadWorkpadArialLabel": "加载 Workpad“{workpadName}”", + "xpack.canvas.workpadTable.noPermissionToCloneToolTip": "您无权克隆 Workpad", + "xpack.canvas.workpadTable.table.actionsColumnTitle": "操作", + "xpack.canvas.workpadTable.table.createdColumnTitle": "创建时间", + "xpack.canvas.workpadTable.table.nameColumnTitle": "Workpad 名称", + "xpack.canvas.workpadTable.table.updatedColumnTitle": "更新时间", + "xpack.canvas.workpadTableTools.deleteButtonAriaLabel": "删除 {numberOfWorkpads} 个 Workpad", + "xpack.canvas.workpadTableTools.deleteButtonLabel": "删除 ({numberOfWorkpads})", + "xpack.canvas.workpadTableTools.deleteModalConfirmButtonLabel": "删除", + "xpack.canvas.workpadTableTools.deleteModalDescription": "您无法恢复删除的 Workpad。", + "xpack.canvas.workpadTableTools.deleteMultipleWorkpadsModalTitle": "删除 {numberOfWorkpads} 个 Workpad?", + "xpack.canvas.workpadTableTools.deleteSingleWorkpadModalTitle": "删除 Workpad“{workpadName}”?", + "xpack.canvas.workpadTableTools.exportButtonAriaLabel": "导出 {numberOfWorkpads} 个 Workpad", + "xpack.canvas.workpadTableTools.exportButtonLabel": "导出 ({numberOfWorkpads})", + "xpack.canvas.workpadTableTools.noPermissionToCreateToolTip": "您无权创建 Workpad", + "xpack.canvas.workpadTableTools.noPermissionToDeleteToolTip": "您无权删除 Workpad", + "xpack.canvas.workpadTableTools.noPermissionToUploadToolTip": "您无权上传 Workpad", + "xpack.canvas.workpadTemplates.cloneTemplateLinkAriaLabel": "克隆 Workpad 模板“{templateName}”", + "xpack.canvas.workpadTemplates.creatingTemplateLabel": "正在从模板“{templateName}”创建", + "xpack.canvas.workpadTemplates.searchPlaceholder": "查找模板", "xpack.canvas.workpadTemplates.table.descriptionColumnTitle": "描述", "xpack.canvas.workpadTemplates.table.nameColumnTitle": "模板名称", "xpack.canvas.workpadTemplates.table.tagsColumnTitle": "标签", diff --git a/x-pack/test/accessibility/apps/canvas.ts b/x-pack/test/accessibility/apps/canvas.ts index a79fb7b60e76a1..609c8bf5bb1ae4 100644 --- a/x-pack/test/accessibility/apps/canvas.ts +++ b/x-pack/test/accessibility/apps/canvas.ts @@ -23,7 +23,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('loads workpads', async function () { await retry.waitFor( 'canvas workpads visible', - async () => await testSubjects.exists('canvasWorkpadLoaderTable') + async () => await testSubjects.exists('canvasWorkpadTable') ); await a11y.testAppSnapshot(); }); diff --git a/x-pack/test/functional/apps/canvas/smoke_test.js b/x-pack/test/functional/apps/canvas/smoke_test.js index 5280ad0118fbac..fcc04aafdbcd85 100644 --- a/x-pack/test/functional/apps/canvas/smoke_test.js +++ b/x-pack/test/functional/apps/canvas/smoke_test.js @@ -17,7 +17,7 @@ export default function canvasSmokeTest({ getService, getPageObjects }) { describe('smoke test', function () { this.tags('includeFirefox'); - const workpadListSelector = 'canvasWorkpadLoaderTable > canvasWorkpadLoaderWorkpad'; + const workpadListSelector = 'canvasWorkpadTable > canvasWorkpadTableWorkpad'; const testWorkpadId = 'workpad-1705f884-6224-47de-ba49-ca224fe6ec31'; before(async () => { diff --git a/x-pack/test/functional/page_objects/canvas_page.ts b/x-pack/test/functional/page_objects/canvas_page.ts index 0e0203046fd16e..df92c1c398d93d 100644 --- a/x-pack/test/functional/page_objects/canvas_page.ts +++ b/x-pack/test/functional/page_objects/canvas_page.ts @@ -39,7 +39,7 @@ export function CanvasPageProvider({ getService, getPageObjects }: FtrProviderCo * to load the workpad. Resolves once the workpad is in the DOM */ async loadFirstWorkpad(workpadName: string) { - const elem = await testSubjects.find('canvasWorkpadLoaderWorkpad'); + const elem = await testSubjects.find('canvasWorkpadTableWorkpad'); const text = await elem.getVisibleText(); expect(text).to.be(workpadName); await elem.click(); From 86fb2cc90e38e5e9c783c94b9904387c6b06dea6 Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Tue, 22 Jun 2021 15:18:35 -0400 Subject: [PATCH 56/57] [actions] add rule saved object reference to action execution event log doc (#101526) resolves https://github.com/elastic/kibana/issues/99225 Prior to this PR, when an alerting connection action was executed, the event log document generated did not contain a reference to the originating rule. This makes it difficult to diagnose problems with connector errors, since the error is often in the parameters specified in the actions in the alert. In this PR, a reference to the alerting rule is added to the saved_objects field in the event document for these events. --- .../actions/server/actions_client.test.ts | 64 ++++++++++++++ .../plugins/actions/server/actions_client.ts | 9 +- .../server/create_execute_function.test.ts | 56 ++++++++++++ .../actions/server/create_execute_function.ts | 5 +- .../actions/server/lib/action_executor.ts | 13 +++ .../server/lib/related_saved_objects.test.ts | 86 +++++++++++++++++++ .../server/lib/related_saved_objects.ts | 31 +++++++ .../server/lib/task_runner_factory.test.ts | 76 ++++++++++++++++ .../actions/server/lib/task_runner_factory.ts | 4 +- .../actions/server/routes/execute.test.ts | 2 + .../plugins/actions/server/routes/execute.ts | 1 + .../server/routes/legacy/execute.test.ts | 2 + .../actions/server/routes/legacy/execute.ts | 1 + .../server/saved_objects/mappings.json | 4 + .../create_execution_handler.test.ts | 32 +++++++ .../task_runner/create_execution_handler.ts | 12 ++- .../server/task_runner/task_runner.test.ts | 32 +++++++ x-pack/plugins/event_log/README.md | 29 ++++--- 18 files changed, 442 insertions(+), 17 deletions(-) create mode 100644 x-pack/plugins/actions/server/lib/related_saved_objects.test.ts create mode 100644 x-pack/plugins/actions/server/lib/related_saved_objects.ts diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts index 3b91b07eb30f4e..16388b2faf52e1 100644 --- a/x-pack/plugins/actions/server/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client.test.ts @@ -1676,6 +1676,70 @@ describe('execute()', () => { name: 'my name', }, }); + + await expect( + actionsClient.execute({ + actionId, + params: { + name: 'my name', + }, + relatedSavedObjects: [ + { + id: 'some-id', + typeId: 'some-type-id', + type: 'some-type', + }, + ], + }) + ).resolves.toMatchObject({ status: 'ok', actionId }); + + expect(actionExecutor.execute).toHaveBeenCalledWith({ + actionId, + request, + params: { + name: 'my name', + }, + relatedSavedObjects: [ + { + id: 'some-id', + typeId: 'some-type-id', + type: 'some-type', + }, + ], + }); + + await expect( + actionsClient.execute({ + actionId, + params: { + name: 'my name', + }, + relatedSavedObjects: [ + { + id: 'some-id', + typeId: 'some-type-id', + type: 'some-type', + namespace: 'some-namespace', + }, + ], + }) + ).resolves.toMatchObject({ status: 'ok', actionId }); + + expect(actionExecutor.execute).toHaveBeenCalledWith({ + actionId, + request, + params: { + name: 'my name', + }, + relatedSavedObjects: [ + { + id: 'some-id', + typeId: 'some-type-id', + type: 'some-type', + namespace: 'some-namespace', + }, + ], + }); }); }); diff --git a/x-pack/plugins/actions/server/actions_client.ts b/x-pack/plugins/actions/server/actions_client.ts index 449d218ed5ae04..f8d13cdafa7557 100644 --- a/x-pack/plugins/actions/server/actions_client.ts +++ b/x-pack/plugins/actions/server/actions_client.ts @@ -469,6 +469,7 @@ export class ActionsClient { actionId, params, source, + relatedSavedObjects, }: Omit): Promise> { if ( (await getAuthorizationModeBySource(this.unsecuredSavedObjectsClient, source)) === @@ -476,7 +477,13 @@ export class ActionsClient { ) { await this.authorization.ensureAuthorized('execute'); } - return this.actionExecutor.execute({ actionId, params, source, request: this.request }); + return this.actionExecutor.execute({ + actionId, + params, + source, + request: this.request, + relatedSavedObjects, + }); } public async enqueueExecution(options: EnqueueExecutionOptions): Promise { diff --git a/x-pack/plugins/actions/server/create_execute_function.test.ts b/x-pack/plugins/actions/server/create_execute_function.test.ts index 4cacba6dc880ab..ee8064d2aadc53 100644 --- a/x-pack/plugins/actions/server/create_execute_function.test.ts +++ b/x-pack/plugins/actions/server/create_execute_function.test.ts @@ -83,6 +83,62 @@ describe('execute()', () => { }); }); + test('schedules the action with all given parameters and relatedSavedObjects', async () => { + const actionTypeRegistry = actionTypeRegistryMock.create(); + const executeFn = createExecutionEnqueuerFunction({ + taskManager: mockTaskManager, + actionTypeRegistry, + isESOCanEncrypt: true, + preconfiguredActions: [], + }); + savedObjectsClient.get.mockResolvedValueOnce({ + id: '123', + type: 'action', + attributes: { + actionTypeId: 'mock-action', + }, + references: [], + }); + savedObjectsClient.create.mockResolvedValueOnce({ + id: '234', + type: 'action_task_params', + attributes: {}, + references: [], + }); + await executeFn(savedObjectsClient, { + id: '123', + params: { baz: false }, + spaceId: 'default', + apiKey: Buffer.from('123:abc').toString('base64'), + source: asHttpRequestExecutionSource(request), + relatedSavedObjects: [ + { + id: 'some-id', + namespace: 'some-namespace', + type: 'some-type', + typeId: 'some-typeId', + }, + ], + }); + expect(savedObjectsClient.create).toHaveBeenCalledWith( + 'action_task_params', + { + actionId: '123', + params: { baz: false }, + apiKey: Buffer.from('123:abc').toString('base64'), + relatedSavedObjects: [ + { + id: 'some-id', + namespace: 'some-namespace', + type: 'some-type', + typeId: 'some-typeId', + }, + ], + }, + {} + ); + }); + test('schedules the action with all given parameters with a preconfigured action', async () => { const executeFn = createExecutionEnqueuerFunction({ taskManager: mockTaskManager, diff --git a/x-pack/plugins/actions/server/create_execute_function.ts b/x-pack/plugins/actions/server/create_execute_function.ts index 4f3ffbef36c6e2..7dcd66c711bdde 100644 --- a/x-pack/plugins/actions/server/create_execute_function.ts +++ b/x-pack/plugins/actions/server/create_execute_function.ts @@ -11,6 +11,7 @@ import { RawAction, ActionTypeRegistryContract, PreConfiguredAction } from './ty import { ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from './constants/saved_objects'; import { ExecuteOptions as ActionExecutorOptions } from './lib/action_executor'; import { isSavedObjectExecutionSource } from './lib'; +import { RelatedSavedObjects } from './lib/related_saved_objects'; interface CreateExecuteFunctionOptions { taskManager: TaskManagerStartContract; @@ -23,6 +24,7 @@ export interface ExecuteOptions extends Pick { request: KibanaRequest; params: Record; source?: ActionExecutionSource; + relatedSavedObjects?: RelatedSavedObjects; } export type ActionExecutorContract = PublicMethodsOf; @@ -68,6 +70,7 @@ export class ActionExecutor { params, request, source, + relatedSavedObjects, }: ExecuteOptions): Promise> { if (!this.isInitialized) { throw new Error('ActionExecutor not initialized'); @@ -154,6 +157,16 @@ export class ActionExecutor { }, }; + for (const relatedSavedObject of relatedSavedObjects || []) { + event.kibana?.saved_objects?.push({ + rel: SAVED_OBJECT_REL_PRIMARY, + type: relatedSavedObject.type, + id: relatedSavedObject.id, + type_id: relatedSavedObject.typeId, + namespace: relatedSavedObject.namespace, + }); + } + eventLogger.startTiming(event); let rawResult: ActionTypeExecutorResult; try { diff --git a/x-pack/plugins/actions/server/lib/related_saved_objects.test.ts b/x-pack/plugins/actions/server/lib/related_saved_objects.test.ts new file mode 100644 index 00000000000000..8fd13d13756977 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/related_saved_objects.test.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { validatedRelatedSavedObjects } from './related_saved_objects'; +import { loggingSystemMock } from '../../../../../src/core/server/mocks'; +import { Logger } from '../../../../../src/core/server'; + +const loggerMock = loggingSystemMock.createLogger(); + +describe('related_saved_objects', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('validates valid objects', () => { + ensureValid(loggerMock, undefined); + ensureValid(loggerMock, []); + ensureValid(loggerMock, [ + { + id: 'some-id', + type: 'some-type', + }, + ]); + ensureValid(loggerMock, [ + { + id: 'some-id', + type: 'some-type', + typeId: 'some-type-id', + }, + ]); + ensureValid(loggerMock, [ + { + id: 'some-id', + type: 'some-type', + namespace: 'some-namespace', + }, + ]); + ensureValid(loggerMock, [ + { + id: 'some-id', + type: 'some-type', + typeId: 'some-type-id', + namespace: 'some-namespace', + }, + ]); + ensureValid(loggerMock, [ + { + id: 'some-id', + type: 'some-type', + }, + { + id: 'some-id-2', + type: 'some-type-2', + }, + ]); + }); +}); + +it('handles invalid objects', () => { + ensureInvalid(loggerMock, 42); + ensureInvalid(loggerMock, {}); + ensureInvalid(loggerMock, [{}]); + ensureInvalid(loggerMock, [{ id: 'some-id' }]); + ensureInvalid(loggerMock, [{ id: 42 }]); + ensureInvalid(loggerMock, [{ id: 'some-id', type: 'some-type', x: 42 }]); +}); + +function ensureValid(logger: Logger, savedObjects: unknown) { + const result = validatedRelatedSavedObjects(logger, savedObjects); + expect(result).toEqual(savedObjects === undefined ? [] : savedObjects); + expect(loggerMock.warn).not.toHaveBeenCalled(); +} + +function ensureInvalid(logger: Logger, savedObjects: unknown) { + const result = validatedRelatedSavedObjects(logger, savedObjects); + expect(result).toEqual([]); + + const message = loggerMock.warn.mock.calls[0][0]; + expect(message).toMatch( + /ignoring invalid related saved objects: expected value of type \[array\] but got/ + ); +} diff --git a/x-pack/plugins/actions/server/lib/related_saved_objects.ts b/x-pack/plugins/actions/server/lib/related_saved_objects.ts new file mode 100644 index 00000000000000..160587a3a9a8be --- /dev/null +++ b/x-pack/plugins/actions/server/lib/related_saved_objects.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; +import { Logger } from '../../../../../src/core/server'; + +export type RelatedSavedObjects = TypeOf; + +const RelatedSavedObjectsSchema = schema.arrayOf( + schema.object({ + namespace: schema.maybe(schema.string({ minLength: 1 })), + id: schema.string({ minLength: 1 }), + type: schema.string({ minLength: 1 }), + // optional; for SO types like action/alert that have type id's + typeId: schema.maybe(schema.string({ minLength: 1 })), + }), + { defaultValue: [] } +); + +export function validatedRelatedSavedObjects(logger: Logger, data: unknown): RelatedSavedObjects { + try { + return RelatedSavedObjectsSchema.validate(data); + } catch (err) { + logger.warn(`ignoring invalid related saved objects: ${err.message}`); + return []; + } +} diff --git a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts index 229324c1f0df38..2292994e3ccfde 100644 --- a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts +++ b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts @@ -126,6 +126,7 @@ test('executes the task by calling the executor with proper parameters', async ( expect(mockedActionExecutor.execute).toHaveBeenCalledWith({ actionId: '2', params: { baz: true }, + relatedSavedObjects: [], request: expect.objectContaining({ headers: { // base64 encoded "123:abc" @@ -247,6 +248,7 @@ test('uses API key when provided', async () => { expect(mockedActionExecutor.execute).toHaveBeenCalledWith({ actionId: '2', params: { baz: true }, + relatedSavedObjects: [], request: expect.objectContaining({ headers: { // base64 encoded "123:abc" @@ -262,6 +264,79 @@ test('uses API key when provided', async () => { ); }); +test('uses relatedSavedObjects when provided', async () => { + const taskRunner = taskRunnerFactory.create({ + taskInstance: mockedTaskInstance, + }); + + mockedActionExecutor.execute.mockResolvedValueOnce({ status: 'ok', actionId: '2' }); + spaceIdToNamespace.mockReturnValueOnce('namespace-test'); + mockedEncryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '3', + type: 'action_task_params', + attributes: { + actionId: '2', + params: { baz: true }, + apiKey: Buffer.from('123:abc').toString('base64'), + relatedSavedObjects: [{ id: 'some-id', type: 'some-type' }], + }, + references: [], + }); + + await taskRunner.run(); + + expect(mockedActionExecutor.execute).toHaveBeenCalledWith({ + actionId: '2', + params: { baz: true }, + relatedSavedObjects: [ + { + id: 'some-id', + type: 'some-type', + }, + ], + request: expect.objectContaining({ + headers: { + // base64 encoded "123:abc" + authorization: 'ApiKey MTIzOmFiYw==', + }, + }), + }); +}); + +test('sanitizes invalid relatedSavedObjects when provided', async () => { + const taskRunner = taskRunnerFactory.create({ + taskInstance: mockedTaskInstance, + }); + + mockedActionExecutor.execute.mockResolvedValueOnce({ status: 'ok', actionId: '2' }); + spaceIdToNamespace.mockReturnValueOnce('namespace-test'); + mockedEncryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '3', + type: 'action_task_params', + attributes: { + actionId: '2', + params: { baz: true }, + apiKey: Buffer.from('123:abc').toString('base64'), + relatedSavedObjects: [{ Xid: 'some-id', type: 'some-type' }], + }, + references: [], + }); + + await taskRunner.run(); + + expect(mockedActionExecutor.execute).toHaveBeenCalledWith({ + actionId: '2', + params: { baz: true }, + relatedSavedObjects: [], + request: expect.objectContaining({ + headers: { + // base64 encoded "123:abc" + authorization: 'ApiKey MTIzOmFiYw==', + }, + }), + }); +}); + test(`doesn't use API key when not provided`, async () => { const factory = new TaskRunnerFactory(mockedActionExecutor); factory.initialize(taskRunnerFactoryInitializerParams); @@ -284,6 +359,7 @@ test(`doesn't use API key when not provided`, async () => { expect(mockedActionExecutor.execute).toHaveBeenCalledWith({ actionId: '2', params: { baz: true }, + relatedSavedObjects: [], request: expect.objectContaining({ headers: {}, }), diff --git a/x-pack/plugins/actions/server/lib/task_runner_factory.ts b/x-pack/plugins/actions/server/lib/task_runner_factory.ts index cf4b1576f27786..0515963ab82f4e 100644 --- a/x-pack/plugins/actions/server/lib/task_runner_factory.ts +++ b/x-pack/plugins/actions/server/lib/task_runner_factory.ts @@ -30,6 +30,7 @@ import { } from '../types'; import { ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from '../constants/saved_objects'; import { asSavedObjectExecutionSource } from './action_execution_source'; +import { validatedRelatedSavedObjects } from './related_saved_objects'; export interface TaskRunnerContext { logger: Logger; @@ -77,7 +78,7 @@ export class TaskRunnerFactory { const namespace = spaceIdToNamespace(spaceId); const { - attributes: { actionId, params, apiKey }, + attributes: { actionId, params, apiKey, relatedSavedObjects }, references, } = await encryptedSavedObjectsClient.getDecryptedAsInternalUser( ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, @@ -117,6 +118,7 @@ export class TaskRunnerFactory { actionId, request: fakeRequest, ...getSourceFromReferences(references), + relatedSavedObjects: validatedRelatedSavedObjects(logger, relatedSavedObjects), }); } catch (e) { if (e instanceof ActionTypeDisabledError) { diff --git a/x-pack/plugins/actions/server/routes/execute.test.ts b/x-pack/plugins/actions/server/routes/execute.test.ts index 4b12bf3111c1f5..54e10698e5af96 100644 --- a/x-pack/plugins/actions/server/routes/execute.test.ts +++ b/x-pack/plugins/actions/server/routes/execute.test.ts @@ -65,6 +65,7 @@ describe('executeActionRoute', () => { someData: 'data', }, source: asHttpRequestExecutionSource(req), + relatedSavedObjects: [], }); expect(res.ok).toHaveBeenCalled(); @@ -101,6 +102,7 @@ describe('executeActionRoute', () => { expect(actionsClient.execute).toHaveBeenCalledWith({ actionId: '1', params: {}, + relatedSavedObjects: [], source: asHttpRequestExecutionSource(req), }); diff --git a/x-pack/plugins/actions/server/routes/execute.ts b/x-pack/plugins/actions/server/routes/execute.ts index 377fe1215b3fb0..7e8110365e87a2 100644 --- a/x-pack/plugins/actions/server/routes/execute.ts +++ b/x-pack/plugins/actions/server/routes/execute.ts @@ -53,6 +53,7 @@ export const executeActionRoute = ( params, actionId: id, source: asHttpRequestExecutionSource(req), + relatedSavedObjects: [], }); return body ? res.ok({ diff --git a/x-pack/plugins/actions/server/routes/legacy/execute.test.ts b/x-pack/plugins/actions/server/routes/legacy/execute.test.ts index 2ac53ddaaedf64..05b71819911a3d 100644 --- a/x-pack/plugins/actions/server/routes/legacy/execute.test.ts +++ b/x-pack/plugins/actions/server/routes/legacy/execute.test.ts @@ -63,6 +63,7 @@ describe('executeActionRoute', () => { someData: 'data', }, source: asHttpRequestExecutionSource(req), + relatedSavedObjects: [], }); expect(res.ok).toHaveBeenCalled(); @@ -100,6 +101,7 @@ describe('executeActionRoute', () => { actionId: '1', params: {}, source: asHttpRequestExecutionSource(req), + relatedSavedObjects: [], }); expect(res.ok).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/actions/server/routes/legacy/execute.ts b/x-pack/plugins/actions/server/routes/legacy/execute.ts index f6ddec1d01c200..d7ed8d2e156041 100644 --- a/x-pack/plugins/actions/server/routes/legacy/execute.ts +++ b/x-pack/plugins/actions/server/routes/legacy/execute.ts @@ -48,6 +48,7 @@ export const executeActionRoute = ( params, actionId: id, source: asHttpRequestExecutionSource(req), + relatedSavedObjects: [], }); return body ? res.ok({ diff --git a/x-pack/plugins/actions/server/saved_objects/mappings.json b/x-pack/plugins/actions/server/saved_objects/mappings.json index c598b96ba24513..57f801ae9a0758 100644 --- a/x-pack/plugins/actions/server/saved_objects/mappings.json +++ b/x-pack/plugins/actions/server/saved_objects/mappings.json @@ -35,6 +35,10 @@ }, "apiKey": { "type": "binary" + }, + "relatedSavedObjects": { + "enabled": false, + "type": "object" } } } diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts index 25f0656163f5d3..033ffcceb6a0ae 100644 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts @@ -135,6 +135,14 @@ test('enqueues execution per selected action', async () => { "foo": true, "stateVal": "My goes here", }, + "relatedSavedObjects": Array [ + Object { + "id": "1", + "namespace": "test1", + "type": "alert", + "typeId": "test", + }, + ], "source": Object { "source": Object { "id": "1", @@ -247,6 +255,14 @@ test(`doesn't call actionsPlugin.execute for disabled actionTypes`, async () => id: '1', type: 'alert', }), + relatedSavedObjects: [ + { + id: '1', + namespace: 'test1', + type: 'alert', + typeId: 'test', + }, + ], spaceId: 'test1', apiKey: createExecutionHandlerParams.apiKey, }); @@ -327,6 +343,14 @@ test('context attribute gets parameterized', async () => { "foo": true, "stateVal": "My goes here", }, + "relatedSavedObjects": Array [ + Object { + "id": "1", + "namespace": "test1", + "type": "alert", + "typeId": "test", + }, + ], "source": Object { "source": Object { "id": "1", @@ -360,6 +384,14 @@ test('state attribute gets parameterized', async () => { "foo": true, "stateVal": "My state-val goes here", }, + "relatedSavedObjects": Array [ + Object { + "id": "1", + "namespace": "test1", + "type": "alert", + "typeId": "test", + }, + ], "source": Object { "source": Object { "id": "1", diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts index c3a36297c217ac..968fff540dc030 100644 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts +++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts @@ -157,6 +157,8 @@ export function createExecutionHandler< continue; } + const namespace = spaceId === 'default' ? {} : { namespace: spaceId }; + // TODO would be nice to add the action name here, but it's not available const actionLabel = `${action.actionTypeId}:${action.id}`; const actionsClient = await actionsPlugin.getActionsClientWithRequest(request); @@ -169,10 +171,16 @@ export function createExecutionHandler< id: alertId, type: 'alert', }), + relatedSavedObjects: [ + { + id: alertId, + type: 'alert', + namespace: namespace.namespace, + typeId: alertType.id, + }, + ], }); - const namespace = spaceId === 'default' ? {} : { namespace: spaceId }; - const event: IEvent = { event: { action: EVENT_LOG_ACTIONS.executeAction, diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index 39a45584631d23..8ab267a5610d3b 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -352,6 +352,14 @@ describe('Task Runner', () => { "params": Object { "foo": true, }, + "relatedSavedObjects": Array [ + Object { + "id": "1", + "namespace": undefined, + "type": "alert", + "typeId": "test", + }, + ], "source": Object { "source": Object { "id": "1", @@ -1098,6 +1106,14 @@ describe('Task Runner', () => { "params": Object { "foo": true, }, + "relatedSavedObjects": Array [ + Object { + "id": "1", + "namespace": undefined, + "type": "alert", + "typeId": "test", + }, + ], "source": Object { "source": Object { "id": "1", @@ -1634,6 +1650,14 @@ describe('Task Runner', () => { "params": Object { "isResolved": true, }, + "relatedSavedObjects": Array [ + Object { + "id": "1", + "namespace": undefined, + "type": "alert", + "typeId": "test", + }, + ], "source": Object { "source": Object { "id": "1", @@ -1826,6 +1850,14 @@ describe('Task Runner', () => { "params": Object { "isResolved": true, }, + "relatedSavedObjects": Array [ + Object { + "id": "1", + "namespace": undefined, + "type": "alert", + "typeId": "test", + }, + ], "source": Object { "source": Object { "id": "1", diff --git a/x-pack/plugins/event_log/README.md b/x-pack/plugins/event_log/README.md index 032f77543acb97..ffbd20dd6f2bec 100644 --- a/x-pack/plugins/event_log/README.md +++ b/x-pack/plugins/event_log/README.md @@ -131,7 +131,7 @@ Below is a document in the expected structure, with descriptions of the fields: instance_id: "alert instance id, for relevant documents", action_group_id: "alert action group, for relevant documents", action_subgroup: "alert action subgroup, for relevant documents", - status: "overall alert status, after alert execution", + status: "overall alert status, after rule execution", }, saved_objects: [ { @@ -160,21 +160,26 @@ plugins: - `action: execute-via-http` - generated when an action is executed via HTTP request - `provider: alerting` - - `action: execute` - generated when an alert executor runs - - `action: execute-action` - generated when an alert schedules an action to run - - `action: new-instance` - generated when an alert has a new instance id that is active - - `action: recovered-instance` - generated when an alert has a previously active instance id that is no longer active - - `action: active-instance` - generated when an alert determines an instance id is active + - `action: execute` - generated when a rule executor runs + - `action: execute-action` - generated when a rule schedules an action to run + - `action: new-instance` - generated when a rule has a new instance id that is active + - `action: recovered-instance` - generated when a rule has a previously active instance id that is no longer active + - `action: active-instance` - generated when a rule determines an instance id is active For the `saved_objects` array elements, these are references to saved objects -associated with the event. For the `alerting` provider, those are alert saved -ojects and for the `actions` provider those are action saved objects. The -`alerts:execute-action` event includes both the alert and action saved object -references. For that event, only the alert reference has the optional `rel` +associated with the event. For the `alerting` provider, those are rule saved +ojects and for the `actions` provider those are connector saved objects. The +`alerts:execute-action` event includes both the rule and connector saved object +references. For that event, only the rule reference has the optional `rel` property with a `primary` value. This property is used when searching the event log to indicate which saved objects should be directly searchable via -saved object references. For the `alerts:execute-action` event, searching -only via the alert saved object reference will return the event. +saved object references. For the `alerts:execute-action` event, only searching +via the rule saved object reference will return the event; searching via the +connector save object reference will **NOT** return the event. The +`actions:execute` event also includes both the rule and connector saved object +references, and both of them have the `rel` property with a `primary` value, +allowing those events to be returned in searches of either the rule or +connector. ## Event Log index - associated resources From b386ce149a8d2175005fbe5045ee48bf8c56f977 Mon Sep 17 00:00:00 2001 From: Constance Date: Tue, 22 Jun 2021 12:27:27 -0700 Subject: [PATCH 57/57] [App Search] Convert Schema pages to new page template (#102846) * Convert Schema page to new page template + update empty state - remove panel wrapper, add create schema field modal * Convert ReindexJob view to new page template + remove breadcrumb prop * Convert Meta Engine Schema view to new page template * Update routers * [Polish] Misc Davey Schema UI tweaks - see https://github.com/elastic/kibana/pull/101958/files + change color away from secondary, since that's going away in EUI at some point * [UX] Fix SchemaAddFieldModal stuttering on first new schema field add - With the new template, transitioning from the empty state to the filled schema state causes the modal to stutter due to the component rerender - Changing the page to not instantly react/update `hasSchema` when local schema state changes but instead to wait for the server call to finish and for cachedSchema to update fixes the UX problem * [UI polish] Revert button color change per Davey's feedback --- .../components/engine/engine_router.tsx | 10 +- .../schema/components/empty_state.test.tsx | 11 ++ .../schema/components/empty_state.tsx | 16 ++- .../schema/reindex_job/reindex_job.test.tsx | 17 +-- .../schema/reindex_job/reindex_job.tsx | 61 ++++---- .../components/schema/schema_logic.test.ts | 8 +- .../components/schema/schema_logic.ts | 5 +- .../components/schema/schema_router.tsx | 12 +- .../schema/views/meta_engine_schema.test.tsx | 10 +- .../schema/views/meta_engine_schema.tsx | 131 +++++++++--------- .../components/schema/views/schema.test.tsx | 28 +--- .../components/schema/views/schema.tsx | 50 +++---- .../shared/schema/add_field_modal/index.tsx | 10 +- 13 files changed, 172 insertions(+), 197 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx index fc057858426d2f..91a21847107a95 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx @@ -109,6 +109,11 @@ export const EngineRouter: React.FC = () => { )} + {canViewEngineSchema && ( + + + + )} {canManageEngineSearchUi && ( @@ -121,11 +126,6 @@ export const EngineRouter: React.FC = () => { )} {/* TODO: Remove layout once page template migration is over */} }> - {canViewEngineSchema && ( - - - - )} {canManageEngineCurations && ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/empty_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/empty_state.test.tsx index ea658c741b8a0d..1b353f17855d2a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/empty_state.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/empty_state.test.tsx @@ -5,12 +5,16 @@ * 2.0. */ +import { setMockValues } from '../../../../__mocks__/kea_logic'; + import React from 'react'; import { shallow } from 'enzyme'; import { EuiEmptyPrompt, EuiButton } from '@elastic/eui'; +import { SchemaAddFieldModal } from '../../../../shared/schema'; + import { EmptyState } from './'; describe('EmptyState', () => { @@ -24,4 +28,11 @@ describe('EmptyState', () => { expect.stringContaining('#indexing-documents-guide-schema') ); }); + + it('renders a modal that lets a user add a new schema field', () => { + setMockValues({ isModalOpen: true }); + const wrapper = shallow(); + + expect(wrapper.find(SchemaAddFieldModal)).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/empty_state.tsx index 6d7dd198d5eef6..ad9285c7b8fefb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/empty_state.tsx @@ -7,14 +7,21 @@ import React from 'react'; -import { EuiPanel, EuiEmptyPrompt, EuiButton } from '@elastic/eui'; +import { useValues, useActions } from 'kea'; + +import { EuiEmptyPrompt, EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { SchemaAddFieldModal } from '../../../../shared/schema'; import { DOCS_PREFIX } from '../../../routes'; +import { SchemaLogic } from '../schema_logic'; export const EmptyState: React.FC = () => { + const { isModalOpen } = useValues(SchemaLogic); + const { addSchemaField, closeModal } = useActions(SchemaLogic); + return ( - + <> { } /> - + {isModalOpen && ( + + )} + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/reindex_job/reindex_job.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/reindex_job/reindex_job.test.tsx index e76ab60005231d..4dd7a869ca27ef 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/reindex_job/reindex_job.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/reindex_job/reindex_job.test.tsx @@ -14,15 +14,11 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { Loading } from '../../../../shared/loading'; import { SchemaErrorsAccordion } from '../../../../shared/schema'; import { ReindexJob } from './'; describe('ReindexJob', () => { - const props = { - schemaBreadcrumb: ['Engines', 'some-engine', 'Schema'], - }; const values = { dataLoading: false, fieldCoercionErrors: {}, @@ -43,27 +39,20 @@ describe('ReindexJob', () => { }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(SchemaErrorsAccordion)).toHaveLength(1); expect(wrapper.find(SchemaErrorsAccordion).prop('generateViewPath')).toHaveLength(1); }); it('calls loadReindexJob on page load', () => { - shallow(); + shallow(); expect(actions.loadReindexJob).toHaveBeenCalledWith('abc1234567890'); }); - it('renders a loading state', () => { - setMockValues({ ...values, dataLoading: true }); - const wrapper = shallow(); - - expect(wrapper.find(Loading)).toHaveLength(1); - }); - it('renders schema errors with links to document pages', () => { - const wrapper = shallow(); + const wrapper = shallow(); const generateViewPath = wrapper .find(SchemaErrorsAccordion) .prop('generateViewPath') as Function; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/reindex_job/reindex_job.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/reindex_job/reindex_job.tsx index 576b4ae11603be..b0a8cbd25f8b04 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/reindex_job/reindex_job.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/reindex_job/reindex_job.tsx @@ -10,25 +10,17 @@ import { useParams } from 'react-router-dom'; import { useActions, useValues } from 'kea'; -import { EuiPageHeader, EuiPageContentBody } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FlashMessages } from '../../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome'; -import { BreadcrumbTrail } from '../../../../shared/kibana_chrome/generate_breadcrumbs'; -import { Loading } from '../../../../shared/loading'; import { SchemaErrorsAccordion } from '../../../../shared/schema'; - import { ENGINE_DOCUMENT_DETAIL_PATH } from '../../../routes'; -import { EngineLogic, generateEnginePath } from '../../engine'; +import { EngineLogic, generateEnginePath, getEngineBreadcrumbs } from '../../engine'; +import { AppSearchPageTemplate } from '../../layout'; +import { SCHEMA_TITLE } from '../constants'; import { ReindexJobLogic } from './reindex_job_logic'; -interface Props { - schemaBreadcrumb: BreadcrumbTrail; -} - -export const ReindexJob: React.FC = ({ schemaBreadcrumb }) => { +export const ReindexJob: React.FC = () => { const { reindexJobId } = useParams() as { reindexJobId: string }; const { loadReindexJob } = useActions(ReindexJobLogic); const { dataLoading, fieldCoercionErrors } = useValues(ReindexJobLogic); @@ -40,34 +32,29 @@ export const ReindexJob: React.FC = ({ schemaBreadcrumb }) => { loadReindexJob(reindexJobId); }, [reindexJobId]); - if (dataLoading) return ; - return ( - <> - - + + generateEnginePath(ENGINE_DOCUMENT_DETAIL_PATH, { documentId }) + } /> - - - - generateEnginePath(ENGINE_DOCUMENT_DETAIL_PATH, { documentId }) - } - /> - - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_logic.test.ts index 7687296cf9f830..dcc5747b0d32f3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_logic.test.ts @@ -140,13 +140,13 @@ describe('SchemaLogic', () => { describe('selectors', () => { describe('hasSchema', () => { - it('returns true when the schema obj has items', () => { - mountAndSetSchema({ schema: { test: SchemaType.Text } }); + it('returns true when the cached server schema obj has items', () => { + mount({ cachedSchema: { test: SchemaType.Text } }); expect(SchemaLogic.values.hasSchema).toEqual(true); }); - it('returns false when the schema obj is empty', () => { - mountAndSetSchema({ schema: {} }); + it('returns false when the cached server schema obj is empty', () => { + mount({ schema: {} }); expect(SchemaLogic.values.hasSchema).toEqual(false); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_logic.ts index 3215a46c8e2998..3dcafd6782afd7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_logic.ts @@ -108,7 +108,10 @@ export const SchemaLogic = kea>({ ], }, selectors: { - hasSchema: [(selectors) => [selectors.schema], (schema) => Object.keys(schema).length > 0], + hasSchema: [ + (selectors) => [selectors.cachedSchema], + (cachedSchema) => Object.keys(cachedSchema).length > 0, + ], hasSchemaChanged: [ (selectors) => [selectors.schema, selectors.cachedSchema], (schema, cachedSchema) => !isEqual(schema, cachedSchema), diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_router.tsx index bfa346fee468bb..d358c489593c5a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_router.tsx @@ -10,27 +10,21 @@ import { Route, Switch } from 'react-router-dom'; import { useValues } from 'kea'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { ENGINE_REINDEX_JOB_PATH } from '../../routes'; -import { EngineLogic, getEngineBreadcrumbs } from '../engine'; +import { EngineLogic } from '../engine'; -import { SCHEMA_TITLE } from './constants'; import { ReindexJob } from './reindex_job'; import { Schema, MetaEngineSchema } from './views'; export const SchemaRouter: React.FC = () => { const { isMetaEngine } = useValues(EngineLogic); - const schemaBreadcrumb = getEngineBreadcrumbs([SCHEMA_TITLE]); return ( - - - - - {isMetaEngine ? : } + + {isMetaEngine ? : } ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/meta_engine_schema.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/meta_engine_schema.test.tsx index 1d677ad08db436..60a0513b774fdd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/meta_engine_schema.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/meta_engine_schema.test.tsx @@ -7,6 +7,7 @@ import { setMockValues, setMockActions } from '../../../../__mocks__/kea_logic'; import '../../../../__mocks__/shallow_useeffect.mock'; +import '../../../__mocks__/engine_logic.mock'; import React from 'react'; @@ -14,8 +15,6 @@ import { shallow } from 'enzyme'; import { EuiCallOut } from '@elastic/eui'; -import { Loading } from '../../../../shared/loading'; - import { MetaEnginesSchemaTable, MetaEnginesConflictsTable } from '../components'; import { MetaEngineSchema } from './'; @@ -46,13 +45,6 @@ describe('MetaEngineSchema', () => { expect(actions.loadSchema).toHaveBeenCalled(); }); - it('renders a loading state', () => { - setMockValues({ ...values, dataLoading: true }); - const wrapper = shallow(); - - expect(wrapper.find(Loading)).toHaveLength(1); - }); - it('renders an inactive fields callout & table when source engines have schema conflicts', () => { setMockValues({ ...values, hasConflicts: true, conflictingFieldsCount: 5 }); const wrapper = shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/meta_engine_schema.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/meta_engine_schema.tsx index 4c0235cf81129b..2eb8bac00a040d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/meta_engine_schema.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/meta_engine_schema.tsx @@ -9,14 +9,15 @@ import React, { useEffect } from 'react'; import { useValues, useActions } from 'kea'; -import { EuiPageHeader, EuiPageContentBody, EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { EuiCallOut, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FlashMessages } from '../../../../shared/flash_messages'; -import { Loading } from '../../../../shared/loading'; import { DataPanel } from '../../data_panel'; +import { getEngineBreadcrumbs } from '../../engine'; +import { AppSearchPageTemplate } from '../../layout'; import { MetaEnginesSchemaTable, MetaEnginesConflictsTable } from '../components'; +import { SCHEMA_TITLE } from '../constants'; import { MetaEngineSchemaLogic } from '../schema_meta_engine_logic'; export const MetaEngineSchema: React.FC = () => { @@ -27,90 +28,88 @@ export const MetaEngineSchema: React.FC = () => { loadSchema(); }, []); - if (dataLoading) return ; - return ( - <> - - - - {hasConflicts && ( - <> - + {hasConflicts && ( + <> + +

    + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.schema.metaEngine.conflictsCalloutDescription', { defaultMessage: - '{conflictingFieldsCount, plural, one {# field is} other {# fields are}} not searchable', - values: { conflictingFieldsCount }, + 'The field(s) have an inconsistent field-type across the source engines that make up this meta engine. Apply a consistent field-type from the source engines to make these fields searchable.', } )} - > -

    - {i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.schema.metaEngine.conflictsCalloutDescription', - { - defaultMessage: - 'The field(s) have an inconsistent field-type across the source engines that make up this meta engine. Apply a consistent field-type from the source engines to make these fields searchable.', - } - )} -

    -
    - - +

    +
    + + + )} + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.schema.metaEngine.activeFieldsTitle', + { defaultMessage: 'Active fields' } + )} + + } + subtitle={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.schema.metaEngine.activeFieldsDescription', + { defaultMessage: 'Fields which belong to one or more engine.' } )} + > + + + + {hasConflicts && ( {i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.schema.metaEngine.activeFieldsTitle', - { defaultMessage: 'Active fields' } + 'xpack.enterpriseSearch.appSearch.engine.schema.metaEngine.inactiveFieldsTitle', + { defaultMessage: 'Inactive fields' } )} } subtitle={i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.schema.metaEngine.activeFieldsDescription', - { defaultMessage: 'Fields which belong to one or more engine.' } + 'xpack.enterpriseSearch.appSearch.engine.schema.metaEngine.inactiveFieldsDescription', + { + defaultMessage: + 'These fields have type conflicts. To activate these fields, change types in the source engines to match.', + } )} > - + - - {hasConflicts && ( - - {i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.schema.metaEngine.inactiveFieldsTitle', - { defaultMessage: 'Inactive fields' } - )} - - } - subtitle={i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.schema.metaEngine.inactiveFieldsDescription', - { - defaultMessage: - 'These fields have type conflicts. To activate these fields, change types in the source engines to match.', - } - )} - > - - - )} -
    - + )} + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.test.tsx index 91ec8eda55fc36..cae16d70592faf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.test.tsx @@ -7,17 +7,18 @@ import { setMockValues, setMockActions } from '../../../../__mocks__/kea_logic'; import '../../../../__mocks__/shallow_useeffect.mock'; +import '../../../__mocks__/engine_logic.mock'; import React from 'react'; import { shallow } from 'enzyme'; -import { EuiPageHeader, EuiButton } from '@elastic/eui'; +import { EuiButton } from '@elastic/eui'; -import { Loading } from '../../../../shared/loading'; import { SchemaAddFieldModal } from '../../../../shared/schema'; +import { getPageHeaderActions } from '../../../../test_helpers'; -import { SchemaCallouts, SchemaTable, EmptyState } from '../components'; +import { SchemaCallouts, SchemaTable } from '../components'; import { Schema } from './'; @@ -56,27 +57,8 @@ describe('Schema', () => { expect(actions.loadSchema).toHaveBeenCalled(); }); - it('renders a loading state', () => { - setMockValues({ ...values, dataLoading: true }); - const wrapper = shallow(); - - expect(wrapper.find(Loading)).toHaveLength(1); - }); - - it('renders an empty state', () => { - setMockValues({ ...values, hasSchema: false }); - const wrapper = shallow(); - - expect(wrapper.find(EmptyState)).toHaveLength(1); - }); - describe('page action buttons', () => { - const subject = () => - shallow() - .find(EuiPageHeader) - .dive() - .children() - .dive(); + const subject = () => getPageHeaderActions(shallow()); it('renders', () => { const wrapper = subject(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.tsx index 7bc995b16468aa..d2a760e8accff3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.tsx @@ -9,14 +9,15 @@ import React, { useEffect } from 'react'; import { useValues, useActions } from 'kea'; -import { EuiPageHeader, EuiButton, EuiPageContentBody } from '@elastic/eui'; +import { EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FlashMessages } from '../../../../shared/flash_messages'; -import { Loading } from '../../../../shared/loading'; import { SchemaAddFieldModal } from '../../../../shared/schema'; +import { getEngineBreadcrumbs } from '../../engine'; +import { AppSearchPageTemplate } from '../../layout'; import { SchemaCallouts, SchemaTable, EmptyState } from '../components'; +import { SCHEMA_TITLE } from '../constants'; import { SchemaLogic } from '../schema_logic'; export const Schema: React.FC = () => { @@ -31,19 +32,18 @@ export const Schema: React.FC = () => { loadSchema(); }, []); - if (dataLoading) return ; - return ( - <> - { > {i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.schema.updateSchemaButtonLabel', - { defaultMessage: 'Update types' } + { defaultMessage: 'Save changes' } )} , { { defaultMessage: 'Create a schema field' } )} , - ]} - /> - - - - {hasSchema ? : } - {isModalOpen && ( - - )} - - + ], + }} + isLoading={dataLoading} + isEmptyState={!hasSchema} + emptyState={} + > + + + {isModalOpen && ( + + )} + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/schema/add_field_modal/index.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/schema/add_field_modal/index.tsx index 902417d02665e6..ba9da900c01456 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/schema/add_field_modal/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/schema/add_field_modal/index.tsx @@ -10,6 +10,7 @@ import React, { ChangeEvent, FormEvent, useEffect, useState } from 'react'; import { EuiButton, EuiButtonEmpty, + EuiCallOut, EuiFieldText, EuiFlexGroup, EuiFlexItem, @@ -83,8 +84,13 @@ export const SchemaAddFieldModal: React.FC = ({ {ADD_FIELD_MODAL_TITLE} -

    {ADD_FIELD_MODAL_DESCRIPTION}

    - + {ADD_FIELD_MODAL_DESCRIPTION}

    } + /> +