From f9804057c85a0357698fa8ef708d14cdbd6342f0 Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Fri, 12 Feb 2021 15:46:05 -0600 Subject: [PATCH 1/4] TypeScript project references for APM (#90049) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Dario Gieselaar --- src/dev/typescript/projects.ts | 4 -- tsconfig.refs.json | 11 ++--- x-pack/plugins/apm/common/service_map.ts | 35 +++++++++------ x-pack/plugins/apm/kibana.json | 15 +++---- .../Filter/FilterTitleButton.tsx | 18 ++++++-- .../components/app/ServiceMap/Cytoscape.tsx | 2 +- .../Waterfall/SyncBadge.stories.tsx | 8 ++-- .../Waterfall/SyncBadge.tsx | 2 +- .../url_params_context/url_params_context.tsx | 2 +- ...se_transaction_throughput_chart_fetcher.ts | 4 +- .../selectors/latency_chart_selectors.ts | 2 +- ....ts => throughput_chart_selectors.test.ts} | 20 ++++----- ...ctors.ts => throughput_chart_selectors.ts} | 20 ++++----- x-pack/plugins/apm/readme.md | 2 +- x-pack/plugins/apm/scripts/precommit.js | 21 +++------ .../apm/scripts/shared/get_es_client.ts | 30 ++++++++----- x-pack/plugins/apm/scripts/tsconfig.json | 13 ------ .../scripts/upload-telemetry-data/index.ts | 2 +- .../create_apm_event_client/index.ts | 28 ++++++------ .../get_dynamic_index_pattern.ts | 2 +- .../services/get_service_metadata_details.ts | 2 +- .../services/get_service_metadata_icons.ts | 2 +- .../settings/custom_link/custom_link_types.ts | 33 ++++++++------ .../apm/server/lib/traces/get_trace_items.ts | 2 +- .../server/lib/transaction_groups/fetcher.ts | 2 +- x-pack/plugins/apm/tsconfig.json | 45 +++++++++++++++++++ x-pack/test/tsconfig.json | 1 + x-pack/tsconfig.json | 2 +- 28 files changed, 189 insertions(+), 141 deletions(-) rename x-pack/plugins/apm/public/selectors/{throuput_chart_selectors.test.ts => throughput_chart_selectors.test.ts} (79%) rename x-pack/plugins/apm/public/selectors/{throuput_chart_selectors.ts => throughput_chart_selectors.ts} (80%) delete mode 100644 x-pack/plugins/apm/scripts/tsconfig.json create mode 100644 x-pack/plugins/apm/tsconfig.json diff --git a/src/dev/typescript/projects.ts b/src/dev/typescript/projects.ts index a5e6c4bef832c3..29379bbb31ee12 100644 --- a/src/dev/typescript/projects.ts +++ b/src/dev/typescript/projects.ts @@ -28,10 +28,6 @@ export const PROJECTS = [ name: 'apm/ftr_e2e', disableTypeCheck: true, }), - new Project(resolve(REPO_ROOT, 'x-pack/plugins/apm/scripts/tsconfig.json'), { - name: 'apm/scripts', - disableTypeCheck: true, - }), // NOTE: using glob.sync rather than glob-all or globby // because it takes less than 10 ms, while the other modules diff --git a/tsconfig.refs.json b/tsconfig.refs.json index 4105f23fd5b3ea..39f3057ec9b2a7 100644 --- a/tsconfig.refs.json +++ b/tsconfig.refs.json @@ -57,6 +57,7 @@ { "path": "./src/plugins/index_pattern_management/tsconfig.json" }, { "path": "./x-pack/plugins/actions/tsconfig.json" }, { "path": "./x-pack/plugins/alerts/tsconfig.json" }, + { "path": "./x-pack/plugins/apm/tsconfig.json" }, { "path": "./x-pack/plugins/beats_management/tsconfig.json" }, { "path": "./x-pack/plugins/canvas/tsconfig.json" }, { "path": "./x-pack/plugins/cloud/tsconfig.json" }, @@ -106,10 +107,10 @@ { "path": "./x-pack/plugins/runtime_fields/tsconfig.json" }, { "path": "./x-pack/plugins/index_management/tsconfig.json" }, { "path": "./x-pack/plugins/watcher/tsconfig.json" }, - { "path": "./x-pack/plugins/rollup/tsconfig.json"}, - { "path": "./x-pack/plugins/remote_clusters/tsconfig.json"}, - { "path": "./x-pack/plugins/cross_cluster_replication/tsconfig.json"}, - { "path": "./x-pack/plugins/index_lifecycle_management/tsconfig.json"}, - { "path": "./x-pack/plugins/uptime/tsconfig.json" }, + { "path": "./x-pack/plugins/rollup/tsconfig.json" }, + { "path": "./x-pack/plugins/remote_clusters/tsconfig.json" }, + { "path": "./x-pack/plugins/cross_cluster_replication/tsconfig.json" }, + { "path": "./x-pack/plugins/index_lifecycle_management/tsconfig.json" }, + { "path": "./x-pack/plugins/uptime/tsconfig.json" } ] } diff --git a/x-pack/plugins/apm/common/service_map.ts b/x-pack/plugins/apm/common/service_map.ts index 31d319bfdbb360..303f6b02c0ea25 100644 --- a/x-pack/plugins/apm/common/service_map.ts +++ b/x-pack/plugins/apm/common/service_map.ts @@ -7,27 +7,34 @@ import { i18n } from '@kbn/i18n'; import cytoscape from 'cytoscape'; -import { - AGENT_NAME, - SERVICE_ENVIRONMENT, - SERVICE_NAME, - SPAN_DESTINATION_SERVICE_RESOURCE, - SPAN_SUBTYPE, - SPAN_TYPE, -} from './elasticsearch_fieldnames'; import { ServiceAnomalyStats } from './anomaly_detection'; +// These should be imported, but until TypeScript 4.2 we're inlining them here. +// All instances of "agent.name", "service.name", "service.environment", "span.type", +// "span.subtype", and "span.destination.service.resource" need to be changed +// back to using the constants. +// See https://github.com/microsoft/TypeScript/issues/37888 +// +// import { +// AGENT_NAME, +// SERVICE_ENVIRONMENT, +// SERVICE_NAME, +// SPAN_DESTINATION_SERVICE_RESOURCE, +// SPAN_SUBTYPE, +// SPAN_TYPE, +// } from './elasticsearch_fieldnames'; + export interface ServiceConnectionNode extends cytoscape.NodeDataDefinition { - [SERVICE_NAME]: string; - [SERVICE_ENVIRONMENT]: string | null; - [AGENT_NAME]: string; + 'service.name': string; + 'service.environment': string | null; + 'agent.name': string; serviceAnomalyStats?: ServiceAnomalyStats; label?: string; } export interface ExternalConnectionNode extends cytoscape.NodeDataDefinition { - [SPAN_DESTINATION_SERVICE_RESOURCE]: string; - [SPAN_TYPE]: string; - [SPAN_SUBTYPE]: string; + 'span.destination.service.resource': string; + 'span.type': string; + 'span.subtype': string; label?: string; } diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index 2625fc2f3c1cd1..fe9294a48893a5 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -25,19 +25,14 @@ ], "server": true, "ui": true, - "configPath": [ - "xpack", - "apm" - ], - "extraPublicDirs": [ - "public/style/variables" - ], + "configPath": ["xpack", "apm"], + "extraPublicDirs": ["public/style/variables"], "requiredBundles": [ + "home", "kibanaReact", "kibanaUtils", - "observability", - "home", "maps", - "ml" + "ml", + "observability" ] } diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/LocalUIFilters/Filter/FilterTitleButton.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/LocalUIFilters/Filter/FilterTitleButton.tsx index 1a59b7d910b1f9..a558813484807e 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/LocalUIFilters/Filter/FilterTitleButton.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/LocalUIFilters/Filter/FilterTitleButton.tsx @@ -5,11 +5,21 @@ * 2.0. */ -import React from 'react'; -import { EuiButtonEmpty, EuiTitle } from '@elastic/eui'; -import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; +import { EuiButtonEmpty, EuiButtonEmptyProps, EuiTitle } from '@elastic/eui'; +import React, { FunctionComponent } from 'react'; +import { StyledComponent } from 'styled-components'; +import { + euiStyled, + EuiTheme, +} from '../../../../../../../../../src/plugins/kibana_react/common'; -const Button = euiStyled(EuiButtonEmpty).attrs(() => ({ +// The return type of this component needs to be specified because the inferred +// return type depends on types that are not exported from EUI. You get a TS4023 +// error if the return type is not specified. +const Button: StyledComponent< + FunctionComponent, + EuiTheme +> = euiStyled(EuiButtonEmpty).attrs(() => ({ contentProps: { className: 'alignLeft', }, diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx index 9abadf2bdb9dbe..7651dba89e27e9 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx @@ -27,7 +27,7 @@ export const CytoscapeContext = createContext( undefined ); -interface CytoscapeProps { +export interface CytoscapeProps { children?: ReactNode; elements: cytoscape.ElementDefinition[]; height: number; diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.stories.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.stories.tsx index 282048a8c85d6b..8275aa1e5f156c 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.stories.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import React, { ComponentProps } from 'react'; -import { SyncBadge } from './SyncBadge'; +import React from 'react'; +import { SyncBadge, SyncBadgeProps } from './SyncBadge'; export default { title: 'app/TransactionDetails/SyncBadge', @@ -18,7 +18,7 @@ export default { }, }; -export function Example({ sync }: ComponentProps) { +export function Example({ sync }: SyncBadgeProps) { return ; } -Example.args = { sync: true } as ComponentProps; +Example.args = { sync: true } as SyncBadgeProps; diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.tsx index 24301b2cf10fbc..b9e4c6951fa06a 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.tsx @@ -16,7 +16,7 @@ const SpanBadge = euiStyled(EuiBadge)` margin-right: ${px(units.quarter)}; `; -interface SyncBadgeProps { +export interface SyncBadgeProps { /** * Is the request synchronous? True will show blocking, false will show async. */ diff --git a/x-pack/plugins/apm/public/context/url_params_context/url_params_context.tsx b/x-pack/plugins/apm/public/context/url_params_context/url_params_context.tsx index e29c092071894e..9cc11eef79eef3 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/url_params_context.tsx +++ b/x-pack/plugins/apm/public/context/url_params_context/url_params_context.tsx @@ -25,7 +25,7 @@ import { getDateRange } from './helpers'; import { resolveUrlParams } from './resolve_url_params'; import { IUrlParams } from './types'; -interface TimeRange { +export interface TimeRange { rangeFrom: string; rangeTo: string; } diff --git a/x-pack/plugins/apm/public/hooks/use_transaction_throughput_chart_fetcher.ts b/x-pack/plugins/apm/public/hooks/use_transaction_throughput_chart_fetcher.ts index cd2dbca7512afc..af9a5fee24877a 100644 --- a/x-pack/plugins/apm/public/hooks/use_transaction_throughput_chart_fetcher.ts +++ b/x-pack/plugins/apm/public/hooks/use_transaction_throughput_chart_fetcher.ts @@ -9,7 +9,7 @@ import { useMemo } from 'react'; import { useParams } from 'react-router-dom'; import { useFetcher } from './use_fetcher'; import { useUrlParams } from '../context/url_params_context/use_url_params'; -import { getThrouputChartSelector } from '../selectors/throuput_chart_selectors'; +import { getThroughputChartSelector } from '../selectors/throughput_chart_selectors'; import { useTheme } from './use_theme'; import { useApmServiceContext } from '../context/apm_service/use_apm_service_context'; @@ -45,7 +45,7 @@ export function useTransactionThroughputChartsFetcher() { ); const memoizedData = useMemo( - () => getThrouputChartSelector({ throuputChart: data, theme }), + () => getThroughputChartSelector({ throughputChart: data, theme }), [data, theme] ); diff --git a/x-pack/plugins/apm/public/selectors/latency_chart_selectors.ts b/x-pack/plugins/apm/public/selectors/latency_chart_selectors.ts index aac0c75dacaebb..858d44de8bb7a0 100644 --- a/x-pack/plugins/apm/public/selectors/latency_chart_selectors.ts +++ b/x-pack/plugins/apm/public/selectors/latency_chart_selectors.ts @@ -15,7 +15,7 @@ import { APIReturnType } from '../services/rest/createCallApmApi'; export type LatencyChartsResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/charts/latency'>; -interface LatencyChartData { +export interface LatencyChartData { latencyTimeseries: Array>; mlJobId?: string; anomalyTimeseries?: { boundaries: APMChartSpec[]; scores: APMChartSpec }; diff --git a/x-pack/plugins/apm/public/selectors/throuput_chart_selectors.test.ts b/x-pack/plugins/apm/public/selectors/throughput_chart_selectors.test.ts similarity index 79% rename from x-pack/plugins/apm/public/selectors/throuput_chart_selectors.test.ts rename to x-pack/plugins/apm/public/selectors/throughput_chart_selectors.test.ts index 89e406a9014671..b76b77abaa7bde 100644 --- a/x-pack/plugins/apm/public/selectors/throuput_chart_selectors.test.ts +++ b/x-pack/plugins/apm/public/selectors/throughput_chart_selectors.test.ts @@ -7,9 +7,9 @@ import { EuiTheme } from '../../../../../src/plugins/kibana_react/common'; import { - getThrouputChartSelector, - ThrouputChartsResponse, -} from './throuput_chart_selectors'; + getThroughputChartSelector, + ThroughputChartsResponse, +} from './throughput_chart_selectors'; const theme = { eui: { @@ -30,26 +30,26 @@ const throughputData = { { key: 'HTTP 4xx', avg: 1, dataPoints: [{ x: 1, y: 2 }] }, { key: 'HTTP 5xx', avg: 1, dataPoints: [{ x: 1, y: 2 }] }, ], -} as ThrouputChartsResponse; +} as ThroughputChartsResponse; -describe('getThrouputChartSelector', () => { +describe('getThroughputChartSelector', () => { it('returns default values when data is undefined', () => { - const throughputTimeseries = getThrouputChartSelector({ theme }); + const throughputTimeseries = getThroughputChartSelector({ theme }); expect(throughputTimeseries).toEqual({ throughputTimeseries: [] }); }); it('returns default values when timeseries is empty', () => { - const throughputTimeseries = getThrouputChartSelector({ + const throughputTimeseries = getThroughputChartSelector({ theme, - throuputChart: { throughputTimeseries: [] }, + throughputChart: { throughputTimeseries: [] }, }); expect(throughputTimeseries).toEqual({ throughputTimeseries: [] }); }); it('return throughput time series', () => { - const throughputTimeseries = getThrouputChartSelector({ + const throughputTimeseries = getThroughputChartSelector({ theme, - throuputChart: throughputData, + throughputChart: throughputData, }); expect(throughputTimeseries).toEqual({ diff --git a/x-pack/plugins/apm/public/selectors/throuput_chart_selectors.ts b/x-pack/plugins/apm/public/selectors/throughput_chart_selectors.ts similarity index 80% rename from x-pack/plugins/apm/public/selectors/throuput_chart_selectors.ts rename to x-pack/plugins/apm/public/selectors/throughput_chart_selectors.ts index daf1e69c2e5f99..f9e72bff231f4a 100644 --- a/x-pack/plugins/apm/public/selectors/throuput_chart_selectors.ts +++ b/x-pack/plugins/apm/public/selectors/throughput_chart_selectors.ts @@ -12,36 +12,36 @@ import { TimeSeries } from '../../typings/timeseries'; import { APIReturnType } from '../services/rest/createCallApmApi'; import { httpStatusCodeToColor } from '../utils/httpStatusCodeToColor'; -export type ThrouputChartsResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/charts/throughput'>; +export type ThroughputChartsResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/charts/throughput'>; -interface ThroughputChart { +export interface ThroughputChart { throughputTimeseries: TimeSeries[]; } -export function getThrouputChartSelector({ +export function getThroughputChartSelector({ theme, - throuputChart, + throughputChart, }: { theme: EuiTheme; - throuputChart?: ThrouputChartsResponse; + throughputChart?: ThroughputChartsResponse; }): ThroughputChart { - if (!throuputChart) { + if (!throughputChart) { return { throughputTimeseries: [] }; } return { - throughputTimeseries: getThroughputTimeseries({ throuputChart, theme }), + throughputTimeseries: getThroughputTimeseries({ throughputChart, theme }), }; } function getThroughputTimeseries({ - throuputChart, + throughputChart, theme, }: { theme: EuiTheme; - throuputChart: ThrouputChartsResponse; + throughputChart: ThroughputChartsResponse; }) { - const { throughputTimeseries } = throuputChart; + const { throughputTimeseries } = throughputChart; const bucketKeys = throughputTimeseries.map(({ key }) => key); const getColor = getColorByKey(bucketKeys, theme); diff --git a/x-pack/plugins/apm/readme.md b/x-pack/plugins/apm/readme.md index 00d7e8e1dd5e42..9ddbd1757ad94f 100644 --- a/x-pack/plugins/apm/readme.md +++ b/x-pack/plugins/apm/readme.md @@ -118,7 +118,7 @@ _Note: Run the following commands from `kibana/`._ ### Typescript ``` -yarn tsc --noEmit --project x-pack/tsconfig.json --skipLibCheck +yarn tsc --noEmit --project x-pack/plugins/apm/tsconfig.json --skipLibCheck ``` ### Prettier diff --git a/x-pack/plugins/apm/scripts/precommit.js b/x-pack/plugins/apm/scripts/precommit.js index 71943842b673ff..c741be6c0e9a6b 100644 --- a/x-pack/plugins/apm/scripts/precommit.js +++ b/x-pack/plugins/apm/scripts/precommit.js @@ -14,7 +14,7 @@ const { resolve } = require('path'); const cwd = resolve(__dirname, '../../../..'); -const execaOpts = { cwd, stderr: 'pipe' }; +const execaOpts = { cwd, stderr: 'inherit' }; const tasks = new Listr( [ @@ -36,18 +36,10 @@ const tasks = new Listr( { title: 'Typescript', task: () => - execa('node', [resolve(__dirname, 'optimize-tsconfig.js')]).then(() => - execa( - require.resolve('typescript/bin/tsc'), - [ - '--project', - resolve(__dirname, '../../../tsconfig.json'), - '--pretty', - '--noEmit', - '--skipLibCheck', - ], - execaOpts - ) + execa( + require.resolve('typescript/bin/tsc'), + ['--project', resolve(__dirname, '../tsconfig.json'), '--pretty'], + execaOpts ), }, { @@ -61,10 +53,9 @@ const tasks = new Listr( tasks.run().catch((error) => { // from src/dev/typescript/exec_in_projects.ts process.exitCode = 1; - const errors = error.errors || [error]; for (const e of errors) { - process.stderr.write(e.stdout); + process.stderr.write(e.stderr || e.stdout); } }); diff --git a/x-pack/plugins/apm/scripts/shared/get_es_client.ts b/x-pack/plugins/apm/scripts/shared/get_es_client.ts index ab443e081825a8..f17a55cf4e215a 100644 --- a/x-pack/plugins/apm/scripts/shared/get_es_client.ts +++ b/x-pack/plugins/apm/scripts/shared/get_es_client.ts @@ -30,17 +30,25 @@ export function getEsClient({ auth, }); - return { - ...client, - async search( - request: TSearchRequest - ) { - const response = await client.search(request as any); + async function search< + TDocument = unknown, + TSearchRequest extends ESSearchRequest = ESSearchRequest + >(request: TSearchRequest) { + const response = await client.search(request); - return { - ...response, - body: response.body as ESSearchResponse, - }; - }, + return { + ...response, + body: (response.body as unknown) as ESSearchResponse< + TDocument, + TSearchRequest + >, + }; + } + + // @ts-expect-error + client.search = search; + + return (client as unknown) as Omit & { + search: typeof search; }; } diff --git a/x-pack/plugins/apm/scripts/tsconfig.json b/x-pack/plugins/apm/scripts/tsconfig.json deleted file mode 100644 index f1643608496ad4..00000000000000 --- a/x-pack/plugins/apm/scripts/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../../../tsconfig.base.json", - "include": [ - "./**/*", - "../observability" - ], - "exclude": [], - "compilerOptions": { - "types": [ - "node" - ] - } -} diff --git a/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts b/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts index a7340cd2cfedf6..e4aedf452002dd 100644 --- a/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts +++ b/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts @@ -83,7 +83,7 @@ async function uploadData() { apmAgentConfigurationIndex: '.apm-agent-configuration', }, search: (body) => { - return unwrapEsResponse(client.search(body)); + return unwrapEsResponse(client.search(body as any)); }, indicesStats: (body) => { return unwrapEsResponse(client.indices.stats(body)); diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts index b93513646fb9f7..c47d511ca565c7 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts @@ -6,29 +6,29 @@ */ import { ValuesType } from 'utility-types'; -import { unwrapEsResponse } from '../../../../../../observability/server'; -import { APMError } from '../../../../../typings/es_schemas/ui/apm_error'; import { ElasticsearchClient, KibanaRequest, } from '../../../../../../../../src/core/server'; -import { ProcessorEvent } from '../../../../../common/processor_event'; import { ESSearchRequest, ESSearchResponse, } from '../../../../../../../typings/elasticsearch'; -import { ApmIndicesConfig } from '../../../settings/apm_indices/get_apm_indices'; -import { addFilterToExcludeLegacyData } from './add_filter_to_exclude_legacy_data'; -import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; -import { Span } from '../../../../../typings/es_schemas/ui/span'; +import { unwrapEsResponse } from '../../../../../../observability/server'; +import { ProcessorEvent } from '../../../../../common/processor_event'; +import { APMError } from '../../../../../typings/es_schemas/ui/apm_error'; import { Metric } from '../../../../../typings/es_schemas/ui/metric'; -import { unpackProcessorEvents } from './unpack_processor_events'; +import { Span } from '../../../../../typings/es_schemas/ui/span'; +import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; +import { ApmIndicesConfig } from '../../../settings/apm_indices/get_apm_indices'; import { callAsyncWithDebug, - getDebugTitle, getDebugBody, + getDebugTitle, } from '../call_async_with_debug'; import { cancelEsRequestOnAbort } from '../cancel_es_request_on_abort'; +import { addFilterToExcludeLegacyData } from './add_filter_to_exclude_legacy_data'; +import { unpackProcessorEvents } from './unpack_processor_events'; export type APMEventESSearchRequest = Omit & { apm: { @@ -36,11 +36,13 @@ export type APMEventESSearchRequest = Omit & { }; }; +// These keys shoul all be `ProcessorEvent.x`, but until TypeScript 4.2 we're inlining them here. +// See https://github.com/microsoft/TypeScript/issues/37888 type TypeOfProcessorEvent = { - [ProcessorEvent.error]: APMError; - [ProcessorEvent.transaction]: Transaction; - [ProcessorEvent.span]: Span; - [ProcessorEvent.metric]: Metric; + error: APMError; + transaction: Transaction; + span: Span; + metric: Metric; }[T]; type ESSearchRequestOf = Omit< diff --git a/x-pack/plugins/apm/server/lib/index_pattern/get_dynamic_index_pattern.ts b/x-pack/plugins/apm/server/lib/index_pattern/get_dynamic_index_pattern.ts index 0d6d8b58b32f22..cb6183510ad168 100644 --- a/x-pack/plugins/apm/server/lib/index_pattern/get_dynamic_index_pattern.ts +++ b/x-pack/plugins/apm/server/lib/index_pattern/get_dynamic_index_pattern.ts @@ -13,7 +13,7 @@ import { import { APMRequestHandlerContext } from '../../routes/typings'; import { withApmSpan } from '../../utils/with_apm_span'; -interface IndexPatternTitleAndFields { +export interface IndexPatternTitleAndFields { title: string; fields: FieldDescriptor[]; } diff --git a/x-pack/plugins/apm/server/lib/services/get_service_metadata_details.ts b/x-pack/plugins/apm/server/lib/services/get_service_metadata_details.ts index 24ed72ea995109..f1198a4d858fda 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_metadata_details.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_metadata_details.ts @@ -33,7 +33,7 @@ type ServiceMetadataDetailsRaw = Pick< 'service' | 'agent' | 'host' | 'container' | 'kubernetes' | 'cloud' >; -interface ServiceMetadataDetails { +export interface ServiceMetadataDetails { service?: { versions?: string[]; runtime?: { diff --git a/x-pack/plugins/apm/server/lib/services/get_service_metadata_icons.ts b/x-pack/plugins/apm/server/lib/services/get_service_metadata_icons.ts index 6636820defdebe..0ea95a08abaa98 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_metadata_icons.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_metadata_icons.ts @@ -27,7 +27,7 @@ type ServiceMetadataIconsRaw = Pick< 'kubernetes' | 'cloud' | 'container' | 'agent' >; -interface ServiceMetadataIcons { +export interface ServiceMetadataIcons { agentName?: string; containerType?: ContainerType; cloudProvider?: string; diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.ts index 3a7f53adc6aaeb..48f547e3deb0f7 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.ts @@ -6,29 +6,34 @@ */ import * as t from 'io-ts'; -import { - SERVICE_NAME, - SERVICE_ENVIRONMENT, - TRANSACTION_NAME, - TRANSACTION_TYPE, -} from '../../../../common/elasticsearch_fieldnames'; + +// These should be imported, but until TypeScript 4.2 we're inlining them here. +// All instances of "service.name", "service.environment", "transaction.name", +// and "transaction.type" need to be changed back to using the constants. +// See https://github.com/microsoft/TypeScript/issues/37888 +// import { +// SERVICE_NAME, +// SERVICE_ENVIRONMENT, +// TRANSACTION_NAME, +// TRANSACTION_TYPE, +// } from '../../../../common/elasticsearch_fieldnames'; export interface CustomLinkES { id?: string; '@timestamp'?: number; label: string; url: string; - [SERVICE_NAME]?: string[]; - [SERVICE_ENVIRONMENT]?: string[]; - [TRANSACTION_NAME]?: string[]; - [TRANSACTION_TYPE]?: string[]; + 'service.name'?: string[]; + 'service.environment'?: string[]; + 'transaction.name'?: string[]; + 'transaction.type'?: string[]; } export const filterOptionsRt = t.partial({ - [SERVICE_NAME]: t.string, - [SERVICE_ENVIRONMENT]: t.string, - [TRANSACTION_NAME]: t.string, - [TRANSACTION_TYPE]: t.string, + 'service.name': t.string, + 'service.environment': t.string, + 'transaction.name': t.string, + 'transaction.type': t.string, }); export const payloadRt = t.intersection([ diff --git a/x-pack/plugins/apm/server/lib/traces/get_trace_items.ts b/x-pack/plugins/apm/server/lib/traces/get_trace_items.ts index c9769b6143c951..bd3ecf1e0f862d 100644 --- a/x-pack/plugins/apm/server/lib/traces/get_trace_items.ts +++ b/x-pack/plugins/apm/server/lib/traces/get_trace_items.ts @@ -20,7 +20,7 @@ import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { PromiseValueType } from '../../../typings/common'; import { withApmSpan } from '../../utils/with_apm_span'; -interface ErrorsPerTransaction { +export interface ErrorsPerTransaction { [transactionId: string]: number; } diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts index 46a55d9004aba0..0fad948edde19b 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts @@ -213,7 +213,7 @@ export function transactionGroupsFetcher( }); } -interface TransactionGroup { +export interface TransactionGroup { key: string | Record<'service.name' | 'transaction.name', string>; serviceName: string; transactionName: string; diff --git a/x-pack/plugins/apm/tsconfig.json b/x-pack/plugins/apm/tsconfig.json new file mode 100644 index 00000000000000..bb2e0e06679a29 --- /dev/null +++ b/x-pack/plugins/apm/tsconfig.json @@ -0,0 +1,45 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "../../typings/**/*", + "common/**/*", + "public/**/*", + "scripts/**/*", + "server/**/*", + "typings/**/*", + // have to declare *.json explicitly due to https://github.com/microsoft/TypeScript/issues/25636 + "public/**/*.json", + "server/**/*.json" + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/apm_oss/tsconfig.json" }, + { "path": "../../../src/plugins/data/tsconfig.json" }, + { "path": "../../../src/plugins/embeddable/tsconfig.json" }, + { "path": "../../../src/plugins/home/tsconfig.json" }, + { "path": "../../../src/plugins/index_pattern_management/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, + { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, + { "path": "../actions/tsconfig.json" }, + { "path": "../alerts/tsconfig.json" }, + { "path": "../cloud/tsconfig.json" }, + { "path": "../features/tsconfig.json" }, + { "path": "../infra/tsconfig.json" }, + { "path": "../licensing/tsconfig.json" }, + { "path": "../maps/tsconfig.json" }, + { "path": "../ml/tsconfig.json" }, + { "path": "../observability/tsconfig.json" }, + { "path": "../reporting/tsconfig.json" }, + { "path": "../security/tsconfig.json" }, + { "path": "../task_manager/tsconfig.json" }, + { "path": "../triggers_actions_ui/tsconfig.json" } + ] +} diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index 4b56ebc83d9893..1c2e0aeecd2477 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -40,6 +40,7 @@ { "path": "../../src/plugins/usage_collection/tsconfig.json" }, { "path": "../plugins/actions/tsconfig.json" }, { "path": "../plugins/alerts/tsconfig.json" }, + { "path": "../plugins/apm/tsconfig.json" }, { "path": "../plugins/banners/tsconfig.json" }, { "path": "../plugins/beats_management/tsconfig.json" }, { "path": "../plugins/cloud/tsconfig.json" }, diff --git a/x-pack/tsconfig.json b/x-pack/tsconfig.json index 2c475083b589a8..813811d4a9ce45 100644 --- a/x-pack/tsconfig.json +++ b/x-pack/tsconfig.json @@ -4,7 +4,6 @@ "mocks.ts", "typings/**/*", "tasks/**/*", - "plugins/apm/**/*", "plugins/case/**/*", "plugins/lists/**/*", "plugins/logstash/**/*", @@ -62,6 +61,7 @@ { "path": "../src/plugins/usage_collection/tsconfig.json" }, { "path": "./plugins/actions/tsconfig.json" }, { "path": "./plugins/alerts/tsconfig.json" }, + { "path": "./plugins/apm/tsconfig.json" }, { "path": "./plugins/beats_management/tsconfig.json" }, { "path": "./plugins/canvas/tsconfig.json" }, { "path": "./plugins/cloud/tsconfig.json" }, From 874fadf388eed6fc43dde79e74698158b5245398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Fri, 12 Feb 2021 22:53:05 +0100 Subject: [PATCH 2/4] [APM] Adding comparison to throughput chart (#90128) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Dario Gieselaar --- .../date_as_string_rt/index.test.ts | 29 - .../iso_to_epoch_rt/index.test.ts | 32 + .../index.ts | 19 +- .../runtime_types/to_boolean_rt/index.ts | 24 + .../runtime_types/to_number_rt/index.ts | 2 +- .../service_inventory.test.tsx | 9 +- .../service_overview_throughput_chart.tsx | 54 +- .../transaction_overview.test.tsx | 4 +- .../shared/charts/timeseries_chart.tsx | 5 +- .../get_time_range_comparison.test.ts | 120 +++ .../get_time_range_comparison.ts | 88 ++ .../shared/time_comparison/index.test.tsx | 43 +- .../shared/time_comparison/index.tsx | 75 +- .../url_params_context/resolve_url_params.ts | 4 +- .../context/url_params_context/types.ts | 3 +- .../apm/server/lib/helpers/setup_request.ts | 13 +- .../apm/server/lib/services/get_throughput.ts | 50 +- .../apm/server/routes/default_api_types.ts | 11 +- x-pack/plugins/apm/server/routes/services.ts | 67 +- .../routes/settings/agent_configuration.ts | 4 +- x-pack/plugins/apm/server/routes/typings.ts | 2 +- .../offset_previous_period_coordinate.test.ts | 57 ++ .../offset_previous_period_coordinate.ts | 35 + .../services/__snapshots__/throughput.snap | 795 +++++++++++++++++- .../tests/services/throughput.ts | 91 +- 25 files changed, 1454 insertions(+), 182 deletions(-) delete mode 100644 x-pack/plugins/apm/common/runtime_types/date_as_string_rt/index.test.ts create mode 100644 x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.test.ts rename x-pack/plugins/apm/common/runtime_types/{date_as_string_rt => iso_to_epoch_rt}/index.ts (57%) create mode 100644 x-pack/plugins/apm/common/runtime_types/to_boolean_rt/index.ts create mode 100644 x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts create mode 100644 x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts create mode 100644 x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.test.ts create mode 100644 x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.ts diff --git a/x-pack/plugins/apm/common/runtime_types/date_as_string_rt/index.test.ts b/x-pack/plugins/apm/common/runtime_types/date_as_string_rt/index.test.ts deleted file mode 100644 index 313b597e5d4090..00000000000000 --- a/x-pack/plugins/apm/common/runtime_types/date_as_string_rt/index.test.ts +++ /dev/null @@ -1,29 +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 { dateAsStringRt } from './index'; -import { isLeft, isRight } from 'fp-ts/lib/Either'; - -describe('dateAsStringRt', () => { - it('validates whether a string is a valid date', () => { - expect(isLeft(dateAsStringRt.decode(1566299881499))).toBe(true); - - expect(isRight(dateAsStringRt.decode('2019-08-20T11:18:31.407Z'))).toBe( - true - ); - }); - - it('returns the string it was given', () => { - const either = dateAsStringRt.decode('2019-08-20T11:18:31.407Z'); - - if (isRight(either)) { - expect(either.right).toBe('2019-08-20T11:18:31.407Z'); - } else { - fail(); - } - }); -}); diff --git a/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.test.ts b/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.test.ts new file mode 100644 index 00000000000000..573bfdc83e429e --- /dev/null +++ b/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.test.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 { isoToEpochRt } from './index'; +import { isRight } from 'fp-ts/lib/Either'; + +describe('isoToEpochRt', () => { + it('validates whether its input is a valid ISO timestamp', () => { + expect(isRight(isoToEpochRt.decode(1566299881499))).toBe(false); + + expect(isRight(isoToEpochRt.decode('2019-08-20T11:18:31.407Z'))).toBe(true); + }); + + it('decodes valid ISO timestamps to epoch time', () => { + const iso = '2019-08-20T11:18:31.407Z'; + const result = isoToEpochRt.decode(iso); + + if (isRight(result)) { + expect(result.right).toBe(new Date(iso).getTime()); + } else { + fail(); + } + }); + + it('encodes epoch time to ISO string', () => { + expect(isoToEpochRt.encode(1566299911407)).toBe('2019-08-20T11:18:31.407Z'); + }); +}); diff --git a/x-pack/plugins/apm/common/runtime_types/date_as_string_rt/index.ts b/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.ts similarity index 57% rename from x-pack/plugins/apm/common/runtime_types/date_as_string_rt/index.ts rename to x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.ts index 182399657f6f3c..1a17f82a521413 100644 --- a/x-pack/plugins/apm/common/runtime_types/date_as_string_rt/index.ts +++ b/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.ts @@ -9,15 +9,20 @@ import * as t from 'io-ts'; import { either } from 'fp-ts/lib/Either'; // Checks whether a string is a valid ISO timestamp, -// but doesn't convert it into a Date object when decoding +// and returns an epoch timestamp -export const dateAsStringRt = new t.Type( - 'DateAsString', - t.string.is, +export const isoToEpochRt = new t.Type( + 'isoToEpochRt', + t.number.is, (input, context) => either.chain(t.string.validate(input, context), (str) => { - const date = new Date(str); - return isNaN(date.getTime()) ? t.failure(input, context) : t.success(str); + const epochDate = new Date(str).getTime(); + return isNaN(epochDate) + ? t.failure(input, context) + : t.success(epochDate); }), - t.identity + (a) => { + const d = new Date(a); + return d.toISOString(); + } ); diff --git a/x-pack/plugins/apm/common/runtime_types/to_boolean_rt/index.ts b/x-pack/plugins/apm/common/runtime_types/to_boolean_rt/index.ts new file mode 100644 index 00000000000000..1e6828ed4ead35 --- /dev/null +++ b/x-pack/plugins/apm/common/runtime_types/to_boolean_rt/index.ts @@ -0,0 +1,24 @@ +/* + * 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 * as t from 'io-ts'; + +export const toBooleanRt = new t.Type( + 'ToBoolean', + t.boolean.is, + (input) => { + let value: boolean; + if (typeof input === 'string') { + value = input === 'true'; + } else { + value = !!input; + } + + return t.success(value); + }, + t.identity +); diff --git a/x-pack/plugins/apm/common/runtime_types/to_number_rt/index.ts b/x-pack/plugins/apm/common/runtime_types/to_number_rt/index.ts index 4103cb8837cde1..a4632680cb6e18 100644 --- a/x-pack/plugins/apm/common/runtime_types/to_number_rt/index.ts +++ b/x-pack/plugins/apm/common/runtime_types/to_number_rt/index.ts @@ -9,7 +9,7 @@ import * as t from 'io-ts'; export const toNumberRt = new t.Type( 'ToNumber', - t.any.is, + t.number.is, (input, context) => { const number = Number(input); return !isNaN(number) ? t.success(number) : t.failure(input, context); diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_inventory.test.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/service_inventory.test.tsx index 69b4149625824d..419b66da5d2220 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_inventory.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_inventory.test.tsx @@ -20,10 +20,12 @@ import { MockApmPluginContextWrapper, } from '../../../context/apm_plugin/mock_apm_plugin_context'; import { FETCH_STATUS } from '../../../hooks/use_fetcher'; +import { clearCache } from '../../../services/rest/callApi'; import * as useDynamicIndexPatternHooks from '../../../hooks/use_dynamic_index_pattern'; import { SessionStorageMock } from '../../../services/__mocks__/SessionStorageMock'; import { MockUrlParamsContextProvider } from '../../../context/url_params_context/mock_url_params_context_provider'; import * as hook from './use_anomaly_detection_jobs_fetcher'; +import { TimeRangeComparisonType } from '../../shared/time_comparison/get_time_range_comparison'; const KibanaReactContext = createKibanaReactContext({ usageCollection: { reportUiCounter: () => {} }, @@ -55,10 +57,10 @@ function wrapper({ children }: { children?: ReactNode }) { params={{ rangeFrom: 'now-15m', rangeTo: 'now', - start: 'mystart', - end: 'myend', + start: '2021-02-12T13:20:43.344Z', + end: '2021-02-12T13:20:58.344Z', comparisonEnabled: true, - comparisonType: 'yesterday', + comparisonType: TimeRangeComparisonType.DayBefore, }} > {children} @@ -74,6 +76,7 @@ describe('ServiceInventory', () => { beforeEach(() => { // @ts-expect-error global.sessionStorage = new SessionStorageMock(); + clearCache(); jest.spyOn(hook, 'useAnomalyDetectionJobsFetcher').mockReturnValue({ anomalyDetectionJobsStatus: FETCH_STATUS.SUCCESS, diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx index d70dae5ae63166..92111c5671c91e 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx @@ -15,6 +15,15 @@ import { useTheme } from '../../../hooks/use_theme'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { TimeseriesChart } from '../../shared/charts/timeseries_chart'; +import { + getTimeRangeComparison, + getComparisonChartTheme, +} from '../../shared/time_comparison/get_time_range_comparison'; + +const INITIAL_STATE = { + currentPeriod: [], + previousPeriod: [], +}; export function ServiceOverviewThroughputChart({ height, @@ -25,9 +34,20 @@ export function ServiceOverviewThroughputChart({ const { serviceName } = useParams<{ serviceName?: string }>(); const { urlParams, uiFilters } = useUrlParams(); const { transactionType } = useApmServiceContext(); - const { start, end } = urlParams; + const { start, end, comparisonEnabled, comparisonType } = urlParams; + const comparisonChartTheme = getComparisonChartTheme(theme); + const { + comparisonStart = undefined, + comparisonEnd = undefined, + } = comparisonType + ? getTimeRangeComparison({ + start, + end, + comparisonType, + }) + : {}; - const { data, status } = useFetcher( + const { data = INITIAL_STATE, status } = useFetcher( (callApmApi) => { if (serviceName && transactionType && start && end) { return callApmApi({ @@ -41,12 +61,22 @@ export function ServiceOverviewThroughputChart({ end, transactionType, uiFilters: JSON.stringify(uiFilters), + comparisonStart, + comparisonEnd, }, }, }); } }, - [serviceName, start, end, uiFilters, transactionType] + [ + serviceName, + start, + end, + uiFilters, + transactionType, + comparisonStart, + comparisonEnd, + ] ); return ( @@ -63,9 +93,10 @@ export function ServiceOverviewThroughputChart({ height={height} showAnnotations={false} fetchStatus={status} + customTheme={comparisonChartTheme} timeseries={[ { - data: data?.throughput ?? [], + data: data.currentPeriod, type: 'linemark', color: theme.eui.euiColorVis0, title: i18n.translate( @@ -73,6 +104,21 @@ export function ServiceOverviewThroughputChart({ { defaultMessage: 'Throughput' } ), }, + ...(comparisonEnabled + ? [ + { + data: data.previousPeriod, + type: 'area', + color: theme.eui.euiColorLightestShade, + title: i18n.translate( + 'xpack.apm.serviceOverview.throughtputChart.previousPeriodLabel', + { + defaultMessage: 'Previous period', + } + ), + }, + ] + : []), ]} yLabelFormat={asTransactionRate} /> diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx index 7d0ada3e31bffc..8fb5166bd8676e 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx @@ -131,7 +131,7 @@ describe('TransactionOverview', () => { }); expect(history.location.search).toEqual( - '?transactionType=secondType&rangeFrom=now-15m&rangeTo=now&comparisonEnabled=true&comparisonType=yesterday' + '?transactionType=secondType&rangeFrom=now-15m&rangeTo=now&comparisonEnabled=true&comparisonType=day' ); expect(getByText(container, 'firstType')).toBeInTheDocument(); expect(getByText(container, 'secondType')).toBeInTheDocument(); @@ -142,7 +142,7 @@ describe('TransactionOverview', () => { expect(history.push).toHaveBeenCalled(); expect(history.location.search).toEqual( - '?transactionType=firstType&rangeFrom=now-15m&rangeTo=now&comparisonEnabled=true&comparisonType=yesterday' + '?transactionType=firstType&rangeFrom=now-15m&rangeTo=now&comparisonEnabled=true&comparisonType=day' ); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx b/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx index 441dbf4df28271..7bfe17e82bf4ae 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx @@ -59,6 +59,7 @@ interface Props { anomalyTimeseries?: ReturnType< typeof getLatencyChartSelector >['anomalyTimeseries']; + customTheme?: Record; } export function TimeseriesChart({ @@ -72,13 +73,14 @@ export function TimeseriesChart({ showAnnotations = true, yDomain, anomalyTimeseries, + customTheme = {}, }: Props) { const history = useHistory(); const { annotations } = useAnnotationsContext(); - const chartTheme = useChartTheme(); const { setPointerEvent, chartRef } = useChartPointerEventContext(); const { urlParams } = useUrlParams(); const theme = useTheme(); + const chartTheme = useChartTheme(); const { start, end } = urlParams; @@ -103,6 +105,7 @@ export function TimeseriesChart({ areaSeriesStyle: { line: { visible: false }, }, + ...customTheme, }} onPointerUpdate={setPointerEvent} externalPointerEvents={{ diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts new file mode 100644 index 00000000000000..7234e94881ce79 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts @@ -0,0 +1,120 @@ +/* + * 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 { + getTimeRangeComparison, + TimeRangeComparisonType, +} from './get_time_range_comparison'; + +describe('getTimeRangeComparison', () => { + describe('return empty object', () => { + it('when start is not defined', () => { + const end = '2021-01-28T15:00:00.000Z'; + const result = getTimeRangeComparison({ + start: undefined, + end, + comparisonType: TimeRangeComparisonType.DayBefore, + }); + expect(result).toEqual({}); + }); + + it('when end is not defined', () => { + const start = '2021-01-28T14:45:00.000Z'; + const result = getTimeRangeComparison({ + start, + end: undefined, + comparisonType: TimeRangeComparisonType.DayBefore, + }); + expect(result).toEqual({}); + }); + }); + + describe('Time range is between 0 - 24 hours', () => { + describe('when day before is selected', () => { + it('returns the correct time range - 15 min', () => { + const start = '2021-01-28T14:45:00.000Z'; + const end = '2021-01-28T15:00:00.000Z'; + const result = getTimeRangeComparison({ + comparisonType: TimeRangeComparisonType.DayBefore, + start, + end, + }); + expect(result.comparisonStart).toEqual('2021-01-27T14:45:00.000Z'); + expect(result.comparisonEnd).toEqual('2021-01-27T15:00:00.000Z'); + }); + }); + describe('when a week before is selected', () => { + it('returns the correct time range - 15 min', () => { + const start = '2021-01-28T14:45:00.000Z'; + const end = '2021-01-28T15:00:00.000Z'; + const result = getTimeRangeComparison({ + comparisonType: TimeRangeComparisonType.WeekBefore, + start, + end, + }); + expect(result.comparisonStart).toEqual('2021-01-21T14:45:00.000Z'); + expect(result.comparisonEnd).toEqual('2021-01-21T15:00:00.000Z'); + }); + }); + describe('when previous period is selected', () => { + it('returns the correct time range - 15 min', () => { + const start = '2021-02-09T14:40:01.087Z'; + const end = '2021-02-09T14:56:00.000Z'; + const result = getTimeRangeComparison({ + start, + end, + comparisonType: TimeRangeComparisonType.PeriodBefore, + }); + expect(result).toEqual({ + comparisonStart: '2021-02-09T14:24:02.174Z', + comparisonEnd: '2021-02-09T14:40:01.087Z', + }); + }); + }); + }); + + describe('Time range is between 24 hours - 1 week', () => { + describe('when a week before is selected', () => { + it('returns the correct time range - 2 days', () => { + const start = '2021-01-26T15:00:00.000Z'; + const end = '2021-01-28T15:00:00.000Z'; + const result = getTimeRangeComparison({ + comparisonType: TimeRangeComparisonType.WeekBefore, + start, + end, + }); + expect(result.comparisonStart).toEqual('2021-01-19T15:00:00.000Z'); + expect(result.comparisonEnd).toEqual('2021-01-21T15:00:00.000Z'); + }); + }); + }); + + describe('Time range is greater than 7 days', () => { + it('uses the date difference to calculate the time range - 8 days', () => { + const start = '2021-01-10T15:00:00.000Z'; + const end = '2021-01-18T15:00:00.000Z'; + const result = getTimeRangeComparison({ + comparisonType: TimeRangeComparisonType.PeriodBefore, + start, + end, + }); + expect(result.comparisonStart).toEqual('2021-01-02T15:00:00.000Z'); + expect(result.comparisonEnd).toEqual('2021-01-10T15:00:00.000Z'); + }); + + it('uses the date difference to calculate the time range - 30 days', () => { + const start = '2021-01-01T15:00:00.000Z'; + const end = '2021-01-31T15:00:00.000Z'; + const result = getTimeRangeComparison({ + comparisonType: TimeRangeComparisonType.PeriodBefore, + start, + end, + }); + expect(result.comparisonStart).toEqual('2020-12-02T15:00:00.000Z'); + expect(result.comparisonEnd).toEqual('2021-01-01T15:00:00.000Z'); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts new file mode 100644 index 00000000000000..5dd014441a9e4f --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment'; +import { EuiTheme } from 'src/plugins/kibana_react/common'; +import { getDateDifference } from '../../../../common/utils/formatters'; + +export enum TimeRangeComparisonType { + WeekBefore = 'week', + DayBefore = 'day', + PeriodBefore = 'period', +} + +export function getComparisonChartTheme(theme: EuiTheme) { + return { + areaSeriesStyle: { + area: { + fill: theme.eui.euiColorLightestShade, + visible: true, + opacity: 1, + }, + line: { + stroke: theme.eui.euiColorMediumShade, + strokeWidth: 1, + visible: true, + }, + point: { + visible: false, + }, + }, + }; +} + +const oneDayInMilliseconds = moment.duration(1, 'day').asMilliseconds(); +const oneWeekInMilliseconds = moment.duration(1, 'week').asMilliseconds(); + +export function getTimeRangeComparison({ + comparisonType, + start, + end, +}: { + comparisonType: TimeRangeComparisonType; + start?: string; + end?: string; +}) { + if (!start || !end) { + return {}; + } + + const startMoment = moment(start); + const endMoment = moment(end); + + const startEpoch = startMoment.valueOf(); + const endEpoch = endMoment.valueOf(); + + let diff: number; + + switch (comparisonType) { + case TimeRangeComparisonType.DayBefore: + diff = oneDayInMilliseconds; + break; + + case TimeRangeComparisonType.WeekBefore: + diff = oneWeekInMilliseconds; + break; + + case TimeRangeComparisonType.PeriodBefore: + diff = getDateDifference({ + start: startMoment, + end: endMoment, + unitOfTime: 'milliseconds', + precise: true, + }); + break; + + default: + throw new Error('Unknown comparisonType'); + } + + return { + comparisonStart: new Date(startEpoch - diff).toISOString(), + comparisonEnd: new Date(endEpoch - diff).toISOString(), + }; +} diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/index.test.tsx b/x-pack/plugins/apm/public/components/shared/time_comparison/index.test.tsx index 4ace78f74ee79e..a4f44290fe777f 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/index.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/index.test.tsx @@ -18,6 +18,7 @@ import { import { TimeComparison } from './'; import * as urlHelpers from '../../shared/Links/url_helpers'; import moment from 'moment'; +import { TimeRangeComparisonType } from './get_time_range_comparison'; function getWrapper(params?: IUrlParams) { return ({ children }: { children?: ReactNode }) => { @@ -53,22 +54,22 @@ describe('TimeComparison', () => { expect(spy).toHaveBeenCalledWith(expect.anything(), { query: { comparisonEnabled: 'true', - comparisonType: 'yesterday', + comparisonType: TimeRangeComparisonType.DayBefore, }, }); }); - it('selects yesterday and enables comparison', () => { + it('selects day before and enables comparison', () => { const Wrapper = getWrapper({ start: '2021-01-28T14:45:00.000Z', end: '2021-01-28T15:00:00.000Z', comparisonEnabled: true, - comparisonType: 'yesterday', + comparisonType: TimeRangeComparisonType.DayBefore, rangeTo: 'now', }); const component = render(, { wrapper: Wrapper, }); - expectTextsInDocument(component, ['Yesterday', 'A week ago']); + expectTextsInDocument(component, ['Day before', 'Week before']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex @@ -80,13 +81,13 @@ describe('TimeComparison', () => { start: '2021-01-28T10:00:00.000Z', end: '2021-01-29T10:00:00.000Z', comparisonEnabled: true, - comparisonType: 'yesterday', + comparisonType: TimeRangeComparisonType.DayBefore, rangeTo: 'now', }); const component = render(, { wrapper: Wrapper, }); - expectTextsInDocument(component, ['Yesterday', 'A week ago']); + expectTextsInDocument(component, ['Day before', 'Week before']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex @@ -98,13 +99,13 @@ describe('TimeComparison', () => { start: '2021-01-28T10:00:00.000Z', end: '2021-01-29T10:00:00.000Z', comparisonEnabled: true, - comparisonType: 'previousPeriod', + comparisonType: TimeRangeComparisonType.PeriodBefore, rangeTo: 'now-15m', }); const component = render(, { wrapper: Wrapper, }); - expectTextsInDocument(component, ['28/01 11:00 - 29/01 11:00']); + expectTextsInDocument(component, ['27/01 11:00 - 28/01 11:00']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex @@ -118,14 +119,14 @@ describe('TimeComparison', () => { start: '2021-01-28T10:00:00.000Z', end: '2021-01-29T11:00:00.000Z', comparisonEnabled: true, - comparisonType: 'week', + comparisonType: TimeRangeComparisonType.WeekBefore, rangeTo: 'now', }); const component = render(, { wrapper: Wrapper, }); - expectTextsNotInDocument(component, ['Yesterday']); - expectTextsInDocument(component, ['A week ago']); + expectTextsNotInDocument(component, ['Day before']); + expectTextsInDocument(component, ['Week before']); }); it('sets default values', () => { const Wrapper = getWrapper({ @@ -139,7 +140,7 @@ describe('TimeComparison', () => { expect(spy).toHaveBeenCalledWith(expect.anything(), { query: { comparisonEnabled: 'true', - comparisonType: 'week', + comparisonType: TimeRangeComparisonType.WeekBefore, }, }); }); @@ -148,14 +149,14 @@ describe('TimeComparison', () => { start: '2021-01-26T15:00:00.000Z', end: '2021-01-28T15:00:00.000Z', comparisonEnabled: true, - comparisonType: 'week', + comparisonType: TimeRangeComparisonType.WeekBefore, rangeTo: 'now', }); const component = render(, { wrapper: Wrapper, }); - expectTextsNotInDocument(component, ['Yesterday']); - expectTextsInDocument(component, ['A week ago']); + expectTextsNotInDocument(component, ['Day before']); + expectTextsInDocument(component, ['Week before']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex @@ -167,13 +168,13 @@ describe('TimeComparison', () => { start: '2021-01-26T15:00:00.000Z', end: '2021-01-28T15:00:00.000Z', comparisonEnabled: true, - comparisonType: 'previousPeriod', + comparisonType: TimeRangeComparisonType.PeriodBefore, rangeTo: '2021-01-28T15:00:00.000Z', }); const component = render(, { wrapper: Wrapper, }); - expectTextsInDocument(component, ['26/01 16:00 - 28/01 16:00']); + expectTextsInDocument(component, ['24/01 16:00 - 26/01 16:00']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex @@ -187,14 +188,14 @@ describe('TimeComparison', () => { start: '2021-01-20T15:00:00.000Z', end: '2021-01-28T15:00:00.000Z', comparisonEnabled: true, - comparisonType: 'previousPeriod', + comparisonType: TimeRangeComparisonType.PeriodBefore, rangeTo: 'now', }); const component = render(, { wrapper: Wrapper, }); expect(spy).not.toHaveBeenCalled(); - expectTextsInDocument(component, ['20/01 16:00 - 28/01 16:00']); + expectTextsInDocument(component, ['12/01 16:00 - 20/01 16:00']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex @@ -206,14 +207,14 @@ describe('TimeComparison', () => { start: '2020-12-20T15:00:00.000Z', end: '2021-01-28T15:00:00.000Z', comparisonEnabled: true, - comparisonType: 'previousPeriod', + comparisonType: TimeRangeComparisonType.PeriodBefore, rangeTo: 'now', }); const component = render(, { wrapper: Wrapper, }); expect(spy).not.toHaveBeenCalled(); - expectTextsInDocument(component, ['20/12/20 16:00 - 28/01/21 16:00']); + expectTextsInDocument(component, ['11/11/20 16:00 - 20/12/20 16:00']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx b/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx index e4b03bd57377aa..0b6c1a2c52a980 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx @@ -16,6 +16,10 @@ import { useUrlParams } from '../../../context/url_params_context/use_url_params import { px, unit } from '../../../style/variables'; import * as urlHelpers from '../../shared/Links/url_helpers'; import { useBreakPoints } from '../../../hooks/use_break_points'; +import { + getTimeRangeComparison, + TimeRangeComparisonType, +} from './get_time_range_comparison'; const PrependContainer = euiStyled.div` display: flex; @@ -25,15 +29,32 @@ const PrependContainer = euiStyled.div` padding: 0 ${px(unit)}; `; -function formatPreviousPeriodDates({ - momentStart, - momentEnd, +function getDateFormat({ + previousPeriodStart, + currentPeriodEnd, }: { - momentStart: moment.Moment; - momentEnd: moment.Moment; + previousPeriodStart?: string; + currentPeriodEnd?: string; }) { - const isDifferentYears = momentStart.get('year') !== momentEnd.get('year'); - const dateFormat = isDifferentYears ? 'DD/MM/YY HH:mm' : 'DD/MM HH:mm'; + const momentPreviousPeriodStart = moment(previousPeriodStart); + const momentCurrentPeriodEnd = moment(currentPeriodEnd); + const isDifferentYears = + momentPreviousPeriodStart.get('year') !== + momentCurrentPeriodEnd.get('year'); + return isDifferentYears ? 'DD/MM/YY HH:mm' : 'DD/MM HH:mm'; +} + +function formatDate({ + dateFormat, + previousPeriodStart, + previousPeriodEnd, +}: { + dateFormat: string; + previousPeriodStart?: string; + previousPeriodEnd?: string; +}) { + const momentStart = moment(previousPeriodStart); + const momentEnd = moment(previousPeriodEnd); return `${momentStart.format(dateFormat)} - ${momentEnd.format(dateFormat)}`; } @@ -49,17 +70,17 @@ function getSelectOptions({ const momentStart = moment(start); const momentEnd = moment(end); - const yesterdayOption = { - value: 'yesterday', - text: i18n.translate('xpack.apm.timeComparison.select.yesterday', { - defaultMessage: 'Yesterday', + const dayBeforeOption = { + value: TimeRangeComparisonType.DayBefore, + text: i18n.translate('xpack.apm.timeComparison.select.dayBefore', { + defaultMessage: 'Day before', }), }; - const aWeekAgoOption = { - value: 'week', - text: i18n.translate('xpack.apm.timeComparison.select.weekAgo', { - defaultMessage: 'A week ago', + const weekBeforeOption = { + value: TimeRangeComparisonType.WeekBefore, + text: i18n.translate('xpack.apm.timeComparison.select.weekBefore', { + defaultMessage: 'Week before', }), }; @@ -69,23 +90,39 @@ function getSelectOptions({ unitOfTime: 'days', precise: true, }); + const isRangeToNow = rangeTo === 'now'; if (isRangeToNow) { // Less than or equals to one day if (dateDiff <= 1) { - return [yesterdayOption, aWeekAgoOption]; + return [dayBeforeOption, weekBeforeOption]; } // Less than or equals to one week if (dateDiff <= 7) { - return [aWeekAgoOption]; + return [weekBeforeOption]; } } + const { comparisonStart, comparisonEnd } = getTimeRangeComparison({ + comparisonType: TimeRangeComparisonType.PeriodBefore, + start, + end, + }); + + const dateFormat = getDateFormat({ + previousPeriodStart: comparisonStart, + currentPeriodEnd: end, + }); + const prevPeriodOption = { - value: 'previousPeriod', - text: formatPreviousPeriodDates({ momentStart, momentEnd }), + value: TimeRangeComparisonType.PeriodBefore, + text: formatDate({ + dateFormat, + previousPeriodStart: comparisonStart, + previousPeriodEnd: comparisonEnd, + }), }; // above one week or when rangeTo is not "now" diff --git a/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts b/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts index 5b72a50e8dbd89..addef74f5b25b0 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts +++ b/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts @@ -11,6 +11,7 @@ import { pickKeys } from '../../../common/utils/pick_keys'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { localUIFilterNames } from '../../../server/lib/ui_filters/local_ui_filters/config'; import { toQuery } from '../../components/shared/Links/url_helpers'; +import { TimeRangeComparisonType } from '../../components/shared/time_comparison/get_time_range_comparison'; import { getDateRange, removeUndefinedProps, @@ -84,8 +85,7 @@ export function resolveUrlParams(location: Location, state: TimeUrlParams) { comparisonEnabled: comparisonEnabled ? toBoolean(comparisonEnabled) : undefined, - comparisonType, - + comparisonType: comparisonType as TimeRangeComparisonType | undefined, // ui filters environment, ...localUIFilters, diff --git a/x-pack/plugins/apm/public/context/url_params_context/types.ts b/x-pack/plugins/apm/public/context/url_params_context/types.ts index 723fca4487237f..4332019d1a1c9e 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/types.ts +++ b/x-pack/plugins/apm/public/context/url_params_context/types.ts @@ -7,6 +7,7 @@ import { LatencyAggregationType } from '../../../common/latency_aggregation_types'; import { LocalUIFilterName } from '../../../common/ui_filter'; +import { TimeRangeComparisonType } from '../../components/shared/time_comparison/get_time_range_comparison'; export type IUrlParams = { detailTab?: string; @@ -32,5 +33,5 @@ export type IUrlParams = { percentile?: number; latencyAggregationType?: LatencyAggregationType; comparisonEnabled?: boolean; - comparisonType?: string; + comparisonType?: TimeRangeComparisonType; } & Partial>; diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts index 5de2abc312815e..b12a396befe8c4 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts @@ -6,7 +6,6 @@ */ import { Logger } from 'kibana/server'; -import moment from 'moment'; import { isActivePlatinumLicense } from '../../../common/license_check'; import { APMConfig } from '../..'; import { KibanaRequest } from '../../../../../../src/core/server'; @@ -54,19 +53,19 @@ interface SetupRequestParams { /** * Timestamp in ms since epoch */ - start?: string; + start?: number; /** * Timestamp in ms since epoch */ - end?: string; + end?: number; uiFilters?: string; }; } type InferSetup = Setup & - (TParams extends { query: { start: string } } ? { start: number } : {}) & - (TParams extends { query: { end: string } } ? { end: number } : {}); + (TParams extends { query: { start: number } } ? { start: number } : {}) & + (TParams extends { query: { end: number } } ? { end: number } : {}); export async function setupRequest( context: APMRequestHandlerContext, @@ -115,8 +114,8 @@ export async function setupRequest( }; return { - ...('start' in query ? { start: moment.utc(query.start).valueOf() } : {}), - ...('end' in query ? { end: moment.utc(query.end).valueOf() } : {}), + ...('start' in query ? { start: query.start } : {}), + ...('end' in query ? { end: query.end } : {}), ...coreSetupRequest, } as InferSetup; }); diff --git a/x-pack/plugins/apm/server/lib/services/get_throughput.ts b/x-pack/plugins/apm/server/lib/services/get_throughput.ts index c4e217f95bcd1b..33268e9b3332d7 100644 --- a/x-pack/plugins/apm/server/lib/services/get_throughput.ts +++ b/x-pack/plugins/apm/server/lib/services/get_throughput.ts @@ -6,7 +6,6 @@ */ import { ESFilter } from '../../../../../typings/elasticsearch'; -import { PromiseReturnType } from '../../../../observability/typings/common'; import { SERVICE_NAME, TRANSACTION_TYPE, @@ -17,38 +16,27 @@ import { getProcessorEventForAggregatedTransactions, } from '../helpers/aggregated_transactions'; import { getBucketSize } from '../helpers/get_bucket_size'; -import { calculateThroughput } from '../helpers/calculate_throughput'; -import { Setup, SetupTimeRange } from '../helpers/setup_request'; +import { Setup } from '../helpers/setup_request'; import { withApmSpan } from '../../utils/with_apm_span'; interface Options { searchAggregatedTransactions: boolean; serviceName: string; - setup: Setup & SetupTimeRange; + setup: Setup; transactionType: string; + start: number; + end: number; } -type ESResponse = PromiseReturnType; - -function transform(options: Options, response: ESResponse) { - if (response.hits.total.value === 0) { - return []; - } - const { start, end } = options.setup; - const buckets = response.aggregations?.throughput.buckets ?? []; - return buckets.map(({ key: x, doc_count: value }) => ({ - x, - y: calculateThroughput({ start, end, value }), - })); -} - -async function fetcher({ +function fetcher({ searchAggregatedTransactions, serviceName, setup, transactionType, + start, + end, }: Options) { - const { start, end, apmEventClient } = setup; + const { apmEventClient } = setup; const { intervalString } = getBucketSize({ start, end }); const filter: ESFilter[] = [ { term: { [SERVICE_NAME]: serviceName } }, @@ -72,13 +60,20 @@ async function fetcher({ size: 0, query: { bool: { filter } }, aggs: { - throughput: { + timeseries: { date_histogram: { field: '@timestamp', fixed_interval: intervalString, min_doc_count: 0, extended_bounds: { min: start, max: end }, }, + aggs: { + throughput: { + rate: { + unit: 'minute' as const, + }, + }, + }, }, }, }, @@ -89,8 +84,15 @@ async function fetcher({ export function getThroughput(options: Options) { return withApmSpan('get_throughput_for_service', async () => { - return { - throughput: transform(options, await fetcher(options)), - }; + const response = await fetcher(options); + + return ( + response.aggregations?.timeseries.buckets.map((bucket) => { + return { + x: bucket.key, + y: bucket.throughput.value, + }; + }) ?? [] + ); }); } diff --git a/x-pack/plugins/apm/server/routes/default_api_types.ts b/x-pack/plugins/apm/server/routes/default_api_types.ts index 0ab4e0331652b3..fdc1e8ebe5a55f 100644 --- a/x-pack/plugins/apm/server/routes/default_api_types.ts +++ b/x-pack/plugins/apm/server/routes/default_api_types.ts @@ -6,11 +6,16 @@ */ import * as t from 'io-ts'; -import { dateAsStringRt } from '../../common/runtime_types/date_as_string_rt'; +import { isoToEpochRt } from '../../common/runtime_types/iso_to_epoch_rt'; export const rangeRt = t.type({ - start: dateAsStringRt, - end: dateAsStringRt, + start: isoToEpochRt, + end: isoToEpochRt, +}); + +export const comparisonRangeRt = t.partial({ + comparisonStart: isoToEpochRt, + comparisonEnd: isoToEpochRt, }); export const uiFiltersRt = t.type({ uiFilters: t.string }); diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index a5c4de7552d784..ff064e0571d138 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -5,26 +5,27 @@ * 2.0. */ -import * as t from 'io-ts'; import Boom from '@hapi/boom'; +import * as t from 'io-ts'; import { uniq } from 'lodash'; +import { isoToEpochRt } from '../../common/runtime_types/iso_to_epoch_rt'; +import { toNumberRt } from '../../common/runtime_types/to_number_rt'; +import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { setupRequest } from '../lib/helpers/setup_request'; -import { getServiceAgentName } from '../lib/services/get_service_agent_name'; -import { getServices } from '../lib/services/get_services'; -import { getServiceTransactionTypes } from '../lib/services/get_service_transaction_types'; -import { getServiceNodeMetadata } from '../lib/services/get_service_node_metadata'; -import { createRoute } from './create_route'; -import { uiFiltersRt, rangeRt } from './default_api_types'; import { getServiceAnnotations } from '../lib/services/annotations'; -import { dateAsStringRt } from '../../common/runtime_types/date_as_string_rt'; -import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; -import { getServiceErrorGroups } from '../lib/services/get_service_error_groups'; +import { getServices } from '../lib/services/get_services'; +import { getServiceAgentName } from '../lib/services/get_service_agent_name'; import { getServiceDependencies } from '../lib/services/get_service_dependencies'; -import { toNumberRt } from '../../common/runtime_types/to_number_rt'; -import { getThroughput } from '../lib/services/get_throughput'; +import { getServiceErrorGroups } from '../lib/services/get_service_error_groups'; import { getServiceInstances } from '../lib/services/get_service_instances'; import { getServiceMetadataDetails } from '../lib/services/get_service_metadata_details'; import { getServiceMetadataIcons } from '../lib/services/get_service_metadata_icons'; +import { getServiceNodeMetadata } from '../lib/services/get_service_node_metadata'; +import { getServiceTransactionTypes } from '../lib/services/get_service_transaction_types'; +import { getThroughput } from '../lib/services/get_throughput'; +import { offsetPreviousPeriodCoordinates } from '../utils/offset_previous_period_coordinate'; +import { createRoute } from './create_route'; +import { comparisonRangeRt, rangeRt, uiFiltersRt } from './default_api_types'; import { withApmSpan } from '../utils/with_apm_span'; export const servicesRoute = createRoute({ @@ -216,7 +217,7 @@ export const serviceAnnotationsCreateRoute = createRoute({ }), body: t.intersection([ t.type({ - '@timestamp': dateAsStringRt, + '@timestamp': isoToEpochRt, service: t.intersection([ t.type({ version: t.string, @@ -251,6 +252,7 @@ export const serviceAnnotationsCreateRoute = createRoute({ annotationsClient.create({ message: body.service.version, ...body, + '@timestamp': new Date(body['@timestamp']).toISOString(), annotation: { type: 'deployment', }, @@ -325,23 +327,56 @@ export const serviceThroughputRoute = createRoute({ t.type({ transactionType: t.string }), uiFiltersRt, rangeRt, + comparisonRangeRt, ]), }), options: { tags: ['access:apm'] }, handler: async ({ context, request }) => { const setup = await setupRequest(context, request); const { serviceName } = context.params.path; - const { transactionType } = context.params.query; + const { + transactionType, + comparisonStart, + comparisonEnd, + } = context.params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); - return getThroughput({ + const { start, end } = setup; + + const commonProps = { searchAggregatedTransactions, serviceName, setup, transactionType, - }); + }; + + const [currentPeriod, previousPeriod] = await Promise.all([ + getThroughput({ + ...commonProps, + start, + end, + }), + comparisonStart && comparisonEnd + ? getThroughput({ + ...commonProps, + start: comparisonStart, + end: comparisonEnd, + }).then((coordinates) => + offsetPreviousPeriodCoordinates({ + currentPeriodStart: start, + previousPeriodStart: comparisonStart, + previousPeriodTimeseries: coordinates, + }) + ) + : [], + ]); + + return { + currentPeriod, + previousPeriod, + }; }, }); diff --git a/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts b/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts index 61875db0985e49..ae0d9aeeaade16 100644 --- a/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts +++ b/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts @@ -7,6 +7,7 @@ import * as t from 'io-ts'; import Boom from '@hapi/boom'; +import { toBooleanRt } from '../../../common/runtime_types/to_boolean_rt'; import { setupRequest } from '../../lib/helpers/setup_request'; import { getServiceNames } from '../../lib/settings/agent_configuration/get_service_names'; import { createOrUpdateConfiguration } from '../../lib/settings/agent_configuration/create_or_update_configuration'; @@ -22,7 +23,6 @@ import { serviceRt, agentConfigurationIntakeRt, } from '../../../common/agent_configuration/runtime_types/agent_configuration_intake_rt'; -import { jsonRt } from '../../../common/runtime_types/json_rt'; import { getSearchAggregatedTransactions } from '../../lib/helpers/aggregated_transactions'; // get list of configurations @@ -103,7 +103,7 @@ export const createOrUpdateAgentConfigurationRoute = createRoute({ tags: ['access:apm', 'access:apm_write'], }, params: t.intersection([ - t.partial({ query: t.partial({ overwrite: jsonRt.pipe(t.boolean) }) }), + t.partial({ query: t.partial({ overwrite: toBooleanRt }) }), t.type({ body: agentConfigurationIntakeRt }), ]), handler: async ({ context, request }) => { diff --git a/x-pack/plugins/apm/server/routes/typings.ts b/x-pack/plugins/apm/server/routes/typings.ts index e5901cabc4ef69..4d3e07040f76b0 100644 --- a/x-pack/plugins/apm/server/routes/typings.ts +++ b/x-pack/plugins/apm/server/routes/typings.ts @@ -143,7 +143,7 @@ export type Client< forceCache?: boolean; endpoint: TEndpoint; } & (TRouteState[TEndpoint] extends { params: t.Any } - ? MaybeOptional<{ params: t.TypeOf }> + ? MaybeOptional<{ params: t.OutputOf }> : {}) & (TOptions extends { abortable: true } ? { signal: AbortSignal | null } : {}) ) => Promise< diff --git a/x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.test.ts b/x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.test.ts new file mode 100644 index 00000000000000..6436c7c5193ecb --- /dev/null +++ b/x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.test.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 { Coordinate } from '../../typings/timeseries'; +import { offsetPreviousPeriodCoordinates } from './offset_previous_period_coordinate'; + +const previousPeriodStart = new Date('2021-01-27T14:45:00.000Z').valueOf(); +const currentPeriodStart = new Date('2021-01-28T14:45:00.000Z').valueOf(); + +describe('mergePeriodsTimeseries', () => { + describe('returns empty array', () => { + it('when previous timeseries is not defined', () => { + expect( + offsetPreviousPeriodCoordinates({ + currentPeriodStart, + previousPeriodStart, + previousPeriodTimeseries: undefined, + }) + ).toEqual([]); + }); + + it('when previous timeseries is empty', () => { + expect( + offsetPreviousPeriodCoordinates({ + currentPeriodStart, + previousPeriodStart, + previousPeriodTimeseries: [], + }) + ).toEqual([]); + }); + }); + + it('offsets previous period timeseries', () => { + const previousPeriodTimeseries: Coordinate[] = [ + { x: new Date('2021-01-27T14:45:00.000Z').valueOf(), y: 1 }, + { x: new Date('2021-01-27T15:00:00.000Z').valueOf(), y: 2 }, + { x: new Date('2021-01-27T15:15:00.000Z').valueOf(), y: 2 }, + { x: new Date('2021-01-27T15:30:00.000Z').valueOf(), y: 3 }, + ]; + + expect( + offsetPreviousPeriodCoordinates({ + currentPeriodStart, + previousPeriodStart, + previousPeriodTimeseries, + }) + ).toEqual([ + { x: new Date('2021-01-28T14:45:00.000Z').valueOf(), y: 1 }, + { x: new Date('2021-01-28T15:00:00.000Z').valueOf(), y: 2 }, + { x: new Date('2021-01-28T15:15:00.000Z').valueOf(), y: 2 }, + { x: new Date('2021-01-28T15:30:00.000Z').valueOf(), y: 3 }, + ]); + }); +}); diff --git a/x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.ts b/x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.ts new file mode 100644 index 00000000000000..837e3d02056f0f --- /dev/null +++ b/x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment'; +import { Coordinate } from '../../typings/timeseries'; + +export function offsetPreviousPeriodCoordinates({ + currentPeriodStart, + previousPeriodStart, + previousPeriodTimeseries, +}: { + currentPeriodStart: number; + previousPeriodStart: number; + previousPeriodTimeseries?: Coordinate[]; +}) { + if (!previousPeriodTimeseries) { + return []; + } + + const dateOffset = moment(currentPeriodStart).diff( + moment(previousPeriodStart) + ); + + return previousPeriodTimeseries.map(({ x, y }) => { + const offsetX = moment(x).add(dateOffset).valueOf(); + return { + x: offsetX, + y, + }; + }); +} diff --git a/x-pack/test/apm_api_integration/tests/services/__snapshots__/throughput.snap b/x-pack/test/apm_api_integration/tests/services/__snapshots__/throughput.snap index eee0ec7f9ad38a..b4fd2219cb7331 100644 --- a/x-pack/test/apm_api_integration/tests/services/__snapshots__/throughput.snap +++ b/x-pack/test/apm_api_integration/tests/services/__snapshots__/throughput.snap @@ -8,7 +8,7 @@ Array [ }, Object { "x": 1607435880000, - "y": 0.133333333333333, + "y": 8, }, Object { "x": 1607435910000, @@ -16,7 +16,7 @@ Array [ }, Object { "x": 1607435940000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607435970000, @@ -24,11 +24,11 @@ Array [ }, Object { "x": 1607436000000, - "y": 0.1, + "y": 6, }, Object { "x": 1607436030000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607436060000, @@ -40,7 +40,7 @@ Array [ }, Object { "x": 1607436120000, - "y": 0.133333333333333, + "y": 8, }, Object { "x": 1607436150000, @@ -56,7 +56,7 @@ Array [ }, Object { "x": 1607436240000, - "y": 0.2, + "y": 12, }, Object { "x": 1607436270000, @@ -68,15 +68,15 @@ Array [ }, Object { "x": 1607436330000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607436360000, - "y": 0.166666666666667, + "y": 10, }, Object { "x": 1607436390000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607436420000, @@ -88,11 +88,11 @@ Array [ }, Object { "x": 1607436480000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607436510000, - "y": 0.166666666666667, + "y": 10, }, Object { "x": 1607436540000, @@ -104,11 +104,11 @@ Array [ }, Object { "x": 1607436600000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607436630000, - "y": 0.233333333333333, + "y": 14, }, Object { "x": 1607436660000, @@ -124,7 +124,7 @@ Array [ }, Object { "x": 1607436750000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607436780000, @@ -132,15 +132,15 @@ Array [ }, Object { "x": 1607436810000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607436840000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607436870000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607436900000, @@ -152,11 +152,11 @@ Array [ }, Object { "x": 1607436960000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607436990000, - "y": 0.133333333333333, + "y": 8, }, Object { "x": 1607437020000, @@ -168,11 +168,11 @@ Array [ }, Object { "x": 1607437080000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607437110000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607437140000, @@ -184,15 +184,15 @@ Array [ }, Object { "x": 1607437200000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607437230000, - "y": 0.233333333333333, + "y": 14, }, Object { "x": 1607437260000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607437290000, @@ -200,11 +200,11 @@ Array [ }, Object { "x": 1607437320000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607437350000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607437380000, @@ -216,11 +216,11 @@ Array [ }, Object { "x": 1607437440000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607437470000, - "y": 0.1, + "y": 6, }, Object { "x": 1607437500000, @@ -232,7 +232,7 @@ Array [ }, Object { "x": 1607437560000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607437590000, @@ -248,3 +248,740 @@ Array [ }, ] `; + +exports[`APM API tests basic apm_8.0.0 Throughput when data is loaded with time comparison has the correct throughput 1`] = ` +Object { + "currentPeriod": Array [ + Object { + "x": 1607436770000, + "y": 0, + }, + Object { + "x": 1607436780000, + "y": 0, + }, + Object { + "x": 1607436790000, + "y": 0, + }, + Object { + "x": 1607436800000, + "y": 0, + }, + Object { + "x": 1607436810000, + "y": 0, + }, + Object { + "x": 1607436820000, + "y": 6, + }, + Object { + "x": 1607436830000, + "y": 0, + }, + Object { + "x": 1607436840000, + "y": 0, + }, + Object { + "x": 1607436850000, + "y": 0, + }, + Object { + "x": 1607436860000, + "y": 6, + }, + Object { + "x": 1607436870000, + "y": 6, + }, + Object { + "x": 1607436880000, + "y": 6, + }, + Object { + "x": 1607436890000, + "y": 0, + }, + Object { + "x": 1607436900000, + "y": 0, + }, + Object { + "x": 1607436910000, + "y": 0, + }, + Object { + "x": 1607436920000, + "y": 0, + }, + Object { + "x": 1607436930000, + "y": 0, + }, + Object { + "x": 1607436940000, + "y": 0, + }, + Object { + "x": 1607436950000, + "y": 0, + }, + Object { + "x": 1607436960000, + "y": 0, + }, + Object { + "x": 1607436970000, + "y": 0, + }, + Object { + "x": 1607436980000, + "y": 12, + }, + Object { + "x": 1607436990000, + "y": 6, + }, + Object { + "x": 1607437000000, + "y": 18, + }, + Object { + "x": 1607437010000, + "y": 0, + }, + Object { + "x": 1607437020000, + "y": 0, + }, + Object { + "x": 1607437030000, + "y": 0, + }, + Object { + "x": 1607437040000, + "y": 0, + }, + Object { + "x": 1607437050000, + "y": 0, + }, + Object { + "x": 1607437060000, + "y": 0, + }, + Object { + "x": 1607437070000, + "y": 0, + }, + Object { + "x": 1607437080000, + "y": 0, + }, + Object { + "x": 1607437090000, + "y": 0, + }, + Object { + "x": 1607437100000, + "y": 6, + }, + Object { + "x": 1607437110000, + "y": 6, + }, + Object { + "x": 1607437120000, + "y": 0, + }, + Object { + "x": 1607437130000, + "y": 0, + }, + Object { + "x": 1607437140000, + "y": 0, + }, + Object { + "x": 1607437150000, + "y": 0, + }, + Object { + "x": 1607437160000, + "y": 0, + }, + Object { + "x": 1607437170000, + "y": 0, + }, + Object { + "x": 1607437180000, + "y": 0, + }, + Object { + "x": 1607437190000, + "y": 0, + }, + Object { + "x": 1607437200000, + "y": 0, + }, + Object { + "x": 1607437210000, + "y": 0, + }, + Object { + "x": 1607437220000, + "y": 12, + }, + Object { + "x": 1607437230000, + "y": 30, + }, + Object { + "x": 1607437240000, + "y": 12, + }, + Object { + "x": 1607437250000, + "y": 0, + }, + Object { + "x": 1607437260000, + "y": 0, + }, + Object { + "x": 1607437270000, + "y": 6, + }, + Object { + "x": 1607437280000, + "y": 0, + }, + Object { + "x": 1607437290000, + "y": 0, + }, + Object { + "x": 1607437300000, + "y": 0, + }, + Object { + "x": 1607437310000, + "y": 0, + }, + Object { + "x": 1607437320000, + "y": 0, + }, + Object { + "x": 1607437330000, + "y": 0, + }, + Object { + "x": 1607437340000, + "y": 6, + }, + Object { + "x": 1607437350000, + "y": 0, + }, + Object { + "x": 1607437360000, + "y": 12, + }, + Object { + "x": 1607437370000, + "y": 0, + }, + Object { + "x": 1607437380000, + "y": 0, + }, + Object { + "x": 1607437390000, + "y": 0, + }, + Object { + "x": 1607437400000, + "y": 0, + }, + Object { + "x": 1607437410000, + "y": 0, + }, + Object { + "x": 1607437420000, + "y": 0, + }, + Object { + "x": 1607437430000, + "y": 0, + }, + Object { + "x": 1607437440000, + "y": 0, + }, + Object { + "x": 1607437450000, + "y": 0, + }, + Object { + "x": 1607437460000, + "y": 6, + }, + Object { + "x": 1607437470000, + "y": 12, + }, + Object { + "x": 1607437480000, + "y": 6, + }, + Object { + "x": 1607437490000, + "y": 0, + }, + Object { + "x": 1607437500000, + "y": 0, + }, + Object { + "x": 1607437510000, + "y": 0, + }, + Object { + "x": 1607437520000, + "y": 0, + }, + Object { + "x": 1607437530000, + "y": 0, + }, + Object { + "x": 1607437540000, + "y": 0, + }, + Object { + "x": 1607437550000, + "y": 0, + }, + Object { + "x": 1607437560000, + "y": 0, + }, + Object { + "x": 1607437570000, + "y": 6, + }, + Object { + "x": 1607437580000, + "y": 0, + }, + Object { + "x": 1607437590000, + "y": 0, + }, + Object { + "x": 1607437600000, + "y": 0, + }, + Object { + "x": 1607437610000, + "y": 0, + }, + Object { + "x": 1607437620000, + "y": 0, + }, + Object { + "x": 1607437630000, + "y": 0, + }, + Object { + "x": 1607437640000, + "y": 0, + }, + Object { + "x": 1607437650000, + "y": 0, + }, + Object { + "x": 1607437660000, + "y": 0, + }, + Object { + "x": 1607437670000, + "y": 0, + }, + ], + "previousPeriod": Array [ + Object { + "x": 1607436770000, + "y": 0, + }, + Object { + "x": 1607436780000, + "y": 0, + }, + Object { + "x": 1607436790000, + "y": 0, + }, + Object { + "x": 1607436800000, + "y": 24, + }, + Object { + "x": 1607436810000, + "y": 0, + }, + Object { + "x": 1607436820000, + "y": 0, + }, + Object { + "x": 1607436830000, + "y": 0, + }, + Object { + "x": 1607436840000, + "y": 12, + }, + Object { + "x": 1607436850000, + "y": 0, + }, + Object { + "x": 1607436860000, + "y": 0, + }, + Object { + "x": 1607436870000, + "y": 0, + }, + Object { + "x": 1607436880000, + "y": 0, + }, + Object { + "x": 1607436890000, + "y": 0, + }, + Object { + "x": 1607436900000, + "y": 0, + }, + Object { + "x": 1607436910000, + "y": 12, + }, + Object { + "x": 1607436920000, + "y": 6, + }, + Object { + "x": 1607436930000, + "y": 6, + }, + Object { + "x": 1607436940000, + "y": 0, + }, + Object { + "x": 1607436950000, + "y": 0, + }, + Object { + "x": 1607436960000, + "y": 0, + }, + Object { + "x": 1607436970000, + "y": 0, + }, + Object { + "x": 1607436980000, + "y": 0, + }, + Object { + "x": 1607436990000, + "y": 0, + }, + Object { + "x": 1607437000000, + "y": 0, + }, + Object { + "x": 1607437010000, + "y": 0, + }, + Object { + "x": 1607437020000, + "y": 0, + }, + Object { + "x": 1607437030000, + "y": 6, + }, + Object { + "x": 1607437040000, + "y": 18, + }, + Object { + "x": 1607437050000, + "y": 0, + }, + Object { + "x": 1607437060000, + "y": 0, + }, + Object { + "x": 1607437070000, + "y": 0, + }, + Object { + "x": 1607437080000, + "y": 0, + }, + Object { + "x": 1607437090000, + "y": 0, + }, + Object { + "x": 1607437100000, + "y": 0, + }, + Object { + "x": 1607437110000, + "y": 0, + }, + Object { + "x": 1607437120000, + "y": 0, + }, + Object { + "x": 1607437130000, + "y": 0, + }, + Object { + "x": 1607437140000, + "y": 0, + }, + Object { + "x": 1607437150000, + "y": 0, + }, + Object { + "x": 1607437160000, + "y": 36, + }, + Object { + "x": 1607437170000, + "y": 0, + }, + Object { + "x": 1607437180000, + "y": 0, + }, + Object { + "x": 1607437190000, + "y": 0, + }, + Object { + "x": 1607437200000, + "y": 0, + }, + Object { + "x": 1607437210000, + "y": 0, + }, + Object { + "x": 1607437220000, + "y": 0, + }, + Object { + "x": 1607437230000, + "y": 0, + }, + Object { + "x": 1607437240000, + "y": 6, + }, + Object { + "x": 1607437250000, + "y": 0, + }, + Object { + "x": 1607437260000, + "y": 0, + }, + Object { + "x": 1607437270000, + "y": 0, + }, + Object { + "x": 1607437280000, + "y": 30, + }, + Object { + "x": 1607437290000, + "y": 6, + }, + Object { + "x": 1607437300000, + "y": 0, + }, + Object { + "x": 1607437310000, + "y": 0, + }, + Object { + "x": 1607437320000, + "y": 0, + }, + Object { + "x": 1607437330000, + "y": 0, + }, + Object { + "x": 1607437340000, + "y": 0, + }, + Object { + "x": 1607437350000, + "y": 0, + }, + Object { + "x": 1607437360000, + "y": 0, + }, + Object { + "x": 1607437370000, + "y": 0, + }, + Object { + "x": 1607437380000, + "y": 0, + }, + Object { + "x": 1607437390000, + "y": 0, + }, + Object { + "x": 1607437400000, + "y": 12, + }, + Object { + "x": 1607437410000, + "y": 6, + }, + Object { + "x": 1607437420000, + "y": 24, + }, + Object { + "x": 1607437430000, + "y": 0, + }, + Object { + "x": 1607437440000, + "y": 0, + }, + Object { + "x": 1607437450000, + "y": 0, + }, + Object { + "x": 1607437460000, + "y": 0, + }, + Object { + "x": 1607437470000, + "y": 0, + }, + Object { + "x": 1607437480000, + "y": 0, + }, + Object { + "x": 1607437490000, + "y": 0, + }, + Object { + "x": 1607437500000, + "y": 0, + }, + Object { + "x": 1607437510000, + "y": 0, + }, + Object { + "x": 1607437520000, + "y": 12, + }, + Object { + "x": 1607437530000, + "y": 30, + }, + Object { + "x": 1607437540000, + "y": 12, + }, + Object { + "x": 1607437550000, + "y": 0, + }, + Object { + "x": 1607437560000, + "y": 0, + }, + Object { + "x": 1607437570000, + "y": 0, + }, + Object { + "x": 1607437580000, + "y": 0, + }, + Object { + "x": 1607437590000, + "y": 0, + }, + Object { + "x": 1607437600000, + "y": 0, + }, + Object { + "x": 1607437610000, + "y": 0, + }, + Object { + "x": 1607437620000, + "y": 0, + }, + Object { + "x": 1607437630000, + "y": 0, + }, + Object { + "x": 1607437640000, + "y": 0, + }, + Object { + "x": 1607437650000, + "y": 6, + }, + Object { + "x": 1607437660000, + "y": 6, + }, + Object { + "x": 1607437670000, + "y": 0, + }, + ], +} +`; diff --git a/x-pack/test/apm_api_integration/tests/services/throughput.ts b/x-pack/test/apm_api_integration/tests/services/throughput.ts index 29f5d84d31b07d..787436ea37b05d 100644 --- a/x-pack/test/apm_api_integration/tests/services/throughput.ts +++ b/x-pack/test/apm_api_integration/tests/services/throughput.ts @@ -8,10 +8,15 @@ import expect from '@kbn/expect'; import qs from 'querystring'; import { first, last } from 'lodash'; +import moment from 'moment'; +import { isFiniteNumber } from '../../../../plugins/apm/common/utils/is_finite_number'; +import { APIReturnType } from '../../../../plugins/apm/public/services/rest/createCallApmApi'; import archives_metadata from '../../common/fixtures/es_archiver/archives_metadata'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { registry } from '../../common/registry'; +type ThroughputReturn = APIReturnType<'GET /api/apm/services/{serviceName}/throughput'>; + export default function ApiTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -29,17 +34,16 @@ export default function ApiTest({ getService }: FtrProviderContext) { })}` ); expect(response.status).to.be(200); - expect(response.body.throughput.length).to.be(0); + expect(response.body.currentPeriod.length).to.be(0); + expect(response.body.previousPeriod.length).to.be(0); }); }); + let throughputResponse: ThroughputReturn; registry.when( 'Throughput when data is loaded', { config: 'basic', archives: [archiveName] }, () => { - let throughputResponse: { - throughput: Array<{ x: number; y: number | null }>; - }; before(async () => { const response = await supertest.get( `/api/apm/services/opbeans-java/throughput?${qs.stringify({ @@ -53,31 +57,98 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); it('returns some data', () => { - expect(throughputResponse.throughput.length).to.be.greaterThan(0); + expect(throughputResponse.currentPeriod.length).to.be.greaterThan(0); + expect(throughputResponse.previousPeriod.length).not.to.be.greaterThan(0); - const nonNullDataPoints = throughputResponse.throughput.filter(({ y }) => y !== null); + const nonNullDataPoints = throughputResponse.currentPeriod.filter(({ y }) => + isFiniteNumber(y) + ); expect(nonNullDataPoints.length).to.be.greaterThan(0); }); it('has the correct start date', () => { expectSnapshot( - new Date(first(throughputResponse.throughput)?.x ?? NaN).toISOString() + new Date(first(throughputResponse.currentPeriod)?.x ?? NaN).toISOString() ).toMatchInline(`"2020-12-08T13:57:30.000Z"`); }); it('has the correct end date', () => { expectSnapshot( - new Date(last(throughputResponse.throughput)?.x ?? NaN).toISOString() + new Date(last(throughputResponse.currentPeriod)?.x ?? NaN).toISOString() ).toMatchInline(`"2020-12-08T14:27:30.000Z"`); }); it('has the correct number of buckets', () => { - expectSnapshot(throughputResponse.throughput.length).toMatchInline(`61`); + expectSnapshot(throughputResponse.currentPeriod.length).toMatchInline(`61`); + }); + + it('has the correct throughput', () => { + expectSnapshot(throughputResponse.currentPeriod).toMatch(); + }); + } + ); + + registry.when( + 'Throughput when data is loaded with time comparison', + { config: 'basic', archives: [archiveName] }, + () => { + before(async () => { + const response = await supertest.get( + `/api/apm/services/opbeans-java/throughput?${qs.stringify({ + uiFilters: encodeURIComponent('{}'), + transactionType: 'request', + start: moment(metadata.end).subtract(15, 'minutes').toISOString(), + end: metadata.end, + comparisonStart: metadata.start, + comparisonEnd: moment(metadata.start).add(15, 'minutes').toISOString(), + })}` + ); + throughputResponse = response.body; + }); + + it('returns some data', () => { + expect(throughputResponse.currentPeriod.length).to.be.greaterThan(0); + expect(throughputResponse.previousPeriod.length).to.be.greaterThan(0); + + const currentPeriodNonNullDataPoints = throughputResponse.currentPeriod.filter(({ y }) => + isFiniteNumber(y) + ); + const previousPeriodNonNullDataPoints = throughputResponse.previousPeriod.filter(({ y }) => + isFiniteNumber(y) + ); + + expect(currentPeriodNonNullDataPoints.length).to.be.greaterThan(0); + expect(previousPeriodNonNullDataPoints.length).to.be.greaterThan(0); + }); + + it('has the correct start date', () => { + expectSnapshot( + new Date(first(throughputResponse.currentPeriod)?.x ?? NaN).toISOString() + ).toMatchInline(`"2020-12-08T14:12:50.000Z"`); + + expectSnapshot( + new Date(first(throughputResponse.previousPeriod)?.x ?? NaN).toISOString() + ).toMatchInline(`"2020-12-08T14:12:50.000Z"`); + }); + + it('has the correct end date', () => { + expectSnapshot( + new Date(last(throughputResponse.currentPeriod)?.x ?? NaN).toISOString() + ).toMatchInline(`"2020-12-08T14:27:50.000Z"`); + + expectSnapshot( + new Date(last(throughputResponse.previousPeriod)?.x ?? NaN).toISOString() + ).toMatchInline(`"2020-12-08T14:27:50.000Z"`); + }); + + it('has the correct number of buckets', () => { + expectSnapshot(throughputResponse.currentPeriod.length).toMatchInline(`91`); + expectSnapshot(throughputResponse.previousPeriod.length).toMatchInline(`91`); }); it('has the correct throughput', () => { - expectSnapshot(throughputResponse.throughput).toMatch(); + expectSnapshot(throughputResponse).toMatch(); }); } ); From 104eacb59a82b55d26e433123cd416f2983052cd Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Sat, 13 Feb 2021 01:42:56 -0700 Subject: [PATCH 3/4] [data.search] Add user information to background session service (#84975) * [data.search] Move search method inside session service and add tests * Move background session service to data_enhanced plugin * Fix types * [data.search] Add user information to background session service * Update trackId & getId to accept user * Fix remaining merge conflicts * Fix test * Remove todos * Fix session service to use user * Remove user conflicts and update SO filter * Allow filter as string or KQL node * Add back user checks * Add API integration tests * Remove unnecessary get calls --- .../common/search/session/types.ts | 6 + x-pack/plugins/data_enhanced/kibana.json | 2 +- x-pack/plugins/data_enhanced/server/plugin.ts | 5 +- .../server/saved_objects/search_session.ts | 9 + .../search/session/session_service.test.ts | 725 ++++++++++++++---- .../server/search/session/session_service.ts | 170 +++- x-pack/plugins/data_enhanced/tsconfig.json | 1 + .../api_integration/apis/search/session.ts | 116 +++ 8 files changed, 838 insertions(+), 196 deletions(-) diff --git a/x-pack/plugins/data_enhanced/common/search/session/types.ts b/x-pack/plugins/data_enhanced/common/search/session/types.ts index 4c5fe846cebd2f..788ab30756e1c4 100644 --- a/x-pack/plugins/data_enhanced/common/search/session/types.ts +++ b/x-pack/plugins/data_enhanced/common/search/session/types.ts @@ -57,6 +57,12 @@ export interface SearchSessionSavedObjectAttributes { * This value is true if the session was actively stored by the user. If it is false, the session may be purged by the system. */ persisted: boolean; + /** + * The realm type/name & username uniquely identifies the user who created this search session + */ + realmType?: string; + realmName?: string; + username?: string; } export interface SearchSessionRequestInfo { diff --git a/x-pack/plugins/data_enhanced/kibana.json b/x-pack/plugins/data_enhanced/kibana.json index 037f52fcb4b05a..a0489ecd30aaac 100644 --- a/x-pack/plugins/data_enhanced/kibana.json +++ b/x-pack/plugins/data_enhanced/kibana.json @@ -4,7 +4,7 @@ "kibanaVersion": "kibana", "configPath": ["xpack", "data_enhanced"], "requiredPlugins": ["bfetch", "data", "features", "management", "share", "taskManager"], - "optionalPlugins": ["kibanaUtils", "usageCollection"], + "optionalPlugins": ["kibanaUtils", "usageCollection", "security"], "server": true, "ui": true, "requiredBundles": ["kibanaUtils", "kibanaReact"] diff --git a/x-pack/plugins/data_enhanced/server/plugin.ts b/x-pack/plugins/data_enhanced/server/plugin.ts index 3aaf50fbeb3e69..c3d342b8159e37 100644 --- a/x-pack/plugins/data_enhanced/server/plugin.ts +++ b/x-pack/plugins/data_enhanced/server/plugin.ts @@ -24,12 +24,15 @@ import { import { getUiSettings } from './ui_settings'; import type { DataEnhancedRequestHandlerContext } from './type'; import { ConfigSchema } from '../config'; +import { SecurityPluginSetup } from '../../security/server'; interface SetupDependencies { data: DataPluginSetup; usageCollection?: UsageCollectionSetup; taskManager: TaskManagerSetupContract; + security?: SecurityPluginSetup; } + export interface StartDependencies { data: DataPluginStart; taskManager: TaskManagerStartContract; @@ -67,7 +70,7 @@ export class EnhancedDataServerPlugin eqlSearchStrategyProvider(this.logger) ); - this.sessionService = new SearchSessionService(this.logger, this.config); + this.sessionService = new SearchSessionService(this.logger, this.config, deps.security); deps.data.__enhance({ search: { diff --git a/x-pack/plugins/data_enhanced/server/saved_objects/search_session.ts b/x-pack/plugins/data_enhanced/server/saved_objects/search_session.ts index fe522005e45581..fd3d24b71f97da 100644 --- a/x-pack/plugins/data_enhanced/server/saved_objects/search_session.ts +++ b/x-pack/plugins/data_enhanced/server/saved_objects/search_session.ts @@ -53,6 +53,15 @@ export const searchSessionMapping: SavedObjectsType = { type: 'object', enabled: false, }, + realmType: { + type: 'keyword', + }, + realmName: { + type: 'keyword', + }, + username: { + type: 'keyword', + }, }, }, }; diff --git a/x-pack/plugins/data_enhanced/server/search/session/session_service.test.ts b/x-pack/plugins/data_enhanced/server/search/session/session_service.test.ts index b195a32ad481f2..f61d89e2301abd 100644 --- a/x-pack/plugins/data_enhanced/server/search/session/session_service.test.ts +++ b/x-pack/plugins/data_enhanced/server/search/session/session_service.test.ts @@ -19,6 +19,8 @@ import { coreMock } from 'src/core/server/mocks'; import { ConfigSchema } from '../../../config'; // @ts-ignore import { taskManagerMock } from '../../../../task_manager/server/mocks'; +import { AuthenticatedUser } from '../../../../security/common/model'; +import { nodeBuilder } from '../../../../../../src/plugins/data/common'; const MAX_UPDATE_RETRIES = 3; @@ -31,7 +33,21 @@ describe('SearchSessionService', () => { const MOCK_STRATEGY = 'ese'; const sessionId = 'd7170a35-7e2c-48d6-8dec-9a056721b489'; - const mockSavedObject: SavedObject = { + const mockUser1 = { + username: 'my_username', + authentication_realm: { + type: 'my_realm_type', + name: 'my_realm_name', + }, + } as AuthenticatedUser; + const mockUser2 = { + username: 'bar', + authentication_realm: { + type: 'bar', + name: 'bar', + }, + } as AuthenticatedUser; + const mockSavedObject: SavedObject = { id: 'd7170a35-7e2c-48d6-8dec-9a056721b489', type: SEARCH_SESSION_TYPE, attributes: { @@ -39,6 +55,9 @@ describe('SearchSessionService', () => { appId: 'my_app_id', urlGeneratorId: 'my_url_generator_id', idMapping: {}, + realmType: mockUser1.authentication_realm.type, + realmName: mockUser1.authentication_realm.name, + username: mockUser1.username, }, references: [], }; @@ -77,66 +96,551 @@ describe('SearchSessionService', () => { service.stop(); }); - it('get calls saved objects client', async () => { - savedObjectsClient.get.mockResolvedValue(mockSavedObject); + describe('save', () => { + it('throws if `name` is not provided', () => { + expect(() => + service.save({ savedObjectsClient }, mockUser1, sessionId, {}) + ).rejects.toMatchInlineSnapshot(`[Error: Name is required]`); + }); + + it('throws if `appId` is not provided', () => { + expect( + service.save({ savedObjectsClient }, mockUser1, sessionId, { name: 'banana' }) + ).rejects.toMatchInlineSnapshot(`[Error: AppId is required]`); + }); + + it('throws if `generator id` is not provided', () => { + expect( + service.save({ savedObjectsClient }, mockUser1, sessionId, { + name: 'banana', + appId: 'nanana', + }) + ).rejects.toMatchInlineSnapshot(`[Error: UrlGeneratorId is required]`); + }); + + it('saving updates an existing saved object and persists it', async () => { + const mockUpdateSavedObject = { + ...mockSavedObject, + attributes: {}, + }; + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject); + + await service.save({ savedObjectsClient }, mockUser1, sessionId, { + name: 'banana', + appId: 'nanana', + urlGeneratorId: 'panama', + }); + + expect(savedObjectsClient.update).toHaveBeenCalled(); + expect(savedObjectsClient.create).not.toHaveBeenCalled(); + + const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0]; + expect(type).toBe(SEARCH_SESSION_TYPE); + expect(id).toBe(sessionId); + expect(callAttributes).not.toHaveProperty('idMapping'); + expect(callAttributes).toHaveProperty('touched'); + expect(callAttributes).toHaveProperty('persisted', true); + expect(callAttributes).toHaveProperty('name', 'banana'); + expect(callAttributes).toHaveProperty('appId', 'nanana'); + expect(callAttributes).toHaveProperty('urlGeneratorId', 'panama'); + expect(callAttributes).toHaveProperty('initialState', {}); + expect(callAttributes).toHaveProperty('restoreState', {}); + }); + + it('saving creates a new persisted saved object, if it did not exist', async () => { + const mockCreatedSavedObject = { + ...mockSavedObject, + attributes: {}, + }; + + savedObjectsClient.update.mockRejectedValue( + SavedObjectsErrorHelpers.createGenericNotFoundError(sessionId) + ); + savedObjectsClient.create.mockResolvedValue(mockCreatedSavedObject); + + await service.save({ savedObjectsClient }, mockUser1, sessionId, { + name: 'banana', + appId: 'nanana', + urlGeneratorId: 'panama', + }); + + expect(savedObjectsClient.update).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); + + const [type, callAttributes, options] = savedObjectsClient.create.mock.calls[0]; + expect(type).toBe(SEARCH_SESSION_TYPE); + expect(options?.id).toBe(sessionId); + expect(callAttributes).toHaveProperty('idMapping', {}); + expect(callAttributes).toHaveProperty('touched'); + expect(callAttributes).toHaveProperty('expires'); + expect(callAttributes).toHaveProperty('created'); + expect(callAttributes).toHaveProperty('persisted', true); + expect(callAttributes).toHaveProperty('name', 'banana'); + expect(callAttributes).toHaveProperty('appId', 'nanana'); + expect(callAttributes).toHaveProperty('urlGeneratorId', 'panama'); + expect(callAttributes).toHaveProperty('initialState', {}); + expect(callAttributes).toHaveProperty('restoreState', {}); + expect(callAttributes).toHaveProperty('realmType', mockUser1.authentication_realm.type); + expect(callAttributes).toHaveProperty('realmName', mockUser1.authentication_realm.name); + expect(callAttributes).toHaveProperty('username', mockUser1.username); + }); + + it('throws error if user conflicts', () => { + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + + expect( + service.get({ savedObjectsClient }, mockUser2, sessionId) + ).rejects.toMatchInlineSnapshot(`[Error: Not Found]`); + }); + + it('works without security', async () => { + savedObjectsClient.update.mockRejectedValue( + SavedObjectsErrorHelpers.createGenericNotFoundError(sessionId) + ); + + await service.save( + { savedObjectsClient }, + + null, + sessionId, + { + name: 'my_name', + appId: 'my_app_id', + urlGeneratorId: 'my_url_generator_id', + } + ); + + expect(savedObjectsClient.create).toHaveBeenCalled(); + const [[, attributes]] = savedObjectsClient.create.mock.calls; + expect(attributes).toHaveProperty('realmType', undefined); + expect(attributes).toHaveProperty('realmName', undefined); + expect(attributes).toHaveProperty('username', undefined); + }); + }); + + describe('get', () => { + it('calls saved objects client', async () => { + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + + const response = await service.get({ savedObjectsClient }, mockUser1, sessionId); + + expect(response).toBe(mockSavedObject); + expect(savedObjectsClient.get).toHaveBeenCalledWith(SEARCH_SESSION_TYPE, sessionId); + }); - const response = await service.get({ savedObjectsClient }, sessionId); + it('works without security', async () => { + savedObjectsClient.get.mockResolvedValue(mockSavedObject); - expect(response).toBe(mockSavedObject); - expect(savedObjectsClient.get).toHaveBeenCalledWith(SEARCH_SESSION_TYPE, sessionId); + const response = await service.get({ savedObjectsClient }, null, sessionId); + + expect(response).toBe(mockSavedObject); + expect(savedObjectsClient.get).toHaveBeenCalledWith(SEARCH_SESSION_TYPE, sessionId); + }); }); - it('find calls saved objects client', async () => { - const mockFindSavedObject = { - ...mockSavedObject, - score: 1, - }; - const mockResponse = { - saved_objects: [mockFindSavedObject], - total: 1, - per_page: 1, - page: 0, - }; - savedObjectsClient.find.mockResolvedValue(mockResponse); + describe('find', () => { + it('calls saved objects client with user filter', async () => { + const mockFindSavedObject = { + ...mockSavedObject, + score: 1, + }; + const mockResponse = { + saved_objects: [mockFindSavedObject], + total: 1, + per_page: 1, + page: 0, + }; + savedObjectsClient.find.mockResolvedValue(mockResponse); + + const options = { page: 0, perPage: 5 }; + const response = await service.find({ savedObjectsClient }, mockUser1, options); + + expect(response).toBe(mockResponse); + const [[findOptions]] = savedObjectsClient.find.mock.calls; + expect(findOptions).toMatchInlineSnapshot(` + Object { + "filter": Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "search-session.attributes.realmType", + }, + Object { + "type": "literal", + "value": "my_realm_type", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "search-session.attributes.realmName", + }, + Object { + "type": "literal", + "value": "my_realm_name", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "search-session.attributes.username", + }, + Object { + "type": "literal", + "value": "my_username", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "and", + "type": "function", + }, + "page": 0, + "perPage": 5, + "type": "search-session", + } + `); + }); - const options = { page: 0, perPage: 5 }; - const response = await service.find({ savedObjectsClient }, options); + it('mixes in passed-in filter as string and KQL node', async () => { + const mockFindSavedObject = { + ...mockSavedObject, + score: 1, + }; + const mockResponse = { + saved_objects: [mockFindSavedObject], + total: 1, + per_page: 1, + page: 0, + }; + savedObjectsClient.find.mockResolvedValue(mockResponse); + + const options1 = { filter: 'foobar' }; + const response1 = await service.find({ savedObjectsClient }, mockUser1, options1); + + const options2 = { filter: nodeBuilder.is('foo', 'bar') }; + const response2 = await service.find({ savedObjectsClient }, mockUser1, options2); + + expect(response1).toBe(mockResponse); + expect(response2).toBe(mockResponse); + + const [[findOptions1], [findOptions2]] = savedObjectsClient.find.mock.calls; + expect(findOptions1).toMatchInlineSnapshot(` + Object { + "filter": Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "search-session.attributes.realmType", + }, + Object { + "type": "literal", + "value": "my_realm_type", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "search-session.attributes.realmName", + }, + Object { + "type": "literal", + "value": "my_realm_name", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "search-session.attributes.username", + }, + Object { + "type": "literal", + "value": "my_username", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": null, + }, + Object { + "type": "literal", + "value": "foobar", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "and", + "type": "function", + }, + "type": "search-session", + } + `); + expect(findOptions2).toMatchInlineSnapshot(` + Object { + "filter": Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "search-session.attributes.realmType", + }, + Object { + "type": "literal", + "value": "my_realm_type", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "search-session.attributes.realmName", + }, + Object { + "type": "literal", + "value": "my_realm_name", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "search-session.attributes.username", + }, + Object { + "type": "literal", + "value": "my_username", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "foo", + }, + Object { + "type": "literal", + "value": "bar", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "and", + "type": "function", + }, + "type": "search-session", + } + `); + }); - expect(response).toBe(mockResponse); - expect(savedObjectsClient.find).toHaveBeenCalledWith({ - ...options, - type: SEARCH_SESSION_TYPE, + it('has no filter without security', async () => { + const mockFindSavedObject = { + ...mockSavedObject, + score: 1, + }; + const mockResponse = { + saved_objects: [mockFindSavedObject], + total: 1, + per_page: 1, + page: 0, + }; + savedObjectsClient.find.mockResolvedValue(mockResponse); + + const options = { page: 0, perPage: 5 }; + const response = await service.find({ savedObjectsClient }, null, options); + + expect(response).toBe(mockResponse); + const [[findOptions]] = savedObjectsClient.find.mock.calls; + expect(findOptions).toMatchInlineSnapshot(` + Object { + "filter": undefined, + "page": 0, + "perPage": 5, + "type": "search-session", + } + `); }); }); - it('update calls saved objects client with added touch time', async () => { - const mockUpdateSavedObject = { - ...mockSavedObject, - attributes: {}, - }; - savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject); + describe('update', () => { + it('update calls saved objects client with added touch time', async () => { + const mockUpdateSavedObject = { + ...mockSavedObject, + attributes: {}, + }; + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject); + + const attributes = { name: 'new_name' }; + const response = await service.update( + { savedObjectsClient }, + mockUser1, + sessionId, + attributes + ); + + expect(response).toBe(mockUpdateSavedObject); + + const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0]; + + expect(type).toBe(SEARCH_SESSION_TYPE); + expect(id).toBe(sessionId); + expect(callAttributes).toHaveProperty('name', attributes.name); + expect(callAttributes).toHaveProperty('touched'); + }); + + it('throws if user conflicts', () => { + const mockUpdateSavedObject = { + ...mockSavedObject, + attributes: {}, + }; + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject); - const attributes = { name: 'new_name' }; - const response = await service.update({ savedObjectsClient }, sessionId, attributes); + const attributes = { name: 'new_name' }; + expect( + service.update({ savedObjectsClient }, mockUser2, sessionId, attributes) + ).rejects.toMatchInlineSnapshot(`[Error: Not Found]`); + }); - expect(response).toBe(mockUpdateSavedObject); + it('works without security', async () => { + const mockUpdateSavedObject = { + ...mockSavedObject, + attributes: {}, + }; + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject); - const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0]; + const attributes = { name: 'new_name' }; + const response = await service.update({ savedObjectsClient }, null, sessionId, attributes); + const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0]; - expect(type).toBe(SEARCH_SESSION_TYPE); - expect(id).toBe(sessionId); - expect(callAttributes).toHaveProperty('name', attributes.name); - expect(callAttributes).toHaveProperty('touched'); + expect(response).toBe(mockUpdateSavedObject); + expect(type).toBe(SEARCH_SESSION_TYPE); + expect(id).toBe(sessionId); + expect(callAttributes).toHaveProperty('name', 'new_name'); + expect(callAttributes).toHaveProperty('touched'); + }); }); - it('cancel updates object status', async () => { - await service.cancel({ savedObjectsClient }, sessionId); - const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0]; + describe('cancel', () => { + it('updates object status', async () => { + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + + await service.cancel({ savedObjectsClient }, mockUser1, sessionId); + const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0]; - expect(type).toBe(SEARCH_SESSION_TYPE); - expect(id).toBe(sessionId); - expect(callAttributes).toHaveProperty('status', SearchSessionStatus.CANCELLED); - expect(callAttributes).toHaveProperty('touched'); + expect(type).toBe(SEARCH_SESSION_TYPE); + expect(id).toBe(sessionId); + expect(callAttributes).toHaveProperty('status', SearchSessionStatus.CANCELLED); + expect(callAttributes).toHaveProperty('touched'); + }); + + it('throws if user conflicts', () => { + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + + expect( + service.cancel({ savedObjectsClient }, mockUser2, sessionId) + ).rejects.toMatchInlineSnapshot(`[Error: Not Found]`); + }); + + it('works without security', async () => { + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + + await service.cancel({ savedObjectsClient }, null, sessionId); + + const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0]; + + expect(type).toBe(SEARCH_SESSION_TYPE); + expect(id).toBe(sessionId); + expect(callAttributes).toHaveProperty('status', SearchSessionStatus.CANCELLED); + expect(callAttributes).toHaveProperty('touched'); + }); }); describe('trackId', () => { @@ -151,7 +655,7 @@ describe('SearchSessionService', () => { }; savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject); - await service.trackId({ savedObjectsClient }, searchRequest, searchId, { + await service.trackId({ savedObjectsClient }, mockUser1, searchRequest, searchId, { sessionId, strategy: MOCK_STRATEGY, }); @@ -194,7 +698,7 @@ describe('SearchSessionService', () => { }); }); - await service.trackId({ savedObjectsClient }, searchRequest, searchId, { + await service.trackId({ savedObjectsClient }, mockUser1, searchRequest, searchId, { sessionId, strategy: MOCK_STRATEGY, }); @@ -213,7 +717,7 @@ describe('SearchSessionService', () => { }); }); - await service.trackId({ savedObjectsClient }, searchRequest, searchId, { + await service.trackId({ savedObjectsClient }, mockUser1, searchRequest, searchId, { sessionId, strategy: MOCK_STRATEGY, }); @@ -238,7 +742,7 @@ describe('SearchSessionService', () => { ); savedObjectsClient.create.mockResolvedValue(mockCreatedSavedObject); - await service.trackId({ savedObjectsClient }, searchRequest, searchId, { + await service.trackId({ savedObjectsClient }, mockUser1, searchRequest, searchId, { sessionId, strategy: MOCK_STRATEGY, }); @@ -289,7 +793,7 @@ describe('SearchSessionService', () => { SavedObjectsErrorHelpers.createConflictError(SEARCH_SESSION_TYPE, searchId) ); - await service.trackId({ savedObjectsClient }, searchRequest, searchId, { + await service.trackId({ savedObjectsClient }, mockUser1, searchRequest, searchId, { sessionId, strategy: MOCK_STRATEGY, }); @@ -309,7 +813,7 @@ describe('SearchSessionService', () => { SavedObjectsErrorHelpers.createConflictError(SEARCH_SESSION_TYPE, searchId) ); - await service.trackId({ savedObjectsClient }, searchRequest, searchId, { + await service.trackId({ savedObjectsClient }, mockUser1, searchRequest, searchId, { sessionId, strategy: MOCK_STRATEGY, }); @@ -341,15 +845,15 @@ describe('SearchSessionService', () => { savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject); await Promise.all([ - service.trackId({ savedObjectsClient }, searchRequest1, searchId1, { + service.trackId({ savedObjectsClient }, mockUser1, searchRequest1, searchId1, { sessionId: sessionId1, strategy: MOCK_STRATEGY, }), - service.trackId({ savedObjectsClient }, searchRequest2, searchId2, { + service.trackId({ savedObjectsClient }, mockUser1, searchRequest2, searchId2, { sessionId: sessionId1, strategy: MOCK_STRATEGY, }), - service.trackId({ savedObjectsClient }, searchRequest3, searchId3, { + service.trackId({ savedObjectsClient }, mockUser1, searchRequest3, searchId3, { sessionId: sessionId2, strategy: MOCK_STRATEGY, }), @@ -394,7 +898,7 @@ describe('SearchSessionService', () => { const searchRequest = { params: {} }; expect(() => - service.getId({ savedObjectsClient }, searchRequest, {}) + service.getId({ savedObjectsClient }, mockUser1, searchRequest, {}) ).rejects.toMatchInlineSnapshot(`[Error: Session ID is required]`); }); @@ -402,7 +906,10 @@ describe('SearchSessionService', () => { const searchRequest = { params: {} }; expect(() => - service.getId({ savedObjectsClient }, searchRequest, { sessionId, isStored: false }) + service.getId({ savedObjectsClient }, mockUser1, searchRequest, { + sessionId, + isStored: false, + }) ).rejects.toMatchInlineSnapshot( `[Error: Cannot get search ID from a session that is not stored]` ); @@ -412,7 +919,7 @@ describe('SearchSessionService', () => { const searchRequest = { params: {} }; expect(() => - service.getId({ savedObjectsClient }, searchRequest, { + service.getId({ savedObjectsClient }, mockUser1, searchRequest, { sessionId, isStored: true, isRestore: false, @@ -427,24 +934,19 @@ describe('SearchSessionService', () => { const requestHash = createRequestHash(searchRequest.params); const searchId = 'FnpFYlBpeXdCUTMyZXhCLTc1TWFKX0EbdDFDTzJzTE1Sck9PVTBIcW1iU05CZzo4MDA0'; const mockSession = { - id: 'd7170a35-7e2c-48d6-8dec-9a056721b489', - type: SEARCH_SESSION_TYPE, + ...mockSavedObject, attributes: { - name: 'my_name', - appId: 'my_app_id', - urlGeneratorId: 'my_url_generator_id', + ...mockSavedObject.attributes, idMapping: { [requestHash]: { id: searchId, - strategy: MOCK_STRATEGY, }, }, }, - references: [], }; savedObjectsClient.get.mockResolvedValue(mockSession); - const id = await service.getId({ savedObjectsClient }, searchRequest, { + const id = await service.getId({ savedObjectsClient }, mockUser1, searchRequest, { sessionId, isStored: true, isRestore: true, @@ -457,12 +959,9 @@ describe('SearchSessionService', () => { describe('getSearchIdMapping', () => { it('retrieves the search IDs and strategies from the saved object', async () => { const mockSession = { - id: 'd7170a35-7e2c-48d6-8dec-9a056721b489', - type: SEARCH_SESSION_TYPE, + ...mockSavedObject, attributes: { - name: 'my_name', - appId: 'my_app_id', - urlGeneratorId: 'my_url_generator_id', + ...mockSavedObject.attributes, idMapping: { foo: { id: 'FnpFYlBpeXdCUTMyZXhCLTc1TWFKX0EbdDFDTzJzTE1Sck9PVTBIcW1iU05CZzo4MDA0', @@ -470,11 +969,11 @@ describe('SearchSessionService', () => { }, }, }, - references: [], }; savedObjectsClient.get.mockResolvedValue(mockSession); const searchIdMapping = await service.getSearchIdMapping( { savedObjectsClient }, + mockUser1, mockSession.id ); expect(searchIdMapping).toMatchInlineSnapshot(` @@ -484,88 +983,4 @@ describe('SearchSessionService', () => { `); }); }); - - describe('save', () => { - it('save throws if `name` is not provided', () => { - expect(service.save({ savedObjectsClient }, sessionId, {})).rejects.toMatchInlineSnapshot( - `[Error: Name is required]` - ); - }); - - it('save throws if `appId` is not provided', () => { - expect( - service.save({ savedObjectsClient }, sessionId, { name: 'banana' }) - ).rejects.toMatchInlineSnapshot(`[Error: AppId is required]`); - }); - - it('save throws if `generator id` is not provided', () => { - expect( - service.save({ savedObjectsClient }, sessionId, { name: 'banana', appId: 'nanana' }) - ).rejects.toMatchInlineSnapshot(`[Error: UrlGeneratorId is required]`); - }); - - it('saving updates an existing saved object and persists it', async () => { - const mockUpdateSavedObject = { - ...mockSavedObject, - attributes: {}, - }; - savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject); - - await service.save({ savedObjectsClient }, sessionId, { - name: 'banana', - appId: 'nanana', - urlGeneratorId: 'panama', - }); - - expect(savedObjectsClient.update).toHaveBeenCalled(); - expect(savedObjectsClient.create).not.toHaveBeenCalled(); - - const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0]; - expect(type).toBe(SEARCH_SESSION_TYPE); - expect(id).toBe(sessionId); - expect(callAttributes).not.toHaveProperty('idMapping'); - expect(callAttributes).toHaveProperty('touched'); - expect(callAttributes).toHaveProperty('persisted', true); - expect(callAttributes).toHaveProperty('name', 'banana'); - expect(callAttributes).toHaveProperty('appId', 'nanana'); - expect(callAttributes).toHaveProperty('urlGeneratorId', 'panama'); - expect(callAttributes).toHaveProperty('initialState', {}); - expect(callAttributes).toHaveProperty('restoreState', {}); - }); - - it('saving creates a new persisted saved object, if it did not exist', async () => { - const mockCreatedSavedObject = { - ...mockSavedObject, - attributes: {}, - }; - - savedObjectsClient.update.mockRejectedValue( - SavedObjectsErrorHelpers.createGenericNotFoundError(sessionId) - ); - savedObjectsClient.create.mockResolvedValue(mockCreatedSavedObject); - - await service.save({ savedObjectsClient }, sessionId, { - name: 'banana', - appId: 'nanana', - urlGeneratorId: 'panama', - }); - - expect(savedObjectsClient.update).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); - - const [type, callAttributes, options] = savedObjectsClient.create.mock.calls[0]; - expect(type).toBe(SEARCH_SESSION_TYPE); - expect(options?.id).toBe(sessionId); - expect(callAttributes).toHaveProperty('idMapping', {}); - expect(callAttributes).toHaveProperty('touched'); - expect(callAttributes).toHaveProperty('expires'); - expect(callAttributes).toHaveProperty('created'); - expect(callAttributes).toHaveProperty('persisted', true); - expect(callAttributes).toHaveProperty('name', 'banana'); - expect(callAttributes).toHaveProperty('appId', 'nanana'); - expect(callAttributes).toHaveProperty('urlGeneratorId', 'panama'); - expect(callAttributes).toHaveProperty('initialState', {}); - expect(callAttributes).toHaveProperty('restoreState', {}); - }); - }); }); diff --git a/x-pack/plugins/data_enhanced/server/search/session/session_service.ts b/x-pack/plugins/data_enhanced/server/search/session/session_service.ts index 6a36b1b4859ed3..c95c58a8dc06ba 100644 --- a/x-pack/plugins/data_enhanced/server/search/session/session_service.ts +++ b/x-pack/plugins/data_enhanced/server/search/session/session_service.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { notFound } from '@hapi/boom'; import { debounce } from 'lodash'; import { CoreSetup, @@ -16,8 +17,13 @@ import { SavedObjectsFindOptions, SavedObjectsErrorHelpers, } from '../../../../../../src/core/server'; -import { IKibanaSearchRequest, ISearchOptions } from '../../../../../../src/plugins/data/common'; -import { ISearchSessionService } from '../../../../../../src/plugins/data/server'; +import { + IKibanaSearchRequest, + ISearchOptions, + nodeBuilder, +} from '../../../../../../src/plugins/data/common'; +import { esKuery, ISearchSessionService } from '../../../../../../src/plugins/data/server'; +import { AuthenticatedUser, SecurityPluginSetup } from '../../../../security/server'; import { TaskManagerSetupContract, TaskManagerStartContract, @@ -49,6 +55,7 @@ const DEBOUNCE_UPDATE_OR_CREATE_MAX_WAIT = 5000; interface UpdateOrCreateQueueEntry { deps: SearchSessionDependencies; + user: AuthenticatedUser | null; sessionId: string; attributes: Partial; resolve: () => void; @@ -63,7 +70,11 @@ export class SearchSessionService private sessionConfig: SearchSessionsConfig; private readonly updateOrCreateBatchQueue: UpdateOrCreateQueueEntry[] = []; - constructor(private readonly logger: Logger, private readonly config: ConfigSchema) { + constructor( + private readonly logger: Logger, + private readonly config: ConfigSchema, + private readonly security?: SecurityPluginSetup + ) { this.sessionConfig = this.config.search.sessions; } @@ -114,7 +125,12 @@ export class SearchSessionService Object.keys(batchedSessionAttributes).forEach((sessionId) => { const thisSession = queue.filter((s) => s.sessionId === sessionId); - this.updateOrCreate(thisSession[0].deps, sessionId, batchedSessionAttributes[sessionId]) + this.updateOrCreate( + thisSession[0].deps, + thisSession[0].user, + sessionId, + batchedSessionAttributes[sessionId] + ) .then(() => { thisSession.forEach((s) => s.resolve()); }) @@ -128,11 +144,12 @@ export class SearchSessionService ); private scheduleUpdateOrCreate = ( deps: SearchSessionDependencies, + user: AuthenticatedUser | null, sessionId: string, attributes: Partial ): Promise => { return new Promise((resolve, reject) => { - this.updateOrCreateBatchQueue.push({ deps, sessionId, attributes, resolve, reject }); + this.updateOrCreateBatchQueue.push({ deps, user, sessionId, attributes, resolve, reject }); // TODO: this would be better if we'd debounce per sessionId this.processUpdateOrCreateBatchQueue(); }); @@ -140,6 +157,7 @@ export class SearchSessionService private updateOrCreate = async ( deps: SearchSessionDependencies, + user: AuthenticatedUser | null, sessionId: string, attributes: Partial, retry: number = 1 @@ -148,13 +166,14 @@ export class SearchSessionService this.logger.debug(`Conflict error | ${sessionId}`); // Randomize sleep to spread updates out in case of conflicts await sleep(100 + Math.random() * 50); - return await this.updateOrCreate(deps, sessionId, attributes, retry + 1); + return await this.updateOrCreate(deps, user, sessionId, attributes, retry + 1); }; this.logger.debug(`updateOrCreate | ${sessionId} | ${retry}`); try { return (await this.update( deps, + user, sessionId, attributes )) as SavedObject; @@ -162,7 +181,7 @@ export class SearchSessionService if (SavedObjectsErrorHelpers.isNotFoundError(e)) { try { this.logger.debug(`Object not found | ${sessionId}`); - return await this.create(deps, sessionId, attributes); + return await this.create(deps, user, sessionId, attributes); } catch (createError) { if ( SavedObjectsErrorHelpers.isConflictError(createError) && @@ -188,6 +207,7 @@ export class SearchSessionService public save = async ( deps: SearchSessionDependencies, + user: AuthenticatedUser | null, sessionId: string, { name, @@ -201,7 +221,7 @@ export class SearchSessionService if (!appId) throw new Error('AppId is required'); if (!urlGeneratorId) throw new Error('UrlGeneratorId is required'); - return this.updateOrCreate(deps, sessionId, { + return this.updateOrCreate(deps, user, sessionId, { name, appId, urlGeneratorId, @@ -213,10 +233,16 @@ export class SearchSessionService private create = ( { savedObjectsClient }: SearchSessionDependencies, + user: AuthenticatedUser | null, sessionId: string, attributes: Partial ) => { this.logger.debug(`create | ${sessionId}`); + + const realmType = user?.authentication_realm.type; + const realmName = user?.authentication_realm.name; + const username = user?.username; + return savedObjectsClient.create( SEARCH_SESSION_TYPE, { @@ -229,40 +255,69 @@ export class SearchSessionService touched: new Date().toISOString(), idMapping: {}, persisted: false, + realmType, + realmName, + username, ...attributes, }, { id: sessionId } ); }; - // TODO: Throw an error if this session doesn't belong to this user - public get = ({ savedObjectsClient }: SearchSessionDependencies, sessionId: string) => { + public get = async ( + { savedObjectsClient }: SearchSessionDependencies, + user: AuthenticatedUser | null, + sessionId: string + ) => { this.logger.debug(`get | ${sessionId}`); - return savedObjectsClient.get( + const session = await savedObjectsClient.get( SEARCH_SESSION_TYPE, sessionId ); + this.throwOnUserConflict(user, session); + return session; }; - // TODO: Throw an error if this session doesn't belong to this user public find = ( { savedObjectsClient }: SearchSessionDependencies, + user: AuthenticatedUser | null, options: Omit ) => { + const userFilters = + user === null + ? [] + : [ + nodeBuilder.is( + `${SEARCH_SESSION_TYPE}.attributes.realmType`, + `${user.authentication_realm.type}` + ), + nodeBuilder.is( + `${SEARCH_SESSION_TYPE}.attributes.realmName`, + `${user.authentication_realm.name}` + ), + nodeBuilder.is(`${SEARCH_SESSION_TYPE}.attributes.username`, `${user.username}`), + ]; + const filterKueryNode = + typeof options.filter === 'string' + ? esKuery.fromKueryExpression(options.filter) + : options.filter; + const filter = nodeBuilder.and(userFilters.concat(filterKueryNode ?? [])); return savedObjectsClient.find({ ...options, + filter, type: SEARCH_SESSION_TYPE, }); }; - // TODO: Throw an error if this session doesn't belong to this user - public update = ( - { savedObjectsClient }: SearchSessionDependencies, + public update = async ( + deps: SearchSessionDependencies, + user: AuthenticatedUser | null, sessionId: string, attributes: Partial ) => { this.logger.debug(`update | ${sessionId}`); - return savedObjectsClient.update( + await this.get(deps, user, sessionId); // Verify correct user + return deps.savedObjectsClient.update( SEARCH_SESSION_TYPE, sessionId, { @@ -272,22 +327,35 @@ export class SearchSessionService ); }; - public extend(deps: SearchSessionDependencies, sessionId: string, expires: Date) { + public async extend( + deps: SearchSessionDependencies, + user: AuthenticatedUser | null, + sessionId: string, + expires: Date + ) { this.logger.debug(`extend | ${sessionId}`); - - return this.update(deps, sessionId, { expires: expires.toISOString() }); + return this.update(deps, user, sessionId, { expires: expires.toISOString() }); } - // TODO: Throw an error if this session doesn't belong to this user - public cancel = (deps: SearchSessionDependencies, sessionId: string) => { - return this.update(deps, sessionId, { + public cancel = async ( + deps: SearchSessionDependencies, + user: AuthenticatedUser | null, + sessionId: string + ) => { + this.logger.debug(`delete | ${sessionId}`); + return this.update(deps, user, sessionId, { status: SearchSessionStatus.CANCELLED, }); }; - // TODO: Throw an error if this session doesn't belong to this user - public delete = ({ savedObjectsClient }: SearchSessionDependencies, sessionId: string) => { - return savedObjectsClient.delete(SEARCH_SESSION_TYPE, sessionId); + public delete = async ( + deps: SearchSessionDependencies, + user: AuthenticatedUser | null, + sessionId: string + ) => { + this.logger.debug(`delete | ${sessionId}`); + await this.get(deps, user, sessionId); // Verify correct user + return deps.savedObjectsClient.delete(SEARCH_SESSION_TYPE, sessionId); }; /** @@ -296,6 +364,7 @@ export class SearchSessionService */ public trackId = async ( deps: SearchSessionDependencies, + user: AuthenticatedUser | null, searchRequest: IKibanaSearchRequest, searchId: string, { sessionId, strategy }: ISearchOptions @@ -315,11 +384,15 @@ export class SearchSessionService idMapping = { [requestHash]: searchInfo }; } - await this.scheduleUpdateOrCreate(deps, sessionId, { idMapping }); + await this.scheduleUpdateOrCreate(deps, user, sessionId, { idMapping }); }; - public async getSearchIdMapping(deps: SearchSessionDependencies, sessionId: string) { - const searchSession = await this.get(deps, sessionId); + public async getSearchIdMapping( + deps: SearchSessionDependencies, + user: AuthenticatedUser | null, + sessionId: string + ) { + const searchSession = await this.get(deps, user, sessionId); const searchIdMapping = new Map(); Object.values(searchSession.attributes.idMapping).forEach((requestInfo) => { searchIdMapping.set(requestInfo.id, requestInfo.strategy); @@ -334,6 +407,7 @@ export class SearchSessionService */ public getId = async ( deps: SearchSessionDependencies, + user: AuthenticatedUser | null, searchRequest: IKibanaSearchRequest, { sessionId, isStored, isRestore }: ISearchOptions ) => { @@ -345,7 +419,7 @@ export class SearchSessionService throw new Error('Get search ID is only supported when restoring a session'); } - const session = await this.get(deps, sessionId); + const session = await this.get(deps, user, sessionId); const requestHash = createRequestHash(searchRequest.params); if (!session.attributes.idMapping.hasOwnProperty(requestHash)) { this.logger.error(`getId | ${sessionId} | ${requestHash} not found`); @@ -358,22 +432,40 @@ export class SearchSessionService public asScopedProvider = ({ savedObjects }: CoreStart) => { return (request: KibanaRequest) => { + const user = this.security?.authc.getCurrentUser(request) ?? null; const savedObjectsClient = savedObjects.getScopedClient(request, { includedHiddenTypes: [SEARCH_SESSION_TYPE], }); const deps = { savedObjectsClient }; return { - getId: this.getId.bind(this, deps), - trackId: this.trackId.bind(this, deps), - getSearchIdMapping: this.getSearchIdMapping.bind(this, deps), - save: this.save.bind(this, deps), - get: this.get.bind(this, deps), - find: this.find.bind(this, deps), - update: this.update.bind(this, deps), - extend: this.extend.bind(this, deps), - cancel: this.cancel.bind(this, deps), - delete: this.delete.bind(this, deps), + getId: this.getId.bind(this, deps, user), + trackId: this.trackId.bind(this, deps, user), + getSearchIdMapping: this.getSearchIdMapping.bind(this, deps, user), + save: this.save.bind(this, deps, user), + get: this.get.bind(this, deps, user), + find: this.find.bind(this, deps, user), + update: this.update.bind(this, deps, user), + extend: this.extend.bind(this, deps, user), + cancel: this.cancel.bind(this, deps, user), + delete: this.delete.bind(this, deps, user), }; }; }; + + private throwOnUserConflict = ( + user: AuthenticatedUser | null, + session?: SavedObject + ) => { + if (user === null || !session) return; + if ( + user.authentication_realm.type !== session.attributes.realmType || + user.authentication_realm.name !== session.attributes.realmName || + user.username !== session.attributes.username + ) { + this.logger.debug( + `User ${user.username} has no access to search session ${session.attributes.sessionId}` + ); + throw notFound(); + } + }; } diff --git a/x-pack/plugins/data_enhanced/tsconfig.json b/x-pack/plugins/data_enhanced/tsconfig.json index 29bfd71cb32b40..216c115545a451 100644 --- a/x-pack/plugins/data_enhanced/tsconfig.json +++ b/x-pack/plugins/data_enhanced/tsconfig.json @@ -25,6 +25,7 @@ { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, { "path": "../../../src/plugins/management/tsconfig.json" }, + { "path": "../security/tsconfig.json" }, { "path": "../task_manager/tsconfig.json" }, { "path": "../features/tsconfig.json" }, diff --git a/x-pack/test/api_integration/apis/search/session.ts b/x-pack/test/api_integration/apis/search/session.ts index 8412d6c1ad5d1e..27010a6ab90f6f 100644 --- a/x-pack/test/api_integration/apis/search/session.ts +++ b/x-pack/test/api_integration/apis/search/session.ts @@ -328,6 +328,122 @@ export default function ({ getService }: FtrProviderContext) { ); }); + describe('with security', () => { + before(async () => { + await security.user.create('other_user', { + password: 'password', + roles: ['superuser'], + full_name: 'other user', + }); + }); + + after(async () => { + await security.user.delete('other_user'); + }); + + it(`should prevent users from accessing other users' sessions`, async () => { + const sessionId = `my-session-${Math.random()}`; + await supertest + .post(`/internal/session`) + .set('kbn-xsrf', 'foo') + .send({ + sessionId, + name: 'My Session', + appId: 'discover', + expires: '123', + urlGeneratorId: 'discover', + }) + .expect(200); + + await supertestWithoutAuth + .get(`/internal/session/${sessionId}`) + .set('kbn-xsrf', 'foo') + .auth('other_user', 'password') + .expect(404); + }); + + it(`should prevent users from deleting other users' sessions`, async () => { + const sessionId = `my-session-${Math.random()}`; + await supertest + .post(`/internal/session`) + .set('kbn-xsrf', 'foo') + .send({ + sessionId, + name: 'My Session', + appId: 'discover', + expires: '123', + urlGeneratorId: 'discover', + }) + .expect(200); + + await supertestWithoutAuth + .delete(`/internal/session/${sessionId}`) + .set('kbn-xsrf', 'foo') + .auth('other_user', 'password') + .expect(404); + }); + + it(`should prevent users from cancelling other users' sessions`, async () => { + const sessionId = `my-session-${Math.random()}`; + await supertest + .post(`/internal/session`) + .set('kbn-xsrf', 'foo') + .send({ + sessionId, + name: 'My Session', + appId: 'discover', + expires: '123', + urlGeneratorId: 'discover', + }) + .expect(200); + + await supertestWithoutAuth + .post(`/internal/session/${sessionId}/cancel`) + .set('kbn-xsrf', 'foo') + .auth('other_user', 'password') + .expect(404); + }); + + it(`should prevent users from extending other users' sessions`, async () => { + const sessionId = `my-session-${Math.random()}`; + await supertest + .post(`/internal/session`) + .set('kbn-xsrf', 'foo') + .send({ + sessionId, + name: 'My Session', + appId: 'discover', + expires: '123', + urlGeneratorId: 'discover', + }) + .expect(200); + + await supertestWithoutAuth + .post(`/internal/session/${sessionId}/_extend`) + .set('kbn-xsrf', 'foo') + .auth('other_user', 'password') + .send({ + expires: '2021-02-26T21:02:43.742Z', + }) + .expect(404); + }); + + it(`should prevent unauthorized users from creating sessions`, async () => { + const sessionId = `my-session-${Math.random()}`; + await supertestWithoutAuth + .post(`/internal/session`) + .set('kbn-xsrf', 'foo') + .send({ + sessionId, + name: 'My Session', + appId: 'discover', + expires: '123', + urlGeneratorId: 'discover', + }) + .expect(401); + }); + }); + describe('search session permissions', () => { before(async () => { await security.role.create('data_analyst', { From 5c3c3efdd87089fb1a326854c83397a7253bd7c6 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Sat, 13 Feb 2021 04:28:35 -0500 Subject: [PATCH 4/4] Sharing saved objects, phase 2.5 (#89344) --- ...migrating-legacy-plugins-examples.asciidoc | 2 +- .../core/public/kibana-plugin-core-public.md | 2 +- ...n-core-public.savedobjectsnamespacetype.md | 4 +- .../core/server/kibana-plugin-core-server.md | 2 +- ...text.converttomultinamespacetypeversion.md | 13 + ...core-server.savedobjectmigrationcontext.md | 2 + ...objectmigrationcontext.migrationversion.md | 13 + ...n-core-server.savedobjectsnamespacetype.md | 4 +- ...vedobjectsresolveresponse.aliastargetid.md | 13 + ...core-server.savedobjectsresolveresponse.md | 1 + ...type.converttomultinamespacetypeversion.md | 24 +- ...ana-plugin-core-server.savedobjectstype.md | 19 +- ...avedobjecttyperegistry.ismultinamespace.md | 2 +- ...ver.savedobjecttyperegistry.isshareable.md | 24 + ...gin-core-server.savedobjecttyperegistry.md | 3 +- src/core/public/public.api.md | 2 +- .../migrations/core/document_migrator.test.ts | 4 +- .../migrations/core/document_migrator.ts | 18 +- .../server/saved_objects/migrations/mocks.ts | 10 +- .../server/saved_objects/migrations/types.ts | 8 + .../saved_objects_type_registry.mock.ts | 2 + .../saved_objects_type_registry.test.ts | 26 + .../saved_objects_type_registry.ts | 11 +- .../service/lib/repository.test.js | 240 ++++-- .../saved_objects/service/lib/repository.ts | 10 +- .../service/saved_objects_client.ts | 4 + src/core/server/saved_objects/types.ts | 38 +- src/core/server/server.api.md | 6 +- .../saved_objects_management/kibana.json | 2 +- .../management_section/mount_section.tsx | 9 +- .../objects_table/components/table.tsx | 11 - .../saved_objects_table_page.tsx | 68 +- .../saved_objects_management/public/plugin.ts | 2 + .../public/services/types/column.ts | 3 - .../saved_objects_management/tsconfig.json | 1 + src/plugins/spaces_oss/public/api.mock.ts | 29 +- src/plugins/spaces_oss/public/api.ts | 235 ++++++ src/plugins/spaces_oss/public/index.ts | 18 +- src/plugins/spaces_oss/public/types.ts | 4 +- .../apis/saved_objects/migrations.ts | 2 +- .../server/saved_objects/migrations.test.ts | 30 +- .../server/create_migration.test.ts | 96 ++- .../server/create_migration.ts | 25 +- .../encrypted_saved_objects_service.test.ts | 180 +++++ .../crypto/encrypted_saved_objects_service.ts | 39 +- .../saved_objects/get_descriptor_namespace.ts | 2 +- .../server/saved_objects/index.ts | 4 +- x-pack/plugins/ml/kibana.json | 1 - .../components/job_spaces_list/index.ts | 2 +- .../job_spaces_list/job_spaces_list.tsx | 93 ++- .../cannot_edit_callout.tsx | 30 - .../jobs_spaces_flyout.tsx | 132 ---- .../job_spaces_selector/spaces_selector.scss | 3 - .../job_spaces_selector/spaces_selectors.tsx | 223 ------ .../contexts/spaces/spaces_context.ts | 36 - .../analytics_list/analytics_list.tsx | 7 +- .../components/analytics_list/use_columns.tsx | 6 +- .../components/jobs_list/jobs_list.js | 5 +- .../jobs_list_view/jobs_list_view.js | 17 +- .../jobs_list_page/jobs_list_page.tsx | 32 +- .../application/management/jobs_list/index.ts | 4 +- .../components/copy_to_space_flyout.test.tsx | 21 +- .../components/copy_to_space_flyout.tsx | 31 +- .../components/copy_to_space_form.tsx | 9 +- .../components/processing_copy_to_space.tsx | 15 +- .../components/space_result.tsx | 4 - .../components/space_result_details.tsx | 2 - .../copy_saved_objects_to_space_action.tsx | 11 +- .../summarize_copy_result.test.ts | 20 +- .../summarize_copy_result.ts | 21 +- .../copy_saved_objects_to_space/types.ts | 27 + x-pack/plugins/spaces/public/index.ts | 4 +- x-pack/plugins/spaces/public/plugin.tsx | 31 +- .../components/constants.ts | 12 + .../components/context_wrapper.tsx | 40 - .../components/index.ts | 5 +- .../components/legacy_url_conflict.tsx | 18 + .../legacy_url_conflict_internal.test.tsx | 68 ++ .../legacy_url_conflict_internal.tsx | 114 +++ .../components/no_spaces_available.tsx | 4 +- .../components/selectable_spaces_control.tsx | 164 +++- .../components/share_mode_control.tsx | 156 ++-- .../components/share_to_space_flyout.test.tsx | 489 ------------ .../components/share_to_space_flyout.tsx | 288 +------ .../share_to_space_flyout_internal.test.tsx | 741 ++++++++++++++++++ .../share_to_space_flyout_internal.tsx | 352 +++++++++ .../components/share_to_space_form.tsx | 93 ++- .../share_saved_objects_to_space/index.ts | 2 + ...are_saved_objects_to_space_action.test.tsx | 13 +- .../share_saved_objects_to_space_action.tsx | 41 +- ...are_saved_objects_to_space_column.test.tsx | 207 ----- .../share_saved_objects_to_space_column.tsx | 145 +--- ...are_saved_objects_to_space_service.test.ts | 7 +- .../share_saved_objects_to_space_service.ts | 23 +- .../share_saved_objects_to_space/types.ts | 7 +- .../utils}/index.ts | 7 +- .../utils/redirect_legacy_url.test.ts | 40 + .../utils/redirect_legacy_url.ts | 33 + .../public/space_list}/index.ts | 2 +- .../spaces/public/space_list/space_list.tsx | 16 + .../space_list/space_list_internal.test.tsx | 310 ++++++++ .../public/space_list/space_list_internal.tsx | 144 ++++ .../spaces/public/spaces_context/context.tsx | 42 + .../spaces/public/spaces_context/index.ts | 9 + .../spaces/public/spaces_context/types.ts | 25 + .../spaces/public/spaces_context/wrapper.tsx | 89 +++ x-pack/plugins/spaces/public/types.ts | 31 + .../spaces/public/ui_api/components.ts | 34 + x-pack/plugins/spaces/public/ui_api/index.ts | 27 + x-pack/plugins/spaces/public/ui_api/mocks.ts | 31 + .../translations/translations/ja-JP.json | 52 -- .../translations/translations/zh-CN.json | 52 -- .../saved_objects/spaces/data.json | 34 + .../saved_objects/spaces/mappings.json | 13 + .../saved_object_test_plugin/server/plugin.ts | 7 + .../common/lib/saved_object_test_cases.ts | 10 + .../common/lib/saved_object_test_utils.ts | 2 +- .../common/suites/export.ts | 21 + .../common/suites/find.ts | 7 + .../common/suites/resolve.ts | 12 +- .../security_and_spaces/apis/bulk_create.ts | 10 + .../security_and_spaces/apis/bulk_get.ts | 5 + .../security_and_spaces/apis/bulk_update.ts | 5 + .../security_and_spaces/apis/create.ts | 8 + .../security_and_spaces/apis/delete.ts | 5 + .../security_and_spaces/apis/export.ts | 2 + .../security_and_spaces/apis/find.ts | 1 + .../security_and_spaces/apis/get.ts | 5 + .../security_and_spaces/apis/import.ts | 11 + .../apis/resolve_import_errors.ts | 11 + .../security_and_spaces/apis/update.ts | 5 + .../security_only/apis/bulk_create.ts | 2 + .../security_only/apis/bulk_get.ts | 2 + .../security_only/apis/bulk_update.ts | 2 + .../security_only/apis/create.ts | 2 + .../security_only/apis/delete.ts | 2 + .../security_only/apis/export.ts | 2 + .../security_only/apis/find.ts | 1 + .../security_only/apis/get.ts | 2 + .../security_only/apis/import.ts | 3 + .../apis/resolve_import_errors.ts | 2 + .../security_only/apis/update.ts | 2 + .../spaces_only/apis/bulk_create.ts | 10 + .../spaces_only/apis/bulk_get.ts | 5 + .../spaces_only/apis/bulk_update.ts | 5 + .../spaces_only/apis/create.ts | 8 + .../spaces_only/apis/delete.ts | 5 + .../spaces_only/apis/get.ts | 5 + .../spaces_only/apis/import.ts | 10 + .../spaces_only/apis/resolve_import_errors.ts | 10 + .../spaces_only/apis/update.ts | 5 + 151 files changed, 3982 insertions(+), 2264 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md delete mode 100644 x-pack/plugins/ml/public/application/components/job_spaces_selector/cannot_edit_callout.tsx delete mode 100644 x-pack/plugins/ml/public/application/components/job_spaces_selector/jobs_spaces_flyout.tsx delete mode 100644 x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selector.scss delete mode 100644 x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selectors.tsx delete mode 100644 x-pack/plugins/ml/public/application/contexts/spaces/spaces_context.ts create mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/components/constants.ts delete mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/components/context_wrapper.tsx create mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict.tsx create mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.test.tsx create mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.tsx delete mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx create mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx create mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx delete mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx rename x-pack/plugins/{ml/public/application/contexts/spaces => spaces/public/share_saved_objects_to_space/utils}/index.ts (68%) create mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.test.ts create mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.ts rename x-pack/plugins/{ml/public/application/components/job_spaces_selector => spaces/public/space_list}/index.ts (81%) create mode 100644 x-pack/plugins/spaces/public/space_list/space_list.tsx create mode 100644 x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx create mode 100644 x-pack/plugins/spaces/public/space_list/space_list_internal.tsx create mode 100644 x-pack/plugins/spaces/public/spaces_context/context.tsx create mode 100644 x-pack/plugins/spaces/public/spaces_context/index.ts create mode 100644 x-pack/plugins/spaces/public/spaces_context/types.ts create mode 100644 x-pack/plugins/spaces/public/spaces_context/wrapper.tsx create mode 100644 x-pack/plugins/spaces/public/types.ts create mode 100644 x-pack/plugins/spaces/public/ui_api/components.ts create mode 100644 x-pack/plugins/spaces/public/ui_api/index.ts create mode 100644 x-pack/plugins/spaces/public/ui_api/mocks.ts diff --git a/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc b/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc index 92a624649d3c50..6361b3c921128a 100644 --- a/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc +++ b/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc @@ -800,7 +800,7 @@ However, there are some minor changes: * The `schema.isNamespaceAgnostic` property has been renamed: `SavedObjectsType.namespaceType`. It no longer accepts a boolean but -instead an enum of `single`, `multiple`, or `agnostic` (see +instead an enum of `single`, `multiple`, `multiple-isolated`, or `agnostic` (see {kib-repo}/tree/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md[SavedObjectsNamespaceType]). * The `schema.indexPattern` was accepting either a `string` or a `(config: LegacyConfig) => string`. `SavedObjectsType.indexPattern` only diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index 5524cf328fbfe6..ba48011ef84e08 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -168,7 +168,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectAttributeSingle](./kibana-plugin-core-public.savedobjectattributesingle.md) | Don't use this type, it's simply a helper type for [SavedObjectAttribute](./kibana-plugin-core-public.savedobjectattribute.md) | | [SavedObjectsClientContract](./kibana-plugin-core-public.savedobjectsclientcontract.md) | SavedObjectsClientContract as implemented by the [SavedObjectsClient](./kibana-plugin-core-public.savedobjectsclient.md) | | [SavedObjectsImportWarning](./kibana-plugin-core-public.savedobjectsimportwarning.md) | Composite type of all the possible types of import warnings.See [SavedObjectsImportSimpleWarning](./kibana-plugin-core-public.savedobjectsimportsimplewarning.md) and [SavedObjectsImportActionRequiredWarning](./kibana-plugin-core-public.savedobjectsimportactionrequiredwarning.md) for more details. | -| [SavedObjectsNamespaceType](./kibana-plugin-core-public.savedobjectsnamespacetype.md) | The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global. | +| [SavedObjectsNamespaceType](./kibana-plugin-core-public.savedobjectsnamespacetype.md) | The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): This type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: This type of saved object is shareable, e.g., it can exist in one or more namespaces. \* multiple-isolated: This type of saved object is namespace-isolated, e.g., it exists in only one namespace, but object IDs must be unique across all namespaces. This is intended to be an intermediate step when objects with a "single" namespace type are being converted to a "multiple" namespace type. In other words, objects with a "multiple-isolated" namespace type will be \*share-capable\*, but will not actually be shareable until the namespace type is changed to "multiple". \* agnostic: This type of saved object is global. | | [StartServicesAccessor](./kibana-plugin-core-public.startservicesaccessor.md) | Allows plugins to get access to APIs available in start inside async handlers, such as [App.mount](./kibana-plugin-core-public.app.mount.md). Promise will not resolve until Core and plugin dependencies have completed start. | | [StringValidation](./kibana-plugin-core-public.stringvalidation.md) | Allows regex objects or a regex string | | [Toast](./kibana-plugin-core-public.toast.md) | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsnamespacetype.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsnamespacetype.md index f2205d2cee4240..cf5e6cb29a5329 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsnamespacetype.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsnamespacetype.md @@ -4,10 +4,10 @@ ## SavedObjectsNamespaceType type -The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global. +The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): This type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: This type of saved object is shareable, e.g., it can exist in one or more namespaces. \* multiple-isolated: This type of saved object is namespace-isolated, e.g., it exists in only one namespace, but object IDs must be unique across all namespaces. This is intended to be an intermediate step when objects with a "single" namespace type are being converted to a "multiple" namespace type. In other words, objects with a "multiple-isolated" namespace type will be \*share-capable\*, but will not actually be shareable until the namespace type is changed to "multiple". \* agnostic: This type of saved object is global. Signature: ```typescript -export declare type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; +export declare type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic'; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 1791335d58fef9..3ec63840a67cba 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -310,7 +310,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) | Describe a [saved object type mapping](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md) field.Please refer to [elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-types.html) For the mapping documentation | | [SavedObjectsImportHook](./kibana-plugin-core-server.savedobjectsimporthook.md) | A hook associated with a specific saved object type, that will be invoked during the import process. The hook will have access to the objects of the registered type.Currently, the only supported feature for import hooks is to return warnings to be displayed in the UI when the import succeeds. The only interactions the hook can have with the import process is via the hook's response. Mutating the objects inside the hook's code will have no effect. | | [SavedObjectsImportWarning](./kibana-plugin-core-server.savedobjectsimportwarning.md) | Composite type of all the possible types of import warnings.See [SavedObjectsImportSimpleWarning](./kibana-plugin-core-server.savedobjectsimportsimplewarning.md) and [SavedObjectsImportActionRequiredWarning](./kibana-plugin-core-server.savedobjectsimportactionrequiredwarning.md) for more details. | -| [SavedObjectsNamespaceType](./kibana-plugin-core-server.savedobjectsnamespacetype.md) | The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global. | +| [SavedObjectsNamespaceType](./kibana-plugin-core-server.savedobjectsnamespacetype.md) | The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): This type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: This type of saved object is shareable, e.g., it can exist in one or more namespaces. \* multiple-isolated: This type of saved object is namespace-isolated, e.g., it exists in only one namespace, but object IDs must be unique across all namespaces. This is intended to be an intermediate step when objects with a "single" namespace type are being converted to a "multiple" namespace type. In other words, objects with a "multiple-isolated" namespace type will be \*share-capable\*, but will not actually be shareable until the namespace type is changed to "multiple". \* agnostic: This type of saved object is global. | | [SavedObjectUnsanitizedDoc](./kibana-plugin-core-server.savedobjectunsanitizeddoc.md) | Describes Saved Object documents from Kibana < 7.0.0 which don't have a references root property defined. This type should only be used in migrations. | | [ScopeableRequest](./kibana-plugin-core-server.scopeablerequest.md) | A user credentials container. It accommodates the necessary auth credentials to impersonate the current user.See [KibanaRequest](./kibana-plugin-core-server.kibanarequest.md). | | [ServiceStatusLevel](./kibana-plugin-core-server.servicestatuslevel.md) | A convenience type that represents the union of each value in [ServiceStatusLevels](./kibana-plugin-core-server.servicestatuslevels.md). | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md new file mode 100644 index 00000000000000..2a30693f4da84a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectMigrationContext](./kibana-plugin-core-server.savedobjectmigrationcontext.md) > [convertToMultiNamespaceTypeVersion](./kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md) + +## SavedObjectMigrationContext.convertToMultiNamespaceTypeVersion property + +The version in which this object type is being converted to a multi-namespace type + +Signature: + +```typescript +convertToMultiNamespaceTypeVersion?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.md index 901f2dde0944ce..c8a291e5028453 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.md @@ -16,5 +16,7 @@ export interface SavedObjectMigrationContext | Property | Type | Description | | --- | --- | --- | +| [convertToMultiNamespaceTypeVersion](./kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md) | string | The version in which this object type is being converted to a multi-namespace type | | [log](./kibana-plugin-core-server.savedobjectmigrationcontext.log.md) | SavedObjectsMigrationLogger | logger instance to be used by the migration handler | +| [migrationVersion](./kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md) | string | The migration version that this migration function is defined for | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md new file mode 100644 index 00000000000000..7b20ae41048f66 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectMigrationContext](./kibana-plugin-core-server.savedobjectmigrationcontext.md) > [migrationVersion](./kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md) + +## SavedObjectMigrationContext.migrationVersion property + +The migration version that this migration function is defined for + +Signature: + +```typescript +migrationVersion: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md index 9075a780bd2c79..01a712aa89aa9a 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md @@ -4,10 +4,10 @@ ## SavedObjectsNamespaceType type -The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global. +The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): This type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: This type of saved object is shareable, e.g., it can exist in one or more namespaces. \* multiple-isolated: This type of saved object is namespace-isolated, e.g., it exists in only one namespace, but object IDs must be unique across all namespaces. This is intended to be an intermediate step when objects with a "single" namespace type are being converted to a "multiple" namespace type. In other words, objects with a "multiple-isolated" namespace type will be \*share-capable\*, but will not actually be shareable until the namespace type is changed to "multiple". \* agnostic: This type of saved object is global. Signature: ```typescript -export declare type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; +export declare type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic'; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md new file mode 100644 index 00000000000000..2e73d6ba2e1a9f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsResolveResponse](./kibana-plugin-core-server.savedobjectsresolveresponse.md) > [aliasTargetId](./kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md) + +## SavedObjectsResolveResponse.aliasTargetId property + +The ID of the object that the legacy URL alias points to. This is only defined when the outcome is `'aliasMatch'` or `'conflict'`. + +Signature: + +```typescript +aliasTargetId?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md index cfb309da0a716f..ffcf15dbc80c7c 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md @@ -15,6 +15,7 @@ export interface SavedObjectsResolveResponse | Property | Type | Description | | --- | --- | --- | +| [aliasTargetId](./kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md) | string | The ID of the object that the legacy URL alias points to. This is only defined when the outcome is 'aliasMatch' or 'conflict'. | | [outcome](./kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md) | 'exactMatch' | 'aliasMatch' | 'conflict' | The outcome for a successful resolve call is one of the following values:\* 'exactMatch' -- One document exactly matched the given ID. \* 'aliasMatch' -- One document with a legacy URL alias matched the given ID; in this case the saved_object.id field is different than the given ID. \* 'conflict' -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the saved_object object is the exact match, and the saved_object.id field is the same as the given ID. | | [saved\_object](./kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md) | SavedObject<T> | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md index 064bd0b35699df..20346919fc652e 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md @@ -4,13 +4,13 @@ ## SavedObjectsType.convertToMultiNamespaceTypeVersion property -If defined, objects of this type will be converted to multi-namespace objects when migrating to this version. +If defined, objects of this type will be converted to a 'multiple' or 'multiple-isolated' namespace type when migrating to this version. Requirements: -1. This string value must be a valid semver version 2. This type must have previously specified [\`namespaceType: 'single'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) 3. This type must also specify [\`namespaceType: 'multiple'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) +1. This string value must be a valid semver version 2. This type must have previously specified [\`namespaceType: 'single'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) 3. This type must also specify [\`namespaceType: 'multiple'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) \*or\* [\`namespaceType: 'multiple-isolated'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) -Example of a single-namespace type in 7.10: +Example of a single-namespace type in 7.12: ```ts { @@ -21,7 +21,19 @@ Example of a single-namespace type in 7.10: } ``` -Example after converting to a multi-namespace type in 7.11: +Example after converting to a multi-namespace (isolated) type in 8.0: + +```ts +{ + name: 'foo', + hidden: false, + namespaceType: 'multiple-isolated', + mappings: {...}, + convertToMultiNamespaceTypeVersion: '8.0.0' +} + +``` +Example after converting to a multi-namespace (shareable) type in 8.1: ```ts { @@ -29,11 +41,11 @@ Example after converting to a multi-namespace type in 7.11: hidden: false, namespaceType: 'multiple', mappings: {...}, - convertToMultiNamespaceTypeVersion: '7.11.0' + convertToMultiNamespaceTypeVersion: '8.0.0' } ``` -Note: a migration function can be optionally specified for the same version. +Note: migration function(s) can be optionally specified for any of these versions and will not interfere with the conversion process. Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md index eacad53be39fe0..d882938d731c8c 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md @@ -19,7 +19,7 @@ This is only internal for now, and will only be public when we expose the regist | Property | Type | Description | | --- | --- | --- | | [convertToAliasScript](./kibana-plugin-core-server.savedobjectstype.converttoaliasscript.md) | string | If defined, will be used to convert the type to an alias. | -| [convertToMultiNamespaceTypeVersion](./kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md) | string | If defined, objects of this type will be converted to multi-namespace objects when migrating to this version.Requirements:1. This string value must be a valid semver version 2. This type must have previously specified [\`namespaceType: 'single'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) 3. This type must also specify [\`namespaceType: 'multiple'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md)Example of a single-namespace type in 7.10: +| [convertToMultiNamespaceTypeVersion](./kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md) | string | If defined, objects of this type will be converted to a 'multiple' or 'multiple-isolated' namespace type when migrating to this version.Requirements:1. This string value must be a valid semver version 2. This type must have previously specified [\`namespaceType: 'single'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) 3. This type must also specify [\`namespaceType: 'multiple'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) \*or\* [\`namespaceType: 'multiple-isolated'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md)Example of a single-namespace type in 7.12: ```ts { name: 'foo', @@ -29,18 +29,29 @@ This is only internal for now, and will only be public when we expose the regist } ``` -Example after converting to a multi-namespace type in 7.11: +Example after converting to a multi-namespace (isolated) type in 8.0: +```ts +{ + name: 'foo', + hidden: false, + namespaceType: 'multiple-isolated', + mappings: {...}, + convertToMultiNamespaceTypeVersion: '8.0.0' +} + +``` +Example after converting to a multi-namespace (shareable) type in 8.1: ```ts { name: 'foo', hidden: false, namespaceType: 'multiple', mappings: {...}, - convertToMultiNamespaceTypeVersion: '7.11.0' + convertToMultiNamespaceTypeVersion: '8.0.0' } ``` -Note: a migration function can be optionally specified for the same version. | +Note: migration function(s) can be optionally specified for any of these versions and will not interfere with the conversion process. | | [hidden](./kibana-plugin-core-server.savedobjectstype.hidden.md) | boolean | Is the type hidden by default. If true, repositories will not have access to this type unless explicitly declared as an extraType when creating the repository.See [createInternalRepository](./kibana-plugin-core-server.savedobjectsservicestart.createinternalrepository.md). | | [indexPattern](./kibana-plugin-core-server.savedobjectstype.indexpattern.md) | string | If defined, the type instances will be stored in the given index instead of the default one. | | [management](./kibana-plugin-core-server.savedobjectstype.management.md) | SavedObjectsTypeManagementDefinition | An optional [saved objects management section](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.md) definition for the type. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md index 6532c5251d816f..0ff07ae2804ff8 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md @@ -4,7 +4,7 @@ ## SavedObjectTypeRegistry.isMultiNamespace() method -Returns whether the type is multi-namespace (shareable); resolves to `false` if the type is not registered +Returns whether the type is multi-namespace (shareable \*or\* isolated); resolves to `false` if the type is not registered Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md new file mode 100644 index 00000000000000..ee240268f9d67a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectTypeRegistry](./kibana-plugin-core-server.savedobjecttyperegistry.md) > [isShareable](./kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md) + +## SavedObjectTypeRegistry.isShareable() method + +Returns whether the type is multi-namespace (shareable); resolves to `false` if the type is not registered + +Signature: + +```typescript +isShareable(type: string): boolean; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | + +Returns: + +`boolean` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md index 55ad7ca137de0a..0f2de8c8ef9b33 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md @@ -23,8 +23,9 @@ export declare class SavedObjectTypeRegistry | [getVisibleTypes()](./kibana-plugin-core-server.savedobjecttyperegistry.getvisibletypes.md) | | Returns all visible [types](./kibana-plugin-core-server.savedobjectstype.md).A visible type is a type that doesn't explicitly define hidden=true during registration. | | [isHidden(type)](./kibana-plugin-core-server.savedobjecttyperegistry.ishidden.md) | | Returns the hidden property for given type, or false if the type is not registered. | | [isImportableAndExportable(type)](./kibana-plugin-core-server.savedobjecttyperegistry.isimportableandexportable.md) | | Returns the management.importableAndExportable property for given type, or false if the type is not registered or does not define a management section. | -| [isMultiNamespace(type)](./kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md) | | Returns whether the type is multi-namespace (shareable); resolves to false if the type is not registered | +| [isMultiNamespace(type)](./kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md) | | Returns whether the type is multi-namespace (shareable \*or\* isolated); resolves to false if the type is not registered | | [isNamespaceAgnostic(type)](./kibana-plugin-core-server.savedobjecttyperegistry.isnamespaceagnostic.md) | | Returns whether the type is namespace-agnostic (global); resolves to false if the type is not registered | +| [isShareable(type)](./kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md) | | Returns whether the type is multi-namespace (shareable); resolves to false if the type is not registered | | [isSingleNamespace(type)](./kibana-plugin-core-server.savedobjecttyperegistry.issinglenamespace.md) | | Returns whether the type is single-namespace (isolated); resolves to true if the type is not registered | | [registerType(type)](./kibana-plugin-core-server.savedobjecttyperegistry.registertype.md) | | Register a [type](./kibana-plugin-core-server.savedobjectstype.md) inside the registry. A type can only be registered once. subsequent calls with the same type name will throw an error. | diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 8ee530f5a04e87..2e23b26f636c8f 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1385,7 +1385,7 @@ export interface SavedObjectsMigrationVersion { } // @public -export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; +export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic'; // @public (undocumented) export interface SavedObjectsStart { diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts index 776c7b195922e1..f29a8b61b48858 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts @@ -143,7 +143,7 @@ describe('DocumentMigrator', () => { ).toThrow(/Migrations are not ready. Make sure prepareMigrations is called first./i); }); - it(`validates convertToMultiNamespaceTypeVersion can only be used with namespaceType 'multiple'`, () => { + it(`validates convertToMultiNamespaceTypeVersion can only be used with namespaceType 'multiple' or 'multiple-isolated'`, () => { const invalidDefinition = { kibanaVersion: '3.2.3', typeRegistry: createRegistry({ @@ -154,7 +154,7 @@ describe('DocumentMigrator', () => { log: mockLogger, }; expect(() => new DocumentMigrator(invalidDefinition)).toThrow( - `Invalid convertToMultiNamespaceTypeVersion for type foo. Expected namespaceType to be 'multiple', but got 'single'.` + `Invalid convertToMultiNamespaceTypeVersion for type foo. Expected namespaceType to be 'multiple' or 'multiple-isolated', but got 'single'.` ); }); diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts index b61c4cfe967e71..47f4dda75cdcd7 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts @@ -312,9 +312,9 @@ function validateMigrationDefinition( convertToMultiNamespaceTypeVersion: string, type: string ) { - if (namespaceType !== 'multiple') { + if (namespaceType !== 'multiple' && namespaceType !== 'multiple-isolated') { throw new Error( - `Invalid convertToMultiNamespaceTypeVersion for type ${type}. Expected namespaceType to be 'multiple', but got '${namespaceType}'.` + `Invalid convertToMultiNamespaceTypeVersion for type ${type}. Expected namespaceType to be 'multiple' or 'multiple-isolated', but got '${namespaceType}'.` ); } else if (!Semver.valid(convertToMultiNamespaceTypeVersion)) { throw new Error( @@ -374,7 +374,7 @@ function buildActiveMigrations( const migrationTransforms = Object.entries(migrationsMap ?? {}).map( ([version, transform]) => ({ version, - transform: wrapWithTry(version, type.name, transform, log), + transform: wrapWithTry(version, type, transform, log), transformType: 'migrate', }) ); @@ -655,24 +655,28 @@ function transformComparator(a: Transform, b: Transform) { */ function wrapWithTry( version: string, - type: string, + type: SavedObjectsType, migrationFn: SavedObjectMigrationFn, log: Logger ) { return function tryTransformDoc(doc: SavedObjectUnsanitizedDoc) { try { - const context = { log: new MigrationLogger(log) }; + const context = { + log: new MigrationLogger(log), + migrationVersion: version, + convertToMultiNamespaceTypeVersion: type.convertToMultiNamespaceTypeVersion, + }; const result = migrationFn(doc, context); // A basic sanity check to help migration authors detect basic errors // (e.g. forgetting to return the transformed doc) if (!result || !result.type) { - throw new Error(`Invalid saved object returned from migration ${type}:${version}.`); + throw new Error(`Invalid saved object returned from migration ${type.name}:${version}.`); } return { transformedDoc: result, additionalDocs: [] }; } catch (error) { - const failedTransform = `${type}:${version}`; + const failedTransform = `${type.name}:${version}`; const failedDoc = JSON.stringify(doc); log.warn( `Failed to transform document ${doc?.id}. Transform: ${failedTransform}\nDoc: ${failedDoc}` diff --git a/src/core/server/saved_objects/migrations/mocks.ts b/src/core/server/saved_objects/migrations/mocks.ts index f0360ec180d6e4..4a62fcc95997bb 100644 --- a/src/core/server/saved_objects/migrations/mocks.ts +++ b/src/core/server/saved_objects/migrations/mocks.ts @@ -21,9 +21,17 @@ export const createSavedObjectsMigrationLoggerMock = (): jest.Mocked => { +const createContextMock = ({ + migrationVersion = '8.0.0', + convertToMultiNamespaceTypeVersion, +}: { + migrationVersion?: string; + convertToMultiNamespaceTypeVersion?: string; +} = {}): jest.Mocked => { const mock = { log: createSavedObjectsMigrationLoggerMock(), + migrationVersion, + convertToMultiNamespaceTypeVersion, }; return mock; }; diff --git a/src/core/server/saved_objects/migrations/types.ts b/src/core/server/saved_objects/migrations/types.ts index 630be58eb047dc..619a7f85a327b3 100644 --- a/src/core/server/saved_objects/migrations/types.ts +++ b/src/core/server/saved_objects/migrations/types.ts @@ -57,6 +57,14 @@ export interface SavedObjectMigrationContext { * logger instance to be used by the migration handler */ log: SavedObjectsMigrationLogger; + /** + * The migration version that this migration function is defined for + */ + migrationVersion: string; + /** + * The version in which this object type is being converted to a multi-namespace type + */ + convertToMultiNamespaceTypeVersion?: string; } /** diff --git a/src/core/server/saved_objects/saved_objects_type_registry.mock.ts b/src/core/server/saved_objects/saved_objects_type_registry.mock.ts index 79b9c2feb1cbb4..d53a53d745c0c8 100644 --- a/src/core/server/saved_objects/saved_objects_type_registry.mock.ts +++ b/src/core/server/saved_objects/saved_objects_type_registry.mock.ts @@ -20,6 +20,7 @@ const createRegistryMock = (): jest.Mocked< isNamespaceAgnostic: jest.fn(), isSingleNamespace: jest.fn(), isMultiNamespace: jest.fn(), + isShareable: jest.fn(), isHidden: jest.fn(), getIndex: jest.fn(), isImportableAndExportable: jest.fn(), @@ -36,6 +37,7 @@ const createRegistryMock = (): jest.Mocked< (type: string) => type !== 'global' && type !== 'shared' ); mock.isMultiNamespace.mockImplementation((type: string) => type === 'shared'); + mock.isShareable.mockImplementation((type: string) => type === 'shared'); mock.isImportableAndExportable.mockReturnValue(true); return mock; diff --git a/src/core/server/saved_objects/saved_objects_type_registry.test.ts b/src/core/server/saved_objects/saved_objects_type_registry.test.ts index c0eb7891cd7d43..872b61706c526a 100644 --- a/src/core/server/saved_objects/saved_objects_type_registry.test.ts +++ b/src/core/server/saved_objects/saved_objects_type_registry.test.ts @@ -239,6 +239,7 @@ describe('SavedObjectTypeRegistry', () => { it(`returns false for other namespaceType`, () => { expectResult(false, { namespaceType: 'multiple' }); + expectResult(false, { namespaceType: 'multiple-isolated' }); expectResult(false, { namespaceType: 'single' }); expectResult(false, { namespaceType: undefined }); }); @@ -263,6 +264,7 @@ describe('SavedObjectTypeRegistry', () => { it(`returns false for other namespaceType`, () => { expectResult(false, { namespaceType: 'agnostic' }); expectResult(false, { namespaceType: 'multiple' }); + expectResult(false, { namespaceType: 'multiple-isolated' }); }); }); @@ -277,12 +279,36 @@ describe('SavedObjectTypeRegistry', () => { expect(registry.isMultiNamespace('unknownType')).toEqual(false); }); + it(`returns true for namespaceType 'multiple' and 'multiple-isolated'`, () => { + expectResult(true, { namespaceType: 'multiple' }); + expectResult(true, { namespaceType: 'multiple-isolated' }); + }); + + it(`returns false for other namespaceType`, () => { + expectResult(false, { namespaceType: 'agnostic' }); + expectResult(false, { namespaceType: 'single' }); + expectResult(false, { namespaceType: undefined }); + }); + }); + + describe('#isShareable', () => { + const expectResult = (expected: boolean, schemaDefinition?: Partial) => { + registry = new SavedObjectTypeRegistry(); + registry.registerType(createType({ name: 'foo', ...schemaDefinition })); + expect(registry.isShareable('foo')).toBe(expected); + }; + + it(`returns false when the type is not registered`, () => { + expect(registry.isShareable('unknownType')).toEqual(false); + }); + it(`returns true for namespaceType 'multiple'`, () => { expectResult(true, { namespaceType: 'multiple' }); }); it(`returns false for other namespaceType`, () => { expectResult(false, { namespaceType: 'agnostic' }); + expectResult(false, { namespaceType: 'multiple-isolated' }); expectResult(false, { namespaceType: 'single' }); expectResult(false, { namespaceType: undefined }); }); diff --git a/src/core/server/saved_objects/saved_objects_type_registry.ts b/src/core/server/saved_objects/saved_objects_type_registry.ts index 8a50beda83d2a0..a63837132b652e 100644 --- a/src/core/server/saved_objects/saved_objects_type_registry.ts +++ b/src/core/server/saved_objects/saved_objects_type_registry.ts @@ -86,10 +86,19 @@ export class SavedObjectTypeRegistry { } /** - * Returns whether the type is multi-namespace (shareable); + * Returns whether the type is multi-namespace (shareable *or* isolated); * resolves to `false` if the type is not registered */ public isMultiNamespace(type: string) { + const namespaceType = this.types.get(type)?.namespaceType; + return namespaceType === 'multiple' || namespaceType === 'multiple-isolated'; + } + + /** + * Returns whether the type is multi-namespace (shareable); + * resolves to `false` if the type is not registered + */ + public isShareable(type: string) { return this.types.get(type)?.namespaceType === 'multiple'; } 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 e77143d13612ff..d26d92e84925a7 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -48,9 +48,29 @@ describe('SavedObjectsRepository', () => { const KIBANA_VERSION = '2.0.0'; const CUSTOM_INDEX_TYPE = 'customIndex'; + /** This type has namespaceType: 'agnostic'. */ const NAMESPACE_AGNOSTIC_TYPE = 'globalType'; - const MULTI_NAMESPACE_TYPE = 'shareableType'; - const MULTI_NAMESPACE_CUSTOM_INDEX_TYPE = 'shareableTypeCustomIndex'; + /** + * This type has namespaceType: 'multiple'. + * + * That means that the object is serialized with a globally unique ID across namespaces. It also means that the object is shareable across + * namespaces. + **/ + const MULTI_NAMESPACE_TYPE = 'multiNamespaceType'; + /** + * This type has namespaceType: 'multiple-isolated'. + * + * That means that the object is serialized with a globally unique ID across namespaces. It also means that the object is NOT shareable + * across namespaces. This distinction only matters when using the `addToNamespaces` and `deleteFromNamespaces` APIs, or when using the + * `initialNamespaces` argument with the `create` and `bulkCreate` APIs. Those allow you to define or change what namespaces an object + * exists in. + * + * In a nutshell, this type is more restrictive than `MULTI_NAMESPACE_TYPE`, so we use `MULTI_NAMESPACE_ISOLATED_TYPE` for any test cases + * where `MULTI_NAMESPACE_TYPE` would also satisfy the test case. + **/ + const MULTI_NAMESPACE_ISOLATED_TYPE = 'multiNamespaceIsolatedType'; + /** This type has namespaceType: 'multiple', and it uses a custom index. */ + const MULTI_NAMESPACE_CUSTOM_INDEX_TYPE = 'multiNamespaceTypeCustomIndex'; const HIDDEN_TYPE = 'hiddenType'; const mappings = { @@ -93,6 +113,13 @@ describe('SavedObjectsRepository', () => { }, }, }, + [MULTI_NAMESPACE_ISOLATED_TYPE]: { + properties: { + evenYetAnotherField: { + type: 'keyword', + }, + }, + }, [MULTI_NAMESPACE_CUSTOM_INDEX_TYPE]: { properties: { evenYetAnotherField: { @@ -132,6 +159,10 @@ describe('SavedObjectsRepository', () => { ...createType(MULTI_NAMESPACE_TYPE), namespaceType: 'multiple', }); + registry.registerType({ + ...createType(MULTI_NAMESPACE_ISOLATED_TYPE), + namespaceType: 'multiple-isolated', + }); registry.registerType({ ...createType(MULTI_NAMESPACE_CUSTOM_INDEX_TYPE), namespaceType: 'multiple', @@ -345,13 +376,14 @@ describe('SavedObjectsRepository', () => { expect(client.update).not.toHaveBeenCalled(); }); - it(`throws when type is not multi-namespace`, async () => { + it(`throws when type is not shareable`, async () => { const test = async (type) => { const message = `${type} doesn't support multiple namespaces`; await expectBadRequestError(type, id, [newNs1, newNs2], message); expect(client.update).not.toHaveBeenCalled(); }; await test('index-pattern'); + await test(MULTI_NAMESPACE_ISOLATED_TYPE); await test(NAMESPACE_AGNOSTIC_TYPE); }); @@ -518,11 +550,13 @@ describe('SavedObjectsRepository', () => { }); it(`should use the ES mget action before bulk action for any types that are multi-namespace, when id is defined`, async () => { - const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_TYPE }]; + const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }]; await bulkCreateSuccess(objects); expect(client.bulk).toHaveBeenCalledTimes(1); expect(client.mget).toHaveBeenCalledTimes(1); - const docs = [expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${obj2.id}` })]; + const docs = [ + expect.objectContaining({ _id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${obj2.id}` }), + ]; expect(client.mget.mock.calls[0][0].body).toEqual({ docs }); }); @@ -601,7 +635,7 @@ describe('SavedObjectsRepository', () => { it(`doesn't add namespace to request body for any types that are not single-namespace`, async () => { const objects = [ { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, ]; await bulkCreateSuccess(objects, { namespace }); const expected = expect.not.objectContaining({ namespace: expect.anything() }); @@ -614,7 +648,7 @@ describe('SavedObjectsRepository', () => { it(`adds namespaces to request body for any types that are multi-namespace`, async () => { const test = async (namespace) => { - const objects = [obj1, obj2].map((x) => ({ ...x, type: MULTI_NAMESPACE_TYPE })); + const objects = [obj1, obj2].map((x) => ({ ...x, type: MULTI_NAMESPACE_ISOLATED_TYPE })); const namespaces = [namespace ?? 'default']; await bulkCreateSuccess(objects, { namespace, overwrite: true }); const expected = expect.objectContaining({ namespaces }); @@ -706,7 +740,7 @@ describe('SavedObjectsRepository', () => { const getId = (type, id) => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) const objects = [ { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, ]; await bulkCreateSuccess(objects, { namespace }); expectClientCallArgsAction(objects, { method: 'create', getId }); @@ -753,7 +787,7 @@ describe('SavedObjectsRepository', () => { ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); }); - it(`returns error when initialNamespaces is used with a non-multi-namespace object`, async () => { + 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( @@ -767,9 +801,10 @@ describe('SavedObjectsRepository', () => { }; await test('dashboard'); await test(NAMESPACE_AGNOSTIC_TYPE); + await test(MULTI_NAMESPACE_ISOLATED_TYPE); }); - it(`throws when options.initialNamespaces is used with a multi-namespace type and is empty`, async () => { + it(`throws when options.initialNamespaces is used with a shareable type and is empty`, async () => { const obj = { ...obj3, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [] }; await bulkCreateError( obj, @@ -792,7 +827,7 @@ describe('SavedObjectsRepository', () => { }); it(`returns error when there is a conflict with an existing multi-namespace saved object (get)`, async () => { - const obj = { ...obj3, type: MULTI_NAMESPACE_TYPE }; + const obj = { ...obj3, type: MULTI_NAMESPACE_ISOLATED_TYPE }; const response1 = { status: 200, docs: [ @@ -884,7 +919,7 @@ describe('SavedObjectsRepository', () => { it(`doesn't add namespace to body when not using single-namespace type`, async () => { const objects = [ { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, ]; await bulkCreateSuccess(objects, { namespace }); expectMigrationArgs({ namespace: expect.anything() }, false, 1); @@ -892,14 +927,20 @@ describe('SavedObjectsRepository', () => { }); it(`adds namespaces to body when providing namespace for multi-namespace type`, async () => { - const objects = [obj1, obj2].map((obj) => ({ ...obj, type: MULTI_NAMESPACE_TYPE })); + const objects = [obj1, obj2].map((obj) => ({ + ...obj, + type: MULTI_NAMESPACE_ISOLATED_TYPE, + })); await bulkCreateSuccess(objects, { namespace }); expectMigrationArgs({ namespaces: [namespace] }, true, 1); expectMigrationArgs({ namespaces: [namespace] }, true, 2); }); it(`adds default namespaces to body when providing no namespace for multi-namespace type`, async () => { - const objects = [obj1, obj2].map((obj) => ({ ...obj, type: MULTI_NAMESPACE_TYPE })); + const objects = [obj1, obj2].map((obj) => ({ + ...obj, + type: MULTI_NAMESPACE_ISOLATED_TYPE, + })); await bulkCreateSuccess(objects); expectMigrationArgs({ namespaces: ['default'] }, true, 1); expectMigrationArgs({ namespaces: ['default'] }, true, 2); @@ -1070,7 +1111,7 @@ describe('SavedObjectsRepository', () => { _expectClientCallArgs(objects, { getId }); client.mget.mockClear(); - objects = [obj1, obj2].map((obj) => ({ ...obj, type: MULTI_NAMESPACE_TYPE })); + objects = [obj1, obj2].map((obj) => ({ ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE })); await bulkGetSuccess(objects, { namespace }); _expectClientCallArgs(objects, { getId }); }); @@ -1130,7 +1171,7 @@ describe('SavedObjectsRepository', () => { }); it(`returns error when type is multi-namespace and the document exists, but not in this namespace`, async () => { - const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const obj = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'three' }; const response = getMockMgetResponse([obj1, obj, obj2]); response.docs[1].namespaces = ['bar-namespace']; await bulkGetErrorNotFound([obj1, obj, obj2], { namespace }, response); @@ -1189,7 +1230,7 @@ describe('SavedObjectsRepository', () => { }); it(`includes namespaces property for single-namespace and multi-namespace documents`, async () => { - const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const obj = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'three' }; const result = await bulkGetSuccess([obj1, obj]); expect(result).toEqual({ saved_objects: [ @@ -1291,12 +1332,14 @@ describe('SavedObjectsRepository', () => { }); it(`should use the ES mget action before bulk action for any types that are multi-namespace`, async () => { - const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_TYPE }]; + const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }]; await bulkUpdateSuccess(objects); expect(client.bulk).toHaveBeenCalled(); expect(client.mget).toHaveBeenCalled(); - const docs = [expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${obj2.id}` })]; + const docs = [ + expect.objectContaining({ _id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${obj2.id}` }), + ]; expect(client.mget).toHaveBeenCalledWith( expect.objectContaining({ body: { docs } }), expect.anything() @@ -1313,7 +1356,7 @@ describe('SavedObjectsRepository', () => { }); it(`formats the ES request for any types that are multi-namespace`, async () => { - const _obj2 = { ...obj2, type: MULTI_NAMESPACE_TYPE }; + const _obj2 = { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }; await bulkUpdateSuccess([obj1, _obj2]); const body = [...expectObjArgs(obj1), ...expectObjArgs(_obj2)]; expect(client.bulk).toHaveBeenCalledWith( @@ -1384,8 +1427,8 @@ describe('SavedObjectsRepository', () => { it(`defaults to the version of the existing document for multi-namespace types`, async () => { // only multi-namespace documents are obtained using a pre-flight mget request const objects = [ - { ...obj1, type: MULTI_NAMESPACE_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_TYPE }, + { ...obj1, type: MULTI_NAMESPACE_ISOLATED_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, ]; await bulkUpdateSuccess(objects); const overrides = { @@ -1406,7 +1449,7 @@ describe('SavedObjectsRepository', () => { // test with both non-multi-namespace and multi-namespace types const objects = [ { ...obj1, version }, - { ...obj2, type: MULTI_NAMESPACE_TYPE, version }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE, version }, ]; await bulkUpdateSuccess(objects); const overrides = { if_seq_no: 100, if_primary_term: 200 }; @@ -1459,7 +1502,7 @@ describe('SavedObjectsRepository', () => { if_seq_no: expect.any(Number), }; const _obj1 = { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }; - const _obj2 = { ...obj2, type: MULTI_NAMESPACE_TYPE }; + const _obj2 = { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }; await bulkUpdateSuccess([_obj1], { namespace }); expectClientCallArgsAction([_obj1], { method: 'update', getId }); @@ -1558,19 +1601,19 @@ describe('SavedObjectsRepository', () => { }); it(`returns error when ES is unable to find the document (mget)`, async () => { - const _obj = { ...obj, type: MULTI_NAMESPACE_TYPE, found: false }; + const _obj = { ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE, found: false }; const mgetResponse = getMockMgetResponse([_obj]); await bulkUpdateMultiError([obj1, _obj, obj2], undefined, mgetResponse); }); it(`returns error when ES is unable to find the index (mget)`, async () => { - const _obj = { ...obj, type: MULTI_NAMESPACE_TYPE }; + const _obj = { ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE }; const mgetResponse = { statusCode: 404 }; await bulkUpdateMultiError([obj1, _obj, obj2], { namespace }, mgetResponse); }); it(`returns error when there is a conflict with an existing multi-namespace saved object (mget)`, async () => { - const _obj = { ...obj, type: MULTI_NAMESPACE_TYPE }; + const _obj = { ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE }; const mgetResponse = getMockMgetResponse([_obj], 'bar-namespace'); await bulkUpdateMultiError([obj1, _obj, obj2], { namespace }, mgetResponse); }); @@ -1643,7 +1686,7 @@ describe('SavedObjectsRepository', () => { }); it(`includes namespaces property for single-namespace and multi-namespace documents`, async () => { - const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const obj = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'three' }; const result = await bulkUpdateSuccess([obj1, obj]); expect(result).toEqual({ saved_objects: [ @@ -1654,7 +1697,7 @@ describe('SavedObjectsRepository', () => { }); it(`includes originId property if present in cluster call response`, async () => { - const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const obj = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'three' }; const result = await bulkUpdateSuccess([obj1, obj], {}, true); expect(result).toEqual({ saved_objects: [ @@ -1669,9 +1712,9 @@ describe('SavedObjectsRepository', () => { describe('#checkConflicts', () => { const obj1 = { type: 'dashboard', id: 'one' }; const obj2 = { type: 'dashboard', id: 'two' }; - const obj3 = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; - const obj4 = { type: MULTI_NAMESPACE_TYPE, id: 'four' }; - const obj5 = { type: MULTI_NAMESPACE_TYPE, id: 'five' }; + const obj3 = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'three' }; + const obj4 = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'four' }; + const obj5 = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'five' }; const obj6 = { type: NAMESPACE_AGNOSTIC_TYPE, id: 'six' }; const obj7 = { type: NAMESPACE_AGNOSTIC_TYPE, id: 'seven' }; const namespace = 'foo-namespace'; @@ -1854,7 +1897,7 @@ describe('SavedObjectsRepository', () => { }); it(`should use the ES get action then index action if type is multi-namespace, ID is defined, and overwrite=true`, async () => { - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, overwrite: true }); + await createSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id, overwrite: true }); expect(client.get).toHaveBeenCalled(); expect(client.index).toHaveBeenCalled(); }); @@ -1975,10 +2018,10 @@ describe('SavedObjectsRepository', () => { }); it(`doesn't prepend namespace to the id and adds namespaces to body when using multi-namespace type`, async () => { - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, namespace }); + await createSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id, namespace }); expect(client.create).toHaveBeenCalledWith( expect.objectContaining({ - id: `${MULTI_NAMESPACE_TYPE}:${id}`, + id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}`, body: expect.objectContaining({ namespaces: [namespace] }), }), expect.anything() @@ -2013,7 +2056,7 @@ describe('SavedObjectsRepository', () => { }); describe('errors', () => { - it(`throws when options.initialNamespaces is used with a non-multi-namespace object`, async () => { + 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] }) @@ -2024,10 +2067,11 @@ describe('SavedObjectsRepository', () => { ); }; await test('dashboard'); + await test(MULTI_NAMESPACE_ISOLATED_TYPE); await test(NAMESPACE_AGNOSTIC_TYPE); }); - it(`throws when options.initialNamespaces is used with a multi-namespace type and is empty`, async () => { + it(`throws when options.initialNamespaces is used with a shareable type and is empty`, async () => { await expect( savedObjectsRepository.create(MULTI_NAMESPACE_TYPE, attributes, { initialNamespaces: [] }) ).rejects.toThrowError( @@ -2056,17 +2100,20 @@ describe('SavedObjectsRepository', () => { }); it(`throws when there is a conflict with an existing multi-namespace saved object (get)`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, 'bar-namespace'); + const response = getMockGetResponse( + { type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, + 'bar-namespace' + ); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); await expect( - savedObjectsRepository.create(MULTI_NAMESPACE_TYPE, attributes, { + savedObjectsRepository.create(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id, overwrite: true, namespace, }) - ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_TYPE, id)); + ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_ISOLATED_TYPE, id)); expect(client.get).toHaveBeenCalled(); }); @@ -2105,17 +2152,17 @@ describe('SavedObjectsRepository', () => { expectMigrationArgs({ namespace: expect.anything() }, false, 1); client.create.mockClear(); - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id }); + await createSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id }); expectMigrationArgs({ namespace: expect.anything() }, false, 2); }); it(`adds namespaces to body when providing namespace for multi-namespace type`, async () => { - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, namespace }); + await createSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id, namespace }); expectMigrationArgs({ namespaces: [namespace] }); }); it(`adds default namespaces to body when providing no namespace for multi-namespace type`, async () => { - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id }); + await createSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id }); expectMigrationArgs({ namespaces: ['default'] }); }); @@ -2181,13 +2228,13 @@ describe('SavedObjectsRepository', () => { }); it(`should use ES get action then delete action when using a multi-namespace type`, async () => { - await deleteSuccess(MULTI_NAMESPACE_TYPE, id); + await deleteSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); expect(client.delete).toHaveBeenCalledTimes(1); }); it(`includes the version of the existing document when using a multi-namespace type`, async () => { - await deleteSuccess(MULTI_NAMESPACE_TYPE, id); + await deleteSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id); const versionProperties = { if_seq_no: mockVersionProps._seq_no, if_primary_term: mockVersionProps._primary_term, @@ -2238,9 +2285,9 @@ describe('SavedObjectsRepository', () => { ); client.delete.mockClear(); - await deleteSuccess(MULTI_NAMESPACE_TYPE, id, { namespace }); + await deleteSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }); expect(client.delete).toHaveBeenCalledWith( - expect.objectContaining({ id: `${MULTI_NAMESPACE_TYPE}:${id}` }), + expect.objectContaining({ id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}` }), expect.anything() ); }); @@ -2273,7 +2320,7 @@ describe('SavedObjectsRepository', () => { client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); }); @@ -2281,27 +2328,29 @@ describe('SavedObjectsRepository', () => { client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when the type is multi-namespace and the document exists, but not in this namespace`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, namespace); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, namespace); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id, { + namespace: 'bar-namespace', + }); expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when the type is multi-namespace and the document has multiple namespaces and the force option is not enabled`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace }); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id, namespace }); response._source.namespaces = [namespace, 'bar-namespace']; client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); await expect( - savedObjectsRepository.delete(MULTI_NAMESPACE_TYPE, id, { namespace }) + savedObjectsRepository.delete(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }) ).rejects.toThrowError( 'Unable to delete saved object that exists in multiple namespaces, use the `force` option to delete it anyway' ); @@ -2309,13 +2358,13 @@ describe('SavedObjectsRepository', () => { }); it(`throws when the type is multi-namespace and the document has all namespaces and the force option is not enabled`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace }); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id, namespace }); response._source.namespaces = ['*']; client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); await expect( - savedObjectsRepository.delete(MULTI_NAMESPACE_TYPE, id, { namespace }) + savedObjectsRepository.delete(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }) ).rejects.toThrowError( 'Unable to delete saved object that exists in multiple namespaces, use the `force` option to delete it anyway' ); @@ -3200,10 +3249,10 @@ describe('SavedObjectsRepository', () => { ); client.get.mockClear(); - await getSuccess(MULTI_NAMESPACE_TYPE, id, { namespace }); + await getSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }); expect(client.get).toHaveBeenCalledWith( expect.objectContaining({ - id: `${MULTI_NAMESPACE_TYPE}:${id}`, + id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}`, }), expect.anything() ); @@ -3250,11 +3299,13 @@ describe('SavedObjectsRepository', () => { }); it(`throws when type is multi-namespace and the document exists, but not in this namespace`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, namespace); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, namespace); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id, { + namespace: 'bar-namespace', + }); expect(client.get).toHaveBeenCalledTimes(1); }); }); @@ -3276,7 +3327,7 @@ describe('SavedObjectsRepository', () => { }); it(`includes namespaces if type is multi-namespace`, async () => { - const result = await getSuccess(MULTI_NAMESPACE_TYPE, id); + const result = await getSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(result).toMatchObject({ namespaces: expect.any(Array), }); @@ -3451,8 +3502,12 @@ describe('SavedObjectsRepository', () => { it('but alias target does not exist in this namespace', async () => { const objects = [ - { type: MULTI_NAMESPACE_TYPE, id }, // correct namespace field is added by getMockMgetResponse - { type: MULTI_NAMESPACE_TYPE, id: aliasTargetId, namespace: `not-${namespace}` }, // overrides namespace field that would otherwise be added by getMockMgetResponse + { type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, // correct namespace field is added by getMockMgetResponse + { + type: MULTI_NAMESPACE_ISOLATED_TYPE, + id: aliasTargetId, + namespace: `not-${namespace}`, + }, // overrides namespace field that would otherwise be added by getMockMgetResponse ]; await expectExactMatchResult(objects); }); @@ -3475,6 +3530,7 @@ describe('SavedObjectsRepository', () => { expect(result).toEqual({ saved_object: expect.objectContaining({ type, id: aliasTargetId }), outcome: 'aliasMatch', + aliasTargetId, }); }; @@ -3488,8 +3544,8 @@ describe('SavedObjectsRepository', () => { it('because actual target does not exist in this namespace', async () => { const objects = [ - { type: MULTI_NAMESPACE_TYPE, id, namespace: `not-${namespace}` }, // overrides namespace field that would otherwise be added by getMockMgetResponse - { type: MULTI_NAMESPACE_TYPE, id: aliasTargetId }, // correct namespace field is added by getMockMgetResponse + { type: MULTI_NAMESPACE_ISOLATED_TYPE, id, namespace: `not-${namespace}` }, // overrides namespace field that would otherwise be added by getMockMgetResponse + { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: aliasTargetId }, // correct namespace field is added by getMockMgetResponse ]; await expectAliasMatchResult(objects); }); @@ -3515,6 +3571,7 @@ describe('SavedObjectsRepository', () => { expect(result).toEqual({ saved_object: expect.objectContaining({ type, id }), outcome: 'conflict', + aliasTargetId, }); }); }); @@ -3570,7 +3627,9 @@ describe('SavedObjectsRepository', () => { }); it(`should use the ES get action then update action if type is multi-namespace, ID is defined, and overwrite=true`, async () => { - await incrementCounterSuccess(MULTI_NAMESPACE_TYPE, id, counterFields, { namespace }); + await incrementCounterSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, counterFields, { + namespace, + }); expect(client.get).toHaveBeenCalledTimes(1); expect(client.update).toHaveBeenCalledTimes(1); }); @@ -3625,10 +3684,12 @@ describe('SavedObjectsRepository', () => { ); client.update.mockClear(); - await incrementCounterSuccess(MULTI_NAMESPACE_TYPE, id, counterFields, { namespace }); + await incrementCounterSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, counterFields, { + namespace, + }); expect(client.update).toHaveBeenCalledWith( expect.objectContaining({ - id: `${MULTI_NAMESPACE_TYPE}:${id}`, + id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}`, }), expect.anything() ); @@ -3693,15 +3754,23 @@ describe('SavedObjectsRepository', () => { }); it(`throws when there is a conflict with an existing multi-namespace saved object (get)`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, 'bar-namespace'); + const response = getMockGetResponse( + { type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, + 'bar-namespace' + ); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); await expect( - savedObjectsRepository.incrementCounter(MULTI_NAMESPACE_TYPE, id, counterFields, { - namespace, - }) - ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_TYPE, id)); + savedObjectsRepository.incrementCounter( + MULTI_NAMESPACE_ISOLATED_TYPE, + id, + counterFields, + { + namespace, + } + ) + ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_ISOLATED_TYPE, id)); expect(client.get).toHaveBeenCalledTimes(1); }); }); @@ -4009,7 +4078,7 @@ describe('SavedObjectsRepository', () => { expect(client.update).not.toHaveBeenCalled(); }); - it(`throws when type is not multi-namespace`, async () => { + it(`throws when type is not shareable`, async () => { const test = async (type) => { const message = `${type} doesn't support multiple namespaces`; await expectBadRequestError(type, id, [namespace1, namespace2], message); @@ -4017,6 +4086,7 @@ describe('SavedObjectsRepository', () => { expect(client.update).not.toHaveBeenCalled(); }; await test('index-pattern'); + await test(MULTI_NAMESPACE_ISOLATED_TYPE); await test(NAMESPACE_AGNOSTIC_TYPE); }); @@ -4181,7 +4251,7 @@ describe('SavedObjectsRepository', () => { describe('client calls', () => { it(`should use the ES get action then update action when type is multi-namespace`, async () => { - await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); + await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes); expect(client.get).toHaveBeenCalledTimes(1); expect(client.update).toHaveBeenCalledTimes(1); }); @@ -4245,7 +4315,7 @@ describe('SavedObjectsRepository', () => { }); it(`defaults to the version of the existing document when type is multi-namespace`, async () => { - await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes, { references }); + await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes, { references }); const versionProperties = { if_seq_no: mockVersionProps._seq_no, if_primary_term: mockVersionProps._primary_term, @@ -4300,15 +4370,17 @@ describe('SavedObjectsRepository', () => { ); client.update.mockClear(); - await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes, { namespace }); + await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes, { namespace }); expect(client.update).toHaveBeenCalledWith( - expect.objectContaining({ id: expect.stringMatching(`${MULTI_NAMESPACE_TYPE}:${id}`) }), + expect.objectContaining({ + id: expect.stringMatching(`${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}`), + }), expect.anything() ); }); it(`includes _source_includes when type is multi-namespace`, async () => { - await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); + await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes); expect(client.update).toHaveBeenCalledWith( expect.objectContaining({ _source_includes: ['namespace', 'namespaces', 'originId'] }), expect.anything() @@ -4353,7 +4425,7 @@ describe('SavedObjectsRepository', () => { client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); }); @@ -4361,16 +4433,18 @@ describe('SavedObjectsRepository', () => { client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when type is multi-namespace and the document exists, but not in this namespace`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, namespace); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, namespace); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id, { + namespace: 'bar-namespace', + }); expect(client.get).toHaveBeenCalledTimes(1); }); @@ -4407,7 +4481,7 @@ describe('SavedObjectsRepository', () => { }); it(`includes namespaces if type is multi-namespace`, async () => { - const result = await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); + const result = await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes); expect(result).toMatchObject({ namespaces: expect.any(Array), }); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index b8a72377b0d764..78c3cdcb91e029 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -251,7 +251,7 @@ export class SavedObjectsRepository { const namespace = normalizeNamespace(options.namespace); if (initialNamespaces) { - if (!this._registry.isMultiNamespace(type)) { + if (!this._registry.isShareable(type)) { throw SavedObjectsErrorHelpers.createBadRequestError( '"options.initialNamespaces" can only be used on multi-namespace types' ); @@ -340,7 +340,7 @@ export class SavedObjectsRepository { if (!this._allowedTypes.includes(object.type)) { error = SavedObjectsErrorHelpers.createUnsupportedTypeError(object.type); } else if (object.initialNamespaces) { - if (!this._registry.isMultiNamespace(object.type)) { + if (!this._registry.isShareable(object.type)) { error = SavedObjectsErrorHelpers.createBadRequestError( '"initialNamespaces" can only be used on multi-namespace types' ); @@ -1085,6 +1085,7 @@ export class SavedObjectsRepository { return { saved_object: this.getSavedObjectFromSource(type, id, exactMatchDoc), outcome: 'conflict', + aliasTargetId: legacyUrlAlias.targetId, }; } else if (foundExactMatch) { return { @@ -1095,6 +1096,7 @@ export class SavedObjectsRepository { return { saved_object: this.getSavedObjectFromSource(type, legacyUrlAlias.targetId, aliasMatchDoc), outcome: 'aliasMatch', + aliasTargetId: legacyUrlAlias.targetId, }; } throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); @@ -1194,7 +1196,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - if (!this._registry.isMultiNamespace(type)) { + if (!this._registry.isShareable(type)) { throw SavedObjectsErrorHelpers.createBadRequestError( `${type} doesn't support multiple namespaces` ); @@ -1257,7 +1259,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - if (!this._registry.isMultiNamespace(type)) { + if (!this._registry.isShareable(type)) { throw SavedObjectsErrorHelpers.createBadRequestError( `${type} doesn't support multiple namespaces` ); 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 b93f3022e4236c..b078f3eef018cd 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -339,6 +339,10 @@ export interface SavedObjectsResolveResponse { * `saved_object` object is the exact match, and the `saved_object.id` field is the same as the given ID. */ outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; + /** + * The ID of the object that the legacy URL alias points to. This is only defined when the outcome is `'aliasMatch'` or `'conflict'`. + */ + aliasTargetId?: string; } /** diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index 66110d096213f5..57a77a9ebc5257 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -213,13 +213,17 @@ export type SavedObjectsClientContract = Pick SavedObjectMigrationMap); /** - * If defined, objects of this type will be converted to multi-namespace objects when migrating to this version. + * If defined, objects of this type will be converted to a 'multiple' or 'multiple-isolated' namespace type when migrating to this + * version. * * Requirements: * * 1. This string value must be a valid semver version * 2. This type must have previously specified {@link SavedObjectsNamespaceType | `namespaceType: 'single'`} - * 3. This type must also specify {@link SavedObjectsNamespaceType | `namespaceType: 'multiple'`} + * 3. This type must also specify {@link SavedObjectsNamespaceType | `namespaceType: 'multiple'`} *or* + * {@link SavedObjectsNamespaceType | `namespaceType: 'multiple-isolated'`} * - * Example of a single-namespace type in 7.10: + * Example of a single-namespace type in 7.12: * * ```ts * { @@ -278,7 +284,19 @@ export interface SavedObjectsType { * } * ``` * - * Example after converting to a multi-namespace type in 7.11: + * Example after converting to a multi-namespace (isolated) type in 8.0: + * + * ```ts + * { + * name: 'foo', + * hidden: false, + * namespaceType: 'multiple-isolated', + * mappings: {...}, + * convertToMultiNamespaceTypeVersion: '8.0.0' + * } + * ``` + * + * Example after converting to a multi-namespace (shareable) type in 8.1: * * ```ts * { @@ -286,11 +304,11 @@ export interface SavedObjectsType { * hidden: false, * namespaceType: 'multiple', * mappings: {...}, - * convertToMultiNamespaceTypeVersion: '7.11.0' + * convertToMultiNamespaceTypeVersion: '8.0.0' * } * ``` * - * Note: a migration function can be optionally specified for the same version. + * Note: migration function(s) can be optionally specified for any of these versions and will not interfere with the conversion process. */ convertToMultiNamespaceTypeVersion?: string; /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index b5f8b9d69abf31..34df3bcf853248 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2094,7 +2094,9 @@ export interface SavedObjectExportBaseOptions { // @public export interface SavedObjectMigrationContext { + convertToMultiNamespaceTypeVersion?: string; log: SavedObjectsMigrationLogger; + migrationVersion: string; } // @public @@ -2758,7 +2760,7 @@ export interface SavedObjectsMigrationVersion { } // @public -export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; +export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic'; // @public (undocumented) export interface SavedObjectsOpenPointInTimeOptions extends SavedObjectsBaseOptions { @@ -2850,6 +2852,7 @@ export interface SavedObjectsResolveImportErrorsOptions { // @public (undocumented) export interface SavedObjectsResolveResponse { + aliasTargetId?: string; outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; // (undocumented) saved_object: SavedObject; @@ -2963,6 +2966,7 @@ export class SavedObjectTypeRegistry { isImportableAndExportable(type: string): boolean; isMultiNamespace(type: string): boolean; isNamespaceAgnostic(type: string): boolean; + isShareable(type: string): boolean; isSingleNamespace(type: string): boolean; registerType(type: SavedObjectsType): void; } diff --git a/src/plugins/saved_objects_management/kibana.json b/src/plugins/saved_objects_management/kibana.json index f062433605c537..6c6d11d053c0f9 100644 --- a/src/plugins/saved_objects_management/kibana.json +++ b/src/plugins/saved_objects_management/kibana.json @@ -4,7 +4,7 @@ "server": true, "ui": true, "requiredPlugins": ["management", "data"], - "optionalPlugins": ["dashboard", "visualizations", "discover", "home", "savedObjectsTaggingOss"], + "optionalPlugins": ["dashboard", "visualizations", "discover", "home", "savedObjectsTaggingOss", "spacesOss"], "extraPublicDirs": ["public/lib"], "requiredBundles": ["kibanaReact", "home"] } diff --git a/src/plugins/saved_objects_management/public/management_section/mount_section.tsx b/src/plugins/saved_objects_management/public/management_section/mount_section.tsx index d6cebd491b6e37..b855850ed185d4 100644 --- a/src/plugins/saved_objects_management/public/management_section/mount_section.tsx +++ b/src/plugins/saved_objects_management/public/management_section/mount_section.tsx @@ -37,7 +37,11 @@ export const mountManagementSection = async ({ mountParams, serviceRegistry, }: MountParams) => { - const [coreStart, { data, savedObjectsTaggingOss }, pluginStart] = await core.getStartServices(); + const [ + coreStart, + { data, savedObjectsTaggingOss, spacesOss }, + pluginStart, + ] = await core.getStartServices(); const { element, history, setBreadcrumbs } = mountParams; if (allowedObjectTypes === undefined) { allowedObjectTypes = await getAllowedTypes(coreStart.http); @@ -57,6 +61,8 @@ export const mountManagementSection = async ({ return children! as React.ReactElement; }; + const spacesApi = spacesOss?.isSpacesAvailable ? spacesOss : undefined; + ReactDOM.render( @@ -79,6 +85,7 @@ export const mountManagementSection = async ({ { @@ -80,22 +79,12 @@ export class Table extends PureComponent { isExportPopoverOpen: false, isIncludeReferencesDeepChecked: true, activeAction: undefined, - isColumnDataLoaded: false, }; constructor(props: TableProps) { super(props); } - componentDidMount() { - this.loadColumnData(); - } - - loadColumnData = async () => { - await Promise.all(this.props.columnRegistry.getAll().map((column) => column.loadData())); - this.setState({ isColumnDataLoaded: true }); - }; - onChange = ({ query, error }: any) => { if (error) { this.setState({ diff --git a/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx b/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx index 8049b8adfdf1ce..c5ae2127ac0305 100644 --- a/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx +++ b/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx @@ -15,6 +15,7 @@ import { i18n } from '@kbn/i18n'; import { CoreStart, ChromeBreadcrumb } from 'src/core/public'; import { DataPublicPluginStart } from '../../../data/public'; import { SavedObjectsTaggingApi } from '../../../saved_objects_tagging_oss/public'; +import type { SpacesAvailableStartContract } from '../../../spaces_oss/public'; import { ISavedObjectsManagementServiceRegistry, SavedObjectsManagementActionServiceStart, @@ -22,10 +23,13 @@ import { } from '../services'; import { SavedObjectsTable } from './objects_table'; +const EmptyFunctionComponent: React.FC = ({ children }) => <>{children}; + const SavedObjectsTablePage = ({ coreStart, dataStart, taggingApi, + spacesApi, allowedTypes, serviceRegistry, actionRegistry, @@ -35,6 +39,7 @@ const SavedObjectsTablePage = ({ coreStart: CoreStart; dataStart: DataPublicPluginStart; taggingApi?: SavedObjectsTaggingApi; + spacesApi?: SpacesAvailableStartContract; allowedTypes: string[]; serviceRegistry: ISavedObjectsManagementServiceRegistry; actionRegistry: SavedObjectsManagementActionServiceStart; @@ -65,35 +70,42 @@ const SavedObjectsTablePage = ({ ]); }, [setBreadcrumbs]); + const ContextWrapper = useMemo( + () => spacesApi?.ui.components.SpacesContext || EmptyFunctionComponent, + [spacesApi] + ); + return ( - { - const { editUrl } = savedObject.meta; - if (editUrl) { - return coreStart.application.navigateToUrl( - coreStart.http.basePath.prepend(`/app${editUrl}`) - ); - } - }} - canGoInApp={(savedObject) => { - const { inAppUrl } = savedObject.meta; - return inAppUrl ? Boolean(get(capabilities, inAppUrl.uiCapabilitiesPath)) : false; - }} - /> + + { + const { editUrl } = savedObject.meta; + if (editUrl) { + return coreStart.application.navigateToUrl( + coreStart.http.basePath.prepend(`/app${editUrl}`) + ); + } + }} + canGoInApp={(savedObject) => { + const { inAppUrl } = savedObject.meta; + return inAppUrl ? Boolean(get(capabilities, inAppUrl.uiCapabilitiesPath)) : false; + }} + /> + ); }; // eslint-disable-next-line import/no-default-export diff --git a/src/plugins/saved_objects_management/public/plugin.ts b/src/plugins/saved_objects_management/public/plugin.ts index a4c7a84b419baa..f4578c4c4b8e10 100644 --- a/src/plugins/saved_objects_management/public/plugin.ts +++ b/src/plugins/saved_objects_management/public/plugin.ts @@ -15,6 +15,7 @@ import { DiscoverStart } from '../../discover/public'; import { HomePublicPluginSetup, FeatureCatalogueCategory } from '../../home/public'; import { VisualizationsStart } from '../../visualizations/public'; import { SavedObjectTaggingOssPluginStart } from '../../saved_objects_tagging_oss/public'; +import type { SpacesOssPluginStart } from '../../spaces_oss/public'; import { SavedObjectsManagementActionService, SavedObjectsManagementActionServiceSetup, @@ -49,6 +50,7 @@ export interface StartDependencies { visualizations?: VisualizationsStart; discover?: DiscoverStart; savedObjectsTaggingOss?: SavedObjectTaggingOssPluginStart; + spacesOss?: SpacesOssPluginStart; } export class SavedObjectsManagementPlugin diff --git a/src/plugins/saved_objects_management/public/services/types/column.ts b/src/plugins/saved_objects_management/public/services/types/column.ts index 6103f1bd3d5c0c..1be279db912051 100644 --- a/src/plugins/saved_objects_management/public/services/types/column.ts +++ b/src/plugins/saved_objects_management/public/services/types/column.ts @@ -12,7 +12,4 @@ import { SavedObjectsManagementRecord } from '.'; export interface SavedObjectsManagementColumn { id: string; euiColumn: Omit, 'sortable'>; - - data?: T; - loadData: () => Promise; } diff --git a/src/plugins/saved_objects_management/tsconfig.json b/src/plugins/saved_objects_management/tsconfig.json index eb047c346714ca..99849dea386181 100644 --- a/src/plugins/saved_objects_management/tsconfig.json +++ b/src/plugins/saved_objects_management/tsconfig.json @@ -21,5 +21,6 @@ { "path": "../kibana_react/tsconfig.json" }, { "path": "../management/tsconfig.json" }, { "path": "../visualizations/tsconfig.json" }, + { "path": "../spaces_oss/tsconfig.json" }, ] } diff --git a/src/plugins/spaces_oss/public/api.mock.ts b/src/plugins/spaces_oss/public/api.mock.ts index 4c6b8bb6ee3381..c4a410c76e7962 100644 --- a/src/plugins/spaces_oss/public/api.mock.ts +++ b/src/plugins/spaces_oss/public/api.mock.ts @@ -7,13 +7,40 @@ */ import { of } from 'rxjs'; -import { SpacesApi } from './api'; +import { SpacesApi, SpacesApiUi, SpacesApiUiComponent } from './api'; const createApiMock = (): jest.Mocked => ({ activeSpace$: of(), getActiveSpace: jest.fn(), + ui: createApiUiMock(), }); +type SpacesApiUiMock = Omit, 'components'> & { + components: SpacesApiUiComponentMock; +}; + +const createApiUiMock = () => { + const mock: SpacesApiUiMock = { + components: createApiUiComponentsMock(), + redirectLegacyUrl: jest.fn(), + }; + + return mock; +}; + +type SpacesApiUiComponentMock = jest.Mocked; + +const createApiUiComponentsMock = () => { + const mock: SpacesApiUiComponentMock = { + SpacesContext: jest.fn(), + ShareToSpaceFlyout: jest.fn(), + SpaceList: jest.fn(), + LegacyUrlConflict: jest.fn(), + }; + + return mock; +}; + export const spacesApiMock = { create: createApiMock, }; diff --git a/src/plugins/spaces_oss/public/api.ts b/src/plugins/spaces_oss/public/api.ts index 5fa8b4fc29719a..2d5e144158d78b 100644 --- a/src/plugins/spaces_oss/public/api.ts +++ b/src/plugins/spaces_oss/public/api.ts @@ -7,6 +7,7 @@ */ import { Observable } from 'rxjs'; +import type { FunctionComponent } from 'react'; import { Space } from '../common'; /** @@ -15,4 +16,238 @@ import { Space } from '../common'; export interface SpacesApi { readonly activeSpace$: Observable; getActiveSpace(): Promise; + /** + * UI API to use to add spaces capabilities to an application + */ + ui: SpacesApiUi; +} + +/** + * @public + */ +export interface SpacesApiUi { + /** + * {@link SpacesApiUiComponent | React components} to support the spaces feature. + */ + components: SpacesApiUiComponent; + /** + * Redirect the user from a legacy URL to a new URL. This needs to be used if a call to `SavedObjectsClient.resolve()` results in an + * `"aliasMatch"` outcome, which indicates that the user has loaded the page using a legacy URL. Calling this function will trigger a + * client-side redirect to the new URL, and it will display a toast to the user. + * + * Consumers need to determine the local path for the new URL on their own, based on the object ID that was used to call + * `SavedObjectsClient.resolve()` (old ID) and the object ID in the result (new ID). For example... + * + * The old object ID is `workpad-123` and the new object ID is `workpad-e08b9bdb-ec14-4339-94c4-063bddfd610e`. + * + * Full legacy URL: `https://localhost:5601/app/canvas#/workpad/workpad-123/page/1` + * + * New URL path: `#/workpad/workpad-e08b9bdb-ec14-4339-94c4-063bddfd610e/page/1` + * + * The protocol, hostname, port, base path, and app path are automatically included. + * + * @param path The path to use for the new URL, optionally including `search` and/or `hash` URL components. + * @param objectNoun The string that is used to describe the object in the toast, e.g., _The **object** you're looking for has a new + * location_. Default value is 'object'. + */ + redirectLegacyUrl: (path: string, objectNoun?: string) => Promise; +} + +/** + * React UI components to be used to display the spaces feature in any application. + * + * @public + */ +export interface SpacesApiUiComponent { + /** + * Provides a context that is required to render some Spaces components. + */ + SpacesContext: FunctionComponent; + /** + * Displays a flyout to edit the spaces that an object is shared to. + * + * Note: must be rendered inside of a SpacesContext. + */ + ShareToSpaceFlyout: FunctionComponent; + /** + * Displays a corresponding list of spaces for a given list of saved object namespaces. It shows up to five spaces (and an indicator for + * any number of spaces that the user is not authorized to see) by default. If more than five named spaces would be displayed, the extras + * (along with the unauthorized spaces indicator, if present) are hidden behind a button. If '*' (aka "All spaces") is present, it + * supersedes all of the above and just displays a single badge without a button. + * + * Note: must be rendered inside of a SpacesContext. + */ + SpaceList: FunctionComponent; + /** + * Displays a callout that needs to be used if a call to `SavedObjectsClient.resolve()` results in an `"conflict"` outcome, which + * indicates that the user has loaded the page which is associated directly with one object (A), *and* with a legacy URL that points to a + * different object (B). + * + * In this case, `SavedObjectsClient.resolve()` has returned object A. This component displays a warning callout to the user explaining + * that there is a conflict, and it includes a button that will redirect the user to object B when clicked. + * + * Consumers need to determine the local path for the new URL on their own, based on the object ID that was used to call + * `SavedObjectsClient.resolve()` (A) and the `aliasTargetId` value in the response (B). For example... + * + * A is `workpad-123` and B is `workpad-e08b9bdb-ec14-4339-94c4-063bddfd610e`. + * + * Full legacy URL: `https://localhost:5601/app/canvas#/workpad/workpad-123/page/1` + * + * New URL path: `#/workpad/workpad-e08b9bdb-ec14-4339-94c4-063bddfd610e/page/1` + */ + LegacyUrlConflict: FunctionComponent; +} + +/** + * @public + */ +export interface SpacesContextProps { + /** + * If a feature is specified, all Spaces components will treat it appropriately if the feature is disabled in a given Space. + */ + feature?: string; +} + +/** + * @public + */ +export interface ShareToSpaceFlyoutProps { + /** + * The object to render the flyout for. + */ + savedObjectTarget: ShareToSpaceSavedObjectTarget; + /** + * The EUI icon that is rendered in the flyout's title. + * + * Default is 'share'. + */ + flyoutIcon?: string; + /** + * The string that is rendered in the flyout's title. + * + * Default is 'Edit spaces for object'. + */ + flyoutTitle?: string; + /** + * When enabled, if the object is not yet shared to multiple spaces, a callout will be displayed that suggests the user might want to + * create a copy instead. + * + * Default value is false. + */ + enableCreateCopyCallout?: boolean; + /** + * When enabled, if no other spaces exist _and_ the user has the appropriate privileges, a sentence will be displayed that suggests the + * user might want to create a space. + * + * Default value is false. + */ + enableCreateNewSpaceLink?: boolean; + /** + * When set to 'within-space' (default), the flyout behaves like it is running on a page within the active space, and it will prevent the + * user from removing the object from the active space. + * + * Conversely, when set to 'outside-space', the flyout behaves like it is running on a page outside of any space, so it will allow the + * user to remove the object from the active space. + */ + behaviorContext?: 'within-space' | 'outside-space'; + /** + * Optional handler that is called when the user has saved changes and there are spaces to be added to and/or removed from the object. If + * this is not defined, a default handler will be used that calls `/api/spaces/_share_saved_object_add` and/or + * `/api/spaces/_share_saved_object_remove` and displays toast(s) indicating what occurred. + */ + changeSpacesHandler?: (spacesToAdd: string[], spacesToRemove: string[]) => Promise; + /** + * Optional callback when the target object is updated. + */ + onUpdate?: () => void; + /** + * Optional callback when the flyout is closed. + */ + onClose?: () => void; +} + +/** + * @public + */ +export interface ShareToSpaceSavedObjectTarget { + /** + * The object's type. + */ + type: string; + /** + * The object's ID. + */ + id: string; + /** + * The namespaces that the object currently exists in. + */ + namespaces: string[]; + /** + * The EUI icon that is rendered in the flyout's subtitle. + * + * Default is 'empty'. + */ + icon?: string; + /** + * The string that is rendered in the flyout's subtitle. + * + * Default is `${type} [id=${id}]`. + */ + title?: string; + /** + * The string that is used to describe the object in several places, e.g., _Make **object** available in selected spaces only_. + * + * Default value is 'object'. + */ + noun?: string; +} + +/** + * @public + */ +export interface SpaceListProps { + /** + * The namespaces of a saved object to render into a corresponding list of spaces. + */ + namespaces: string[]; + /** + * Optional limit to the number of spaces that can be displayed in the list. If the number of spaces exceeds this limit, they will be + * hidden behind a "show more" button. Set to 0 to disable. + * + * Default value is 5. + */ + displayLimit?: number; + /** + * When set to 'within-space' (default), the space list behaves like it is running on a page within the active space, and it will omit the + * active space (e.g., it displays a list of all the _other_ spaces that an object is shared to). + * + * Conversely, when set to 'outside-space', the space list behaves like it is running on a page outside of any space, so it will not omit + * the active space. + */ + behaviorContext?: 'within-space' | 'outside-space'; +} + +/** + * @public + */ +export interface LegacyUrlConflictProps { + /** + * The string that is used to describe the object in the callout, e.g., _There is a legacy URL for this page that points to a different + * **object**_. + * + * Default value is 'object'. + */ + objectNoun?: string; + /** + * The ID of the object that is currently shown on the page. + */ + currentObjectId: string; + /** + * The ID of the other object that the legacy URL alias points to. + */ + otherObjectId: string; + /** + * The path to use for the new URL, optionally including `search` and/or `hash` URL components. + */ + otherObjectPath: string; } diff --git a/src/plugins/spaces_oss/public/index.ts b/src/plugins/spaces_oss/public/index.ts index 70172f620d0435..be42bd9a899b10 100644 --- a/src/plugins/spaces_oss/public/index.ts +++ b/src/plugins/spaces_oss/public/index.ts @@ -8,8 +8,22 @@ import { SpacesOssPlugin } from './plugin'; -export { SpacesOssPluginSetup, SpacesOssPluginStart } from './types'; +export { + SpacesOssPluginSetup, + SpacesOssPluginStart, + SpacesAvailableStartContract, + SpacesUnavailableStartContract, +} from './types'; -export { SpacesApi } from './api'; +export { + SpacesApi, + SpacesApiUi, + SpacesApiUiComponent, + SpacesContextProps, + ShareToSpaceFlyoutProps, + ShareToSpaceSavedObjectTarget, + SpaceListProps, + LegacyUrlConflictProps, +} from './api'; export const plugin = () => new SpacesOssPlugin(); diff --git a/src/plugins/spaces_oss/public/types.ts b/src/plugins/spaces_oss/public/types.ts index 80b1f7aa840bbc..831aaa2c459439 100644 --- a/src/plugins/spaces_oss/public/types.ts +++ b/src/plugins/spaces_oss/public/types.ts @@ -8,11 +8,11 @@ import { SpacesApi } from './api'; -interface SpacesAvailableStartContract extends SpacesApi { +export interface SpacesAvailableStartContract extends SpacesApi { isSpacesAvailable: true; } -interface SpacesUnavailableStartContract { +export interface SpacesUnavailableStartContract { isSpacesAvailable: false; } diff --git a/test/api_integration/apis/saved_objects/migrations.ts b/test/api_integration/apis/saved_objects/migrations.ts index f2f9d24488ac04..5a5158825a2248 100644 --- a/test/api_integration/apis/saved_objects/migrations.ts +++ b/test/api_integration/apis/saved_objects/migrations.ts @@ -440,7 +440,7 @@ export default ({ getService }: FtrProviderContext) => { }, { ...BAR_TYPE, - namespaceType: 'multiple', + namespaceType: 'multiple-isolated', convertToMultiNamespaceTypeVersion: '2.0.0', }, BAZ_TYPE, // must be registered for reference transforms to be applied to objects of this type diff --git a/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts b/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts index 2f9187b1ccc6aa..36e228ead31da7 100644 --- a/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts @@ -12,7 +12,7 @@ import { SavedObjectUnsanitizedDoc } from 'kibana/server'; import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/mocks'; import { migrationMocks } from 'src/core/server/mocks'; -const { log } = migrationMocks.createContext(); +const migrationContext = migrationMocks.createContext(); const encryptedSavedObjectsSetup = encryptedSavedObjectsMock.createSetup(); describe('7.10.0', () => { @@ -26,7 +26,7 @@ describe('7.10.0', () => { test('marks alerts as legacy', () => { const migration710 = getMigrations(encryptedSavedObjectsSetup)['7.10.0']; const alert = getMockData({}); - expect(migration710(alert, { log })).toMatchObject({ + expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, attributes: { ...alert.attributes, @@ -42,7 +42,7 @@ describe('7.10.0', () => { const alert = getMockData({ consumer: 'metrics', }); - expect(migration710(alert, { log })).toMatchObject({ + expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, attributes: { ...alert.attributes, @@ -59,7 +59,7 @@ describe('7.10.0', () => { const alert = getMockData({ consumer: 'securitySolution', }); - expect(migration710(alert, { log })).toMatchObject({ + expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, attributes: { ...alert.attributes, @@ -76,7 +76,7 @@ describe('7.10.0', () => { const alert = getMockData({ consumer: 'alerting', }); - expect(migration710(alert, { log })).toMatchObject({ + expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, attributes: { ...alert.attributes, @@ -104,7 +104,7 @@ describe('7.10.0', () => { }, ], }); - expect(migration710(alert, { log })).toMatchObject({ + expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, attributes: { ...alert.attributes, @@ -142,7 +142,7 @@ describe('7.10.0', () => { }, ], }); - expect(migration710(alert, { log })).toMatchObject({ + expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, attributes: { ...alert.attributes, @@ -179,7 +179,7 @@ describe('7.10.0', () => { }, ], }); - expect(migration710(alert, { log })).toMatchObject({ + expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, attributes: { ...alert.attributes, @@ -206,7 +206,7 @@ describe('7.10.0', () => { const migration710 = getMigrations(encryptedSavedObjectsSetup)['7.10.0']; const alert = getMockData(); const dateStart = Date.now(); - const migratedAlert = migration710(alert, { log }); + const migratedAlert = migration710(alert, migrationContext); const dateStop = Date.now(); const dateExecutionStatus = Date.parse( migratedAlert.attributes.executionStatus.lastExecutionDate @@ -242,14 +242,14 @@ describe('7.10.0 migrates with failure', () => { const alert = getMockData({ consumer: 'alerting', }); - const res = migration710(alert, { log }); + const res = migration710(alert, migrationContext); expect(res).toMatchObject({ ...alert, attributes: { ...alert.attributes, }, }); - expect(log.error).toHaveBeenCalledWith( + expect(migrationContext.log.error).toHaveBeenCalledWith( `encryptedSavedObject 7.10.0 migration failed for alert ${alert.id} with error: Can't migrate!`, { alertDocument: { @@ -274,7 +274,7 @@ describe('7.11.0', () => { test('add updatedAt field to alert - set to SavedObject updated_at attribute', () => { const migration711 = getMigrations(encryptedSavedObjectsSetup)['7.11.0']; const alert = getMockData({}, true); - expect(migration711(alert, { log })).toEqual({ + expect(migration711(alert, migrationContext)).toEqual({ ...alert, attributes: { ...alert.attributes, @@ -287,7 +287,7 @@ describe('7.11.0', () => { test('add updatedAt field to alert - set to createdAt when SavedObject updated_at is not defined', () => { const migration711 = getMigrations(encryptedSavedObjectsSetup)['7.11.0']; const alert = getMockData({}); - expect(migration711(alert, { log })).toEqual({ + expect(migration711(alert, migrationContext)).toEqual({ ...alert, attributes: { ...alert.attributes, @@ -300,7 +300,7 @@ describe('7.11.0', () => { test('add notifyWhen=onActiveAlert when throttle is null', () => { const migration711 = getMigrations(encryptedSavedObjectsSetup)['7.11.0']; const alert = getMockData({}); - expect(migration711(alert, { log })).toEqual({ + expect(migration711(alert, migrationContext)).toEqual({ ...alert, attributes: { ...alert.attributes, @@ -313,7 +313,7 @@ describe('7.11.0', () => { test('add notifyWhen=onActiveAlert when throttle is set', () => { const migration711 = getMigrations(encryptedSavedObjectsSetup)['7.11.0']; const alert = getMockData({ throttle: '5m' }); - expect(migration711(alert, { log })).toEqual({ + expect(migration711(alert, migrationContext)).toEqual({ ...alert, attributes: { ...alert.attributes, diff --git a/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts b/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts index 508879c3596e54..4df51af8b16b02 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts @@ -15,7 +15,7 @@ afterEach(() => { }); describe('createMigration()', () => { - const { log } = migrationMocks.createContext(); + const migrationContext = migrationMocks.createContext(); const inputType = { type: 'known-type-1', attributesToEncrypt: new Set(['firstAttr']) }; const migrationType = { type: 'known-type-1', @@ -88,7 +88,7 @@ describe('createMigration()', () => { namespace: 'namespace', attributes, }, - { log } + migrationContext ); expect(encryptionSavedObjectService.decryptAttributesSync).toHaveBeenCalledWith( @@ -97,7 +97,8 @@ describe('createMigration()', () => { type: 'known-type-1', namespace: 'namespace', }, - attributes + attributes, + { convertToMultiNamespaceType: false } ); expect(encryptionSavedObjectService.encryptAttributesSync).toHaveBeenCalledWith( @@ -112,7 +113,7 @@ describe('createMigration()', () => { }); describe('migration of a single legacy type', () => { - it('uses the input type as the mirgation type when omitted', async () => { + it('uses the input type as the migration type when omitted', async () => { const serviceWithLegacyType = encryptedSavedObjectsServiceMock.create(); const instantiateServiceWithLegacyType = jest.fn(() => serviceWithLegacyType); @@ -142,7 +143,7 @@ describe('createMigration()', () => { namespace: 'namespace', attributes, }, - { log } + migrationContext ); expect(serviceWithLegacyType.decryptAttributesSync).toHaveBeenCalledWith( @@ -151,7 +152,8 @@ describe('createMigration()', () => { type: 'known-type-1', namespace: 'namespace', }, - attributes + attributes, + { convertToMultiNamespaceType: false } ); expect(encryptionSavedObjectService.encryptAttributesSync).toHaveBeenCalledWith( @@ -163,6 +165,81 @@ describe('createMigration()', () => { attributes ); }); + + describe('uses the object `namespaces` field to populate the descriptor when the migration context indicates this type is being converted', () => { + const doTest = ({ + objectNamespace, + decryptDescriptorNamespace, + }: { + objectNamespace: string | undefined; + decryptDescriptorNamespace: string | undefined; + }) => { + const instantiateServiceWithLegacyType = jest.fn(() => + encryptedSavedObjectsServiceMock.create() + ); + + const migrationCreator = getCreateMigration( + encryptionSavedObjectService, + instantiateServiceWithLegacyType + ); + const noopMigration = migrationCreator( + function (doc): doc is SavedObjectUnsanitizedDoc { + return true; + }, + (doc) => doc + ); + + const attributes = { + firstAttr: 'first_attr', + }; + + encryptionSavedObjectService.decryptAttributesSync.mockReturnValueOnce(attributes); + encryptionSavedObjectService.encryptAttributesSync.mockReturnValueOnce(attributes); + + noopMigration( + { + id: '123', + type: 'known-type-1', + namespaces: objectNamespace ? [objectNamespace] : [], + attributes, + }, + migrationMocks.createContext({ + migrationVersion: '8.0.0', + convertToMultiNamespaceTypeVersion: '8.0.0', + }) + ); + + expect(encryptionSavedObjectService.decryptAttributesSync).toHaveBeenCalledWith( + { + id: '123', + type: 'known-type-1', + namespace: decryptDescriptorNamespace, + }, + attributes, + { convertToMultiNamespaceType: true } + ); + + expect(encryptionSavedObjectService.encryptAttributesSync).toHaveBeenCalledWith( + { + id: '123', + type: 'known-type-1', + }, + attributes + ); + }; + + it('when namespaces is an empty array', () => { + doTest({ objectNamespace: undefined, decryptDescriptorNamespace: undefined }); + }); + + it('when the first namespace element is "default"', () => { + doTest({ objectNamespace: 'default', decryptDescriptorNamespace: undefined }); + }); + + it('when the first namespace element is another string', () => { + doTest({ objectNamespace: 'foo', decryptDescriptorNamespace: 'foo' }); + }); + }); }); describe('migration across two legacy types', () => { @@ -216,7 +293,7 @@ describe('createMigration()', () => { firstAttr: '#####', }, }, - { log } + migrationContext ) ).toMatchObject({ id: '123', @@ -257,7 +334,7 @@ describe('createMigration()', () => { nonEncryptedAttr: 'non encrypted', }, }, - { log } + migrationContext ) ).toMatchObject({ id: '123', @@ -278,7 +355,8 @@ describe('createMigration()', () => { { firstAttr: '#####', nonEncryptedAttr: 'non encrypted', - } + }, + { convertToMultiNamespaceType: false } ); expect(serviceWithMigrationLegacyType.encryptAttributesSync).toHaveBeenCalledWith( diff --git a/x-pack/plugins/encrypted_saved_objects/server/create_migration.ts b/x-pack/plugins/encrypted_saved_objects/server/create_migration.ts index eb262997a8e451..cf5357c40fa20a 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/create_migration.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/create_migration.ts @@ -11,6 +11,7 @@ import { SavedObjectMigrationContext, } from 'src/core/server'; import { EncryptedSavedObjectTypeRegistration, EncryptedSavedObjectsService } from './crypto'; +import { normalizeNamespace } from './saved_objects'; type SavedObjectOptionalMigrationFn = ( doc: SavedObjectUnsanitizedDoc | SavedObjectUnsanitizedDoc, @@ -63,11 +64,19 @@ export const getCreateMigration = ( return encryptedDoc; } - const descriptor = { - id: encryptedDoc.id!, - type: encryptedDoc.type, - namespace: encryptedDoc.namespace, - }; + // If an object has been converted right before this migration function is called, it will no longer have a `namespace` field, but it + // will have a `namespaces` field; in that case, the first/only element in that array should be used as the namespace in the descriptor + // during decryption. + const convertToMultiNamespaceType = + context.convertToMultiNamespaceTypeVersion === context.migrationVersion; + const decryptDescriptorNamespace = convertToMultiNamespaceType + ? normalizeNamespace(encryptedDoc.namespaces?.[0]) // `namespaces` contains string values, but we need to normalize this to the namespace ID representation + : encryptedDoc.namespace; + + const { id, type } = encryptedDoc; + // These descriptors might have a `namespace` that is undefined. That is expected for multi-namespace and namespace-agnostic types. + const decryptDescriptor = { id, type, namespace: decryptDescriptorNamespace }; + const encryptDescriptor = { id, type, namespace: encryptedDoc.namespace }; // decrypt the attributes using the input type definition // then migrate the document @@ -75,12 +84,14 @@ export const getCreateMigration = ( return mapAttributes( migration( mapAttributes(encryptedDoc, (inputAttributes) => - inputService.decryptAttributesSync(descriptor, inputAttributes) + inputService.decryptAttributesSync(decryptDescriptor, inputAttributes, { + convertToMultiNamespaceType, + }) ), context ), (migratedAttributes) => - migratedService.encryptAttributesSync(descriptor, migratedAttributes) + migratedService.encryptAttributesSync(encryptDescriptor, migratedAttributes) ); }; }; diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts index f70810943d179f..7bc08d0e7b30fa 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts @@ -819,6 +819,55 @@ describe('#decryptAttributes', () => { ); }); + it('retries decryption without namespace if incorrect namespace is provided and convertToMultiNamespaceType option is enabled', async () => { + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; + + service.registerType({ + type: 'known-type-1', + attributesToEncrypt: new Set(['attrThree']), + }); + + const encryptedAttributes = service.encryptAttributesSync( + { type: 'known-type-1', id: 'object-id' }, // namespace was not included in descriptor during encryption + attributes + ); + expect(encryptedAttributes).toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: expect.not.stringMatching(/^three$/), + }); + + const mockUser = mockAuthenticatedUser(); + await expect( + service.decryptAttributes( + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + encryptedAttributes, + { user: mockUser, convertToMultiNamespaceType: true } + ) + ).resolves.toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: 'three', + }); + expect(mockNodeCrypto.decrypt).toHaveBeenCalledTimes(2); + expect(mockNodeCrypto.decrypt).toHaveBeenNthCalledWith( + 1, // first attempted to decrypt with the namespace in the descriptor (fail) + expect.anything(), + `["object-ns","known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + expect(mockNodeCrypto.decrypt).toHaveBeenNthCalledWith( + 2, // then attempted to decrypt without the namespace in the descriptor (success) + expect.anything(), + `["known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( + ['attrThree'], + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + mockUser + ); + }); + it('decrypts even if no attributes are included into AAD', async () => { const attributes = { attrOne: 'one', attrThree: 'three' }; service.registerType({ @@ -1017,6 +1066,47 @@ describe('#decryptAttributes', () => { ); }); + it('fails if retry decryption without namespace is not correct', async () => { + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; + + encryptedAttributes = service.encryptAttributesSync( + { type: 'known-type-1', id: 'object-id', namespace: 'some-other-ns' }, + attributes + ); + expect(encryptedAttributes).toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: expect.not.stringMatching(/^three$/), + }); + + const mockUser = mockAuthenticatedUser(); + await expect(() => + service.decryptAttributes( + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + encryptedAttributes, + { user: mockUser, convertToMultiNamespaceType: true } + ) + ).rejects.toThrowError(EncryptionError); + expect(mockNodeCrypto.decrypt).toHaveBeenCalledTimes(2); + expect(mockNodeCrypto.decrypt).toHaveBeenNthCalledWith( + 1, // first attempted to decrypt with the namespace in the descriptor (fail) + expect.anything(), + `["object-ns","known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + expect(mockNodeCrypto.decrypt).toHaveBeenNthCalledWith( + 2, // then attempted to decrypt without the namespace in the descriptor (fail) + expect.anything(), + `["known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + + expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); + expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith( + 'attrThree', + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + mockUser + ); + }); + it('fails to decrypt if encrypted attribute is defined, but not a string', async () => { const mockUser = mockAuthenticatedUser(); await expect( @@ -1707,6 +1797,55 @@ describe('#decryptAttributesSync', () => { }); }); + it('retries decryption without namespace if incorrect namespace is provided and convertToMultiNamespaceType option is enabled', () => { + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; + + service.registerType({ + type: 'known-type-1', + attributesToEncrypt: new Set(['attrThree']), + }); + + const encryptedAttributes = service.encryptAttributesSync( + { type: 'known-type-1', id: 'object-id' }, // namespace was not included in descriptor during encryption + attributes + ); + expect(encryptedAttributes).toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: expect.not.stringMatching(/^three$/), + }); + + const mockUser = mockAuthenticatedUser(); + expect( + service.decryptAttributesSync( + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + encryptedAttributes, + { user: mockUser, convertToMultiNamespaceType: true } + ) + ).toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: 'three', + }); + expect(mockNodeCrypto.decryptSync).toHaveBeenCalledTimes(2); + expect(mockNodeCrypto.decryptSync).toHaveBeenNthCalledWith( + 1, // first attempted to decrypt with the namespace in the descriptor (fail) + expect.anything(), + `["object-ns","known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + expect(mockNodeCrypto.decryptSync).toHaveBeenNthCalledWith( + 2, // then attempted to decrypt without the namespace in the descriptor (success) + expect.anything(), + `["known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( + ['attrThree'], + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + mockUser + ); + }); + it('decrypts even if no attributes are included into AAD', () => { const attributes = { attrOne: 'one', attrThree: 'three' }; service.registerType({ @@ -1852,6 +1991,47 @@ describe('#decryptAttributesSync', () => { ).toThrowError(EncryptionError); }); + it('fails if retry decryption without namespace is not correct', () => { + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; + + encryptedAttributes = service.encryptAttributesSync( + { type: 'known-type-1', id: 'object-id', namespace: 'some-other-ns' }, + attributes + ); + expect(encryptedAttributes).toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: expect.not.stringMatching(/^three$/), + }); + + const mockUser = mockAuthenticatedUser(); + expect(() => + service.decryptAttributesSync( + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + encryptedAttributes, + { user: mockUser, convertToMultiNamespaceType: true } + ) + ).toThrowError(EncryptionError); + expect(mockNodeCrypto.decryptSync).toHaveBeenCalledTimes(2); + expect(mockNodeCrypto.decryptSync).toHaveBeenNthCalledWith( + 1, // first attempted to decrypt with the namespace in the descriptor (fail) + expect.anything(), + `["object-ns","known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + expect(mockNodeCrypto.decryptSync).toHaveBeenNthCalledWith( + 2, // then attempted to decrypt without the namespace in the descriptor (fail) + expect.anything(), + `["known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + + expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); + expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith( + 'attrThree', + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + mockUser + ); + }); + it('fails to decrypt if encrypted attribute is defined, but not a string', () => { expect(() => service.decryptAttributesSync( diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts index 23aef07ff8781f..17757c9d8b2ba3 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts @@ -61,6 +61,14 @@ interface DecryptParameters extends CommonParameters { * Indicates whether decryption should only be performed using secondary decryption-only keys. */ omitPrimaryEncryptionKey?: boolean; + /** + * Indicates whether the object to be decrypted is being converted from a single-namespace type to a multi-namespace type. In this case, + * we may need to attempt decryption twice: once with a namespace in the descriptor (for use during index migration), and again without a + * namespace in the descriptor (for use during object migration). In other words, if the object is being decrypted during index migration, + * the object was previously encrypted with its namespace in the descriptor portion of the AAD; on the other hand, if the object is being + * decrypted during object migration, the object was never encrypted with its namespace in the descriptor portion of the AAD. + */ + convertToMultiNamespaceType?: boolean; } interface EncryptedSavedObjectsServiceOptions { @@ -366,14 +374,17 @@ export class EncryptedSavedObjectsService { let iteratorResult = iterator.next(); while (!iteratorResult.done) { - const [attributeValue, encryptionAAD] = iteratorResult.value; + const [attributeValue, encryptionAADs] = iteratorResult.value; // We check this inside of the iterator to throw only if we do need to decrypt anything. let decryptionError = decrypters.length === 0 ? new Error('Decryption is disabled because of missing decryption keys.') : undefined; - for (const decrypter of decrypters) { + const decryptersPerAAD = decrypters.flatMap((decr) => + encryptionAADs.map((aad) => [decr, aad] as [Crypto, string]) + ); + for (const [decrypter, encryptionAAD] of decryptersPerAAD) { try { iteratorResult = iterator.next(await decrypter.decrypt(attributeValue, encryptionAAD)); decryptionError = undefined; @@ -414,14 +425,17 @@ export class EncryptedSavedObjectsService { let iteratorResult = iterator.next(); while (!iteratorResult.done) { - const [attributeValue, encryptionAAD] = iteratorResult.value; + const [attributeValue, encryptionAADs] = iteratorResult.value; // We check this inside of the iterator to throw only if we do need to decrypt anything. let decryptionError = decrypters.length === 0 ? new Error('Decryption is disabled because of missing decryption keys.') : undefined; - for (const decrypter of decrypters) { + const decryptersPerAAD = decrypters.flatMap((decr) => + encryptionAADs.map((aad) => [decr, aad] as [Crypto, string]) + ); + for (const [decrypter, encryptionAAD] of decryptersPerAAD) { try { iteratorResult = iterator.next(decrypter.decryptSync(attributeValue, encryptionAAD)); decryptionError = undefined; @@ -445,13 +459,13 @@ export class EncryptedSavedObjectsService { private *attributesToDecryptIterator>( descriptor: SavedObjectDescriptor, attributes: T, - params?: CommonParameters - ): Iterator<[string, string], T, EncryptOutput> { + params?: DecryptParameters + ): Iterator<[string, string[]], T, EncryptOutput> { const typeDefinition = this.typeDefinitions.get(descriptor.type); if (typeDefinition === undefined) { return attributes; } - let encryptionAAD: string | undefined; + const encryptionAADs: string[] = []; const decryptedAttributes: Record = {}; for (const attributeName of typeDefinition.attributesToEncrypt) { const attributeValue = attributes[attributeName]; @@ -467,11 +481,16 @@ export class EncryptedSavedObjectsService { )}` ); } - if (!encryptionAAD) { - encryptionAAD = this.getAAD(typeDefinition, descriptor, attributes); + if (!encryptionAADs.length) { + encryptionAADs.push(this.getAAD(typeDefinition, descriptor, attributes)); + if (params?.convertToMultiNamespaceType && descriptor.namespace) { + // This is happening during a migration; create an alternate AAD for decrypting the object attributes by stripping out the namespace from the descriptor. + const { namespace, ...alternateDescriptor } = descriptor; + encryptionAADs.push(this.getAAD(typeDefinition, alternateDescriptor, attributes)); + } } try { - decryptedAttributes[attributeName] = (yield [attributeValue, encryptionAAD])!; + decryptedAttributes[attributeName] = (yield [attributeValue, encryptionAADs])!; } catch (err) { this.options.logger.error( `Failed to decrypt "${attributeName}" attribute: ${err.message || err}` diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts index 627e15e591a81a..0f737995e8d2af 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts @@ -24,5 +24,5 @@ export const getDescriptorNamespace = ( * Ensure that a namespace is always in its namespace ID representation. * This allows `'default'` to be used interchangeably with `undefined`. */ -const normalizeNamespace = (namespace?: string) => +export const normalizeNamespace = (namespace?: string) => namespace === undefined ? namespace : SavedObjectsUtils.namespaceStringToId(namespace); diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts index cac7b9ba9d5cc4..9e7c1f65922907 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts @@ -17,7 +17,9 @@ import { import { SecurityPluginSetup } from '../../../security/server'; import { EncryptedSavedObjectsService } from '../crypto'; import { EncryptedSavedObjectsClientWrapper } from './encrypted_saved_objects_client_wrapper'; -import { getDescriptorNamespace } from './get_descriptor_namespace'; +import { getDescriptorNamespace, normalizeNamespace } from './get_descriptor_namespace'; + +export { normalizeNamespace }; interface SetupSavedObjectsParams { service: PublicMethodsOf; diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index 790c9a28b656c9..d13920b084183c 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -39,7 +39,6 @@ "dashboard", "savedObjects", "home", - "spaces", "maps" ], "extraPublicDirs": [ diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_list/index.ts b/x-pack/plugins/ml/public/application/components/job_spaces_list/index.ts index cac8f63b6e0496..8acec6a45a0c8c 100644 --- a/x-pack/plugins/ml/public/application/components/job_spaces_list/index.ts +++ b/x-pack/plugins/ml/public/application/components/job_spaces_list/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { JobSpacesList, ALL_SPACES_ID } from './job_spaces_list'; +export { JobSpacesList } from './job_spaces_list'; diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx index 2aa7c6bb4a6e38..6e0715de12fb9f 100644 --- a/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx +++ b/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx @@ -5,64 +5,87 @@ * 2.0. */ -import React, { FC, useState, useEffect } from 'react'; +import React, { FC, useState } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; -import { JobSpacesFlyout } from '../job_spaces_selector'; -import { JobType } from '../../../../common/types/saved_objects'; -import { useSpacesContext } from '../../contexts/spaces'; -import { Space, SpaceAvatar } from '../../../../../spaces/public'; - -export const ALL_SPACES_ID = '*'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import type { ShareToSpaceFlyoutProps } from 'src/plugins/spaces_oss/public'; +import { + JobType, + ML_SAVED_OBJECT_TYPE, + SavedObjectResult, +} from '../../../../common/types/saved_objects'; +import type { SpacesPluginStart } from '../../../../../spaces/public'; +import { ml } from '../../services/ml_api_service'; +import { useToastNotificationService } from '../../services/toast_notification_service'; interface Props { + spacesApi: SpacesPluginStart; spaceIds: string[]; jobId: string; jobType: JobType; refresh(): void; } -function filterUnknownSpaces(ids: string[]) { - return ids.filter((id) => id !== '?'); -} +const ALL_SPACES_ID = '*'; +const objectNoun = i18n.translate('xpack.ml.management.jobsSpacesList.objectNoun', { + defaultMessage: 'job', +}); -export const JobSpacesList: FC = ({ spaceIds, jobId, jobType, refresh }) => { - const { allSpaces } = useSpacesContext(); +export const JobSpacesList: FC = ({ spacesApi, spaceIds, jobId, jobType, refresh }) => { + const { displayErrorToast } = useToastNotificationService(); const [showFlyout, setShowFlyout] = useState(false); - const [spaces, setSpaces] = useState([]); - useEffect(() => { - const tempSpaces = spaceIds.includes(ALL_SPACES_ID) - ? [{ id: ALL_SPACES_ID, name: ALL_SPACES_ID, disabledFeatures: [], color: '#DDD' }] - : allSpaces.filter((s) => spaceIds.includes(s.id)); - setSpaces(tempSpaces); - }, [spaceIds, allSpaces]); + async function changeSpacesHandler(spacesToAdd: string[], spacesToRemove: string[]) { + if (spacesToAdd.length) { + const resp = await ml.savedObjects.assignJobToSpace(jobType, [jobId], spacesToAdd); + handleApplySpaces(resp); + } + if (spacesToRemove.length && !spacesToAdd.includes(ALL_SPACES_ID)) { + const resp = await ml.savedObjects.removeJobFromSpace(jobType, [jobId], spacesToRemove); + handleApplySpaces(resp); + } + onClose(); + } function onClose() { setShowFlyout(false); refresh(); } + function handleApplySpaces(resp: SavedObjectResult) { + Object.entries(resp).forEach(([id, { success, error }]) => { + if (success === false) { + const title = i18n.translate('xpack.ml.management.jobsSpacesList.updateSpaces.error', { + defaultMessage: 'Error updating {id}', + values: { id }, + }); + displayErrorToast(error, title); + } + }); + } + + const { SpaceList, ShareToSpaceFlyout } = spacesApi.ui.components; + const shareToSpaceFlyoutProps: ShareToSpaceFlyoutProps = { + savedObjectTarget: { + type: ML_SAVED_OBJECT_TYPE, + id: jobId, + namespaces: spaceIds, + title: jobId, + noun: objectNoun, + }, + behaviorContext: 'outside-space', + changeSpacesHandler, + onClose, + }; + return ( <> setShowFlyout(true)} style={{ height: 'auto' }}> - - {spaces.map((space) => ( - - - - ))} - + - {showFlyout && ( - - )} + {showFlyout && } ); }; diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_selector/cannot_edit_callout.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_selector/cannot_edit_callout.tsx deleted file mode 100644 index 94ed9ad0d30748..00000000000000 --- a/x-pack/plugins/ml/public/application/components/job_spaces_selector/cannot_edit_callout.tsx +++ /dev/null @@ -1,30 +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 } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiSpacer, EuiCallOut } from '@elastic/eui'; - -export const CannotEditCallout: FC<{ jobId: string }> = ({ jobId }) => ( - <> - - - - - -); diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_selector/jobs_spaces_flyout.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_selector/jobs_spaces_flyout.tsx deleted file mode 100644 index 12304cd133d8ef..00000000000000 --- a/x-pack/plugins/ml/public/application/components/job_spaces_selector/jobs_spaces_flyout.tsx +++ /dev/null @@ -1,132 +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, useEffect } from 'react'; -import { i18n } from '@kbn/i18n'; -import { difference, xor } from 'lodash'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiFlyout, - EuiFlyoutHeader, - EuiFlyoutFooter, - EuiFlexGroup, - EuiFlexItem, - EuiButton, - EuiButtonEmpty, - EuiTitle, - EuiFlyoutBody, -} from '@elastic/eui'; - -import { JobType, SavedObjectResult } from '../../../../common/types/saved_objects'; -import { ml } from '../../services/ml_api_service'; -import { useToastNotificationService } from '../../services/toast_notification_service'; - -import { SpacesSelector } from './spaces_selectors'; - -interface Props { - jobId: string; - jobType: JobType; - spaceIds: string[]; - onClose: () => void; -} -export const JobSpacesFlyout: FC = ({ jobId, jobType, spaceIds, onClose }) => { - const { displayErrorToast } = useToastNotificationService(); - - const [selectedSpaceIds, setSelectedSpaceIds] = useState(spaceIds); - const [saving, setSaving] = useState(false); - const [savable, setSavable] = useState(false); - const [canEditSpaces, setCanEditSpaces] = useState(false); - - useEffect(() => { - const different = xor(selectedSpaceIds, spaceIds).length !== 0; - setSavable(different === true && selectedSpaceIds.length > 0); - }, [selectedSpaceIds.length]); - - async function applySpaces() { - if (savable) { - setSaving(true); - const addedSpaces = difference(selectedSpaceIds, spaceIds); - const removedSpaces = difference(spaceIds, selectedSpaceIds); - if (addedSpaces.length) { - const resp = await ml.savedObjects.assignJobToSpace(jobType, [jobId], addedSpaces); - handleApplySpaces(resp); - } - if (removedSpaces.length) { - const resp = await ml.savedObjects.removeJobFromSpace(jobType, [jobId], removedSpaces); - handleApplySpaces(resp); - } - onClose(); - } - } - - function handleApplySpaces(resp: SavedObjectResult) { - Object.entries(resp).forEach(([id, { success, error }]) => { - if (success === false) { - const title = i18n.translate( - 'xpack.ml.management.spacesSelectorFlyout.updateSpaces.error', - { - defaultMessage: 'Error updating {id}', - values: { id }, - } - ); - displayErrorToast(error, title); - } - }); - } - - return ( - <> - - - -

- -

-
-
- - - - - - - - - - - - - - - - - -
- - ); -}; diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selector.scss b/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selector.scss deleted file mode 100644 index 75cdbd972455b0..00000000000000 --- a/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selector.scss +++ /dev/null @@ -1,3 +0,0 @@ -.mlCopyToSpace__spacesList { - margin-top: $euiSizeXS; -} diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selectors.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selectors.tsx deleted file mode 100644 index 281ac5028995b2..00000000000000 --- a/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selectors.tsx +++ /dev/null @@ -1,223 +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 './spaces_selector.scss'; -import React, { FC, useState, useEffect, useMemo } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiFormRow, - EuiSelectable, - EuiSelectableOption, - EuiIconTip, - EuiText, - EuiCheckableCard, - EuiFormFieldset, -} from '@elastic/eui'; - -import { SpaceAvatar } from '../../../../../spaces/public'; -import { useSpacesContext } from '../../contexts/spaces'; -import { ML_SAVED_OBJECT_TYPE } from '../../../../common/types/saved_objects'; -import { ALL_SPACES_ID } from '../job_spaces_list'; -import { CannotEditCallout } from './cannot_edit_callout'; - -type SpaceOption = EuiSelectableOption & { ['data-space-id']: string }; - -interface Props { - jobId: string; - spaceIds: string[]; - setSelectedSpaceIds: (ids: string[]) => void; - selectedSpaceIds: string[]; - canEditSpaces: boolean; - setCanEditSpaces: (canEditSpaces: boolean) => void; -} - -export const SpacesSelector: FC = ({ - jobId, - spaceIds, - setSelectedSpaceIds, - selectedSpaceIds, - canEditSpaces, - setCanEditSpaces, -}) => { - const { spacesManager, allSpaces } = useSpacesContext(); - - const [canShareToAllSpaces, setCanShareToAllSpaces] = useState(false); - - useEffect(() => { - if (spacesManager !== null) { - const getPermissions = spacesManager.getShareSavedObjectPermissions(ML_SAVED_OBJECT_TYPE); - Promise.all([getPermissions]).then(([{ shareToAllSpaces }]) => { - setCanShareToAllSpaces(shareToAllSpaces); - setCanEditSpaces(shareToAllSpaces || spaceIds.includes(ALL_SPACES_ID) === false); - }); - } - }, []); - - function toggleShareOption(isAllSpaces: boolean) { - const updatedSpaceIds = isAllSpaces - ? [ALL_SPACES_ID, ...selectedSpaceIds] - : selectedSpaceIds.filter((id) => id !== ALL_SPACES_ID); - setSelectedSpaceIds(updatedSpaceIds); - } - - function updateSelectedSpaces(selectedOptions: SpaceOption[]) { - const ids = selectedOptions.filter((opt) => opt.checked).map((opt) => opt['data-space-id']); - setSelectedSpaceIds(ids); - } - - const isGlobalControlChecked = useMemo(() => selectedSpaceIds.includes(ALL_SPACES_ID), [ - selectedSpaceIds, - ]); - - const options = useMemo( - () => - allSpaces.map((space) => { - return { - label: space.name, - prepend: , - checked: selectedSpaceIds.includes(space.id) ? 'on' : undefined, - disabled: canEditSpaces === false, - ['data-space-id']: space.id, - ['data-test-subj']: `mlSpaceSelectorRow_${space.id}`, - }; - }), - [allSpaces, selectedSpaceIds, canEditSpaces] - ); - - const shareToAllSpaces = useMemo( - () => ({ - id: 'shareToAllSpaces', - title: i18n.translate('xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.title', { - defaultMessage: 'All spaces', - }), - text: i18n.translate('xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.text', { - defaultMessage: 'Make job available in all current and future spaces.', - }), - ...(!canShareToAllSpaces && { - tooltip: isGlobalControlChecked - ? i18n.translate( - 'xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.cannotUncheckTooltip', - { defaultMessage: 'You need additional privileges to change this option.' } - ) - : i18n.translate( - 'xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.cannotCheckTooltip', - { defaultMessage: 'You need additional privileges to use this option.' } - ), - }), - disabled: !canShareToAllSpaces, - }), - [isGlobalControlChecked, canShareToAllSpaces] - ); - - const shareToExplicitSpaces = useMemo( - () => ({ - id: 'shareToExplicitSpaces', - title: i18n.translate( - 'xpack.ml.management.spacesSelectorFlyout.shareToExplicitSpaces.title', - { - defaultMessage: 'Select spaces', - } - ), - text: i18n.translate('xpack.ml.management.spacesSelectorFlyout.shareToExplicitSpaces.text', { - defaultMessage: 'Make job available in selected spaces only.', - }), - disabled: !canShareToAllSpaces && isGlobalControlChecked, - }), - [canShareToAllSpaces, isGlobalControlChecked] - ); - - return ( - <> - {canEditSpaces === false && } - - toggleShareOption(false)} - disabled={shareToExplicitSpaces.disabled} - > - - } - fullWidth - > - updateSelectedSpaces(newOptions as SpaceOption[])} - listProps={{ - bordered: true, - rowHeight: 40, - className: 'mlCopyToSpace__spacesList', - 'data-test-subj': 'mlFormSpaceSelector', - }} - searchable - > - {(list, search) => { - return ( - <> - {search} - {list} - - ); - }} - - - - - - - toggleShareOption(true)} - disabled={shareToAllSpaces.disabled} - /> - - - ); -}; - -function createLabel({ - title, - text, - disabled, - tooltip, -}: { - title: string; - text: string; - disabled: boolean; - tooltip?: string; -}) { - return ( - <> - - - {title} - - {tooltip && ( - - - - )} - - - - {text} - - - ); -} diff --git a/x-pack/plugins/ml/public/application/contexts/spaces/spaces_context.ts b/x-pack/plugins/ml/public/application/contexts/spaces/spaces_context.ts deleted file mode 100644 index dca7d0989d4de9..00000000000000 --- a/x-pack/plugins/ml/public/application/contexts/spaces/spaces_context.ts +++ /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 { createContext, useContext } from 'react'; -import { HttpSetup } from 'src/core/public'; -import { SpacesManager, Space } from '../../../../../spaces/public'; - -export interface SpacesContextValue { - spacesManager: SpacesManager | null; - allSpaces: Space[]; - spacesEnabled: boolean; -} - -export const SpacesContext = createContext>({}); - -export function createSpacesContext(http: HttpSetup, spacesEnabled: boolean) { - return { - spacesManager: spacesEnabled ? new SpacesManager(http) : null, - allSpaces: [], - spacesEnabled, - } as SpacesContextValue; -} - -export function useSpacesContext() { - const context = useContext(SpacesContext); - - if (context.spacesManager === undefined) { - throw new Error('required attribute is undefined'); - } - - return context as SpacesContextValue; -} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx index dc5b494d0e1812..8423e569a99f24 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx @@ -29,6 +29,7 @@ import { import { getAnalyticsFactory } from '../../services/analytics_service'; import { getTaskStateBadge, getJobTypeBadge, useColumns } from './use_columns'; import { ExpandedRow } from './expanded_row'; +import type { SpacesPluginStart } from '../../../../../../../../spaces/public'; import { AnalyticStatsBarStats, StatsBar } from '../../../../../components/stats_bar'; import { CreateAnalyticsButton } from '../create_analytics_button'; import { SourceSelection } from '../source_selection'; @@ -84,7 +85,7 @@ function getItemIdToExpandedRowMap( interface Props { isManagementTable?: boolean; isMlEnabledInSpace?: boolean; - spacesEnabled?: boolean; + spacesApi?: SpacesPluginStart; blockRefresh?: boolean; pageState: ListingPageUrlState; updatePageState: (update: Partial) => void; @@ -92,7 +93,7 @@ interface Props { export const DataFrameAnalyticsList: FC = ({ isManagementTable = false, isMlEnabledInSpace = true, - spacesEnabled = false, + spacesApi, blockRefresh = false, pageState, updatePageState, @@ -178,7 +179,7 @@ export const DataFrameAnalyticsList: FC = ({ setExpandedRowItemIds, isManagementTable, isMlEnabledInSpace, - spacesEnabled, + spacesApi, refresh ); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx index 7a0f00fd377bfc..cb0e2b0092c557 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx @@ -33,6 +33,7 @@ import { import { useActions } from './use_actions'; import { useMlLink } from '../../../../../contexts/kibana'; import { ML_PAGES } from '../../../../../../../common/constants/ml_url_generator'; +import type { SpacesPluginStart } from '../../../../../../../../spaces/public'; import { JobSpacesList } from '../../../../../components/job_spaces_list'; enum TASK_STATE_COLOR { @@ -150,7 +151,7 @@ export const useColumns = ( setExpandedRowItemIds: React.Dispatch>, isManagementTable: boolean = false, isMlEnabledInSpace: boolean = true, - spacesEnabled: boolean = true, + spacesApi?: SpacesPluginStart, refresh: () => void = () => {} ) => { const { actions, modals } = useActions(isManagementTable); @@ -281,7 +282,7 @@ export const useColumns = ( ]; if (isManagementTable === true) { - if (spacesEnabled === true) { + if (spacesApi) { // insert before last column columns.splice(columns.length - 1, 0, { name: i18n.translate('xpack.ml.jobsList.analyticsSpacesLabel', { @@ -290,6 +291,7 @@ export const useColumns = ( render: (item: DataFrameAnalyticsListRow) => Array.isArray(item.spaceIds) ? ( job.deleting !== true, selectableMessage: (selectable, rowItem) => @@ -243,7 +243,7 @@ export class JobsList extends Component { ]; if (isManagementTable === true) { - if (spacesEnabled === true) { + if (spacesApi) { // insert before last column columns.splice(columns.length - 1, 0, { name: i18n.translate('xpack.ml.jobsList.spacesLabel', { @@ -251,6 +251,7 @@ export class JobsList extends Component { }), render: (item) => ( {}; @@ -269,10 +268,10 @@ export class JobsListView extends Component { const expandedJobsIds = Object.keys(this.state.itemIdToExpandedRowMap); try { - let spaces = {}; - if (this.props.spacesEnabled && this.props.isManagementTable) { + let jobsSpaces = {}; + if (this.props.spacesApi && this.props.isManagementTable) { const allSpaces = await ml.savedObjects.jobsSpaces(); - spaces = allSpaces['anomaly-detector']; + jobsSpaces = allSpaces['anomaly-detector']; } let jobsAwaitingNodeCount = 0; @@ -285,11 +284,11 @@ export class JobsListView extends Component { } job.latestTimestampSortValue = job.latestTimestampMs || 0; job.spaceIds = - this.props.spacesEnabled && + this.props.spacesApi && this.props.isManagementTable && - spaces && - spaces[job.id] !== undefined - ? spaces[job.id] + jobsSpaces && + jobsSpaces[job.id] !== undefined + ? jobsSpaces[job.id] : []; if (job.awaitingNodeAssignment === true) { @@ -410,7 +409,7 @@ export class JobsListView extends Component { loading={loading} isManagementTable={true} isMlEnabledInSpace={this.props.isMlEnabledInSpace} - spacesEnabled={this.props.spacesEnabled} + spacesApi={this.props.spacesApi} jobsViewState={this.props.jobsViewState} onJobsViewStateUpdate={this.props.onJobsViewStateUpdate} refreshJobs={() => this.refreshJobSummaryList(true)} diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx index a322174e8a8c44..b61a28aff732a6 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx +++ b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx @@ -24,7 +24,6 @@ import { } from '@elastic/eui'; import { PLUGIN_ID } from '../../../../../../common/constants/app'; -import { createSpacesContext, SpacesContext } from '../../../../contexts/spaces'; import { ManagementAppMountParams } from '../../../../../../../../../src/plugins/management/public/'; import { checkGetManagementMlJobsResolver } from '../../../../capabilities/check_capabilities'; @@ -39,7 +38,7 @@ import { JobsListView } from '../../../../jobs/jobs_list/components/jobs_list_vi import { DataFrameAnalyticsList } from '../../../../data_frame_analytics/pages/analytics_management/components/analytics_list'; import { AccessDeniedPage } from '../access_denied_page'; import { SharePluginStart } from '../../../../../../../../../src/plugins/share/public'; -import { SpacesPluginStart } from '../../../../../../../spaces/public'; +import type { SpacesPluginStart } from '../../../../../../../spaces/public'; import { JobSpacesSyncFlyout } from '../../../../components/job_spaces_sync'; import { getDefaultAnomalyDetectionJobsListState } from '../../../../jobs/jobs_list/jobs'; import { getMlGlobalServices } from '../../../../app'; @@ -68,7 +67,9 @@ function usePageState( return [pageState, updateState]; } -function useTabs(isMlEnabledInSpace: boolean, spacesEnabled: boolean): Tab[] { +const EmptyFunctionComponent: React.FC = ({ children }) => <>{children}; + +function useTabs(isMlEnabledInSpace: boolean, spacesApi: SpacesPluginStart | undefined): Tab[] { const [adPageState, updateAdPageState] = usePageState(getDefaultAnomalyDetectionJobsListState()); const [dfaPageState, updateDfaPageState] = usePageState(getDefaultDFAListState()); @@ -88,7 +89,7 @@ function useTabs(isMlEnabledInSpace: boolean, spacesEnabled: boolean): Tab[] { onJobsViewStateUpdate={updateAdPageState} isManagementTable={true} isMlEnabledInSpace={isMlEnabledInSpace} - spacesEnabled={spacesEnabled} + spacesApi={spacesApi} /> ), @@ -105,7 +106,7 @@ function useTabs(isMlEnabledInSpace: boolean, spacesEnabled: boolean): Tab[] { @@ -121,28 +122,21 @@ export const JobsListPage: FC<{ coreStart: CoreStart; share: SharePluginStart; history: ManagementAppMountParams['history']; - spaces?: SpacesPluginStart; -}> = ({ coreStart, share, history, spaces }) => { - const spacesEnabled = spaces !== undefined; + spacesApi?: SpacesPluginStart; +}> = ({ coreStart, share, history, spacesApi }) => { + const spacesEnabled = spacesApi !== undefined; const [initialized, setInitialized] = useState(false); const [accessDenied, setAccessDenied] = useState(false); const [showSyncFlyout, setShowSyncFlyout] = useState(false); const [isMlEnabledInSpace, setIsMlEnabledInSpace] = useState(false); - const tabs = useTabs(isMlEnabledInSpace, spacesEnabled); + const tabs = useTabs(isMlEnabledInSpace, spacesApi); const [currentTabId, setCurrentTabId] = useState(tabs[0].id); const I18nContext = coreStart.i18n.Context; - const spacesContext = useMemo(() => createSpacesContext(coreStart.http, spacesEnabled), []); const check = async () => { try { const { mlFeatureEnabledInSpace } = await checkGetManagementMlJobsResolver(); setIsMlEnabledInSpace(mlFeatureEnabledInSpace); - spacesContext.spacesEnabled = spacesEnabled; - if (spacesEnabled && spacesContext.spacesManager !== null) { - spacesContext.allSpaces = (await spacesContext.spacesManager.getSpaces()).filter( - (space) => space.disabledFeatures.includes(PLUGIN_ID) === false - ); - } } catch (e) { setAccessDenied(true); } @@ -191,13 +185,15 @@ export const JobsListPage: FC<{ return ; } + const ContextWrapper = spacesApi?.ui.components.SpacesContext || EmptyFunctionComponent; + return ( - + - + diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/index.ts b/x-pack/plugins/ml/public/application/management/jobs_list/index.ts index 4059207aafcc36..dde543ac6ac9cb 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/index.ts +++ b/x-pack/plugins/ml/public/application/management/jobs_list/index.ts @@ -22,10 +22,10 @@ const renderApp = ( history: ManagementAppMountParams['history'], coreStart: CoreStart, share: SharePluginStart, - spaces?: SpacesPluginStart + spacesApi?: SpacesPluginStart ) => { ReactDOM.render( - React.createElement(JobsListPage, { coreStart, history, share, spaces }), + React.createElement(JobsListPage, { coreStart, history, share, spacesApi }), element ); return () => { diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.test.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.test.tsx index d89997042a3d89..c880e3144fac04 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.test.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.test.tsx @@ -20,7 +20,7 @@ import { ProcessingCopyToSpace } from './processing_copy_to_space'; import { spacesManagerMock } from '../../spaces_manager/mocks'; import { SpacesManager } from '../../spaces_manager'; import { ToastsApi } from 'src/core/public'; -import { SavedObjectsManagementRecord } from 'src/plugins/saved_objects_management/public'; +import { SavedObjectTarget } from '../types'; interface SetupOpts { mockSpaces?: Space[]; @@ -70,19 +70,14 @@ const setup = async (opts: SetupOpts = {}) => { const savedObjectToCopy = { type: 'dashboard', id: 'my-dash', - references: [ - { - type: 'visualization', - id: 'my-viz', - name: 'My Viz', - }, - ], - meta: { icon: 'dashboard', title: 'foo', namespaceType: 'single' }, - } as SavedObjectsManagementRecord; + namespaces: ['default'], + icon: 'dashboard', + title: 'foo', + } as SavedObjectTarget; const wrapper = mountWithIntl( { }; describe('CopyToSpaceFlyout', () => { - beforeAll(() => { - jest.useFakeTimers(); - }); - it('waits for spaces to load', async () => { const { wrapper } = await setup({ returnBeforeSpacesLoad: true }); diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.tsx index abf7f7fe40e8de..c86a7c92993a26 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { EuiFlyout, EuiIcon, @@ -27,18 +27,17 @@ import { ToastsStart } from 'src/core/public'; import { ProcessedImportResponse, processImportResponse, - SavedObjectsManagementRecord, } from '../../../../../../src/plugins/saved_objects_management/public'; import { Space } from '../../../../../../src/plugins/spaces_oss/common'; import { SpacesManager } from '../../spaces_manager'; import { ProcessingCopyToSpace } from './processing_copy_to_space'; import { CopyToSpaceFlyoutFooter } from './copy_to_space_flyout_footer'; import { CopyToSpaceForm } from './copy_to_space_form'; -import { CopyOptions, ImportRetry } from '../types'; +import { CopyOptions, ImportRetry, SavedObjectTarget } from '../types'; interface Props { onClose: () => void; - savedObject: SavedObjectsManagementRecord; + savedObjectTarget: SavedObjectTarget; spacesManager: SpacesManager; toastNotifications: ToastsStart; } @@ -48,7 +47,17 @@ const CREATE_NEW_COPIES_DEFAULT = true; const OVERWRITE_ALL_DEFAULT = true; export const CopySavedObjectsToSpaceFlyout = (props: Props) => { - const { onClose, savedObject, spacesManager, toastNotifications } = props; + const { onClose, savedObjectTarget: object, spacesManager, toastNotifications } = props; + const savedObjectTarget = useMemo( + () => ({ + type: object.type, + id: object.id, + namespaces: object.namespaces, + icon: object.icon || 'apps', + title: object.title || `${object.type} [id=${object.id}]`, + }), + [object] + ); const [copyOptions, setCopyOptions] = useState({ includeRelated: INCLUDE_RELATED_DEFAULT, createNewCopies: CREATE_NEW_COPIES_DEFAULT, @@ -100,7 +109,7 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => { setCopyResult({}); try { const copySavedObjectsResult = await spacesManager.copySavedObjects( - [{ type: savedObject.type, id: savedObject.id }], + [{ type: savedObjectTarget.type, id: savedObjectTarget.id }], copyOptions.selectedSpaceIds, copyOptions.includeRelated, copyOptions.createNewCopies, @@ -160,7 +169,7 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => { setConflictResolutionInProgress(true); try { await spacesManager.resolveCopySavedObjectsErrors( - [{ type: savedObject.type, id: savedObject.id }], + [{ type: savedObjectTarget.type, id: savedObjectTarget.id }], retries, copyOptions.includeRelated, copyOptions.createNewCopies @@ -220,7 +229,7 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => { if (!copyInProgress) { return ( { // Step3: Copy operation is in progress return ( { - + -

{savedObject.meta.title}

+

{savedObjectTarget.title}

diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.tsx index 5bf171874d5a89..6c0ab695d94d8f 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.tsx @@ -8,27 +8,26 @@ import React from 'react'; import { EuiSpacer, EuiTitle, EuiFormRow } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { CopyOptions } from '../types'; -import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; +import { CopyOptions, SavedObjectTarget } from '../types'; import { Space } from '../../../../../../src/plugins/spaces_oss/common'; import { SelectableSpacesControl } from './selectable_spaces_control'; import { CopyModeControl, CopyMode } from './copy_mode_control'; interface Props { - savedObject: SavedObjectsManagementRecord; + savedObjectTarget: Required; spaces: Space[]; onUpdate: (copyOptions: CopyOptions) => void; copyOptions: CopyOptions; } export const CopyToSpaceForm = (props: Props) => { - const { savedObject, spaces, onUpdate, copyOptions } = props; + const { savedObjectTarget, spaces, onUpdate, copyOptions } = props; // if the user is not creating new copies, prevent them from copying objects an object into a space where it already exists const getDisabledSpaceIds = (createNewCopies: boolean) => createNewCopies ? new Set() - : (savedObject.namespaces ?? []).reduce((acc, cur) => acc.add(cur), new Set()); + : savedObjectTarget.namespaces.reduce((acc, cur) => acc.add(cur), new Set()); const changeCopyMode = ({ createNewCopies, overwrite }: CopyMode) => { const disabled = getDisabledSpaceIds(createNewCopies); diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/processing_copy_to_space.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/processing_copy_to_space.tsx index b30e996dbd0c1d..08c72b595a61d4 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/processing_copy_to_space.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/processing_copy_to_space.tsx @@ -14,17 +14,14 @@ import { EuiHorizontalRule, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - ProcessedImportResponse, - SavedObjectsManagementRecord, -} from 'src/plugins/saved_objects_management/public'; +import { ProcessedImportResponse } from 'src/plugins/saved_objects_management/public'; import { Space } from '../../../../../../src/plugins/spaces_oss/common'; -import { CopyOptions, ImportRetry } from '../types'; +import { CopyOptions, ImportRetry, SavedObjectTarget } from '../types'; import { SpaceResult, SpaceResultProcessing } from './space_result'; import { summarizeCopyResult } from '..'; interface Props { - savedObject: SavedObjectsManagementRecord; + savedObjectTarget: Required; copyInProgress: boolean; conflictResolutionInProgress: boolean; copyResult: Record; @@ -98,7 +95,10 @@ export const ProcessingCopyToSpace = (props: Props) => { {props.copyOptions.selectedSpaceIds.map((id) => { const space = props.spaces.find((s) => s.id === id) as Space; const spaceCopyResult = props.copyResult[space.id]; - const summarizedSpaceCopyResult = summarizeCopyResult(props.savedObject, spaceCopyResult); + const summarizedSpaceCopyResult = summarizeCopyResult( + props.savedObjectTarget, + spaceCopyResult + ); return ( @@ -106,7 +106,6 @@ export const ProcessingCopyToSpace = (props: Props) => { ) : ( { summarizedCopyResult, retries, onRetriesChange, - savedObject, conflictResolutionInProgress, } = props; const { objects } = summarizedCopyResult; @@ -109,7 +106,6 @@ export const SpaceResult = (props: Props) => { > diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.test.ts b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.test.ts index 346bafceabf662..525efc4158f728 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.test.ts +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.test.ts @@ -11,6 +11,7 @@ import { FailedImport, SavedObjectsManagementRecord, } from 'src/plugins/saved_objects_management/public'; +import { SavedObjectTarget } from './types'; // Sample data references: // @@ -21,6 +22,13 @@ import { // Dashboard has references to visualizations, and transitive references to index patterns const OBJECTS = { + COPY_TARGET: { + type: 'dashboard', + id: 'foo', + namespaces: [], + icon: 'dashboardApp', + title: 'my-dashboard-title', + } as Required, MY_DASHBOARD: { type: 'dashboard', id: 'foo', @@ -132,7 +140,7 @@ const createCopyResult = ( describe('summarizeCopyResult', () => { it('indicates the result is processing when not provided', () => { const copyResult = undefined; - const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); + const summarizedResult = summarizeCopyResult(OBJECTS.COPY_TARGET, copyResult); expect(summarizedResult).toMatchInlineSnapshot(` Object { @@ -155,7 +163,7 @@ describe('summarizeCopyResult', () => { it('processes failedImports to extract conflicts, including transitive conflicts', () => { const copyResult = createCopyResult({ withConflicts: true }); - const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); + const summarizedResult = summarizeCopyResult(OBJECTS.COPY_TARGET, copyResult); expect(summarizedResult).toMatchInlineSnapshot(` Object { @@ -235,7 +243,7 @@ describe('summarizeCopyResult', () => { it('processes failedImports to extract missing references errors', () => { const copyResult = createCopyResult({ withMissingReferencesError: true }); - const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); + const summarizedResult = summarizeCopyResult(OBJECTS.COPY_TARGET, copyResult); expect(summarizedResult).toMatchInlineSnapshot(` Object { @@ -292,7 +300,7 @@ describe('summarizeCopyResult', () => { it('processes failedImports to extract unresolvable errors', () => { const copyResult = createCopyResult({ withUnresolvableError: true }); - const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); + const summarizedResult = summarizeCopyResult(OBJECTS.COPY_TARGET, copyResult); expect(summarizedResult).toMatchInlineSnapshot(` Object { @@ -359,7 +367,7 @@ describe('summarizeCopyResult', () => { it('processes a result without errors', () => { const copyResult = createCopyResult(); - const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); + const summarizedResult = summarizeCopyResult(OBJECTS.COPY_TARGET, copyResult); expect(summarizedResult).toMatchInlineSnapshot(` Object { @@ -426,7 +434,7 @@ describe('summarizeCopyResult', () => { it('indicates when successes and failures have been overwritten', () => { const copyResult = createCopyResult({ withMissingReferencesError: true, overwrite: true }); - const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); + const summarizedResult = summarizeCopyResult(OBJECTS.COPY_TARGET, copyResult); expect(summarizedResult.objects).toHaveLength(4); for (const obj of summarizedResult.objects) { diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.ts b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.ts index 1e5282436a4913..0986f5723a6dee 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.ts +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.ts @@ -5,15 +5,12 @@ * 2.0. */ -import { - SavedObjectsManagementRecord, - ProcessedImportResponse, - FailedImport, -} from 'src/plugins/saved_objects_management/public'; +import { ProcessedImportResponse, FailedImport } from 'src/plugins/saved_objects_management/public'; import { SavedObjectsImportConflictError, SavedObjectsImportAmbiguousConflictError, } from 'kibana/public'; +import { SavedObjectTarget } from './types'; export interface SummarizedSavedObjectResult { type: string; @@ -67,7 +64,7 @@ export type SummarizedCopyToSpaceResult = | ProcessingResponse; export function summarizeCopyResult( - savedObject: SavedObjectsManagementRecord, + savedObjectTarget: Required, copyResult: ProcessedImportResponse | undefined ): SummarizedCopyToSpaceResult { const conflicts = copyResult?.failedImports.filter(isAnyConflict) ?? []; @@ -95,12 +92,12 @@ export function summarizeCopyResult( }; const objectMap = new Map(); - objectMap.set(`${savedObject.type}:${savedObject.id}`, { - type: savedObject.type, - id: savedObject.id, - name: savedObject.meta.title, - icon: savedObject.meta.icon, - ...getExtraFields(savedObject), + objectMap.set(`${savedObjectTarget.type}:${savedObjectTarget.id}`, { + type: savedObjectTarget.type, + id: savedObjectTarget.id, + name: savedObjectTarget.title, + icon: savedObjectTarget.icon, + ...getExtraFields(savedObjectTarget), }); const addObjectsToMap = ( diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/types.ts b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/types.ts index 1e3293df8f2581..676b8ee4607518 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/types.ts +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/types.ts @@ -19,3 +19,30 @@ export type ImportRetry = Omit; export interface CopySavedObjectsToSpaceResponse { [spaceId: string]: SavedObjectsImportResponse; } + +export interface SavedObjectTarget { + /** + * The object's type. + */ + type: string; + /** + * The object's ID. + */ + id: string; + /** + * The namespaces that the object currently exists in. + */ + namespaces: string[]; + /** + * The EUI icon that is rendered in the flyout's subtitle. + * + * Default is 'apps'. + */ + icon?: string; + /** + * The string that is rendered in the flyout's subtitle. + * + * Default is `${type} [id=${id}]`. + */ + title?: string; +} diff --git a/x-pack/plugins/spaces/public/index.ts b/x-pack/plugins/spaces/public/index.ts index a87b953f08c62e..3620ae757052da 100644 --- a/x-pack/plugins/spaces/public/index.ts +++ b/x-pack/plugins/spaces/public/index.ts @@ -11,9 +11,7 @@ export { SpaceAvatar, getSpaceColor, getSpaceImageUrl, getSpaceInitials } from ' export { SpacesPluginSetup, SpacesPluginStart } from './plugin'; -export { SpacesManager } from './spaces_manager'; - -export { GetAllSpacesOptions, GetAllSpacesPurpose, GetSpaceResult } from '../common'; +export type { GetAllSpacesPurpose, GetSpaceResult } from '../common'; // re-export types from oss definition export type { Space } from '../../../../src/plugins/spaces_oss/common'; diff --git a/x-pack/plugins/spaces/public/plugin.tsx b/x-pack/plugins/spaces/public/plugin.tsx index 151157180ae491..2d02d4a3b98d81 100644 --- a/x-pack/plugins/spaces/public/plugin.tsx +++ b/x-pack/plugins/spaces/public/plugin.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { CoreSetup, CoreStart, Plugin, StartServicesAccessor } from 'src/core/public'; +import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; import { SpacesOssPluginSetup, SpacesApi } from 'src/plugins/spaces_oss/public'; import { HomePublicPluginSetup } from 'src/plugins/home/public'; import { SavedObjectsManagementPluginSetup } from 'src/plugins/saved_objects_management/public'; @@ -20,6 +20,7 @@ import { ShareSavedObjectsToSpaceService } from './share_saved_objects_to_space' import { AdvancedSettingsService } from './advanced_settings'; import { ManagementService } from './management'; import { spaceSelectorApp } from './space_selector'; +import { getUiApi } from './ui_api'; export interface PluginsSetup { spacesOss: SpacesOssPluginSetup; @@ -39,11 +40,20 @@ export type SpacesPluginStart = ReturnType; export class SpacesPlugin implements Plugin { private spacesManager!: SpacesManager; + private spacesApi!: SpacesApi; private managementService?: ManagementService; - public setup(core: CoreSetup<{}, SpacesPluginStart>, plugins: PluginsSetup) { + public setup(core: CoreSetup, plugins: PluginsSetup) { this.spacesManager = new SpacesManager(core.http); + this.spacesApi = { + ui: getUiApi({ + spacesManager: this.spacesManager, + getStartServices: core.getStartServices, + }), + activeSpace$: this.spacesManager.onActiveSpaceChange$, + getActiveSpace: () => this.spacesManager.getActiveSpace(), + }; if (plugins.home) { plugins.home.featureCatalogue.register(createSpacesFeatureCatalogueEntry()); @@ -53,7 +63,7 @@ export class SpacesPlugin implements Plugin, + getStartServices: core.getStartServices, spacesManager: this.spacesManager, }); } @@ -69,10 +79,8 @@ export class SpacesPlugin implements Plugin, + spacesApiUi: this.spacesApi.ui, }); const copySavedObjectsToSpaceService = new CopySavedObjectsToSpaceService(); copySavedObjectsToSpaceService.setup({ @@ -88,7 +96,7 @@ export class SpacesPlugin implements Plugin this.spacesManager.getActiveSpace(), - }; - } } diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/constants.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/constants.ts new file mode 100644 index 00000000000000..ef3248e1cd60a8 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/constants.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 { i18n } from '@kbn/i18n'; + +export const DEFAULT_OBJECT_NOUN = i18n.translate('xpack.spaces.shareToSpace.objectNoun', { + defaultMessage: 'object', +}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/context_wrapper.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/context_wrapper.tsx deleted file mode 100644 index 17132d291a612a..00000000000000 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/context_wrapper.tsx +++ /dev/null @@ -1,40 +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, useEffect, PropsWithChildren } from 'react'; -import { StartServicesAccessor, CoreStart } from 'src/core/public'; -import { createKibanaReactContext } from '../../../../../../src/plugins/kibana_react/public'; -import { PluginsStart } from '../../plugin'; - -interface Props { - getStartServices: StartServicesAccessor; -} - -export const ContextWrapper = (props: PropsWithChildren) => { - const { getStartServices, children } = props; - - const [coreStart, setCoreStart] = useState(); - - useEffect(() => { - getStartServices().then((startServices) => { - const [coreStartValue] = startServices; - setCoreStart(coreStartValue); - }); - }, [getStartServices]); - - if (!coreStart) { - return null; - } - - const { application, docLinks } = coreStart; - const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ - application, - docLinks, - }); - - return {children}; -}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts index 1fca0980e9d8bc..b133be833d505e 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts @@ -5,5 +5,6 @@ * 2.0. */ -export { ContextWrapper } from './context_wrapper'; -export { ShareSavedObjectsToSpaceFlyout } from './share_to_space_flyout'; +export { ShareToSpaceFlyoutInternal } from './share_to_space_flyout_internal'; +export { getShareToSpaceFlyoutComponent } from './share_to_space_flyout'; +export { getLegacyUrlConflict } from './legacy_url_conflict'; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict.tsx new file mode 100644 index 00000000000000..b9a01d4deabb57 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict.tsx @@ -0,0 +1,18 @@ +/* + * 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 { LegacyUrlConflictProps } from 'src/plugins/spaces_oss/public'; +import { LegacyUrlConflictInternal, InternalProps } from './legacy_url_conflict_internal'; + +export const getLegacyUrlConflict = ( + internalProps: InternalProps +): React.FC => { + return (props: LegacyUrlConflictProps) => { + return ; + }; +}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.test.tsx new file mode 100644 index 00000000000000..1b897e8afa7d2f --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.test.tsx @@ -0,0 +1,68 @@ +/* + * 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 { BehaviorSubject } from 'rxjs'; +import { EuiCallOut } from '@elastic/eui'; +import { mountWithIntl, findTestSubject } from '@kbn/test/jest'; +import { act } from '@testing-library/react'; +import { coreMock } from '../../../../../../src/core/public/mocks'; +import { LegacyUrlConflictInternal } from './legacy_url_conflict_internal'; + +const APP_ID = 'testAppId'; +const PATH = 'path'; + +describe('LegacyUrlConflict', () => { + const setup = async () => { + const { getStartServices } = coreMock.createSetup(); + const startServices = coreMock.createStart(); + const subject = new BehaviorSubject(`not-${APP_ID}`); + subject.next(APP_ID); // test below asserts that the consumer received the most recent APP_ID + startServices.application.currentAppId$ = subject; + const application = startServices.application; + getStartServices.mockResolvedValue([startServices, , ,]); + + const wrapper = mountWithIntl( + + ); + + // wait for wrapper to rerender + await act(async () => {}); + wrapper.update(); + + return { wrapper, application }; + }; + + it('can click the "Go to other object" button', async () => { + const { wrapper, application } = await setup(); + + expect(application.navigateToApp).not.toHaveBeenCalled(); + + const goToOtherButton = findTestSubject(wrapper, 'legacy-url-conflict-go-to-other-button'); + goToOtherButton.simulate('click'); + + expect(application.navigateToApp).toHaveBeenCalledTimes(1); + expect(application.navigateToApp).toHaveBeenCalledWith(APP_ID, { path: PATH }); + }); + + it('can click the "Dismiss" button', async () => { + const { wrapper } = await setup(); + + expect(wrapper.find(EuiCallOut)).toHaveLength(1); // callout is visible + + const dismissButton = findTestSubject(wrapper, 'legacy-url-conflict-dismiss-button'); + dismissButton.simulate('click'); + wrapper.update(); + + expect(wrapper.find(EuiCallOut)).toHaveLength(0); // callout is not visible + }); +}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.tsx new file mode 100644 index 00000000000000..1157725c69ee2d --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { firstValueFrom } from '@kbn/std'; +import React, { useState, useEffect } from 'react'; +import type { ApplicationStart, StartServicesAccessor } from 'src/core/public'; +import type { LegacyUrlConflictProps } from 'src/plugins/spaces_oss/public'; +import type { PluginsStart } from '../../plugin'; +import { DEFAULT_OBJECT_NOUN } from './constants'; + +export interface InternalProps { + getStartServices: StartServicesAccessor; +} + +export const LegacyUrlConflictInternal = (props: InternalProps & LegacyUrlConflictProps) => { + const { + getStartServices, + objectNoun = DEFAULT_OBJECT_NOUN, + currentObjectId, + otherObjectId, + otherObjectPath, + } = props; + + const [applicationStart, setApplicationStart] = useState(); + const [isDismissed, setIsDismissed] = useState(false); + const [appId, setAppId] = useState(); + + useEffect(() => { + async function setup() { + const [{ application }] = await getStartServices(); + const appIdValue = await firstValueFrom(application.currentAppId$); // retrieve the most recent value from the BehaviorSubject + setApplicationStart(application); + setAppId(appIdValue); + } + setup(); + }, [getStartServices]); + + if (!applicationStart || !appId || isDismissed) { + return null; + } + + function clickLinkButton() { + applicationStart!.navigateToApp(appId!, { path: otherObjectPath }); + } + + function clickDismissButton() { + setIsDismissed(true); + } + + return ( + + } + > + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx index 678464bcf4d649..46610a2cc9a7c5 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx @@ -26,7 +26,7 @@ export const NoSpacesAvailable = (props: Props) => { { href={getUrlForApp('management', { path: 'kibana/spaces/create' })} > diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx index 707f60d5979a1a..1b5870b8b540dc 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx @@ -22,58 +22,82 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { NoSpacesAvailable } from './no_spaces_available'; -import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../../common/constants'; import { DocumentationLinksService } from '../../lib'; import { SpaceAvatar } from '../../space_avatar'; -import { SpaceTarget } from '../types'; +import { ShareToSpaceTarget } from '../../types'; +import { useSpaces } from '../../spaces_context'; +import { ShareOptions } from '../types'; interface Props { - spaces: SpaceTarget[]; - selectedSpaceIds: string[]; + spaces: ShareToSpaceTarget[]; + shareOptions: ShareOptions; onChange: (selectedSpaceIds: string[]) => void; + enableCreateNewSpaceLink: boolean; + enableSpaceAgnosticBehavior: boolean; } type SpaceOption = EuiSelectableOption & { ['data-space-id']: string }; const ROW_HEIGHT = 40; -const partiallyAuthorizedTooltip = { - checked: i18n.translate( - 'xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.checked', - { defaultMessage: 'You need additional privileges to deselect this space.' } - ), - unchecked: i18n.translate( - 'xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.unchecked', - { defaultMessage: 'You need additional privileges to select this space.' } - ), -}; -const partiallyAuthorizedSpaceProps = (checked: boolean) => ({ - append: ( - - ), - disabled: true, -}); -const activeSpaceProps = { - append: Current, - disabled: true, - checked: 'on' as 'on', -}; +const APPEND_ACTIVE_SPACE = ( + + {i18n.translate('xpack.spaces.shareToSpace.currentSpaceBadge', { defaultMessage: 'Current' })} + +); +const APPEND_CANNOT_SELECT = ( + +); +const APPEND_CANNOT_DESELECT = ( + +); +const APPEND_FEATURE_IS_DISABLED = ( + +); export const SelectableSpacesControl = (props: Props) => { - const { spaces, selectedSpaceIds, onChange } = props; - const { services } = useKibana(); + const { + spaces, + shareOptions, + onChange, + enableCreateNewSpaceLink, + enableSpaceAgnosticBehavior, + } = props; + const { services } = useSpaces(); const { application, docLinks } = services; + const { selectedSpaceIds, initiallySelectedSpaceIds } = shareOptions; - const activeSpaceId = spaces.find((space) => space.isActiveSpace)!.id; + const activeSpaceId = + !enableSpaceAgnosticBehavior && spaces.find((space) => space.isActiveSpace)!.id; const isGlobalControlChecked = selectedSpaceIds.includes(ALL_SPACES_ID); const options = spaces - .sort((a, b) => (a.isActiveSpace ? -1 : b.isActiveSpace ? 1 : 0)) + .filter( + // filter out spaces that are not already selected and have the feature disabled in that space + ({ id, isFeatureDisabled }) => !isFeatureDisabled || initiallySelectedSpaceIds.includes(id) + ) + .sort(createSpacesComparator(activeSpaceId)) .map((space) => { const checked = selectedSpaceIds.includes(space.id); + const additionalProps = getAdditionalProps(space, activeSpaceId, checked); return { label: space.name, prepend: , @@ -81,8 +105,7 @@ export const SelectableSpacesControl = (props: Props) => { ['data-space-id']: space.id, ['data-test-subj']: `sts-space-selector-row-${space.id}`, ...(isGlobalControlChecked && { disabled: true }), - ...(space.isPartiallyAuthorized && partiallyAuthorizedSpaceProps(checked)), - ...(space.isActiveSpace && activeSpaceProps), + ...additionalProps, }; }); @@ -112,13 +135,13 @@ export const SelectableSpacesControl = (props: Props) => { + @@ -130,25 +153,28 @@ export const SelectableSpacesControl = (props: Props) => { ); }; const getNoSpacesAvailable = () => { - if (spaces.length < 2) { + if (enableCreateNewSpaceLink && spaces.length < 2) { return ; } return null; }; + // if space-agnostic behavior is not enabled, the active space is not selected or deselected by the user, so we have to artificially pad the count for this label + const selectedCountPad = enableSpaceAgnosticBehavior ? 0 : 1; const selectedCount = - selectedSpaceIds.filter((id) => id !== ALL_SPACES_ID && id !== UNKNOWN_SPACE).length + 1; + selectedSpaceIds.filter((id) => id !== ALL_SPACES_ID && id !== UNKNOWN_SPACE).length + + selectedCountPad; const hiddenCount = selectedSpaceIds.filter((id) => id === UNKNOWN_SPACE).length; const selectSpacesLabel = i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.selectSpacesLabel', + 'xpack.spaces.shareToSpace.shareModeControl.selectSpacesLabel', { defaultMessage: 'Select spaces' } ); const selectedSpacesLabel = i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.selectedCountLabel', + 'xpack.spaces.shareToSpace.shareModeControl.selectedCountLabel', { defaultMessage: '{selectedCount} selected', values: { selectedCount } } ); const hiddenSpacesLabel = i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.hiddenCountLabel', + 'xpack.spaces.shareToSpace.shareModeControl.hiddenCountLabel', { defaultMessage: '+{hiddenCount} hidden', values: { hiddenCount } } ); const hiddenSpaces = hiddenCount ? {hiddenSpacesLabel} : null; @@ -193,3 +219,55 @@ export const SelectableSpacesControl = (props: Props) => { ); }; + +/** + * Gets additional props for the selection option. + */ +function getAdditionalProps( + space: ShareToSpaceTarget, + activeSpaceId: string | false, + checked: boolean +) { + if (space.id === activeSpaceId) { + return { + append: APPEND_ACTIVE_SPACE, + disabled: true, + checked: 'on' as 'on', + }; + } + if (space.cannotShareToSpace) { + return { + append: ( + <> + {checked ? APPEND_CANNOT_DESELECT : APPEND_CANNOT_SELECT} + {space.isFeatureDisabled ? APPEND_FEATURE_IS_DISABLED : null} + + ), + disabled: true, + }; + } + if (space.isFeatureDisabled) { + return { + append: APPEND_FEATURE_IS_DISABLED, + }; + } +} + +/** + * Given the active space, create a comparator to sort a ShareToSpaceTarget array so that the active space is at the beginning, and space(s) for + * which the current feature is disabled are all at the end. + */ +function createSpacesComparator(activeSpaceId: string | false) { + return (a: ShareToSpaceTarget, b: ShareToSpaceTarget) => { + if (a.id === activeSpaceId) { + return -1; + } + if (b.id === activeSpaceId) { + return 1; + } + if (a.isFeatureDisabled !== b.isFeatureDisabled) { + return a.isFeatureDisabled ? 1 : -1; + } + return 0; + }; +} diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx index 1f71434de577d4..23b2dc02ec3cc2 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx @@ -8,27 +8,33 @@ import './share_mode_control.scss'; import React from 'react'; import { + EuiCallOut, EuiCheckableCard, EuiFlexGroup, EuiFlexItem, - EuiFormFieldset, EuiIconTip, + EuiLink, EuiLoadingSpinner, EuiSpacer, EuiText, - EuiTitle, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import { SelectableSpacesControl } from './selectable_spaces_control'; import { ALL_SPACES_ID } from '../../../common/constants'; -import { SpaceTarget } from '../types'; +import { DocumentationLinksService } from '../../lib'; +import { useSpaces } from '../../spaces_context'; +import { ShareToSpaceTarget } from '../../types'; +import { ShareOptions } from '../types'; interface Props { - spaces: SpaceTarget[]; + spaces: ShareToSpaceTarget[]; + objectNoun: string; canShareToAllSpaces: boolean; - selectedSpaceIds: string[]; + shareOptions: ShareOptions; onChange: (selectedSpaceIds: string[]) => void; - disabled?: boolean; + enableCreateNewSpaceLink: boolean; + enableSpaceAgnosticBehavior: boolean; } function createLabel({ @@ -63,31 +69,41 @@ function createLabel({ } export const ShareModeControl = (props: Props) => { - const { spaces, canShareToAllSpaces, selectedSpaceIds, onChange } = props; + const { + spaces, + objectNoun, + canShareToAllSpaces, + shareOptions, + onChange, + enableCreateNewSpaceLink, + enableSpaceAgnosticBehavior, + } = props; + const { services } = useSpaces(); + const { docLinks } = services; if (spaces.length === 0) { return ; } + const { selectedSpaceIds } = shareOptions; const isGlobalControlChecked = selectedSpaceIds.includes(ALL_SPACES_ID); const shareToAllSpaces = { id: 'shareToAllSpaces', - title: i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.title', - { defaultMessage: 'All spaces' } - ), - text: i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.text', - { defaultMessage: 'Make object available in all current and future spaces.' } - ), + title: i18n.translate('xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.title', { + defaultMessage: 'All spaces', + }), + text: i18n.translate('xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.text', { + defaultMessage: 'Make {objectNoun} available in all current and future spaces.', + values: { objectNoun }, + }), ...(!canShareToAllSpaces && { tooltip: isGlobalControlChecked ? i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotUncheckTooltip', + 'xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.cannotUncheckTooltip', { defaultMessage: 'You need additional privileges to change this option.' } ) : i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotCheckTooltip', + 'xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.cannotCheckTooltip', { defaultMessage: 'You need additional privileges to use this option.' } ), }), @@ -96,19 +112,15 @@ export const ShareModeControl = (props: Props) => { const shareToExplicitSpaces = { id: 'shareToExplicitSpaces', title: i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.title', + 'xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.title', { defaultMessage: 'Select spaces' } ), - text: i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.text', - { defaultMessage: 'Make object available in selected spaces only.' } - ), + text: i18n.translate('xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.text', { + defaultMessage: 'Make {objectNoun} available in selected spaces only.', + values: { objectNoun }, + }), disabled: !canShareToAllSpaces && isGlobalControlChecked, }; - const shareOptionsTitle = i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.shareOptionsTitle', - { defaultMessage: 'Share options' } - ); const toggleShareOption = (allSpaces: boolean) => { const updatedSpaceIds = allSpaces @@ -117,35 +129,77 @@ export const ShareModeControl = (props: Props) => { onChange(updatedSpaceIds); }; + const getPrivilegeWarning = () => { + if (!shareToExplicitSpaces.disabled) { + return null; + } + + const kibanaPrivilegesUrl = new DocumentationLinksService( + docLinks! + ).getKibanaPrivilegesDocUrl(); + + return ( + <> + + } + color="warning" + > + + + + ), + }} + /> + + + + + ); + }; + return ( <> - - {shareOptionsTitle} - - ), - }} + {getPrivilegeWarning()} + + toggleShareOption(false)} + disabled={shareToExplicitSpaces.disabled} > - toggleShareOption(false)} - disabled={shareToExplicitSpaces.disabled} - > - - - - toggleShareOption(true)} - disabled={shareToAllSpaces.disabled} + - + + + toggleShareOption(true)} + disabled={shareToAllSpaces.disabled} + /> ); }; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx deleted file mode 100644 index 59b8d47e40e024..00000000000000 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx +++ /dev/null @@ -1,489 +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 Boom from '@hapi/boom'; -import { mountWithIntl, nextTick } from '@kbn/test/jest'; -import { ShareSavedObjectsToSpaceFlyout } from './share_to_space_flyout'; -import { ShareToSpaceForm } from './share_to_space_form'; -import { EuiLoadingSpinner, EuiSelectable } from '@elastic/eui'; -import { Space } from '../../../../../../src/plugins/spaces_oss/common'; -import { findTestSubject } from '@kbn/test/jest'; -import { SelectableSpacesControl } from './selectable_spaces_control'; -import { act } from '@testing-library/react'; -import { spacesManagerMock } from '../../spaces_manager/mocks'; -import { SpacesManager } from '../../spaces_manager'; -import { coreMock } from '../../../../../../src/core/public/mocks'; -import { ToastsApi } from 'src/core/public'; -import { EuiCallOut } from '@elastic/eui'; -import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; -import { NoSpacesAvailable } from './no_spaces_available'; -import { SavedObjectsManagementRecord } from 'src/plugins/saved_objects_management/public'; -import { ContextWrapper } from '.'; - -interface SetupOpts { - mockSpaces?: Space[]; - namespaces?: string[]; - returnBeforeSpacesLoad?: boolean; -} - -const setup = async (opts: SetupOpts = {}) => { - const onClose = jest.fn(); - const onObjectUpdated = jest.fn(); - - const mockSpacesManager = spacesManagerMock.create(); - - mockSpacesManager.getActiveSpace.mockResolvedValue({ - id: 'my-active-space', - name: 'my active space', - disabledFeatures: [], - }); - - mockSpacesManager.getSpaces.mockResolvedValue( - opts.mockSpaces || [ - { - id: 'space-1', - name: 'Space 1', - disabledFeatures: [], - }, - { - id: 'space-2', - name: 'Space 2', - disabledFeatures: [], - }, - { - id: 'space-3', - name: 'Space 3', - disabledFeatures: [], - }, - { - id: 'my-active-space', - name: 'my active space', - disabledFeatures: [], - }, - ] - ); - - mockSpacesManager.getShareSavedObjectPermissions.mockResolvedValue({ shareToAllSpaces: true }); - - const mockToastNotifications = { - addError: jest.fn(), - addSuccess: jest.fn(), - }; - const savedObjectToShare = { - type: 'dashboard', - id: 'my-dash', - references: [ - { - type: 'visualization', - id: 'my-viz', - name: 'My Viz', - }, - ], - meta: { icon: 'dashboard', title: 'foo' }, - namespaces: opts.namespaces || ['my-active-space', 'space-1'], - } as SavedObjectsManagementRecord; - - const { getStartServices } = coreMock.createSetup(); - const startServices = coreMock.createStart(); - startServices.application.capabilities = { - ...startServices.application.capabilities, - spaces: { manage: true }, - }; - getStartServices.mockResolvedValue([startServices, , ,]); - - // the flyout depends upon the Kibana React Context, and it cannot be used without the context wrapper - // the context wrapper is only split into a separate component to avoid recreating the context upon every flyout state change - const wrapper = mountWithIntl( - - - - ); - - // wait for context wrapper to rerender - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - if (!opts.returnBeforeSpacesLoad) { - // Wait for spaces manager to complete and flyout to rerender - await act(async () => { - await nextTick(); - wrapper.update(); - }); - } - - return { wrapper, onClose, mockSpacesManager, mockToastNotifications, savedObjectToShare }; -}; - -describe('ShareToSpaceFlyout', () => { - beforeAll(() => { - jest.useFakeTimers(); - }); - - it('waits for spaces to load', async () => { - const { wrapper } = await setup({ returnBeforeSpacesLoad: true }); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); - }); - - it('shows a message within a NoSpacesAvailable when no spaces are available', async () => { - const { wrapper, onClose } = await setup({ - mockSpaces: [{ id: 'my-active-space', name: 'my active space', disabledFeatures: [] }], - }); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(1); - expect(onClose).toHaveBeenCalledTimes(0); - }); - - it('shows a message within a NoSpacesAvailable when only the active space is available', async () => { - const { wrapper, onClose } = await setup({ - mockSpaces: [{ id: 'my-active-space', name: '', disabledFeatures: [] }], - }); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(1); - expect(onClose).toHaveBeenCalledTimes(0); - }); - - it('does not show a warning callout when the saved object has multiple namespaces', async () => { - const { wrapper, onClose } = await setup(); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiCallOut)).toHaveLength(0); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(onClose).toHaveBeenCalledTimes(0); - }); - - it('shows a warning callout when the saved object only has one namespace', async () => { - const { wrapper, onClose } = await setup({ namespaces: ['my-active-space'] }); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiCallOut)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(onClose).toHaveBeenCalledTimes(0); - }); - - it('does not show the Copy flyout by default', async () => { - const { wrapper, onClose } = await setup({ namespaces: ['my-active-space'] }); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(CopySavedObjectsToSpaceFlyout)).toHaveLength(0); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(onClose).toHaveBeenCalledTimes(0); - }); - - it('shows the Copy flyout if the the "Make a copy" button is clicked', async () => { - const { wrapper, onClose } = await setup({ namespaces: ['my-active-space'] }); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); - - const copyButton = findTestSubject(wrapper, 'sts-copy-link'); // this link is only present in the warning callout - - await act(async () => { - copyButton.simulate('click'); - await nextTick(); - wrapper.update(); - }); - - expect(wrapper.find(CopySavedObjectsToSpaceFlyout)).toHaveLength(1); - expect(onClose).toHaveBeenCalledTimes(0); - }); - - it('handles errors thrown from shareSavedObjectsAdd API call', async () => { - const { wrapper, mockSpacesManager, mockToastNotifications } = await setup(); - - mockSpacesManager.shareSavedObjectAdd.mockImplementation(() => { - return Promise.reject(Boom.serverUnavailable('Something bad happened')); - }); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); - - // Using props callback instead of simulating clicks, - // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects - const spaceSelector = wrapper.find(SelectableSpacesControl); - act(() => { - spaceSelector.props().onChange(['space-2', 'space-3']); - }); - - const startButton = findTestSubject(wrapper, 'sts-initiate-button'); - - await act(async () => { - startButton.simulate('click'); - await nextTick(); - wrapper.update(); - }); - - expect(mockSpacesManager.shareSavedObjectAdd).toHaveBeenCalled(); - expect(mockSpacesManager.shareSavedObjectRemove).not.toHaveBeenCalled(); - expect(mockToastNotifications.addError).toHaveBeenCalled(); - }); - - it('handles errors thrown from shareSavedObjectsRemove API call', async () => { - const { wrapper, mockSpacesManager, mockToastNotifications } = await setup(); - - mockSpacesManager.shareSavedObjectRemove.mockImplementation(() => { - return Promise.reject(Boom.serverUnavailable('Something bad happened')); - }); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); - - // Using props callback instead of simulating clicks, - // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects - const spaceSelector = wrapper.find(SelectableSpacesControl); - act(() => { - spaceSelector.props().onChange(['space-2', 'space-3']); - }); - - const startButton = findTestSubject(wrapper, 'sts-initiate-button'); - - await act(async () => { - startButton.simulate('click'); - await nextTick(); - wrapper.update(); - }); - - expect(mockSpacesManager.shareSavedObjectAdd).toHaveBeenCalled(); - expect(mockSpacesManager.shareSavedObjectRemove).toHaveBeenCalled(); - expect(mockToastNotifications.addError).toHaveBeenCalled(); - }); - - it('allows the form to be filled out to add a space', async () => { - const { - wrapper, - onClose, - mockSpacesManager, - mockToastNotifications, - savedObjectToShare, - } = await setup(); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); - - // Using props callback instead of simulating clicks, - // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects - const spaceSelector = wrapper.find(SelectableSpacesControl); - - act(() => { - spaceSelector.props().onChange(['space-1', 'space-2', 'space-3']); - }); - - const startButton = findTestSubject(wrapper, 'sts-initiate-button'); - - await act(async () => { - startButton.simulate('click'); - await nextTick(); - wrapper.update(); - }); - - const { type, id } = savedObjectToShare; - const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; - expect(shareSavedObjectAdd).toHaveBeenCalledWith({ type, id }, ['space-2', 'space-3']); - expect(shareSavedObjectRemove).not.toHaveBeenCalled(); - - expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(1); - expect(mockToastNotifications.addError).not.toHaveBeenCalled(); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it('allows the form to be filled out to remove a space', async () => { - const { - wrapper, - onClose, - mockSpacesManager, - mockToastNotifications, - savedObjectToShare, - } = await setup(); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); - - // Using props callback instead of simulating clicks, - // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects - const spaceSelector = wrapper.find(SelectableSpacesControl); - - act(() => { - spaceSelector.props().onChange([]); - }); - - const startButton = findTestSubject(wrapper, 'sts-initiate-button'); - - await act(async () => { - startButton.simulate('click'); - await nextTick(); - wrapper.update(); - }); - - const { type, id } = savedObjectToShare; - const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; - expect(shareSavedObjectAdd).not.toHaveBeenCalled(); - expect(shareSavedObjectRemove).toHaveBeenCalledWith({ type, id }, ['space-1']); - - expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(1); - expect(mockToastNotifications.addError).not.toHaveBeenCalled(); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it('allows the form to be filled out to add and remove a space', async () => { - const { - wrapper, - onClose, - mockSpacesManager, - mockToastNotifications, - savedObjectToShare, - } = await setup(); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); - - // Using props callback instead of simulating clicks, - // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects - const spaceSelector = wrapper.find(SelectableSpacesControl); - - act(() => { - spaceSelector.props().onChange(['space-2', 'space-3']); - }); - - const startButton = findTestSubject(wrapper, 'sts-initiate-button'); - - await act(async () => { - startButton.simulate('click'); - await nextTick(); - wrapper.update(); - }); - - const { type, id } = savedObjectToShare; - const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; - expect(shareSavedObjectAdd).toHaveBeenCalledWith({ type, id }, ['space-2', 'space-3']); - expect(shareSavedObjectRemove).toHaveBeenCalledWith({ type, id }, ['space-1']); - - expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(2); - expect(mockToastNotifications.addError).not.toHaveBeenCalled(); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - describe('space selection', () => { - const mockSpaces = [ - { - // normal "fully authorized" space selection option -- not the active space - id: 'space-1', - name: 'Space 1', - disabledFeatures: [], - }, - { - // "partially authorized" space selection option -- not the active space - id: 'space-2', - name: 'Space 2', - disabledFeatures: [], - authorizedPurposes: { shareSavedObjectsIntoSpace: false }, - }, - { - // "active space" selection option (determined by an ID that matches the result of `getActiveSpace`, mocked at top) - id: 'my-active-space', - name: 'my active space', - disabledFeatures: [], - }, - ]; - - const expectActiveSpace = (option: any) => { - expect(option.append).toMatchInlineSnapshot(` - - Current - - `); - // by definition, the active space will always be checked - expect(option.checked).toEqual('on'); - expect(option.disabled).toEqual(true); - }; - const expectInactiveSpace = (option: any, checked: boolean) => { - expect(option.append).toBeUndefined(); - expect(option.checked).toEqual(checked ? 'on' : undefined); - expect(option.disabled).toBeUndefined(); - }; - const expectPartiallyAuthorizedSpace = (option: any, checked: boolean) => { - if (checked) { - expect(option.append).toMatchInlineSnapshot(` - - `); - } else { - expect(option.append).toMatchInlineSnapshot(` - - `); - } - expect(option.checked).toEqual(checked ? 'on' : undefined); - expect(option.disabled).toEqual(true); - }; - - it('correctly defines space selection options when spaces are not selected', async () => { - const namespaces = ['my-active-space']; // the saved object's current namespaces; it will always exist in at least the active namespace - const { wrapper } = await setup({ mockSpaces, namespaces }); - - const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); - const selectOptions = selectable.prop('options'); - expect(selectOptions[0]['data-space-id']).toEqual('my-active-space'); - expectActiveSpace(selectOptions[0]); - expect(selectOptions[1]['data-space-id']).toEqual('space-1'); - expectInactiveSpace(selectOptions[1], false); - expect(selectOptions[2]['data-space-id']).toEqual('space-2'); - expectPartiallyAuthorizedSpace(selectOptions[2], false); - }); - - it('correctly defines space selection options when spaces are selected', async () => { - const namespaces = ['my-active-space', 'space-1', 'space-2']; // the saved object's current namespaces - const { wrapper } = await setup({ mockSpaces, namespaces }); - - const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); - const selectOptions = selectable.prop('options'); - expect(selectOptions[0]['data-space-id']).toEqual('my-active-space'); - expectActiveSpace(selectOptions[0]); - expect(selectOptions[1]['data-space-id']).toEqual('space-1'); - expectInactiveSpace(selectOptions[1], true); - expect(selectOptions[2]['data-space-id']).toEqual('space-2'); - expectPartiallyAuthorizedSpace(selectOptions[2], true); - }); - }); -}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx index 69fd89dab58140..0f9783e3ac8c07 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx @@ -5,288 +5,12 @@ * 2.0. */ -import React, { useState, useEffect } from 'react'; -import { - EuiFlyout, - EuiIcon, - EuiFlyoutHeader, - EuiTitle, - EuiText, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiLoadingSpinner, - EuiFlexGroup, - EuiFlexItem, - EuiHorizontalRule, - EuiButton, - EuiButtonEmpty, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { ToastsStart } from 'src/core/public'; -import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; -import { GetSpaceResult } from '../../../common'; -import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../../common/constants'; -import { SpacesManager } from '../../spaces_manager'; -import { ShareToSpaceForm } from './share_to_space_form'; -import { ShareOptions, SpaceTarget } from '../types'; -import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; +import React from 'react'; +import type { ShareToSpaceFlyoutProps } from '../../../../../../src/plugins/spaces_oss/public'; +import { ShareToSpaceFlyoutInternal } from './share_to_space_flyout_internal'; -interface Props { - onClose: () => void; - onObjectUpdated: () => void; - savedObject: SavedObjectsManagementRecord; - spacesManager: SpacesManager; - toastNotifications: ToastsStart; -} - -const arraysAreEqual = (a: unknown[], b: unknown[]) => - a.every((x) => b.includes(x)) && b.every((x) => a.includes(x)); - -export const ShareSavedObjectsToSpaceFlyout = (props: Props) => { - const { onClose, onObjectUpdated, savedObject, spacesManager, toastNotifications } = props; - const { namespaces: currentNamespaces = [] } = savedObject; - const [shareOptions, setShareOptions] = useState({ selectedSpaceIds: [] }); - const [canShareToAllSpaces, setCanShareToAllSpaces] = useState(false); - const [showMakeCopy, setShowMakeCopy] = useState(false); - - const [{ isLoading, spaces }, setSpacesState] = useState<{ - isLoading: boolean; - spaces: SpaceTarget[]; - }>({ isLoading: true, spaces: [] }); - useEffect(() => { - const getSpaces = spacesManager.getSpaces({ includeAuthorizedPurposes: true }); - const getActiveSpace = spacesManager.getActiveSpace(); - const getPermissions = spacesManager.getShareSavedObjectPermissions(savedObject.type); - Promise.all([getSpaces, getActiveSpace, getPermissions]) - .then(([allSpaces, activeSpace, permissions]) => { - setShareOptions({ - selectedSpaceIds: currentNamespaces.filter((spaceId) => spaceId !== activeSpace.id), - }); - setCanShareToAllSpaces(permissions.shareToAllSpaces); - const createSpaceTarget = (space: GetSpaceResult): SpaceTarget => ({ - ...space, - isActiveSpace: space.id === activeSpace.id, - isPartiallyAuthorized: space.authorizedPurposes?.shareSavedObjectsIntoSpace === false, - }); - setSpacesState({ - isLoading: false, - spaces: allSpaces.map((space) => createSpaceTarget(space)), - }); - }) - .catch((e) => { - toastNotifications.addError(e, { - title: i18n.translate('xpack.spaces.management.shareToSpace.spacesLoadErrorTitle', { - defaultMessage: 'Error loading available spaces', - }), - }); - }); - }, [currentNamespaces, spacesManager, savedObject, toastNotifications]); - - const getSelectionChanges = () => { - const activeSpace = spaces.find((space) => space.isActiveSpace); - if (!activeSpace) { - return { isSelectionChanged: false, spacesToAdd: [], spacesToRemove: [] }; - } - const initialSelection = currentNamespaces.filter( - (spaceId) => spaceId !== activeSpace.id && spaceId !== UNKNOWN_SPACE - ); - const { selectedSpaceIds } = shareOptions; - const filteredSelection = selectedSpaceIds.filter((x) => x !== UNKNOWN_SPACE); - const isSharedToAllSpaces = - !initialSelection.includes(ALL_SPACES_ID) && filteredSelection.includes(ALL_SPACES_ID); - const isUnsharedFromAllSpaces = - initialSelection.includes(ALL_SPACES_ID) && !filteredSelection.includes(ALL_SPACES_ID); - const selectedSpacesChanged = - !filteredSelection.includes(ALL_SPACES_ID) && - !arraysAreEqual(initialSelection, filteredSelection); - const isSelectionChanged = - isSharedToAllSpaces || - isUnsharedFromAllSpaces || - (!isSharedToAllSpaces && !isUnsharedFromAllSpaces && selectedSpacesChanged); - - const selectedSpacesToAdd = filteredSelection.filter( - (spaceId) => !initialSelection.includes(spaceId) - ); - const selectedSpacesToRemove = initialSelection.filter( - (spaceId) => !filteredSelection.includes(spaceId) - ); - - const spacesToAdd = isSharedToAllSpaces - ? [ALL_SPACES_ID] - : isUnsharedFromAllSpaces - ? [activeSpace.id, ...selectedSpacesToAdd] - : selectedSpacesToAdd; - const spacesToRemove = isUnsharedFromAllSpaces - ? [ALL_SPACES_ID] - : isSharedToAllSpaces - ? [activeSpace.id, ...initialSelection] - : selectedSpacesToRemove; - return { isSelectionChanged, spacesToAdd, spacesToRemove }; - }; - const { isSelectionChanged, spacesToAdd, spacesToRemove } = getSelectionChanges(); - - const [shareInProgress, setShareInProgress] = useState(false); - - async function startShare() { - setShareInProgress(true); - try { - const { type, id, meta } = savedObject; - const title = - currentNamespaces.length === 1 - ? i18n.translate('xpack.spaces.management.shareToSpace.shareNewSuccessTitle', { - defaultMessage: 'Object is now shared', - }) - : i18n.translate('xpack.spaces.management.shareToSpace.shareEditSuccessTitle', { - defaultMessage: 'Object was updated', - }); - const isSharedToAllSpaces = spacesToAdd.includes(ALL_SPACES_ID); - if (spacesToAdd.length > 0) { - await spacesManager.shareSavedObjectAdd({ type, id }, spacesToAdd); - const spaceTargets = isSharedToAllSpaces ? 'all' : `${spacesToAdd.length}`; - const text = - !isSharedToAllSpaces && spacesToAdd.length === 1 - ? i18n.translate('xpack.spaces.management.shareToSpace.shareAddSuccessTextSingular', { - defaultMessage: `'{object}' was added to 1 space.`, - values: { object: meta.title }, - }) - : i18n.translate('xpack.spaces.management.shareToSpace.shareAddSuccessTextPlural', { - defaultMessage: `'{object}' was added to {spaceTargets} spaces.`, - values: { object: meta.title, spaceTargets }, - }); - toastNotifications.addSuccess({ title, text }); - } - if (spacesToRemove.length > 0) { - await spacesManager.shareSavedObjectRemove({ type, id }, spacesToRemove); - const isUnsharedFromAllSpaces = spacesToRemove.includes(ALL_SPACES_ID); - const spaceTargets = isUnsharedFromAllSpaces ? 'all' : `${spacesToRemove.length}`; - const text = - !isUnsharedFromAllSpaces && spacesToRemove.length === 1 - ? i18n.translate( - 'xpack.spaces.management.shareToSpace.shareRemoveSuccessTextSingular', - { - defaultMessage: `'{object}' was removed from 1 space.`, - values: { object: meta.title }, - } - ) - : i18n.translate('xpack.spaces.management.shareToSpace.shareRemoveSuccessTextPlural', { - defaultMessage: `'{object}' was removed from {spaceTargets} spaces.`, - values: { object: meta.title, spaceTargets }, - }); - if (!isSharedToAllSpaces) { - toastNotifications.addSuccess({ title, text }); - } - } - onObjectUpdated(); - onClose(); - } catch (e) { - setShareInProgress(false); - toastNotifications.addError(e, { - title: i18n.translate('xpack.spaces.management.shareToSpace.shareErrorTitle', { - defaultMessage: 'Error updating saved object', - }), - }); - } - } - - const getFlyoutBody = () => { - // Step 1: loading assets for main form - if (isLoading) { - return ; - } - - const activeSpace = spaces.find((x) => x.isActiveSpace)!; - const showShareWarning = - spaces.length > 1 && arraysAreEqual(currentNamespaces, [activeSpace.id]); - // Step 2: Share has not been initiated yet; User must fill out form to continue. - return ( - setShowMakeCopy(true)} - /> - ); +export const getShareToSpaceFlyoutComponent = (): React.FC => { + return (props: ShareToSpaceFlyoutProps) => { + return ; }; - - if (showMakeCopy) { - return ( - - ); - } - - return ( - - - - - - - - -

- -

-
-
-
-
- - - - - - - -

{savedObject.meta.title}

-
-
-
- - - - {getFlyoutBody()} -
- - - - - onClose()} - data-test-subj="sts-cancel-button" - disabled={shareInProgress} - > - - - - - startShare()} - data-test-subj="sts-initiate-button" - disabled={!isSelectionChanged || shareInProgress} - > - - - - - -
- ); }; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx new file mode 100644 index 00000000000000..1b33b42637fe8b --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx @@ -0,0 +1,741 @@ +/* + * 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 Boom from '@hapi/boom'; +import { mountWithIntl, nextTick, findTestSubject } from '@kbn/test/jest'; +import { ShareToSpaceForm } from './share_to_space_form'; +import { + EuiCallOut, + EuiCheckableCard, + EuiCheckableCardProps, + EuiIconTip, + EuiLoadingSpinner, + EuiSelectable, +} from '@elastic/eui'; +import { Space } from '../../../../../../src/plugins/spaces_oss/common'; +import { SelectableSpacesControl } from './selectable_spaces_control'; +import { act } from '@testing-library/react'; +import { spacesManagerMock } from '../../spaces_manager/mocks'; +import { coreMock } from '../../../../../../src/core/public/mocks'; +import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; +import { NoSpacesAvailable } from './no_spaces_available'; +import { getShareToSpaceFlyoutComponent } from './share_to_space_flyout'; +import { ShareModeControl } from './share_mode_control'; +import { ReactWrapper } from 'enzyme'; +import { ALL_SPACES_ID } from '../../../common/constants'; +import { getSpacesContextWrapper } from '../../spaces_context'; + +interface SetupOpts { + mockSpaces?: Space[]; + namespaces?: string[]; + returnBeforeSpacesLoad?: boolean; + canShareToAllSpaces?: boolean; // default: true + enableCreateCopyCallout?: boolean; + enableCreateNewSpaceLink?: boolean; + behaviorContext?: 'within-space' | 'outside-space'; + mockFeatureId?: string; // optional feature ID to use for the SpacesContext +} + +const setup = async (opts: SetupOpts = {}) => { + const onClose = jest.fn(); + const onUpdate = jest.fn(); + + const mockSpacesManager = spacesManagerMock.create(); + + // note: this call is made in the SpacesContext + mockSpacesManager.getActiveSpace.mockResolvedValue({ + id: 'my-active-space', + name: 'my active space', + disabledFeatures: [], + }); + + // note: this call is made in the SpacesContext + mockSpacesManager.getSpaces.mockResolvedValue( + opts.mockSpaces || [ + { + id: 'space-1', + name: 'Space 1', + disabledFeatures: [], + }, + { + id: 'space-2', + name: 'Space 2', + disabledFeatures: [], + }, + { + id: 'space-3', + name: 'Space 3', + disabledFeatures: [], + }, + { + id: 'my-active-space', + name: 'my active space', + disabledFeatures: [], + }, + ] + ); + + mockSpacesManager.getShareSavedObjectPermissions.mockResolvedValue({ + shareToAllSpaces: opts.canShareToAllSpaces ?? true, + }); + + const savedObjectToShare = { + type: 'dashboard', + id: 'my-dash', + namespaces: opts.namespaces || ['my-active-space', 'space-1'], + icon: 'dashboard', + title: 'foo', + }; + + const { getStartServices } = coreMock.createSetup(); + const startServices = coreMock.createStart(); + startServices.application.capabilities = { + ...startServices.application.capabilities, + spaces: { manage: true }, + }; + const mockToastNotifications = startServices.notifications.toasts; + getStartServices.mockResolvedValue([startServices, , ,]); + + const SpacesContext = getSpacesContextWrapper({ + getStartServices, + spacesManager: mockSpacesManager, + }); + const ShareToSpaceFlyout = getShareToSpaceFlyoutComponent(); + // the internal flyout depends upon the Kibana React Context, and it cannot be used without the context wrapper + // the context wrapper is only split into a separate component to avoid recreating the context upon every flyout state change + // the ShareToSpaceFlyout component renders the internal flyout inside of the context wrapper + const wrapper = mountWithIntl( + + + + ); + + // wait for context wrapper to rerender + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + if (!opts.returnBeforeSpacesLoad) { + // Wait for spaces manager to complete and flyout to rerender + wrapper.update(); + } + + return { wrapper, onClose, mockSpacesManager, mockToastNotifications, savedObjectToShare }; +}; + +describe('ShareToSpaceFlyout', () => { + it('waits for spaces to load', async () => { + const { wrapper } = await setup({ returnBeforeSpacesLoad: true }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1); + + wrapper.update(); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + }); + + describe('without enableCreateCopyCallout', () => { + it('does not show a warning callout when the saved object only has one namespace', async () => { + const { wrapper, onClose } = await setup({ + namespaces: ['my-active-space'], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiCallOut)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); + }); + + describe('with enableCreateCopyCallout', () => { + const enableCreateCopyCallout = true; + + it('does not show a warning callout when the saved object has multiple namespaces', async () => { + const { wrapper, onClose } = await setup({ enableCreateCopyCallout }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiCallOut)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('shows a warning callout when the saved object only has one namespace', async () => { + const { wrapper, onClose } = await setup({ + enableCreateCopyCallout, + namespaces: ['my-active-space'], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiCallOut)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('does not show the Copy flyout by default', async () => { + const { wrapper, onClose } = await setup({ + enableCreateCopyCallout, + namespaces: ['my-active-space'], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(CopySavedObjectsToSpaceFlyout)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('shows the Copy flyout if the the "Make a copy" button is clicked', async () => { + const { wrapper, onClose } = await setup({ + enableCreateCopyCallout, + namespaces: ['my-active-space'], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + + const copyButton = findTestSubject(wrapper, 'sts-copy-link'); // this link is only present in the warning callout + + await act(async () => { + copyButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(CopySavedObjectsToSpaceFlyout)).toHaveLength(1); + expect(onClose).toHaveBeenCalledTimes(0); + }); + }); + + describe('without enableCreateNewSpaceLink', () => { + it('does not render a NoSpacesAvailable component when no spaces are available', async () => { + const { wrapper, onClose } = await setup({ + mockSpaces: [{ id: 'my-active-space', name: 'my active space', disabledFeatures: [] }], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('does not render a NoSpacesAvailable component when only the active space is available', async () => { + const { wrapper, onClose } = await setup({ + mockSpaces: [{ id: 'my-active-space', name: '', disabledFeatures: [] }], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); + }); + + describe('with enableCreateNewSpaceLink', () => { + const enableCreateNewSpaceLink = true; + + it('renders a NoSpacesAvailable component when no spaces are available', async () => { + const { wrapper, onClose } = await setup({ + enableCreateNewSpaceLink, + mockSpaces: [{ id: 'my-active-space', name: 'my active space', disabledFeatures: [] }], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(1); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('renders a NoSpacesAvailable component when only the active space is available', async () => { + const { wrapper, onClose } = await setup({ + enableCreateNewSpaceLink, + mockSpaces: [{ id: 'my-active-space', name: '', disabledFeatures: [] }], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(1); + expect(onClose).toHaveBeenCalledTimes(0); + }); + }); + + it('handles errors thrown from shareSavedObjectsAdd API call', async () => { + const { wrapper, mockSpacesManager, mockToastNotifications } = await setup(); + + mockSpacesManager.shareSavedObjectAdd.mockRejectedValue( + Boom.serverUnavailable('Something bad happened') + ); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + act(() => { + spaceSelector.props().onChange(['space-2', 'space-3']); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(mockSpacesManager.shareSavedObjectAdd).toHaveBeenCalled(); + expect(mockSpacesManager.shareSavedObjectRemove).not.toHaveBeenCalled(); + expect(mockToastNotifications.addError).toHaveBeenCalled(); + }); + + it('handles errors thrown from shareSavedObjectsRemove API call', async () => { + const { wrapper, mockSpacesManager, mockToastNotifications } = await setup(); + + mockSpacesManager.shareSavedObjectRemove.mockRejectedValue( + Boom.serverUnavailable('Something bad happened') + ); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + act(() => { + spaceSelector.props().onChange(['space-2', 'space-3']); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(mockSpacesManager.shareSavedObjectAdd).toHaveBeenCalled(); + expect(mockSpacesManager.shareSavedObjectRemove).toHaveBeenCalled(); + expect(mockToastNotifications.addError).toHaveBeenCalled(); + }); + + it('allows the form to be filled out to add a space', async () => { + const { + wrapper, + onClose, + mockSpacesManager, + mockToastNotifications, + savedObjectToShare, + } = await setup(); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + + act(() => { + spaceSelector.props().onChange(['space-1', 'space-2', 'space-3']); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + const { type, id } = savedObjectToShare; + const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; + expect(shareSavedObjectAdd).toHaveBeenCalledWith({ type, id }, ['space-2', 'space-3']); + expect(shareSavedObjectRemove).not.toHaveBeenCalled(); + + expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(1); + expect(mockToastNotifications.addError).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('allows the form to be filled out to remove a space', async () => { + const { + wrapper, + onClose, + mockSpacesManager, + mockToastNotifications, + savedObjectToShare, + } = await setup(); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + + act(() => { + spaceSelector.props().onChange([]); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + const { type, id } = savedObjectToShare; + const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; + expect(shareSavedObjectAdd).not.toHaveBeenCalled(); + expect(shareSavedObjectRemove).toHaveBeenCalledWith({ type, id }, ['space-1']); + + expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(1); + expect(mockToastNotifications.addError).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('allows the form to be filled out to add and remove a space', async () => { + const { + wrapper, + onClose, + mockSpacesManager, + mockToastNotifications, + savedObjectToShare, + } = await setup(); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + + act(() => { + spaceSelector.props().onChange(['space-2', 'space-3']); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + const { type, id } = savedObjectToShare; + const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; + expect(shareSavedObjectAdd).toHaveBeenCalledWith({ type, id }, ['space-2', 'space-3']); + expect(shareSavedObjectRemove).toHaveBeenCalledWith({ type, id }, ['space-1']); + + expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(2); + expect(mockToastNotifications.addError).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + describe('correctly renders checkable cards', () => { + function getCheckableCardProps( + wrapper: ReactWrapper> + ) { + const iconTip = wrapper.find(EuiIconTip); + return { + checked: wrapper.prop('checked'), + disabled: wrapper.prop('disabled'), + ...(iconTip.length > 0 && { tooltip: iconTip.prop('content') as string }), + }; + } + function getCheckableCards(wrapper: ReactWrapper) { + return { + explicitSpacesCard: getCheckableCardProps( + wrapper.find('#shareToExplicitSpaces').find(EuiCheckableCard) + ), + allSpacesCard: getCheckableCardProps( + wrapper.find('#shareToAllSpaces').find(EuiCheckableCard) + ), + }; + } + + describe('when user has privileges to share to all spaces', () => { + const canShareToAllSpaces = true; + + it('and the object is not shared to all spaces', async () => { + const namespaces = ['my-active-space']; + const { wrapper } = await setup({ canShareToAllSpaces, namespaces }); + const shareModeControl = wrapper.find(ShareModeControl); + const checkableCards = getCheckableCards(shareModeControl); + + expect(checkableCards).toEqual({ + explicitSpacesCard: { checked: true, disabled: false }, + allSpacesCard: { checked: false, disabled: false }, + }); + expect(shareModeControl.find(EuiCallOut)).toHaveLength(0); // "Additional privileges required" callout + }); + + it('and the object is shared to all spaces', async () => { + const namespaces = [ALL_SPACES_ID]; + const { wrapper } = await setup({ canShareToAllSpaces, namespaces }); + const shareModeControl = wrapper.find(ShareModeControl); + const checkableCards = getCheckableCards(shareModeControl); + + expect(checkableCards).toEqual({ + explicitSpacesCard: { checked: false, disabled: false }, + allSpacesCard: { checked: true, disabled: false }, + }); + expect(shareModeControl.find(EuiCallOut)).toHaveLength(0); // "Additional privileges required" callout + }); + }); + + describe('when user does not have privileges to share to all spaces', () => { + const canShareToAllSpaces = false; + + it('and the object is not shared to all spaces', async () => { + const namespaces = ['my-active-space']; + const { wrapper } = await setup({ canShareToAllSpaces, namespaces }); + const shareModeControl = wrapper.find(ShareModeControl); + const checkableCards = getCheckableCards(shareModeControl); + + expect(checkableCards).toEqual({ + explicitSpacesCard: { checked: true, disabled: false }, + allSpacesCard: { + checked: false, + disabled: true, + tooltip: 'You need additional privileges to use this option.', + }, + }); + expect(shareModeControl.find(EuiCallOut)).toHaveLength(0); // "Additional privileges required" callout + }); + + it('and the object is shared to all spaces', async () => { + const namespaces = [ALL_SPACES_ID]; + const { wrapper } = await setup({ canShareToAllSpaces, namespaces }); + const shareModeControl = wrapper.find(ShareModeControl); + const checkableCards = getCheckableCards(shareModeControl); + + expect(checkableCards).toEqual({ + explicitSpacesCard: { checked: false, disabled: true }, + allSpacesCard: { + checked: true, + disabled: true, + tooltip: 'You need additional privileges to change this option.', + }, + }); + expect(shareModeControl.find(EuiCallOut)).toHaveLength(1); // "Additional privileges required" callout + }); + }); + }); + + describe('space selection', () => { + const mockFeatureId = 'some-feature'; + + const mockSpaces = [ + { + // normal "fully authorized" space selection option -- not the active space + id: 'space-1', + name: 'Space 1', + disabledFeatures: [], + }, + { + // normal "fully authorized" space selection option, with a disabled feature -- not the active space + id: 'space-2', + name: 'Space 2', + disabledFeatures: [mockFeatureId], + }, + { + // "partially authorized" space selection option -- not the active space + id: 'space-3', + name: 'Space 3', + disabledFeatures: [], + authorizedPurposes: { shareSavedObjectsIntoSpace: false }, + }, + { + // "partially authorized" space selection option, with a disabled feature -- not the active space + id: 'space-4', + name: 'Space 4', + disabledFeatures: [mockFeatureId], + authorizedPurposes: { shareSavedObjectsIntoSpace: false }, + }, + { + // "active space" selection option (determined by an ID that matches the result of `getActiveSpace`, mocked at top) + id: 'my-active-space', + name: 'my active space', + disabledFeatures: [], + }, + ]; + + const expectActiveSpace = (option: any, { spaceId }: { spaceId: string }) => { + expect(option['data-space-id']).toEqual(spaceId); + expect(option.append).toMatchInlineSnapshot(` + + Current + + `); + // by definition, the active space will always be checked + expect(option.checked).toEqual('on'); + expect(option.disabled).toEqual(true); + }; + const expectNeedAdditionalPrivileges = ( + option: any, + { + spaceId, + checked, + featureIsDisabled, + }: { spaceId: string; checked: boolean; featureIsDisabled?: boolean } + ) => { + expect(option['data-space-id']).toEqual(spaceId); + if (checked && featureIsDisabled) { + expect(option.append).toMatchInlineSnapshot(` + + + + + `); + } else if (checked && !featureIsDisabled) { + expect(option.append).toMatchInlineSnapshot(` + + + + `); + } else if (!checked && !featureIsDisabled) { + expect(option.append).toMatchInlineSnapshot(` + + + + `); + } else { + throw new Error('Unexpected test case!'); + } + expect(option.checked).toEqual(checked ? 'on' : undefined); + expect(option.disabled).toEqual(true); + }; + const expectFeatureIsDisabled = (option: any, { spaceId }: { spaceId: string }) => { + expect(option['data-space-id']).toEqual(spaceId); + expect(option.append).toMatchInlineSnapshot(` + + `); + expect(option.checked).toEqual('on'); + expect(option.disabled).toBeUndefined(); + }; + const expectInactiveSpace = ( + option: any, + { spaceId, checked }: { spaceId: string; checked: boolean } + ) => { + expect(option['data-space-id']).toEqual(spaceId); + expect(option.append).toBeUndefined(); + expect(option.checked).toEqual(checked ? 'on' : undefined); + expect(option.disabled).toBeUndefined(); + }; + + describe('with behaviorContext="within-space" (default)', () => { + it('correctly defines space selection options', async () => { + const namespaces = ['my-active-space', 'space-1', 'space-3']; // the saved object's current namespaces + const { wrapper } = await setup({ mockSpaces, namespaces }); + + const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); + const options = selectable.prop('options'); + expect(options).toHaveLength(5); + expectActiveSpace(options[0], { spaceId: 'my-active-space' }); + expectInactiveSpace(options[1], { spaceId: 'space-1', checked: true }); + expectInactiveSpace(options[2], { spaceId: 'space-2', checked: false }); + expectNeedAdditionalPrivileges(options[3], { spaceId: 'space-3', checked: true }); + expectNeedAdditionalPrivileges(options[4], { spaceId: 'space-4', checked: false }); + }); + + describe('with a SpacesContext for a specific feature', () => { + it('correctly defines space selection options when affected spaces are not selected', async () => { + const namespaces = ['my-active-space']; // the saved object's current namespaces + const { wrapper } = await setup({ mockSpaces, namespaces, mockFeatureId }); + + const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); + const options = selectable.prop('options'); + expect(options).toHaveLength(3); + expectActiveSpace(options[0], { spaceId: 'my-active-space' }); + expectInactiveSpace(options[1], { spaceId: 'space-1', checked: false }); + expectNeedAdditionalPrivileges(options[2], { spaceId: 'space-3', checked: false }); + // space-2 and space-4 are omitted, because they are not selected and the current feature is disabled in those spaces + }); + + it('correctly defines space selection options when affected spaces are already selected', async () => { + const namespaces = ['my-active-space', 'space-1', 'space-2', 'space-3', 'space-4']; // the saved object's current namespaces + const { wrapper } = await setup({ mockSpaces, namespaces, mockFeatureId }); + + const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); + const options = selectable.prop('options'); + expect(options).toHaveLength(5); + expectActiveSpace(options[0], { spaceId: 'my-active-space' }); + expectInactiveSpace(options[1], { spaceId: 'space-1', checked: true }); + expectNeedAdditionalPrivileges(options[2], { spaceId: 'space-3', checked: true }); + // space-2 and space-4 are at the end, because they are selected and the current feature is disabled in those spaces + expectFeatureIsDisabled(options[3], { spaceId: 'space-2' }); + expectNeedAdditionalPrivileges(options[4], { + spaceId: 'space-4', + checked: true, + featureIsDisabled: true, + }); + }); + }); + }); + + describe('with behaviorContext="outside-space"', () => { + const behaviorContext = 'outside-space'; + + it('correctly defines space selection options', async () => { + const namespaces = ['my-active-space', 'space-1', 'space-3']; // the saved object's current namespaces + const { wrapper } = await setup({ behaviorContext, mockSpaces, namespaces }); + + const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); + const options = selectable.prop('options'); + expect(options).toHaveLength(5); + expectInactiveSpace(options[0], { spaceId: 'space-1', checked: true }); + expectInactiveSpace(options[1], { spaceId: 'space-2', checked: false }); + expectNeedAdditionalPrivileges(options[2], { spaceId: 'space-3', checked: true }); + expectNeedAdditionalPrivileges(options[3], { spaceId: 'space-4', checked: false }); + expectInactiveSpace(options[4], { spaceId: 'my-active-space', checked: true }); + }); + }); + }); +}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx new file mode 100644 index 00000000000000..8d9875977af18f --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx @@ -0,0 +1,352 @@ +/* + * 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, useMemo } from 'react'; +import { + EuiFlyout, + EuiIcon, + EuiFlyoutHeader, + EuiTitle, + EuiText, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiLoadingSpinner, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiButton, + EuiButtonEmpty, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ToastsStart } from 'src/core/public'; +import type { + ShareToSpaceFlyoutProps, + ShareToSpaceSavedObjectTarget, +} from 'src/plugins/spaces_oss/public'; +import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../../common/constants'; +import { SpacesManager } from '../../spaces_manager'; +import { ShareToSpaceTarget } from '../../types'; +import { ShareToSpaceForm } from './share_to_space_form'; +import { ShareOptions } from '../types'; +import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; +import { useSpaces } from '../../spaces_context'; +import { DEFAULT_OBJECT_NOUN } from './constants'; + +const ALL_SPACES_TARGET = i18n.translate('xpack.spaces.shareToSpace.allSpacesTarget', { + defaultMessage: 'all', +}); + +const arraysAreEqual = (a: unknown[], b: unknown[]) => + a.every((x) => b.includes(x)) && b.every((x) => a.includes(x)); + +function createDefaultChangeSpacesHandler( + object: Required>, + spacesManager: SpacesManager, + toastNotifications: ToastsStart +) { + return async (spacesToAdd: string[], spacesToRemove: string[]) => { + const { type, id, title } = object; + const toastTitle = i18n.translate('xpack.spaces.shareToSpace.shareSuccessTitle', { + values: { objectNoun: object.noun }, + defaultMessage: 'Updated {objectNoun}', + }); + const isSharedToAllSpaces = spacesToAdd.includes(ALL_SPACES_ID); + if (spacesToAdd.length > 0) { + await spacesManager.shareSavedObjectAdd({ type, id }, spacesToAdd); + const spaceTargets = isSharedToAllSpaces ? ALL_SPACES_TARGET : `${spacesToAdd.length}`; + const toastText = + !isSharedToAllSpaces && spacesToAdd.length === 1 + ? i18n.translate('xpack.spaces.shareToSpace.shareAddSuccessTextSingular', { + defaultMessage: `'{object}' was added to 1 space.`, + values: { object: title }, + }) + : i18n.translate('xpack.spaces.shareToSpace.shareAddSuccessTextPlural', { + defaultMessage: `'{object}' was added to {spaceTargets} spaces.`, + values: { object: title, spaceTargets }, + }); + toastNotifications.addSuccess({ title: toastTitle, text: toastText }); + } + if (spacesToRemove.length > 0) { + await spacesManager.shareSavedObjectRemove({ type, id }, spacesToRemove); + const isUnsharedFromAllSpaces = spacesToRemove.includes(ALL_SPACES_ID); + const spaceTargets = isUnsharedFromAllSpaces ? ALL_SPACES_TARGET : `${spacesToRemove.length}`; + const toastText = + !isUnsharedFromAllSpaces && spacesToRemove.length === 1 + ? i18n.translate('xpack.spaces.shareToSpace.shareRemoveSuccessTextSingular', { + defaultMessage: `'{object}' was removed from 1 space.`, + values: { object: title }, + }) + : i18n.translate('xpack.spaces.shareToSpace.shareRemoveSuccessTextPlural', { + defaultMessage: `'{object}' was removed from {spaceTargets} spaces.`, + values: { object: title, spaceTargets }, + }); + if (!isSharedToAllSpaces) { + toastNotifications.addSuccess({ title: toastTitle, text: toastText }); + } + } + }; +} + +export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { + const { spacesManager, shareToSpacesDataPromise, services } = useSpaces(); + const { notifications } = services; + const toastNotifications = notifications!.toasts; + + const { savedObjectTarget: object } = props; + const savedObjectTarget = useMemo( + () => ({ + type: object.type, + id: object.id, + namespaces: object.namespaces, + icon: object.icon, + title: object.title || `${object.type} [id=${object.id}]`, + noun: object.noun || DEFAULT_OBJECT_NOUN, + }), + [object] + ); + const { + flyoutIcon, + flyoutTitle = i18n.translate('xpack.spaces.shareToSpace.flyoutTitle', { + defaultMessage: 'Edit spaces for {objectNoun}', + values: { objectNoun: savedObjectTarget.noun }, + }), + enableCreateCopyCallout = false, + enableCreateNewSpaceLink = false, + behaviorContext, + changeSpacesHandler = createDefaultChangeSpacesHandler( + savedObjectTarget, + spacesManager, + toastNotifications + ), + onUpdate = () => null, + onClose = () => null, + } = props; + const enableSpaceAgnosticBehavior = behaviorContext === 'outside-space'; + + const [shareOptions, setShareOptions] = useState({ + selectedSpaceIds: [], + initiallySelectedSpaceIds: [], + }); + const [canShareToAllSpaces, setCanShareToAllSpaces] = useState(false); + const [showMakeCopy, setShowMakeCopy] = useState(false); + + const [{ isLoading, spaces }, setSpacesState] = useState<{ + isLoading: boolean; + spaces: ShareToSpaceTarget[]; + }>({ isLoading: true, spaces: [] }); + useEffect(() => { + const getPermissions = spacesManager.getShareSavedObjectPermissions(savedObjectTarget.type); + Promise.all([shareToSpacesDataPromise, getPermissions]) + .then(([shareToSpacesData, permissions]) => { + const activeSpaceId = !enableSpaceAgnosticBehavior && shareToSpacesData.activeSpaceId; + const selectedSpaceIds = savedObjectTarget.namespaces.filter( + (spaceId) => spaceId !== activeSpaceId + ); + setShareOptions({ + selectedSpaceIds, + initiallySelectedSpaceIds: selectedSpaceIds, + }); + setCanShareToAllSpaces(permissions.shareToAllSpaces); + setSpacesState({ + isLoading: false, + spaces: [...shareToSpacesData.spacesMap].map(([, spaceTarget]) => spaceTarget), + }); + }) + .catch((e) => { + toastNotifications.addError(e, { + title: i18n.translate('xpack.spaces.shareToSpace.spacesLoadErrorTitle', { + defaultMessage: 'Error loading available spaces', + }), + }); + }); + }, [ + savedObjectTarget, + spacesManager, + shareToSpacesDataPromise, + toastNotifications, + enableSpaceAgnosticBehavior, + ]); + + const getSelectionChanges = () => { + if (!spaces.length) { + return { isSelectionChanged: false, spacesToAdd: [], spacesToRemove: [] }; + } + const activeSpaceId = + !enableSpaceAgnosticBehavior && spaces.find((space) => space.isActiveSpace)!.id; + const initialSelection = savedObjectTarget.namespaces.filter( + (spaceId) => spaceId !== activeSpaceId && spaceId !== UNKNOWN_SPACE + ); + const { selectedSpaceIds } = shareOptions; + const filteredSelection = selectedSpaceIds.filter((x) => x !== UNKNOWN_SPACE); + + const initiallySharedToAllSpaces = initialSelection.includes(ALL_SPACES_ID); + const selectionIncludesAllSpaces = filteredSelection.includes(ALL_SPACES_ID); + + const isSharedToAllSpaces = !initiallySharedToAllSpaces && selectionIncludesAllSpaces; + const isUnsharedFromAllSpaces = initiallySharedToAllSpaces && !selectionIncludesAllSpaces; + + const selectedSpacesChanged = + !selectionIncludesAllSpaces && !arraysAreEqual(initialSelection, filteredSelection); + const isSelectionChanged = + isSharedToAllSpaces || + isUnsharedFromAllSpaces || + (!isSharedToAllSpaces && !isUnsharedFromAllSpaces && selectedSpacesChanged); + + const selectedSpacesToAdd = filteredSelection.filter( + (spaceId) => !initialSelection.includes(spaceId) + ); + const selectedSpacesToRemove = initialSelection.filter( + (spaceId) => !filteredSelection.includes(spaceId) + ); + + const activeSpaceArray = activeSpaceId ? [activeSpaceId] : []; // if we have an active space, it is automatically selected + const spacesToAdd = isSharedToAllSpaces + ? [ALL_SPACES_ID] + : isUnsharedFromAllSpaces + ? [...activeSpaceArray, ...selectedSpacesToAdd] + : selectedSpacesToAdd; + const spacesToRemove = + isUnsharedFromAllSpaces || !isSharedToAllSpaces + ? selectedSpacesToRemove + : [...activeSpaceArray, ...initialSelection]; + return { isSelectionChanged, spacesToAdd, spacesToRemove }; + }; + const { isSelectionChanged, spacesToAdd, spacesToRemove } = getSelectionChanges(); + + const [shareInProgress, setShareInProgress] = useState(false); + + async function startShare() { + setShareInProgress(true); + try { + await changeSpacesHandler(spacesToAdd, spacesToRemove); + onUpdate(); + onClose(); + } catch (e) { + setShareInProgress(false); + toastNotifications.addError(e, { + title: i18n.translate('xpack.spaces.shareToSpace.shareErrorTitle', { + values: { objectNoun: savedObjectTarget.noun }, + defaultMessage: 'Error updating {objectNoun}', + }), + }); + } + } + + const getFlyoutBody = () => { + // Step 1: loading assets for main form + if (isLoading) { + return ; + } + + // If the object has not been shared yet (e.g., it currently exists in exactly one space), and there is at least one space that we could + // share this object to, we want to display a callout to the user that explains the ramifications of shared objects. They might actually + // want to make a copy instead, so this callout contains a link that opens the Copy flyout. + const showCreateCopyCallout = + enableCreateCopyCallout && + spaces.length > 1 && + savedObjectTarget.namespaces.length === 1 && + !arraysAreEqual(savedObjectTarget.namespaces, [ALL_SPACES_ID]); + // Step 2: Share has not been initiated yet; User must fill out form to continue. + return ( + setShowMakeCopy(true)} + enableCreateNewSpaceLink={enableCreateNewSpaceLink} + enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior} + /> + ); + }; + + if (showMakeCopy) { + return ( + + ); + } + + const isStartShareButtonDisabled = + !isSelectionChanged || + shareInProgress || + (enableSpaceAgnosticBehavior && !shareOptions.selectedSpaceIds.length); // the object must exist in at least one space, or all spaces + + return ( + + + + {flyoutIcon && ( + + + + )} + + +

{flyoutTitle}

+
+
+
+
+ + + {savedObjectTarget.icon && ( + + + + )} + + +

{savedObjectTarget.title}

+
+
+
+ + + + {getFlyoutBody()} +
+ + + + + onClose()} + data-test-subj="sts-cancel-button" + disabled={shareInProgress} + > + + + + + startShare()} + data-test-subj="sts-initiate-button" + disabled={isStartShareButtonDisabled} + > + + + + + +
+ ); +}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx index ef5b731375f495..49c581b07004b8 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx @@ -7,73 +7,84 @@ import './share_to_space_form.scss'; import React, { Fragment } from 'react'; -import { EuiHorizontalRule, EuiCallOut, EuiLink } from '@elastic/eui'; +import { EuiSpacer, EuiCallOut, EuiLink } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { ShareOptions, SpaceTarget } from '../types'; +import { ShareToSpaceTarget } from '../../types'; +import { ShareOptions } from '../types'; import { ShareModeControl } from './share_mode_control'; interface Props { - spaces: SpaceTarget[]; + spaces: ShareToSpaceTarget[]; + objectNoun: string; onUpdate: (shareOptions: ShareOptions) => void; shareOptions: ShareOptions; - showShareWarning: boolean; + showCreateCopyCallout: boolean; canShareToAllSpaces: boolean; makeCopy: () => void; + enableCreateNewSpaceLink: boolean; + enableSpaceAgnosticBehavior: boolean; } export const ShareToSpaceForm = (props: Props) => { - const { spaces, onUpdate, shareOptions, showShareWarning, canShareToAllSpaces, makeCopy } = props; + const { + spaces, + objectNoun, + onUpdate, + shareOptions, + showCreateCopyCallout, + canShareToAllSpaces, + makeCopy, + enableCreateNewSpaceLink, + enableSpaceAgnosticBehavior, + } = props; const setSelectedSpaceIds = (selectedSpaceIds: string[]) => onUpdate({ ...shareOptions, selectedSpaceIds }); - const getShareWarning = () => { - if (!showShareWarning) { - return null; - } - - return ( - - - } - color="warning" - > + const createCopyCallout = showCreateCopyCallout ? ( + + makeCopy()}> - - - ), - }} + id="xpack.spaces.shareToSpace.shareWarningTitle" + defaultMessage="Changes are synchronized across spaces" /> - + } + color="warning" + > + makeCopy()}> + + + ), + }} + /> + - - - ); - }; + +
+ ) : null; return (
- {getShareWarning()} + {createCopyCallout} setSelectedSpaceIds(selection)} + enableCreateNewSpaceLink={enableCreateNewSpaceLink} + enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior} />
); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/index.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/index.ts index 5f8d0dfc2e949c..beed0fd9d592ae 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/index.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/index.ts @@ -6,3 +6,5 @@ */ export { ShareSavedObjectsToSpaceService } from './share_saved_objects_to_space_service'; +export { getShareToSpaceFlyoutComponent, getLegacyUrlConflict } from './components'; +export { createRedirectLegacyUrl } from './utils'; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.test.tsx index abe1579f2058fc..a8d503d306ee81 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.test.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.test.tsx @@ -5,21 +5,14 @@ * 2.0. */ -import { coreMock, notificationServiceMock } from 'src/core/public/mocks'; import { SavedObjectsManagementRecord } from '../../../../../src/plugins/saved_objects_management/public'; -import { spacesManagerMock } from '../spaces_manager/mocks'; +import { uiApiMock } from '../ui_api/mocks'; import { ShareToSpaceSavedObjectsManagementAction } from './share_saved_objects_to_space_action'; describe('ShareToSpaceSavedObjectsManagementAction', () => { const createAction = () => { - const spacesManager = spacesManagerMock.create(); - const notificationsStart = notificationServiceMock.createStartContract(); - const { getStartServices } = coreMock.createSetup(); - return new ShareToSpaceSavedObjectsManagementAction( - spacesManager, - notificationsStart, - getStartServices - ); + const spacesApiUi = uiApiMock.create(); + return new ShareToSpaceSavedObjectsManagementAction(spacesApiUi); }; describe('#euiAction.available', () => { describe('with an object type that has a namespaceType of "multiple"', () => { diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.tsx index f115119275abd6..feb073745c6162 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.tsx @@ -7,23 +7,20 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { NotificationsStart, StartServicesAccessor } from 'src/core/public'; import { SavedObjectsManagementAction, SavedObjectsManagementRecord, } from '../../../../../src/plugins/saved_objects_management/public'; -import { ContextWrapper, ShareSavedObjectsToSpaceFlyout } from './components'; -import { SpacesManager } from '../spaces_manager'; -import { PluginsStart } from '../plugin'; +import type { SpacesApiUi } from '../../../../../src/plugins/spaces_oss/public'; export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManagementAction { public id: string = 'share_saved_objects_to_space'; public euiAction = { - name: i18n.translate('xpack.spaces.management.shareToSpace.actionTitle', { + name: i18n.translate('xpack.spaces.shareToSpace.actionTitle', { defaultMessage: 'Share to space', }), - description: i18n.translate('xpack.spaces.management.shareToSpace.actionDescription', { + description: i18n.translate('xpack.spaces.shareToSpace.actionDescription', { defaultMessage: 'Share this saved object to one or more spaces', }), icon: 'share', @@ -43,11 +40,7 @@ export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManage private isDataChanged: boolean = false; - constructor( - private readonly spacesManager: SpacesManager, - private readonly notifications: NotificationsStart, - private readonly getStartServices: StartServicesAccessor - ) { + constructor(private readonly spacesApiUi: SpacesApiUi) { super(); } @@ -56,16 +49,24 @@ export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManage throw new Error('No record available! `render()` was likely called before `start()`.'); } + const savedObjectTarget = { + type: this.record.type, + id: this.record.id, + namespaces: this.record.namespaces ?? [], + title: this.record.meta.title, + icon: this.record.meta.icon, + }; + const { ShareToSpaceFlyout } = this.spacesApiUi.components; + return ( - - (this.isDataChanged = true)} - savedObject={this.record} - spacesManager={this.spacesManager} - toastNotifications={this.notifications.toasts} - /> - + (this.isDataChanged = true)} + onClose={this.onClose} + enableCreateCopyCallout={true} + enableCreateNewSpaceLink={true} + /> ); }; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx deleted file mode 100644 index d0949da27c5798..00000000000000 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx +++ /dev/null @@ -1,207 +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 { shallowWithIntl } from '@kbn/test/jest'; -import { SpacesManager } from '../spaces_manager'; -import { spacesManagerMock } from '../spaces_manager/mocks'; -import { ShareToSpaceSavedObjectsManagementColumn } from './share_saved_objects_to_space_column'; -import { SpaceTarget } from './types'; - -const ACTIVE_SPACE: SpaceTarget = { - id: 'default', - name: 'Default', - color: '#ffffff', - isActiveSpace: true, -}; -const getSpaceData = (inactiveSpaceCount: number = 0) => { - const inactive = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf', 'Hotel'] - .map((name) => ({ - id: name.toLowerCase(), - name, - color: `#123456`, // must be a valid color as `render()` is used below - isActiveSpace: false, - })) - .slice(0, inactiveSpaceCount); - const spaceTargets = [ACTIVE_SPACE, ...inactive]; - const namespaces = spaceTargets.map(({ id }) => id); - return { spaceTargets, namespaces }; -}; - -describe('ShareToSpaceSavedObjectsManagementColumn', () => { - let spacesManager: SpacesManager; - beforeEach(() => { - spacesManager = spacesManagerMock.create(); - }); - - const createColumn = (spaceTargets: SpaceTarget[], namespaces: string[]) => { - const column = new ShareToSpaceSavedObjectsManagementColumn(spacesManager); - column.data = spaceTargets.reduce( - (acc, cur) => acc.set(cur.id, cur), - new Map() - ); - const element = column.euiColumn.render(namespaces); - return shallowWithIntl(element); - }; - - /** - * This node displays up to five named spaces (and an indicator for any number of unauthorized spaces) by default. The active space is - * omitted from this list. If more than five named spaces would be displayed, the extras (along with the unauthorized spaces indicator, if - * present) are hidden behind a button. - * If '*' (aka "All spaces") is present, it supersedes all of the above and just displays a single badge without a button. - */ - describe('#euiColumn.render', () => { - describe('with only the active space', () => { - const { spaceTargets, namespaces } = getSpaceData(); - const wrapper = createColumn(spaceTargets, namespaces); - - it('does not show badges or button', async () => { - const badges = wrapper.find('EuiBadge'); - expect(badges).toHaveLength(0); - const button = wrapper.find('EuiButtonEmpty'); - expect(button).toHaveLength(0); - }); - }); - - describe('with the active space and one inactive space', () => { - const { spaceTargets, namespaces } = getSpaceData(1); - const wrapper = createColumn(spaceTargets, namespaces); - - it('shows one badge without button', async () => { - const badges = wrapper.find('EuiBadge'); - expect(badges).toMatchInlineSnapshot(` - - Alpha - - `); - const button = wrapper.find('EuiButtonEmpty'); - expect(button).toHaveLength(0); - }); - }); - - describe('with the active space and five inactive spaces', () => { - const { spaceTargets, namespaces } = getSpaceData(5); - const wrapper = createColumn(spaceTargets, namespaces); - - it('shows badges without button', async () => { - const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button).toHaveLength(0); - }); - }); - - describe('with the active space, five inactive spaces, and one unauthorized space', () => { - const { spaceTargets, namespaces } = getSpaceData(5); - const wrapper = createColumn(spaceTargets, [...namespaces, '?']); - - it('shows badges without button', async () => { - const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', '+1']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button).toHaveLength(0); - }); - }); - - describe('with the active space, five inactive spaces, and two unauthorized spaces', () => { - const { spaceTargets, namespaces } = getSpaceData(5); - const wrapper = createColumn(spaceTargets, [...namespaces, '?', '?']); - - it('shows badges without button', async () => { - const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', '+2']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button).toHaveLength(0); - }); - }); - - describe('with the active space and six inactive spaces', () => { - const { spaceTargets, namespaces } = getSpaceData(6); - const wrapper = createColumn(spaceTargets, namespaces); - - it('shows badges with button', async () => { - let badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button.find('FormattedMessage').props()).toEqual({ - defaultMessage: '+{count} more', - id: 'xpack.spaces.management.shareToSpace.showMoreSpacesLink', - values: { count: 1 }, - }); - - button.simulate('click'); - badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot']); - }); - }); - - describe('with the active space, six inactive spaces, and one unauthorized space', () => { - const { spaceTargets, namespaces } = getSpaceData(6); - const wrapper = createColumn(spaceTargets, [...namespaces, '?']); - - it('shows badges with button', async () => { - let badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button.find('FormattedMessage').props()).toEqual({ - defaultMessage: '+{count} more', - id: 'xpack.spaces.management.shareToSpace.showMoreSpacesLink', - values: { count: 2 }, - }); - - button.simulate('click'); - badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', '+1']); - }); - }); - - describe('with the active space, six inactive spaces, and two unauthorized spaces', () => { - const { spaceTargets, namespaces } = getSpaceData(6); - const wrapper = createColumn(spaceTargets, [...namespaces, '?', '?']); - - it('shows badges with button', async () => { - let badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button.find('FormattedMessage').props()).toEqual({ - defaultMessage: '+{count} more', - id: 'xpack.spaces.management.shareToSpace.showMoreSpacesLink', - values: { count: 3 }, - }); - - button.simulate('click'); - badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', '+2']); - }); - }); - - describe('with only "all spaces"', () => { - const wrapper = createColumn([], ['*']); - - it('shows one badge without button', async () => { - const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['* All spaces']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button).toHaveLength(0); - }); - }); - - describe('with "all spaces", the active space, six inactive spaces, and one unauthorized space', () => { - // same as assertions 'with only "all spaces"' test case; if "all spaces" is present, it supersedes everything else - const { spaceTargets, namespaces } = getSpaceData(6); - const wrapper = createColumn(spaceTargets, ['*', ...namespaces, '?']); - - it('shows one badge without button', async () => { - const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['* All spaces']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button).toHaveLength(0); - }); - }); - }); -}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx index 6195095156258c..05e0976da0710c 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx @@ -5,151 +5,30 @@ * 2.0. */ -import React, { useState, ReactNode } from 'react'; +import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiBadge } from '@elastic/eui'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { EuiToolTip } from '@elastic/eui'; -import { EuiButtonEmpty } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; import { SavedObjectsManagementColumn } from '../../../../../src/plugins/saved_objects_management/public'; -import { SpaceTarget } from './types'; -import { SpacesManager } from '../spaces_manager'; -import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../common/constants'; -import { getSpaceColor } from '..'; - -const SPACES_DISPLAY_COUNT = 5; - -type SpaceMap = Map; -interface ColumnDataProps { - namespaces?: string[]; - data?: SpaceMap; -} - -const ColumnDisplay = ({ namespaces, data }: ColumnDataProps) => { - const [isExpanded, setIsExpanded] = useState(false); - - if (!data) { - return null; - } - - const isSharedToAllSpaces = namespaces?.includes(ALL_SPACES_ID); - const unauthorizedCount = (namespaces?.filter((namespace) => namespace === UNKNOWN_SPACE) ?? []) - .length; - let displayedSpaces: SpaceTarget[]; - let button: ReactNode = null; - - if (isSharedToAllSpaces) { - displayedSpaces = [ - { - id: ALL_SPACES_ID, - name: i18n.translate('xpack.spaces.management.shareToSpace.allSpacesLabel', { - defaultMessage: `* All spaces`, - }), - isActiveSpace: false, - color: '#D3DAE6', - }, - ]; - } else { - const authorized = namespaces?.filter((namespace) => namespace !== UNKNOWN_SPACE) ?? []; - const authorizedSpaceTargets: SpaceTarget[] = []; - authorized.forEach((namespace) => { - const spaceTarget = data.get(namespace); - if (spaceTarget === undefined) { - // in the event that a new space was created after this page has loaded, fall back to displaying the space ID - authorizedSpaceTargets.push({ id: namespace, name: namespace, isActiveSpace: false }); - } else if (!spaceTarget.isActiveSpace) { - authorizedSpaceTargets.push(spaceTarget); - } - }); - displayedSpaces = isExpanded - ? authorizedSpaceTargets - : authorizedSpaceTargets.slice(0, SPACES_DISPLAY_COUNT); - - if (authorizedSpaceTargets.length > SPACES_DISPLAY_COUNT) { - button = isExpanded ? ( - setIsExpanded(false)}> - - - ) : ( - setIsExpanded(true)}> - - - ); - } - } - - const unauthorizedCountBadge = - !isSharedToAllSpaces && (isExpanded || button === null) && unauthorizedCount > 0 ? ( - - - } - > - +{unauthorizedCount} - - - ) : null; - - return ( - - {displayedSpaces.map(({ id, name, color }) => ( - - {name} - - ))} - {unauthorizedCountBadge} - {button} - - ); -}; +import type { SpacesApiUi } from '../../../../../src/plugins/spaces_oss/public'; export class ShareToSpaceSavedObjectsManagementColumn - implements SavedObjectsManagementColumn { + implements SavedObjectsManagementColumn { public id: string = 'share_saved_objects_to_space'; - public data: Map | undefined; public euiColumn = { field: 'namespaces', - name: i18n.translate('xpack.spaces.management.shareToSpace.columnTitle', { + name: i18n.translate('xpack.spaces.shareToSpace.columnTitle', { defaultMessage: 'Shared spaces', }), - description: i18n.translate('xpack.spaces.management.shareToSpace.columnDescription', { + description: i18n.translate('xpack.spaces.shareToSpace.columnDescription', { defaultMessage: 'The other spaces that this object is currently shared to', }), - render: (namespaces: string[] | undefined) => ( - - ), - }; - - constructor(private readonly spacesManager: SpacesManager) {} - - public loadData = () => { - this.data = undefined; - return Promise.all([this.spacesManager.getSpaces(), this.spacesManager.getActiveSpace()]).then( - ([spaces, activeSpace]) => { - this.data = spaces - .map((space) => ({ - ...space, - isActiveSpace: space.id === activeSpace.id, - color: getSpaceColor(space), - })) - .reduce((acc, cur) => acc.set(cur.id, cur), new Map()); - return this.data; + render: (namespaces: string[] | undefined) => { + if (!namespaces) { + return null; } - ); + return ; + }, }; + + constructor(private readonly spacesApiUi: SpacesApiUi) {} } diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.ts index eeadb157b51873..6e74fa31ec4b8b 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.ts @@ -7,19 +7,16 @@ import { ShareToSpaceSavedObjectsManagementAction } from './share_saved_objects_to_space_action'; // import { ShareToSpaceSavedObjectsManagementColumn } from './share_saved_objects_to_space_column'; -import { spacesManagerMock } from '../spaces_manager/mocks'; import { ShareSavedObjectsToSpaceService } from '.'; -import { coreMock, notificationServiceMock } from 'src/core/public/mocks'; import { savedObjectsManagementPluginMock } from '../../../../../src/plugins/saved_objects_management/public/mocks'; +import { uiApiMock } from '../ui_api/mocks'; describe('ShareSavedObjectsToSpaceService', () => { describe('#setup', () => { it('registers the ShareToSpaceSavedObjectsManagement Action and Column', () => { const deps = { - spacesManager: spacesManagerMock.create(), - notificationsSetup: notificationServiceMock.createSetupContract(), savedObjectsManagementSetup: savedObjectsManagementPluginMock.createSetupContract(), - getStartServices: coreMock.createSetup().getStartServices, + spacesApiUi: uiApiMock.create(), }; const service = new ShareSavedObjectsToSpaceService(); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.ts index 08a7db106d6bb9..86b9c07bebe924 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.ts @@ -5,35 +5,22 @@ * 2.0. */ -import { NotificationsSetup, StartServicesAccessor } from 'src/core/public'; import { SavedObjectsManagementPluginSetup } from 'src/plugins/saved_objects_management/public'; +import type { SpacesApiUi } from '../../../../../src/plugins/spaces_oss/public'; import { ShareToSpaceSavedObjectsManagementAction } from './share_saved_objects_to_space_action'; // import { ShareToSpaceSavedObjectsManagementColumn } from './share_saved_objects_to_space_column'; -import { SpacesManager } from '../spaces_manager'; -import { PluginsStart } from '../plugin'; interface SetupDeps { - spacesManager: SpacesManager; savedObjectsManagementSetup: SavedObjectsManagementPluginSetup; - notificationsSetup: NotificationsSetup; - getStartServices: StartServicesAccessor; + spacesApiUi: SpacesApiUi; } export class ShareSavedObjectsToSpaceService { - public setup({ - spacesManager, - savedObjectsManagementSetup, - notificationsSetup, - getStartServices, - }: SetupDeps) { - const action = new ShareToSpaceSavedObjectsManagementAction( - spacesManager, - notificationsSetup, - getStartServices - ); + public setup({ savedObjectsManagementSetup, spacesApiUi }: SetupDeps) { + const action = new ShareToSpaceSavedObjectsManagementAction(spacesApiUi); savedObjectsManagementSetup.actions.register(action); // Note: this column is hidden for now because no saved objects are shareable. It should be uncommented when at least one saved object type is multi-namespace. - // const column = new ShareToSpaceSavedObjectsManagementColumn(spacesManager); + // const column = new ShareToSpaceSavedObjectsManagementColumn(spacesApiUi); // savedObjectsManagementSetup.columns.register(column); } } diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts index f5e0d09a99e4bb..fda561d8c4af11 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts @@ -6,10 +6,10 @@ */ import { SavedObjectsImportRetry, SavedObjectsImportResponse } from 'src/core/public'; -import { GetSpaceResult } from '..'; export interface ShareOptions { selectedSpaceIds: string[]; + initiallySelectedSpaceIds: string[]; } export type ImportRetry = Omit; @@ -17,8 +17,3 @@ export type ImportRetry = Omit; export interface ShareSavedObjectsToSpaceResponse { [spaceId: string]: SavedObjectsImportResponse; } - -export interface SpaceTarget extends Omit { - isActiveSpace: boolean; - isPartiallyAuthorized?: boolean; -} diff --git a/x-pack/plugins/ml/public/application/contexts/spaces/index.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/index.ts similarity index 68% rename from x-pack/plugins/ml/public/application/contexts/spaces/index.ts rename to x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/index.ts index 7b87bab8057e9c..a40bc87cd4dc30 100644 --- a/x-pack/plugins/ml/public/application/contexts/spaces/index.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/index.ts @@ -5,9 +5,4 @@ * 2.0. */ -export { - SpacesContext, - SpacesContextValue, - createSpacesContext, - useSpacesContext, -} from './spaces_context'; +export { createRedirectLegacyUrl } from './redirect_legacy_url'; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.test.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.test.ts new file mode 100644 index 00000000000000..84d2958092a650 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.test.ts @@ -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 { BehaviorSubject } from 'rxjs'; +import { coreMock } from '../../../../../../src/core/public/mocks'; +import { createRedirectLegacyUrl } from './redirect_legacy_url'; + +const APP_ID = 'testAppId'; + +describe('#redirectLegacyUrl', () => { + const setup = () => { + const { getStartServices } = coreMock.createSetup(); + const startServices = coreMock.createStart(); + const subject = new BehaviorSubject(`not-${APP_ID}`); + subject.next(APP_ID); // test below asserts that the consumer received the most recent APP_ID + startServices.application.currentAppId$ = subject; + const toasts = startServices.notifications.toasts; + const application = startServices.application; + getStartServices.mockResolvedValue([startServices, , ,]); + + const redirectLegacyUrl = createRedirectLegacyUrl(getStartServices); + + return { redirectLegacyUrl, toasts, application }; + }; + + it('creates a toast and redirects to the given path in the current app', async () => { + const { redirectLegacyUrl, toasts, application } = setup(); + + const path = '/foo?bar#baz'; + await redirectLegacyUrl(path); + + expect(toasts.addInfo).toHaveBeenCalledTimes(1); + expect(application.navigateToApp).toHaveBeenCalledTimes(1); + expect(application.navigateToApp).toHaveBeenCalledWith(APP_ID, { replace: true, path }); + }); +}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.ts new file mode 100644 index 00000000000000..694465e34049c1 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { firstValueFrom } from '@kbn/std'; +import type { StartServicesAccessor } from 'src/core/public'; +import type { SpacesApiUi } from 'src/plugins/spaces_oss/public'; +import type { PluginsStart } from '../../plugin'; +import { DEFAULT_OBJECT_NOUN } from '../components/constants'; + +export function createRedirectLegacyUrl( + getStartServices: StartServicesAccessor +): SpacesApiUi['redirectLegacyUrl'] { + return async function (path: string, objectNoun: string = DEFAULT_OBJECT_NOUN) { + const [{ notifications, application }] = await getStartServices(); + const { currentAppId$, navigateToApp } = application; + const appId = await firstValueFrom(currentAppId$); // retrieve the most recent value from the BehaviorSubject + + const title = i18n.translate('xpack.spaces.shareToSpace.redirectLegacyUrlToast.title', { + defaultMessage: `We redirected you to a new URL`, + }); + const text = i18n.translate('xpack.spaces.shareToSpace.redirectLegacyUrlToast.text', { + defaultMessage: `The {objectNoun} you're looking for has a new location. Use this URL from now on.`, + values: { objectNoun }, + }); + notifications.toasts.addInfo({ title, text }); + await navigateToApp(appId!, { replace: true, path }); + }; +} diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_selector/index.ts b/x-pack/plugins/spaces/public/space_list/index.ts similarity index 81% rename from x-pack/plugins/ml/public/application/components/job_spaces_selector/index.ts rename to x-pack/plugins/spaces/public/space_list/index.ts index da960a20c15388..1570ad123b9ab7 100644 --- a/x-pack/plugins/ml/public/application/components/job_spaces_selector/index.ts +++ b/x-pack/plugins/spaces/public/space_list/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { JobSpacesFlyout } from './jobs_spaces_flyout'; +export { getSpaceListComponent } from './space_list'; diff --git a/x-pack/plugins/spaces/public/space_list/space_list.tsx b/x-pack/plugins/spaces/public/space_list/space_list.tsx new file mode 100644 index 00000000000000..d8bd47b66b5c62 --- /dev/null +++ b/x-pack/plugins/spaces/public/space_list/space_list.tsx @@ -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 React from 'react'; +import type { SpaceListProps } from '../../../../../src/plugins/spaces_oss/public'; +import { SpaceListInternal } from './space_list_internal'; + +export const getSpaceListComponent = (): React.FC => { + return (props: SpaceListProps) => { + return ; + }; +}; diff --git a/x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx b/x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx new file mode 100644 index 00000000000000..e0e8cc23373797 --- /dev/null +++ b/x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx @@ -0,0 +1,310 @@ +/* + * 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 { mountWithIntl } from '@kbn/test/jest'; +import { act } from '@testing-library/react'; +import { coreMock } from 'src/core/public/mocks'; +import type { Space } from 'src/plugins/spaces_oss/common'; +import type { SpaceListProps } from '../../../../../src/plugins/spaces_oss/public'; +import { getSpacesContextWrapper } from '../spaces_context'; +import { spacesManagerMock } from '../spaces_manager/mocks'; +import { ReactWrapper } from 'enzyme'; +import { SpaceListInternal } from './space_list_internal'; + +const ACTIVE_SPACE: Space = { + id: 'default', + name: 'Default', + initials: 'D!', // so it can be differentiated from 'Delta' + disabledFeatures: [], +}; +const getSpaceData = (inactiveSpaceCount: number = 0) => { + const inactive = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf', 'Hotel'] + .map((name) => { + const id = name.toLowerCase(); + return { id, name, disabledFeatures: [`${id}-feature`] }; + }) + .slice(0, inactiveSpaceCount); + const spaces = [ACTIVE_SPACE, ...inactive]; + const namespaces = spaces.map(({ id }) => id); + return { spaces, namespaces }; +}; + +/** + * Displays a corresponding list of spaces for a given list of saved object namespaces. It shows up to five spaces (and an indicator for any + * number of spaces that the user is not authorized to see) by default. If more than five named spaces would be displayed, the extras (along + * with the unauthorized spaces indicator, if present) are hidden behind a button. If '*' (aka "All spaces") is present, it supersedes all + * of the above and just displays a single badge without a button. + */ +describe('SpaceListInternal', () => { + const createSpaceList = async ({ + spaces, + props, + feature, + }: { + spaces: Space[]; + props: SpaceListProps; + feature?: string; + }) => { + const { getStartServices } = coreMock.createSetup(); + const spacesManager = spacesManagerMock.create(); + spacesManager.getActiveSpace.mockResolvedValue(ACTIVE_SPACE); + spacesManager.getSpaces.mockResolvedValue(spaces); + + const SpacesContext = getSpacesContextWrapper({ getStartServices, spacesManager }); + const wrapper = mountWithIntl( + + + + ); + + // wait for context wrapper to rerender + await act(async () => {}); + wrapper.update(); + + return wrapper; + }; + + function getListText(wrapper: ReactWrapper) { + return wrapper.find('EuiFlexItem').map((x) => x.text()); + } + function getButton(wrapper: ReactWrapper) { + return wrapper.find('EuiButtonEmpty'); + } + + describe('using default properties', () => { + describe('with only the active space', () => { + const { spaces, namespaces } = getSpaceData(); + + it('does not show badges or button', async () => { + const props = { namespaces }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toHaveLength(0); + expect(getButton(wrapper)).toHaveLength(0); + }); + }); + + describe('with the active space and one inactive space', () => { + const { spaces, namespaces } = getSpaceData(1); + + it('shows one badge without button', async () => { + const props = { namespaces }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A']); + expect(getButton(wrapper)).toHaveLength(0); + }); + }); + + describe('with the active space and five inactive spaces', () => { + const { spaces, namespaces } = getSpaceData(5); + + it('shows badges without button', async () => { + const props = { namespaces }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E']); + expect(getButton(wrapper)).toHaveLength(0); + }); + }); + + describe('with the active space, five inactive spaces, and one unauthorized space', () => { + const { spaces, namespaces } = getSpaceData(5); + + it('shows badges without button', async () => { + const props = { namespaces: [...namespaces, '?'] }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E', '+1']); + expect(getButton(wrapper)).toHaveLength(0); + }); + }); + + describe('with the active space, five inactive spaces, and two unauthorized spaces', () => { + const { spaces, namespaces } = getSpaceData(5); + + it('shows badges without button', async () => { + const props = { namespaces: [...namespaces, '?', '?'] }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E', '+2']); + expect(getButton(wrapper)).toHaveLength(0); + }); + }); + + describe('with the active space and six inactive spaces', () => { + const { spaces, namespaces } = getSpaceData(6); + + it('shows badges with button', async () => { + const props = { namespaces }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E']); + + const button = getButton(wrapper); + expect(button.text()).toEqual('+1 more'); + + button.simulate('click'); + const badgeText = getListText(wrapper); + expect(badgeText).toEqual(['A', 'B', 'C', 'D', 'E', 'F']); + expect(button.text()).toEqual('show less'); + }); + }); + + describe('with the active space, six inactive spaces, and one unauthorized space', () => { + const { spaces, namespaces } = getSpaceData(6); + + it('shows badges with button', async () => { + const props = { namespaces: [...namespaces, '?'] }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E']); + const button = getButton(wrapper); + expect(button.text()).toEqual('+2 more'); + + button.simulate('click'); + const badgeText = getListText(wrapper); + expect(badgeText).toEqual(['A', 'B', 'C', 'D', 'E', 'F', '+1']); + expect(button.text()).toEqual('show less'); + }); + }); + + describe('with the active space, six inactive spaces, and two unauthorized spaces', () => { + const { spaces, namespaces } = getSpaceData(6); + + it('shows badges with button', async () => { + const props = { namespaces: [...namespaces, '?', '?'] }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E']); + const button = getButton(wrapper); + expect(button.text()).toEqual('+3 more'); + + button.simulate('click'); + const badgeText = getListText(wrapper); + expect(badgeText).toEqual(['A', 'B', 'C', 'D', 'E', 'F', '+2']); + expect(button.text()).toEqual('show less'); + }); + }); + + describe('with only "all spaces"', () => { + it('shows one badge without button', async () => { + const props = { namespaces: ['*'] }; + const wrapper = await createSpaceList({ spaces: [], props }); + + expect(getListText(wrapper)).toEqual(['*']); + expect(getButton(wrapper)).toHaveLength(0); + }); + }); + + describe('with "all spaces", the active space, six inactive spaces, and one unauthorized space', () => { + // same as assertions 'with only "all spaces"' test case; if "all spaces" is present, it supersedes everything else + const { spaces, namespaces } = getSpaceData(6); + + it('shows one badge without button', async () => { + const props = { namespaces: ['*', ...namespaces, '?'] }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['*']); + expect(getButton(wrapper)).toHaveLength(0); + }); + }); + }); + + describe('using custom properties', () => { + describe('with the active space, eight inactive spaces, and one unauthorized space', () => { + const { spaces, namespaces } = getSpaceData(8); + + it('with displayLimit=0, shows badges without button', async () => { + const props = { namespaces: [...namespaces, '?'], displayLimit: 0 }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', '+1']); + expect(getButton(wrapper)).toHaveLength(0); + }); + + it('with displayLimit=1, shows badges with button', async () => { + const props = { namespaces: [...namespaces, '?'], displayLimit: 1 }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A']); + const button = getButton(wrapper); + expect(button.text()).toEqual('+8 more'); + + button.simulate('click'); + const badgeText = getListText(wrapper); + expect(badgeText).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', '+1']); + expect(button.text()).toEqual('show less'); + }); + + it('with displayLimit=7, shows badges with button', async () => { + const props = { namespaces: [...namespaces, '?'], displayLimit: 7 }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G']); + const button = getButton(wrapper); + expect(button.text()).toEqual('+2 more'); + + button.simulate('click'); + const badgeText = getListText(wrapper); + expect(badgeText).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', '+1']); + expect(button.text()).toEqual('show less'); + }); + + it('with displayLimit=8, shows badges without button', async () => { + const props = { namespaces: [...namespaces, '?'], displayLimit: 8 }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', '+1']); + expect(getButton(wrapper)).toHaveLength(0); + }); + + it('with behaviorContext="outside-space", shows badges with button', async () => { + const props: SpaceListProps = { + namespaces: [...namespaces, '?'], + behaviorContext: 'outside-space', + }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['D!', 'A', 'B', 'C', 'D']); + const button = getButton(wrapper); + expect(button.text()).toEqual('+5 more'); + + button.simulate('click'); + const badgeText = getListText(wrapper); + expect(badgeText).toEqual(['D!', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', '+1']); + expect(button.text()).toEqual('show less'); + }); + }); + }); + + describe('with a SpacesContext for a specific feature', () => { + describe('with the active space, eight inactive spaces, and one unauthorized space', () => { + const { spaces, namespaces } = getSpaceData(8); + + it('shows badges with button, showing disabled features at the end of the list', async () => { + // Each space that is generated by the getSpaceData function has a disabled feature derived from its own ID. + // E.g., the Alpha space has `disabledFeatures: ['alpha-feature']`, the Bravo space has `disabledFeatures: ['bravo-feature']`, and + // so on and so forth. For this test case we will render the Space context for the 'bravo-feature' feature, so the SpaceAvatar for + // the Bravo space will appear at the end of the list. + const props = { namespaces: [...namespaces, '?'] }; + const feature = 'bravo-feature'; + const wrapper = await createSpaceList({ spaces, props, feature }); + + expect(getListText(wrapper)).toEqual(['A', 'C', 'D', 'E', 'F']); + const button = getButton(wrapper); + expect(button.text()).toEqual('+4 more'); + + button.simulate('click'); + const badgeText = getListText(wrapper); + expect(badgeText).toEqual(['A', 'C', 'D', 'E', 'F', 'G', 'H', 'B', '+1']); + expect(button.text()).toEqual('show less'); + }); + }); + }); +}); diff --git a/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx b/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx new file mode 100644 index 00000000000000..b0250105885d2f --- /dev/null +++ b/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx @@ -0,0 +1,144 @@ +/* + * 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, ReactNode, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiBadge } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiToolTip } from '@elastic/eui'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import type { SpaceListProps } from '../../../../../src/plugins/spaces_oss/public'; +import { ShareToSpacesData, ShareToSpaceTarget } from '../types'; +import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../common/constants'; +import { useSpaces } from '../spaces_context'; +import { SpaceAvatar } from '../space_avatar'; + +const DEFAULT_DISPLAY_LIMIT = 5; + +/** + * Displays a corresponding list of spaces for a given list of saved object namespaces. It shows up to five spaces (and an indicator for any + * number of spaces that the user is not authorized to see) by default. If more than five named spaces would be displayed, the extras (along + * with the unauthorized spaces indicator, if present) are hidden behind a button. If '*' (aka "All spaces") is present, it supersedes all + * of the above and just displays a single badge without a button. + */ +export const SpaceListInternal = ({ + namespaces, + displayLimit = DEFAULT_DISPLAY_LIMIT, + behaviorContext, +}: SpaceListProps) => { + const { shareToSpacesDataPromise } = useSpaces(); + + const [isExpanded, setIsExpanded] = useState(false); + const [shareToSpacesData, setShareToSpacesData] = useState(); + + useEffect(() => { + shareToSpacesDataPromise.then((x) => { + setShareToSpacesData(x); + }); + }, [shareToSpacesDataPromise]); + + if (!shareToSpacesData) { + return null; + } + + const isSharedToAllSpaces = namespaces.includes(ALL_SPACES_ID); + const unauthorizedSpacesCount = namespaces.filter((namespace) => namespace === UNKNOWN_SPACE) + .length; + let displayedSpaces: ShareToSpaceTarget[]; + let button: ReactNode = null; + + if (isSharedToAllSpaces) { + displayedSpaces = [ + { + id: ALL_SPACES_ID, + name: i18n.translate('xpack.spaces.spaceList.allSpacesLabel', { + defaultMessage: `* All spaces`, + }), + initials: '*', + color: '#D3DAE6', + }, + ]; + } else { + const authorized = namespaces.filter((namespace) => namespace !== UNKNOWN_SPACE); + const enabledSpaceTargets: ShareToSpaceTarget[] = []; + const disabledSpaceTargets: ShareToSpaceTarget[] = []; + authorized.forEach((namespace) => { + const spaceTarget = shareToSpacesData.spacesMap.get(namespace); + if (spaceTarget === undefined) { + // in the event that a new space was created after this page has loaded, fall back to displaying the space ID + enabledSpaceTargets.push({ id: namespace, name: namespace }); + } else if (behaviorContext === 'outside-space' || !spaceTarget.isActiveSpace) { + if (spaceTarget.isFeatureDisabled) { + disabledSpaceTargets.push(spaceTarget); + } else { + enabledSpaceTargets.push(spaceTarget); + } + } + }); + const authorizedSpaceTargets = [...enabledSpaceTargets, ...disabledSpaceTargets]; + + displayedSpaces = + isExpanded || !displayLimit + ? authorizedSpaceTargets + : authorizedSpaceTargets.slice(0, displayLimit); + + if (displayLimit && authorizedSpaceTargets.length > displayLimit) { + button = isExpanded ? ( + setIsExpanded(false)}> + + + ) : ( + setIsExpanded(true)}> + + + ); + } + } + + const unauthorizedSpacesCountBadge = + !isSharedToAllSpaces && (isExpanded || button === null) && unauthorizedSpacesCount > 0 ? ( + + + } + > + +{unauthorizedSpacesCount} + + + ) : null; + + return ( + + {displayedSpaces.map((space) => { + // color may be undefined, which is intentional; SpacesAvatar calls the getSpaceColor function before rendering + const color = space.isFeatureDisabled ? 'hollow' : space.color; + return ( + + + + ); + })} + {unauthorizedSpacesCountBadge} + {button} + + ); +}; diff --git a/x-pack/plugins/spaces/public/spaces_context/context.tsx b/x-pack/plugins/spaces/public/spaces_context/context.tsx new file mode 100644 index 00000000000000..548b2158558c55 --- /dev/null +++ b/x-pack/plugins/spaces/public/spaces_context/context.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 * as React from 'react'; +import { SpacesManager } from '../spaces_manager'; +import { ShareToSpacesData } from '../types'; +import { SpacesReactContext, SpacesReactContextValue, KibanaServices } from './types'; + +const { useContext, createElement, createContext } = React; + +const context = createContext>>({}); + +export const useSpaces = (): SpacesReactContextValue< + KibanaServices & Extra +> => + useContext( + (context as unknown) as React.Context> + ); + +export const createSpacesReactContext = ( + services: Services, + spacesManager: SpacesManager, + shareToSpacesDataPromise: Promise +): SpacesReactContext => { + const value: SpacesReactContextValue = { + spacesManager, + shareToSpacesDataPromise, + services, + }; + const Provider: React.FC = ({ children }) => + createElement(context.Provider as React.ComponentType, { value, children }); + + return { + value, + Provider, + Consumer: (context.Consumer as unknown) as React.Consumer>, + }; +}; diff --git a/x-pack/plugins/spaces/public/spaces_context/index.ts b/x-pack/plugins/spaces/public/spaces_context/index.ts new file mode 100644 index 00000000000000..fdf28ad5957cfe --- /dev/null +++ b/x-pack/plugins/spaces/public/spaces_context/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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { useSpaces } from './context'; +export { getSpacesContextWrapper } from './wrapper'; diff --git a/x-pack/plugins/spaces/public/spaces_context/types.ts b/x-pack/plugins/spaces/public/spaces_context/types.ts new file mode 100644 index 00000000000000..c2f7db69add09c --- /dev/null +++ b/x-pack/plugins/spaces/public/spaces_context/types.ts @@ -0,0 +1,25 @@ +/* + * 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 * as React from 'react'; +import { CoreStart } from 'src/core/public'; +import { ShareToSpacesData } from '../types'; +import { SpacesManager } from '../spaces_manager'; + +export type KibanaServices = Partial; + +export interface SpacesReactContextValue { + readonly spacesManager: SpacesManager; + readonly shareToSpacesDataPromise: Promise; + readonly services: Services; +} + +export interface SpacesReactContext { + value: SpacesReactContextValue; + Provider: React.FC; + Consumer: React.Consumer>; +} diff --git a/x-pack/plugins/spaces/public/spaces_context/wrapper.tsx b/x-pack/plugins/spaces/public/spaces_context/wrapper.tsx new file mode 100644 index 00000000000000..18112945ea738c --- /dev/null +++ b/x-pack/plugins/spaces/public/spaces_context/wrapper.tsx @@ -0,0 +1,89 @@ +/* + * 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, PropsWithChildren, useMemo } from 'react'; +import { + StartServicesAccessor, + DocLinksStart, + ApplicationStart, + NotificationsStart, +} from 'src/core/public'; +import type { SpacesContextProps } from '../../../../../src/plugins/spaces_oss/public'; +import { createSpacesReactContext } from './context'; +import { PluginsStart } from '../plugin'; +import { SpacesManager } from '../spaces_manager'; +import { ShareToSpacesData, ShareToSpaceTarget } from '../types'; +import { SpacesReactContext } from './types'; + +interface InternalProps { + spacesManager: SpacesManager; + getStartServices: StartServicesAccessor; +} + +interface Services { + application: ApplicationStart; + docLinks: DocLinksStart; + notifications: NotificationsStart; +} + +async function getShareToSpacesData( + spacesManager: SpacesManager, + feature?: string +): Promise { + const spaces = await spacesManager.getSpaces({ includeAuthorizedPurposes: true }); + const activeSpace = await spacesManager.getActiveSpace(); + const spacesMap = spaces + .map(({ authorizedPurposes, disabledFeatures, ...space }) => { + const isActiveSpace = space.id === activeSpace.id; + const cannotShareToSpace = authorizedPurposes?.shareSavedObjectsIntoSpace === false; + const isFeatureDisabled = feature !== undefined && disabledFeatures.includes(feature); + return { + ...space, + ...(isActiveSpace && { isActiveSpace }), + ...(cannotShareToSpace && { cannotShareToSpace }), + ...(isFeatureDisabled && { isFeatureDisabled }), + }; + }) + .reduce((acc, cur) => acc.set(cur.id, cur), new Map()); + + return { + spacesMap, + activeSpaceId: activeSpace.id, + }; +} + +const SpacesContextWrapper = (props: PropsWithChildren) => { + const { spacesManager, getStartServices, feature, children } = props; + + const [context, setContext] = useState | undefined>(); + const shareToSpacesDataPromise = useMemo(() => getShareToSpacesData(spacesManager, feature), [ + spacesManager, + feature, + ]); + + useEffect(() => { + getStartServices().then(([coreStart]) => { + const { application, docLinks, notifications } = coreStart; + const services = { application, docLinks, notifications }; + setContext(createSpacesReactContext(services, spacesManager, shareToSpacesDataPromise)); + }); + }, [getStartServices, shareToSpacesDataPromise, spacesManager]); + + if (!context) { + return null; + } + + return {children}; +}; + +export const getSpacesContextWrapper = ( + internalProps: InternalProps +): React.FC => { + return ({ children, ...props }: PropsWithChildren) => { + return ; + }; +}; diff --git a/x-pack/plugins/spaces/public/types.ts b/x-pack/plugins/spaces/public/types.ts new file mode 100644 index 00000000000000..a49df82154849f --- /dev/null +++ b/x-pack/plugins/spaces/public/types.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 { GetSpaceResult } from '../common'; + +/** + * The structure for all of the space data that must be loaded for share-to-space components to function. + */ +export interface ShareToSpacesData { + /** A map of each existing space's ID and its associated {@link ShareToSpaceTarget}. */ + readonly spacesMap: Map; + /** The ID of the active space. */ + readonly activeSpaceId: string; +} + +/** + * The data that was fetched for a specific space. Includes optional additional fields that are needed to handle edge cases in the + * share-to-space components that consume it. + */ +export interface ShareToSpaceTarget extends Omit { + /** True if this space is the active space. */ + isActiveSpace?: true; + /** True if the user has read access to this space, but is not authorized to share objects into this space. */ + cannotShareToSpace?: true; + /** True if the current feature (specified in the `SpacesContext`) is disabled in this space. */ + isFeatureDisabled?: true; +} diff --git a/x-pack/plugins/spaces/public/ui_api/components.ts b/x-pack/plugins/spaces/public/ui_api/components.ts new file mode 100644 index 00000000000000..6a8dedb5f5b683 --- /dev/null +++ b/x-pack/plugins/spaces/public/ui_api/components.ts @@ -0,0 +1,34 @@ +/* + * 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 { StartServicesAccessor } from 'src/core/public'; +import type { SpacesApiUiComponent } from '../../../../../src/plugins/spaces_oss/public'; +import { PluginsStart } from '../plugin'; +import { + getShareToSpaceFlyoutComponent, + getLegacyUrlConflict, +} from '../share_saved_objects_to_space'; +import { getSpacesContextWrapper } from '../spaces_context'; +import { SpacesManager } from '../spaces_manager'; +import { getSpaceListComponent } from '../space_list'; + +export interface GetComponentsOptions { + spacesManager: SpacesManager; + getStartServices: StartServicesAccessor; +} + +export const getComponents = ({ + spacesManager, + getStartServices, +}: GetComponentsOptions): SpacesApiUiComponent => { + return { + SpacesContext: getSpacesContextWrapper({ spacesManager, getStartServices }), + ShareToSpaceFlyout: getShareToSpaceFlyoutComponent(), + SpaceList: getSpaceListComponent(), + LegacyUrlConflict: getLegacyUrlConflict({ getStartServices }), + }; +}; diff --git a/x-pack/plugins/spaces/public/ui_api/index.ts b/x-pack/plugins/spaces/public/ui_api/index.ts new file mode 100644 index 00000000000000..e278eb691910fe --- /dev/null +++ b/x-pack/plugins/spaces/public/ui_api/index.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 type { StartServicesAccessor } from 'src/core/public'; +import type { SpacesApiUi } from '../../../../../src/plugins/spaces_oss/public'; +import type { PluginsStart } from '../plugin'; +import type { SpacesManager } from '../spaces_manager'; +import { getComponents } from './components'; +import { createRedirectLegacyUrl } from '../share_saved_objects_to_space'; + +interface GetUiApiOptions { + spacesManager: SpacesManager; + getStartServices: StartServicesAccessor; +} + +export const getUiApi = ({ spacesManager, getStartServices }: GetUiApiOptions): SpacesApiUi => { + const components = getComponents({ spacesManager, getStartServices }); + + return { + components, + redirectLegacyUrl: createRedirectLegacyUrl(getStartServices), + }; +}; diff --git a/x-pack/plugins/spaces/public/ui_api/mocks.ts b/x-pack/plugins/spaces/public/ui_api/mocks.ts new file mode 100644 index 00000000000000..c9aa2a2b2b52f9 --- /dev/null +++ b/x-pack/plugins/spaces/public/ui_api/mocks.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 type { + SpacesApiUi, + SpacesApiUiComponent, +} from '../../../../../src/plugins/spaces_oss/public'; + +function createComponentsMock(): jest.Mocked { + return { + SpacesContext: jest.fn(), + ShareToSpaceFlyout: jest.fn(), + SpaceList: jest.fn(), + LegacyUrlConflict: jest.fn(), + }; +} + +function createUiApiMock(): jest.Mocked { + return { + components: createComponentsMock(), + redirectLegacyUrl: jest.fn(), + }; +} + +export const uiApiMock = { + create: createUiApiMock, +}; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 801c2141c7d1c6..4d94539a514d1d 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -13283,7 +13283,6 @@ "xpack.ml.jobsList.actionExecuteSuccessfullyNotificationMessage": "{successesJobsCount, plural, one{{successJob}} other{# 件のジョブ}} {actionTextPT}成功", "xpack.ml.jobsList.actionFailedNotificationMessage": "{failureId} が {actionText} に失敗しました", "xpack.ml.jobsList.actionsLabel": "アクション", - "xpack.ml.jobsList.analyticsSpacesLabel": "スペース", "xpack.ml.jobsList.auditMessageColumn.screenReaderDescription": "このカラムは、過去24時間にエラーまたは警告があった場合にアイコンを表示します", "xpack.ml.jobsList.breadcrumb": "ジョブ", "xpack.ml.jobsList.cannotSelectRowForJobMessage": "ジョブID {jobId}を選択できません", @@ -13486,19 +13485,6 @@ "xpack.ml.management.jobsList.noPermissionToAccessLabel": "ML ジョブへのアクセスにはパーミッションが必要です", "xpack.ml.management.jobsList.syncFlyoutButton": "保存されたオブジェクトを同期", "xpack.ml.management.jobsListTitle": "機械学習ジョブ", - "xpack.ml.management.spacesSelectorFlyout.cannotEditCallout.text": "このジョブのスペースを変更するには、すべてのスペースでジョブを修正する権限が必要です。詳細については、システム管理者に連絡してください。", - "xpack.ml.management.spacesSelectorFlyout.cannotEditCallout.title": "{jobId} のスペースを編集する権限が不十分です", - "xpack.ml.management.spacesSelectorFlyout.closeButton": "閉じる", - "xpack.ml.management.spacesSelectorFlyout.headerLabel": "{jobId} のスペースを選択", - "xpack.ml.management.spacesSelectorFlyout.saveButton": "保存", - "xpack.ml.management.spacesSelectorFlyout.selectSpacesLabel": "スペースを選択", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.cannotCheckTooltip": "このオプションを使用するには、追加権限が必要です。", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.cannotUncheckTooltip": "このオプションを変更するには、追加権限が必要です。", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.text": "現在と将来のすべてのスペースでジョブを使用可能にします。", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.title": "すべてのスペース", - "xpack.ml.management.spacesSelectorFlyout.shareToExplicitSpaces.text": "選択したスペースでのみジョブを使用可能にします。", - "xpack.ml.management.spacesSelectorFlyout.shareToExplicitSpaces.title": "スペースを選択", - "xpack.ml.management.spacesSelectorFlyout.updateSpaces.error": "{id} の更新エラー", "xpack.ml.management.syncSavedObjectsFlyout.closeButton": "閉じる", "xpack.ml.management.syncSavedObjectsFlyout.datafeedsAdded.description": "異常検知のデータフィード ID がない保存されたオブジェクトがある場合は、ID が追加されます。", "xpack.ml.management.syncSavedObjectsFlyout.datafeedsAdded.title": "データフィードがない選択されたオブジェクト({count})", @@ -20606,44 +20592,6 @@ "xpack.spaces.management.manageSpacePage.updateSpaceButton": "スペースを更新", "xpack.spaces.management.reversedSpaceBadge.reversedSpacesCanBePartiallyModifiedTooltip": "リザーブされたスペースはビルトインのため、部分的な変更しかできません。", "xpack.spaces.management.selectAllFeaturesLink": "すべて選択", - "xpack.spaces.management.shareToSpace.actionDescription": "この保存されたオブジェクトを1つ以上のスペースと共有します。", - "xpack.spaces.management.shareToSpace.actionTitle": "スペースと共有", - "xpack.spaces.management.shareToSpace.allSpacesLabel": "*すべてのスペース", - "xpack.spaces.management.shareToSpace.cancelButton": "キャンセル", - "xpack.spaces.management.shareToSpace.columnDescription": "このオブジェクトが現在共有されている他のスペース", - "xpack.spaces.management.shareToSpace.columnTitle": "共有されているスペース", - "xpack.spaces.management.shareToSpace.columnUnauthorizedLabel": "これらのスペースを表示するアクセス権がありません。", - "xpack.spaces.management.shareToSpace.noAvailableSpaces.canCreateNewSpace.linkText": "新しいスペースを作成", - "xpack.spaces.management.shareToSpace.noAvailableSpaces.canCreateNewSpace.text": "オブジェクトを共有するには、{createANewSpaceLink}できます。", - "xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.checked": "このスペースの選択を解除するには、追加の権限が必要です。", - "xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.unchecked": "このスペースを選択するには、追加の権限が必要です。", - "xpack.spaces.management.shareToSpace.shareAddSuccessTextPlural": "「{object}」は{spaceTargets}個のスペースに追加されました。", - "xpack.spaces.management.shareToSpace.shareAddSuccessTextSingular": "「{object}」は1つのスペースに追加されました。", - "xpack.spaces.management.shareToSpace.shareEditSuccessTitle": "オブジェクトが更新されました", - "xpack.spaces.management.shareToSpace.shareErrorTitle": "保存されたオブジェクトの更新エラー", - "xpack.spaces.management.shareToSpace.shareModeControl.hiddenCountLabel": "+{hiddenCount}個が非表示", - "xpack.spaces.management.shareToSpace.shareModeControl.selectedCountLabel": "{selectedCount}個が選択済み", - "xpack.spaces.management.shareToSpace.shareModeControl.selectSpacesLabel": "スペースを選択", - "xpack.spaces.management.shareToSpace.shareModeControl.shareOptionsTitle": "共有オプション", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotCheckTooltip": "このオプションを使用するには、追加権限が必要です。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotUncheckTooltip": "このオプションを変更するには、追加権限が必要です。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.text": "現在と将来のすべてのスペースでオブジェクトを使用可能にします。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.title": "すべてのスペース", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.text": "選択したスペースでのみオブジェクトを使用可能にします。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.title": "スペースを選択", - "xpack.spaces.management.shareToSpace.shareNewSuccessTitle": "オブジェクトは共有されています", - "xpack.spaces.management.shareToSpace.shareRemoveSuccessTextPlural": "「{object}」は{spaceTargets}個のスペースから削除されました。", - "xpack.spaces.management.shareToSpace.shareRemoveSuccessTextSingular": "「{object}」は1つのスペースから削除されました。", - "xpack.spaces.management.shareToSpace.shareToSpacesButton": "保存して閉じる", - "xpack.spaces.management.shareToSpace.shareWarningBody": "1つのスペースでのみ編集するには、{makeACopyLink}してください。", - "xpack.spaces.management.shareToSpace.shareWarningLink": "コピーを作成", - "xpack.spaces.management.shareToSpace.shareWarningTitle": "共有オブジェクトの編集は、すべてのスペースで変更を適用します。", - "xpack.spaces.management.shareToSpace.showLessSpacesLink": "縮小表示", - "xpack.spaces.management.shareToSpace.showMoreSpacesLink": "他 {count} 件", - "xpack.spaces.management.shareToSpace.spacesLoadErrorTitle": "利用可能なスペースを読み込み中にエラーが発生", - "xpack.spaces.management.shareToSpace.unknownSpacesLabel.additionalPrivilegesLink": "追加権限", - "xpack.spaces.management.shareToSpace.unknownSpacesLabel.text": "非表示のスペースを表示するには、{additionalPrivilegesLink}が必要です。", - "xpack.spaces.management.shareToSpaceFlyoutHeader": "スペースと共有", "xpack.spaces.management.showAllFeaturesText": "すべて表示", "xpack.spaces.management.spaceIdentifier.customizeSpaceLinkText": "[カスタマイズ]", "xpack.spaces.management.spaceIdentifier.customizeSpaceNameLinkLabel": "URL 識別子をカスタマイズ", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index c7218ddaae2394..a35d4c67dde001 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -13315,7 +13315,6 @@ "xpack.ml.jobsList.actionExecuteSuccessfullyNotificationMessage": "{successesJobsCount, plural, one{{successJob}} other{# 个作业}}{actionTextPT}已成功", "xpack.ml.jobsList.actionFailedNotificationMessage": "{failureId} 未能{actionText}", "xpack.ml.jobsList.actionsLabel": "操作", - "xpack.ml.jobsList.analyticsSpacesLabel": "工作区", "xpack.ml.jobsList.auditMessageColumn.screenReaderDescription": "过去 24 小时里该作业有错误或警告时,此列显示图标", "xpack.ml.jobsList.breadcrumb": "作业", "xpack.ml.jobsList.cannotSelectRowForJobMessage": "无法选择作业 ID {jobId}", @@ -13518,19 +13517,6 @@ "xpack.ml.management.jobsList.noPermissionToAccessLabel": "您需要访问 ML 作业的权限", "xpack.ml.management.jobsList.syncFlyoutButton": "同步已保存对象", "xpack.ml.management.jobsListTitle": "Machine Learning", - "xpack.ml.management.spacesSelectorFlyout.cannotEditCallout.text": "要更改此作业的工作区,您需要有在所有工作区中修改作业的权限。请与您的系统管理员联系,以获取更多信息。", - "xpack.ml.management.spacesSelectorFlyout.cannotEditCallout.title": "权限不足,无法编辑 {jobId} 的工作区", - "xpack.ml.management.spacesSelectorFlyout.closeButton": "关闭", - "xpack.ml.management.spacesSelectorFlyout.headerLabel": "为 {jobId} 选择工作区", - "xpack.ml.management.spacesSelectorFlyout.saveButton": "保存", - "xpack.ml.management.spacesSelectorFlyout.selectSpacesLabel": "选择工作区", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.cannotCheckTooltip": "您还需要其他权限,才能使用此选项。", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.cannotUncheckTooltip": "您还需要其他权限,才能更改此选项。", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.text": "使作业在所有当前和将来工作区中可用。", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.title": "所有工作区", - "xpack.ml.management.spacesSelectorFlyout.shareToExplicitSpaces.text": "使作业仅在选定工作区中可用。", - "xpack.ml.management.spacesSelectorFlyout.shareToExplicitSpaces.title": "选择工作区", - "xpack.ml.management.spacesSelectorFlyout.updateSpaces.error": "更新 {id} 时出错", "xpack.ml.management.syncSavedObjectsFlyout.closeButton": "关闭", "xpack.ml.management.syncSavedObjectsFlyout.datafeedsAdded.description": "如果有已保存对象缺失异常检测作业的数据馈送 ID,则将添加该 ID。", "xpack.ml.management.syncSavedObjectsFlyout.datafeedsAdded.title": "缺失数据馈送的已保存对象 ({count})", @@ -20653,44 +20639,6 @@ "xpack.spaces.management.manageSpacePage.updateSpaceButton": "更新工作区", "xpack.spaces.management.reversedSpaceBadge.reversedSpacesCanBePartiallyModifiedTooltip": "保留的空间是内置的,只能进行部分修改。", "xpack.spaces.management.selectAllFeaturesLink": "全选", - "xpack.spaces.management.shareToSpace.actionDescription": "将此已保存对象共享到一个或多个工作区", - "xpack.spaces.management.shareToSpace.actionTitle": "共享到工作区", - "xpack.spaces.management.shareToSpace.allSpacesLabel": "* 所有工作区", - "xpack.spaces.management.shareToSpace.cancelButton": "取消", - "xpack.spaces.management.shareToSpace.columnDescription": "目前将此对象共享到的其他工作区", - "xpack.spaces.management.shareToSpace.columnTitle": "共享工作区", - "xpack.spaces.management.shareToSpace.columnUnauthorizedLabel": "您无权查看这些工作区。", - "xpack.spaces.management.shareToSpace.noAvailableSpaces.canCreateNewSpace.linkText": "创建新工作区", - "xpack.spaces.management.shareToSpace.noAvailableSpaces.canCreateNewSpace.text": "您可以{createANewSpaceLink},用于共享您的对象。", - "xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.checked": "您需要额外权限才能取消选择此工作区。", - "xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.unchecked": "您需要额外权限才能选择此工作区。", - "xpack.spaces.management.shareToSpace.shareAddSuccessTextPlural": "“{object}”已添加到 {spaceTargets} 个工作区。", - "xpack.spaces.management.shareToSpace.shareAddSuccessTextSingular": "“{object}”已添加到 1 个工作区。", - "xpack.spaces.management.shareToSpace.shareEditSuccessTitle": "对象已更新", - "xpack.spaces.management.shareToSpace.shareErrorTitle": "更新已保存对象时出错", - "xpack.spaces.management.shareToSpace.shareModeControl.hiddenCountLabel": "+{hiddenCount} 个已隐藏", - "xpack.spaces.management.shareToSpace.shareModeControl.selectedCountLabel": "{selectedCount} 个已选择", - "xpack.spaces.management.shareToSpace.shareModeControl.selectSpacesLabel": "选择工作区", - "xpack.spaces.management.shareToSpace.shareModeControl.shareOptionsTitle": "共享选项", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotCheckTooltip": "您还需要其他权限,才能使用此选项。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotUncheckTooltip": "您还需要其他权限,才能更改此选项。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.text": "使对象在当前和将来的所有空间中可用。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.title": "所有工作区", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.text": "仅使对象在选定工作区中可用。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.title": "选择工作区", - "xpack.spaces.management.shareToSpace.shareNewSuccessTitle": "对象现已共享", - "xpack.spaces.management.shareToSpace.shareRemoveSuccessTextPlural": "“{object}”已从 {spaceTargets} 个工作区中移除。", - "xpack.spaces.management.shareToSpace.shareRemoveSuccessTextSingular": "“{object}”已从 1 个工作区中移除。", - "xpack.spaces.management.shareToSpace.shareToSpacesButton": "保存并关闭", - "xpack.spaces.management.shareToSpace.shareWarningBody": "要仅在一个工作区中编辑,请改为{makeACopyLink}。", - "xpack.spaces.management.shareToSpace.shareWarningLink": "创建副本", - "xpack.spaces.management.shareToSpace.shareWarningTitle": "编辑共享对象会在所有工作区中应用更改", - "xpack.spaces.management.shareToSpace.showLessSpacesLink": "显示更少", - "xpack.spaces.management.shareToSpace.showMoreSpacesLink": "另外 {count} 个", - "xpack.spaces.management.shareToSpace.spacesLoadErrorTitle": "加载可用工作区时出错", - "xpack.spaces.management.shareToSpace.unknownSpacesLabel.additionalPrivilegesLink": "其他权限", - "xpack.spaces.management.shareToSpace.unknownSpacesLabel.text": "要查看隐藏的工作区,您需要{additionalPrivilegesLink}。", - "xpack.spaces.management.shareToSpaceFlyoutHeader": "共享到工作区", "xpack.spaces.management.showAllFeaturesText": "全部显示", "xpack.spaces.management.spaceIdentifier.customizeSpaceLinkText": "[定制]", "xpack.spaces.management.spaceIdentifier.customizeSpaceNameLinkLabel": "定制 URL 标识符", diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json index 32cae675dea746..5fac012d5e8b96 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json @@ -618,3 +618,37 @@ } } } + +{ + "type": "doc", + "value": { + "id": "sharecapabletype:only_default_space", + "index": ".kibana", + "source": { + "sharecapabletype": { + "title": "A share-capable (isolated) saved-object only in the default space" + }, + "type": "sharecapabletype", + "namespaces": ["default"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharecapabletype:only_space_1", + "index": ".kibana", + "source": { + "sharecapabletype": { + "title": "A share-capable (isolated) saved-object only in space_1" + }, + "type": "sharecapabletype", + "namespaces": ["space_1"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json index 561c2ecc56fa26..50c4fb305a6d0c 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json @@ -263,6 +263,19 @@ } } }, + "sharecapabletype": { + "properties": { + "title": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, "space": { "properties": { "_reserved": { diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts b/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts index d05a08eeeedd16..e29bbc0db56b6a 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts +++ b/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts @@ -52,6 +52,13 @@ export class Plugin { management, mappings, }); + core.savedObjects.registerType({ + name: 'sharecapabletype', + hidden: false, + namespaceType: 'multiple-isolated', + management, + mappings, + }); core.savedObjects.registerType({ name: 'globaltype', hidden: false, diff --git a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts index c16d26d834b331..8506611f245608 100644 --- a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts +++ b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts @@ -53,6 +53,16 @@ export const SAVED_OBJECT_TEST_CASES: Record = Object.fr id: 'only_space_2', expectedNamespaces: [SPACE_2_ID], }), + MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE: Object.freeze({ + type: 'sharecapabletype', + id: 'only_default_space', + expectedNamespaces: [DEFAULT_SPACE_ID], + }), + MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1: Object.freeze({ + type: 'sharecapabletype', + id: 'only_space_1', + expectedNamespaces: [SPACE_1_ID], + }), NAMESPACE_AGNOSTIC: Object.freeze({ type: 'globaltype', id: 'globaltype-id', 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 6dfe257f21c0b1..43e92cc21c469c 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 @@ -115,7 +115,7 @@ export const createRequest = ({ type, id }: TestCase) => ({ type, id }); const uniq = (arr: T[]): T[] => Array.from(new Set(arr)); const isNamespaceAgnostic = (type: string) => type === 'globaltype'; -const isMultiNamespace = (type: string) => type === 'sharedtype'; +const isMultiNamespace = (type: string) => type === 'sharedtype' || type === 'sharecapabletype'; export const expectResponses = { forbiddenTypes: (action: string) => ( typeOrTypes: string | string[] diff --git a/x-pack/test/saved_object_api_integration/common/suites/export.ts b/x-pack/test/saved_object_api_integration/common/suites/export.ts index f46fdcf01367cc..94b75f1fd536d8 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/export.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/export.ts @@ -89,6 +89,27 @@ export const getTestCases = (spaceId?: string): { [key: string]: ExportTestCase .flat(), ], }, + ...(spaceId !== SPACE_2_ID && { + // we do not have a multi-namespace isolated object in Space 2 + multiNamespaceIsolatedObject: { + title: 'multi-namespace isolated object', + ...(spaceId === SPACE_1_ID + ? CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1 + : CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE), + }, + }), + multiNamespaceIsolatedType: { + title: 'multi-namespace isolated type', + type: 'sharecapabletype', + successResult: [ + ...(spaceId === SPACE_1_ID + ? [CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1] + : spaceId === SPACE_2_ID + ? [] + : [CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE] + ).flat(), + ], + }, namespaceAgnosticObject: { title: 'namespace-agnostic object', ...CASES.NAMESPACE_AGNOSTIC, diff --git a/x-pack/test/saved_object_api_integration/common/suites/find.ts b/x-pack/test/saved_object_api_integration/common/suites/find.ts index cdeb210dddffbc..27905459c29b77 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/find.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/find.ts @@ -107,6 +107,13 @@ export const getTestCases = ( savedObjects: getExpectedSavedObjects((t) => t.type === 'sharedtype'), }, } as FindTestCase, + multiNamespaceIsolatedType: { + title: buildTitle('find multi-namespace isolated type'), + query: `type=sharecapabletype&fields=title${namespacesQueryParam}`, + successResult: { + savedObjects: getExpectedSavedObjects((t) => t.type === 'sharecapabletype'), + }, + } as FindTestCase, namespaceAgnosticType: { title: buildTitle('find namespace-agnostic type'), query: `type=globaltype&fields=title${namespacesQueryParam}`, diff --git a/x-pack/test/saved_object_api_integration/common/suites/resolve.ts b/x-pack/test/saved_object_api_integration/common/suites/resolve.ts index 94c417eeeadd5b..80a4a805224bf5 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/resolve.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/resolve.ts @@ -30,6 +30,7 @@ export type ResolveTestSuite = TestSuite; export interface ResolveTestCase extends TestCase { expectedOutcome?: 'exactMatch' | 'aliasMatch' | 'conflict'; expectedId?: string; + expectedAliasTargetId?: string; } const EACH_SPACE = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; @@ -48,6 +49,7 @@ export const TEST_CASES = Object.freeze({ expectedNamespaces: EACH_SPACE, expectedOutcome: 'aliasMatch' as 'aliasMatch', expectedId: 'alias-match-newid', + expectedAliasTargetId: 'alias-match-newid', }), CONFLICT: Object.freeze({ type: 'resolvetype', @@ -55,6 +57,7 @@ export const TEST_CASES = Object.freeze({ expectedNamespaces: EACH_SPACE, expectedOutcome: 'conflict' as 'conflict', // only in space 1, where the alias exists expectedId: 'conflict', + expectedAliasTargetId: 'conflict-newid', }), DISABLED: Object.freeze({ type: 'resolvetype', @@ -77,10 +80,15 @@ export function resolveTestSuiteFactory(esArchiver: any, supertest: SuperTest { ...fail409(!overwrite || spaceId !== SPACE_2_ID), ...unresolvableConflict(spaceId !== SPACE_2_ID), }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite || spaceId !== DEFAULT_SPACE_ID), + ...unresolvableConflict(spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite || spaceId !== SPACE_1_ID), + ...unresolvableConflict(spaceId !== SPACE_1_ID), + }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts index 89a791b06dc5d9..d547b95d34f7ef 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts @@ -36,6 +36,11 @@ const createTestCases = (spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts index 9cc6cbc967c323..b818a4b6bf33cf 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts @@ -36,6 +36,11 @@ const createTestCases = (spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; 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 eb221fc314ae30..7f5f0b453ff251 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 @@ -49,6 +49,14 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(!overwrite || spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(!overwrite || spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite || spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite || spaceId !== SPACE_1_ID), + }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts index 13c6b418d30333..6a6fc8a15decfd 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts @@ -45,6 +45,11 @@ const createTestCases = (spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts index 788e8e92a9d437..774d7f98f1635d 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts @@ -19,11 +19,13 @@ const createTestCases = (spaceId: string) => { const exportableObjects = [ cases.singleNamespaceObject, cases.multiNamespaceObject, + cases.multiNamespaceIsolatedObject, cases.namespaceAgnosticObject, ]; const exportableTypes = [ cases.singleNamespaceType, cases.multiNamespaceType, + cases.multiNamespaceIsolatedType, cases.namespaceAgnosticType, ]; const nonExportableObjectsAndTypes = [cases.hiddenObject, cases.hiddenType]; diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts index 78c38967f6e1d9..6d9c38ecca5962 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts @@ -27,6 +27,7 @@ const createTestCases = (currentSpace: string, crossSpaceSearch?: string[]) => { const normalTypes = [ cases.singleNamespaceType, cases.multiNamespaceType, + cases.multiNamespaceIsolatedType, cases.namespaceAgnosticType, cases.eachType, cases.pageBeyondTotal, diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts index e493af65257c14..e61d5c10c2dbb3 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts @@ -36,6 +36,11 @@ const createTestCases = (spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts index c40d8c3140c6ea..659ee2c2e23635 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts @@ -75,6 +75,16 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite && spaceId === SPACE_2_ID), ...destinationId(spaceId !== SPACE_2_ID), }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + ...destinationId(spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...destinationId(spaceId !== SPACE_1_ID), + }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_3A_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict @@ -124,6 +134,7 @@ export default function ({ getService }: FtrProviderContext) { 'globaltype', 'isolatedtype', 'sharedtype', + 'sharecapabletype', ]), }), ].flat(), diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts index 0ba8c171b3e259..3f213e519e57d0 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts @@ -72,6 +72,16 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite && spaceId === SPACE_2_ID), ...destinationId(spaceId !== SPACE_2_ID), }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + ...destinationId(spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...destinationId(spaceId !== SPACE_1_ID), + }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID // all of the cases below represent imports that had an inexact match conflict or an ambiguous conflict @@ -112,6 +122,7 @@ export default function ({ getService }: FtrProviderContext) { 'globaltype', 'isolatedtype', 'sharedtype', + 'sharecapabletype', ]), }), ].flat(), diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts index 5007497df5005a..44296597d52ea7 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts @@ -36,6 +36,11 @@ const createTestCases = (spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; 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 bacade65153b23..b8b57289212da4 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 @@ -33,6 +33,8 @@ const createTestCases = (overwrite: boolean) => { { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(), ...unresolvableConflict() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(), ...unresolvableConflict() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, ...fail409(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail409(), ...unresolvableConflict() }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts index b80eb7ed347e0f..18edb7502c65a0 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts @@ -27,6 +27,8 @@ const createTestCases = () => { CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404() }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts index 9b3bc39c64d11c..59da44dcd8ec4a 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts @@ -33,6 +33,8 @@ const createTestCases = () => { CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404() }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; 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 3ffb9b2d6705aa..0aae9ebe7c9145 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 @@ -32,6 +32,8 @@ const createTestCases = (overwrite: boolean) => { { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, ...fail409(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail409() }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts b/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts index e176c254589148..7d9ec0b152174f 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts @@ -31,6 +31,8 @@ const createTestCases = () => { { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, force: true }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404() }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/export.ts b/x-pack/test/saved_object_api_integration/security_only/apis/export.ts index 5cd6ea9242e123..a1580c85a3680b 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/export.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/export.ts @@ -19,11 +19,13 @@ const createTestCases = () => { const exportableObjects = [ cases.singleNamespaceObject, cases.multiNamespaceObject, + cases.multiNamespaceIsolatedObject, cases.namespaceAgnosticObject, ]; const exportableTypes = [ cases.singleNamespaceType, cases.multiNamespaceType, + cases.multiNamespaceIsolatedType, cases.namespaceAgnosticType, ]; const nonExportableObjectsAndTypes = [cases.hiddenObject, cases.hiddenType]; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/find.ts b/x-pack/test/saved_object_api_integration/security_only/apis/find.ts index 5a52402fcdf599..eb30024015fbb2 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/find.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/find.ts @@ -24,6 +24,7 @@ const createTestCases = (crossSpaceSearch?: string[]) => { const normalTypes = [ cases.singleNamespaceType, cases.multiNamespaceType, + cases.multiNamespaceIsolatedType, cases.namespaceAgnosticType, cases.eachType, cases.pageBeyondTotal, diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/get.ts b/x-pack/test/saved_object_api_integration/security_only/apis/get.ts index 5f5417761dbd17..9910900c2f51bc 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/get.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/get.ts @@ -27,6 +27,8 @@ const createTestCases = () => { CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404() }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/import.ts b/x-pack/test/saved_object_api_integration/security_only/apis/import.ts index 0cf5cdd98efa8c..b46e3fabff95b3 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/import.ts @@ -54,6 +54,8 @@ const createTestCases = (overwrite: boolean) => { { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...destinationId() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...destinationId() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, ...fail409(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...destinationId() }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_3A_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict @@ -103,6 +105,7 @@ export default function ({ getService }: FtrProviderContext) { 'globaltype', 'isolatedtype', 'sharedtype', + 'sharecapabletype', ]), }), ].flat(), diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts index 7df930d5086644..1d20de4f620fe2 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts @@ -46,6 +46,7 @@ const createTestCases = (overwrite: boolean) => { const group2 = [ { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, ...fail409(!overwrite) }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID // all of the cases below represent imports that had an inexact match conflict or an ambiguous conflict @@ -85,6 +86,7 @@ export default function ({ getService }: FtrProviderContext) { 'globaltype', 'isolatedtype', 'sharedtype', + 'sharecapabletype', ]), }), ].flat(), diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/update.ts b/x-pack/test/saved_object_api_integration/security_only/apis/update.ts index bafc90c710ac31..c0ec36fcf75c4d 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/update.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/update.ts @@ -27,6 +27,8 @@ const createTestCases = () => { CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404() }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; 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 aa771d7c48dda0..6bb7828e12f238 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 @@ -55,6 +55,16 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite || spaceId !== SPACE_2_ID), ...unresolvableConflict(spaceId !== SPACE_2_ID), }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite || spaceId !== DEFAULT_SPACE_ID), + ...unresolvableConflict(spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite || spaceId !== SPACE_1_ID), + ...unresolvableConflict(spaceId !== SPACE_1_ID), + }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.HIDDEN, ...fail400() }, { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts index 0f78983953bba6..e1d0243377b8e9 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts @@ -30,6 +30,11 @@ const createTestCases = (spaceId: string) => [ }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.HIDDEN, ...fail400() }, { ...CASES.DOES_NOT_EXIST, ...fail404() }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts index 164ecdd2992749..30dc034715ed47 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts @@ -31,6 +31,11 @@ const createTestCases = (spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.HIDDEN, ...fail404() }, { ...CASES.DOES_NOT_EXIST, ...fail404() }, 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 ff192530b47cf1..39c97be1b6285e 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 @@ -44,6 +44,14 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(!overwrite || spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(!overwrite || spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite || spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite || spaceId !== SPACE_1_ID), + }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.HIDDEN, ...fail400() }, { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts index 1d38a50a96d191..1a168bac948bef 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts @@ -39,6 +39,11 @@ const createTestCases = (spaceId: string) => [ }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.HIDDEN, ...fail404() }, { ...CASES.DOES_NOT_EXIST, ...fail404() }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts index b34ee15174e996..374bf4f0c2577e 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts @@ -30,6 +30,11 @@ const createTestCases = (spaceId: string) => [ }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.HIDDEN, ...fail404() }, { ...CASES.DOES_NOT_EXIST, ...fail404() }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts index ffe302883b43ac..b1f30657dd9c0e 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts @@ -61,6 +61,16 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite && spaceId === SPACE_2_ID), ...destinationId(spaceId !== SPACE_2_ID), }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + ...destinationId(spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...destinationId(spaceId !== SPACE_1_ID), + }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.HIDDEN, ...fail400() }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts index dde99164bd38c1..35f5d3dabde88e 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts @@ -65,6 +65,16 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite && spaceId === SPACE_2_ID), ...destinationId(spaceId !== SPACE_2_ID), }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + ...destinationId(spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...destinationId(spaceId !== SPACE_1_ID), + }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.HIDDEN, ...fail400() }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts index 3940c815aa3532..bf5d635a11d8a7 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts @@ -30,6 +30,11 @@ const createTestCases = (spaceId: string) => [ }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.HIDDEN, ...fail404() }, { ...CASES.DOES_NOT_EXIST, ...fail404() },