diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field_details.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field_details.tsx index 875b5a0aa446f..3061839bf3ef0 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field_details.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field_details.tsx @@ -16,13 +16,17 @@ * specific language governing permissions and limitations * under the License. */ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { EuiLink, EuiIconTip, EuiText, EuiPopoverFooter, EuiButton, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { DiscoverFieldBucket } from './discover_field_bucket'; import { getWarnings } from './lib/get_warnings'; +import { + triggerVisualizeActions, + isFieldVisualizable, + getVisualizeHref, +} from './lib/visualize_trigger_utils'; import { Bucket, FieldDetails } from './types'; -import { getServices } from '../../../kibana_services'; import { IndexPatternField, IndexPattern } from '../../../../../data/public'; import './discover_field_details.scss'; @@ -40,6 +44,34 @@ export function DiscoverFieldDetails({ onAddFilter, }: DiscoverFieldDetailsProps) { const warnings = getWarnings(field); + const [showVisualizeLink, setShowVisualizeLink] = useState(false); + const [visualizeLink, setVisualizeLink] = useState(''); + + useEffect(() => { + isFieldVisualizable(field, indexPattern.id, details.columns).then( + (flag) => { + setShowVisualizeLink(flag); + // get href only if Visualize button is enabled + getVisualizeHref(field, indexPattern.id, details.columns).then( + (uri) => { + if (uri) setVisualizeLink(uri); + }, + () => { + setVisualizeLink(''); + } + ); + }, + () => { + setShowVisualizeLink(false); + } + ); + }, [field, indexPattern.id, details.columns]); + + const handleVisualizeLinkClick = (event: React.MouseEvent) => { + // regular link click. let the uiActions code handle the navigation and show popup if needed + event.preventDefault(); + triggerVisualizeActions(field, indexPattern.id, details.columns); + }; return ( <> @@ -58,15 +90,13 @@ export function DiscoverFieldDetails({ )} - {details.visualizeUrl && ( + {showVisualizeLink && ( <> + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} { - getServices().core.application.navigateToApp(details.visualizeUrl.app, { - path: details.visualizeUrl.path, - }); - }} + onClick={(e) => handleVisualizeLinkClick(e)} + href={visualizeLink} size="s" className="dscFieldDetails__visualizeBtn" data-test-subj={`fieldVisualize-${field.name}`} diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx index 450bb93f60bf3..1f27766a1756d 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx @@ -111,9 +111,8 @@ export function DiscoverSidebar({ ); const getDetailsByField = useCallback( - (ipField: IndexPatternField) => - getDetails(ipField, selectedIndexPattern, state, columns, hits, services), - [selectedIndexPattern, state, columns, hits, services] + (ipField: IndexPatternField) => getDetails(ipField, hits, columns), + [hits, columns] ); const popularLimit = services.uiSettings.get(FIELDS_LIMIT_SETTING); diff --git a/src/plugins/discover/public/application/components/sidebar/lib/get_details.ts b/src/plugins/discover/public/application/components/sidebar/lib/get_details.ts index 7ac9f009d73d5..41d3393672474 100644 --- a/src/plugins/discover/public/application/components/sidebar/lib/get_details.ts +++ b/src/plugins/discover/public/application/components/sidebar/lib/get_details.ts @@ -16,32 +16,24 @@ * specific language governing permissions and limitations * under the License. */ -import { getVisualizeUrl, isFieldVisualizable } from './visualize_url_utils'; -import { AppState } from '../../../angular/discover_state'; + // @ts-ignore import { fieldCalculator } from './field_calculator'; -import { IndexPatternField, IndexPattern } from '../../../../../../data/public'; -import { DiscoverServices } from '../../../../build_services'; +import { IndexPatternField } from '../../../../../../data/public'; export function getDetails( field: IndexPatternField, - indexPattern: IndexPattern, - state: AppState, - columns: string[], hits: Array>, - services: DiscoverServices + columns: string[] ) { const details = { - visualizeUrl: - services.capabilities.visualize.show && isFieldVisualizable(field, services.visualizations) - ? getVisualizeUrl(field, indexPattern, state, columns, services) - : null, ...fieldCalculator.getFieldValueCounts({ hits, field, count: 5, grouped: false, }), + columns, }; if (details.buckets) { for (const bucket of details.buckets) { diff --git a/src/plugins/discover/public/application/components/sidebar/lib/visualize_trigger_utils.ts b/src/plugins/discover/public/application/components/sidebar/lib/visualize_trigger_utils.ts new file mode 100644 index 0000000000000..f058c198cae7f --- /dev/null +++ b/src/plugins/discover/public/application/components/sidebar/lib/visualize_trigger_utils.ts @@ -0,0 +1,110 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { + VISUALIZE_FIELD_TRIGGER, + VISUALIZE_GEO_FIELD_TRIGGER, + visualizeFieldTrigger, + visualizeGeoFieldTrigger, +} from '../../../../../../ui_actions/public'; +import { getUiActions } from '../../../../kibana_services'; +import { IndexPatternField, KBN_FIELD_TYPES } from '../../../../../../data/public'; + +function getTriggerConstant(type: string) { + return type === KBN_FIELD_TYPES.GEO_POINT || type === KBN_FIELD_TYPES.GEO_SHAPE + ? VISUALIZE_GEO_FIELD_TRIGGER + : VISUALIZE_FIELD_TRIGGER; +} + +function getTrigger(type: string) { + return type === KBN_FIELD_TYPES.GEO_POINT || type === KBN_FIELD_TYPES.GEO_SHAPE + ? visualizeGeoFieldTrigger + : visualizeFieldTrigger; +} + +async function getCompatibleActions( + fieldName: string, + indexPatternId: string, + contextualFields: string[], + trigger: typeof VISUALIZE_FIELD_TRIGGER | typeof VISUALIZE_GEO_FIELD_TRIGGER +) { + const compatibleActions = await getUiActions().getTriggerCompatibleActions(trigger, { + indexPatternId, + fieldName, + contextualFields, + }); + return compatibleActions; +} + +export async function getVisualizeHref( + field: IndexPatternField, + indexPatternId: string | undefined, + contextualFields: string[] +) { + if (!indexPatternId) return undefined; + const triggerOptions = { + indexPatternId, + fieldName: field.name, + contextualFields, + trigger: getTrigger(field.type), + }; + const compatibleActions = await getCompatibleActions( + field.name, + indexPatternId, + contextualFields, + getTriggerConstant(field.type) + ); + // enable the link only if only one action is registered + return compatibleActions.length === 1 + ? compatibleActions[0].getHref?.(triggerOptions) + : undefined; +} + +export function triggerVisualizeActions( + field: IndexPatternField, + indexPatternId: string | undefined, + contextualFields: string[] +) { + if (!indexPatternId) return; + const trigger = getTriggerConstant(field.type); + const triggerOptions = { + indexPatternId, + fieldName: field.name, + contextualFields, + }; + getUiActions().getTrigger(trigger).exec(triggerOptions); +} + +export async function isFieldVisualizable( + field: IndexPatternField, + indexPatternId: string | undefined, + contextualFields: string[] +) { + if (field.name === '_id' || !indexPatternId) { + // for first condition you'd get a 'Fielddata access on the _id field is disallowed' error on ES side. + return false; + } + const trigger = getTriggerConstant(field.type); + const compatibleActions = await getCompatibleActions( + field.name, + indexPatternId, + contextualFields, + trigger + ); + return compatibleActions.length > 0 && field.visualizable; +} diff --git a/src/plugins/discover/public/application/components/sidebar/lib/visualize_url_utils.ts b/src/plugins/discover/public/application/components/sidebar/lib/visualize_url_utils.ts deleted file mode 100644 index 0c1a44d7845cf..0000000000000 --- a/src/plugins/discover/public/application/components/sidebar/lib/visualize_url_utils.ts +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import uuid from 'uuid/v4'; -import rison from 'rison-node'; -import { parse, stringify } from 'query-string'; -import { - IFieldType, - IIndexPattern, - IndexPatternField, - KBN_FIELD_TYPES, -} from '../../../../../../data/public'; -import { AppState } from '../../../angular/discover_state'; -import { DiscoverServices } from '../../../../build_services'; -import { VisualizationsStart, VisTypeAlias } from '../../../../../../visualizations/public'; -import { AGGS_TERMS_SIZE_SETTING } from '../../../../../common'; - -export function isMapsAppRegistered(visualizations: VisualizationsStart) { - return visualizations.getAliases().some(({ name }: VisTypeAlias) => { - return name === 'maps'; - }); -} - -export function isFieldVisualizable(field: IFieldType, visualizations: VisualizationsStart) { - if (field.name === '_id') { - // Else you'd get a 'Fielddata access on the _id field is disallowed' error on ES side. - return false; - } - if ( - (field.type === KBN_FIELD_TYPES.GEO_POINT || field.type === KBN_FIELD_TYPES.GEO_SHAPE) && - isMapsAppRegistered(visualizations) - ) { - return true; - } - return field.visualizable; -} - -export function getMapsAppUrl( - field: IFieldType, - indexPattern: IIndexPattern, - appState: AppState, - columns: string[] -) { - const mapAppParams = new URLSearchParams(); - - // Copy global state - const locationSplit = window.location.hash.split('?'); - if (locationSplit.length > 1) { - const discoverParams = new URLSearchParams(locationSplit[1]); - const globalStateUrlValue = discoverParams.get('_g'); - if (globalStateUrlValue) { - mapAppParams.set('_g', globalStateUrlValue); - } - } - - // Copy filters and query in app state - const mapsAppState: any = { - filters: appState.filters || [], - }; - if (appState.query) { - mapsAppState.query = appState.query; - } - // @ts-ignore - mapAppParams.set('_a', rison.encode(mapsAppState)); - - // create initial layer descriptor - const hasColumns = columns && columns.length && columns[0] !== '_source'; - const supportsClustering = field.aggregatable; - mapAppParams.set( - 'initialLayers', - // @ts-ignore - rison.encode_array([ - { - id: uuid(), - label: indexPattern.title, - sourceDescriptor: { - id: uuid(), - type: 'ES_SEARCH', - geoField: field.name, - tooltipProperties: hasColumns ? columns : [], - indexPatternId: indexPattern.id, - scalingType: supportsClustering ? 'CLUSTERS' : 'LIMIT', - }, - visible: true, - type: supportsClustering ? 'BLENDED_VECTOR' : 'VECTOR', - }, - ]) - ); - - return { - app: 'maps', - path: `/map#?${mapAppParams.toString()}`, - }; -} - -export function getVisualizeUrl( - field: IndexPatternField, - indexPattern: IIndexPattern, - state: AppState, - columns: string[], - services: DiscoverServices -) { - const aggsTermSize = services.uiSettings.get(AGGS_TERMS_SIZE_SETTING); - const urlParams = parse(services.history().location.search) as Record; - - if ( - (field.type === KBN_FIELD_TYPES.GEO_POINT || field.type === KBN_FIELD_TYPES.GEO_SHAPE) && - isMapsAppRegistered(services.visualizations) - ) { - return getMapsAppUrl(field, indexPattern, state, columns); - } - - let agg; - const isGeoPoint = field.type === KBN_FIELD_TYPES.GEO_POINT; - const type = isGeoPoint ? 'tile_map' : 'histogram'; - // If we're visualizing a date field, and our index is time based (and thus has a time filter), - // then run a date histogram - if (field.type === 'date' && indexPattern.timeFieldName === field.name) { - agg = { - type: 'date_histogram', - schema: 'segment', - params: { - field: field.name, - interval: 'auto', - }, - }; - } else if (isGeoPoint) { - agg = { - type: 'geohash_grid', - schema: 'segment', - params: { - field: field.name, - precision: 3, - }, - }; - } else { - agg = { - type: 'terms', - schema: 'segment', - params: { - field: field.name, - size: parseInt(aggsTermSize, 10), - orderBy: '1', - }, - }; - } - const linkUrlParams = { - ...urlParams, - ...{ - indexPattern: state.index!, - type, - _a: rison.encode({ - filters: state.filters || [], - query: state.query, - vis: { - type, - aggs: [{ schema: 'metric', type: 'count', id: '1' }, agg], - }, - } as any), - }, - }; - - return { - app: 'visualize', - path: `#/create?${stringify(linkUrlParams)}`, - }; -} diff --git a/src/plugins/discover/public/application/components/sidebar/types.ts b/src/plugins/discover/public/application/components/sidebar/types.ts index e86138761c747..d80662b65cc7b 100644 --- a/src/plugins/discover/public/application/components/sidebar/types.ts +++ b/src/plugins/discover/public/application/components/sidebar/types.ts @@ -27,10 +27,7 @@ export interface FieldDetails { exists: number; total: boolean; buckets: Bucket[]; - visualizeUrl: { - app: string; - path: string; - }; + columns: string[]; } export interface Bucket { diff --git a/src/plugins/discover/public/kibana_services.ts b/src/plugins/discover/public/kibana_services.ts index ecb5d7fd90283..bc25fa71dcf41 100644 --- a/src/plugins/discover/public/kibana_services.ts +++ b/src/plugins/discover/public/kibana_services.ts @@ -20,6 +20,7 @@ import _ from 'lodash'; import { createHashHistory } from 'history'; import { ScopedHistory } from 'kibana/public'; +import { UiActionsStart } from 'src/plugins/ui_actions/public'; import { DiscoverServices } from './build_services'; import { createGetterSetter } from '../../kibana_utils/public'; import { search } from '../../data/public'; @@ -27,6 +28,7 @@ import { DocViewsRegistry } from './application/doc_views/doc_views_registry'; let angularModule: any = null; let services: DiscoverServices | null = null; +let uiActions: UiActionsStart; /** * set bootstrapped inner angular module @@ -53,6 +55,9 @@ export function setServices(newServices: any) { services = newServices; } +export const setUiActions = (pluginUiActions: UiActionsStart) => (uiActions = pluginUiActions); +export const getUiActions = () => uiActions; + export const [getUrlTracker, setUrlTracker] = createGetterSetter<{ setTrackedUrl: (url: string) => void; restorePreviousUrl: () => void; diff --git a/src/plugins/discover/public/plugin.ts b/src/plugins/discover/public/plugin.ts index 20e13d204e0e9..015f4267646c1 100644 --- a/src/plugins/discover/public/plugin.ts +++ b/src/plugins/discover/public/plugin.ts @@ -53,6 +53,7 @@ import { setUrlTracker, setAngularModule, setServices, + setUiActions, setScopedHistory, getScopedHistory, syncHistoryLocations, @@ -314,6 +315,8 @@ export class DiscoverPlugin this.innerAngularInitialized = true; }; + setUiActions(plugins.uiActions); + this.initializeServices = async () => { if (this.servicesInitialized) { return { core, plugins }; diff --git a/src/plugins/tile_map/public/plugin.ts b/src/plugins/tile_map/public/plugin.ts index 4582cd2283dc1..9a164f8a303f8 100644 --- a/src/plugins/tile_map/public/plugin.ts +++ b/src/plugins/tile_map/public/plugin.ts @@ -34,8 +34,7 @@ import { createTileMapFn } from './tile_map_fn'; import { createTileMapTypeDefinition } from './tile_map_type'; import { IServiceSettings, MapsLegacyPluginSetup } from '../../maps_legacy/public'; import { DataPublicPluginStart } from '../../data/public'; -import { setFormatService, setQueryService } from './services'; -import { setKibanaLegacy } from './services'; +import { setFormatService, setQueryService, setKibanaLegacy } from './services'; import { KibanaLegacyStart } from '../../kibana_legacy/public'; export interface TileMapConfigType { diff --git a/src/plugins/ui_actions/public/index.ts b/src/plugins/ui_actions/public/index.ts index d76ca124ead2c..476ca0ec17066 100644 --- a/src/plugins/ui_actions/public/index.ts +++ b/src/plugins/ui_actions/public/index.ts @@ -43,8 +43,20 @@ export { valueClickTrigger, APPLY_FILTER_TRIGGER, applyFilterTrigger, + VISUALIZE_FIELD_TRIGGER, + visualizeFieldTrigger, + VISUALIZE_GEO_FIELD_TRIGGER, + visualizeGeoFieldTrigger, } from './triggers'; -export { TriggerContextMapping, TriggerId, ActionContextMapping, ActionType } from './types'; +export { + TriggerContextMapping, + TriggerId, + ActionContextMapping, + ActionType, + VisualizeFieldContext, + ACTION_VISUALIZE_FIELD, + ACTION_VISUALIZE_GEO_FIELD, +} from './types'; export { ActionByType, ActionDefinitionByType, diff --git a/src/plugins/ui_actions/public/plugin.ts b/src/plugins/ui_actions/public/plugin.ts index 71148656cbb16..f83cc97c2a8ef 100644 --- a/src/plugins/ui_actions/public/plugin.ts +++ b/src/plugins/ui_actions/public/plugin.ts @@ -19,7 +19,13 @@ import { CoreStart, CoreSetup, Plugin, PluginInitializerContext } from 'src/core/public'; import { UiActionsService } from './service'; -import { selectRangeTrigger, valueClickTrigger, applyFilterTrigger } from './triggers'; +import { + selectRangeTrigger, + valueClickTrigger, + applyFilterTrigger, + visualizeFieldTrigger, + visualizeGeoFieldTrigger, +} from './triggers'; export type UiActionsSetup = Pick< UiActionsService, @@ -42,6 +48,8 @@ export class UiActionsPlugin implements Plugin { this.service.registerTrigger(selectRangeTrigger); this.service.registerTrigger(valueClickTrigger); this.service.registerTrigger(applyFilterTrigger); + this.service.registerTrigger(visualizeFieldTrigger); + this.service.registerTrigger(visualizeGeoFieldTrigger); return this.service; } diff --git a/src/plugins/ui_actions/public/triggers/index.ts b/src/plugins/ui_actions/public/triggers/index.ts index dbc54163c5af5..b7039d287c6e2 100644 --- a/src/plugins/ui_actions/public/triggers/index.ts +++ b/src/plugins/ui_actions/public/triggers/index.ts @@ -23,4 +23,6 @@ export * from './trigger_internal'; export * from './select_range_trigger'; export * from './value_click_trigger'; export * from './apply_filter_trigger'; +export * from './visualize_field_trigger'; +export * from './visualize_geo_field_trigger'; export * from './default_trigger'; diff --git a/src/plugins/ui_actions/public/triggers/visualize_field_trigger.ts b/src/plugins/ui_actions/public/triggers/visualize_field_trigger.ts new file mode 100644 index 0000000000000..4f3c5f613eddf --- /dev/null +++ b/src/plugins/ui_actions/public/triggers/visualize_field_trigger.ts @@ -0,0 +1,27 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Trigger } from '.'; + +export const VISUALIZE_FIELD_TRIGGER = 'VISUALIZE_FIELD_TRIGGER'; +export const visualizeFieldTrigger: Trigger<'VISUALIZE_FIELD_TRIGGER'> = { + id: VISUALIZE_FIELD_TRIGGER, + title: 'Visualize field', + description: 'Triggered when user wants to visualize a field.', +}; diff --git a/src/plugins/ui_actions/public/triggers/visualize_geo_field_trigger.ts b/src/plugins/ui_actions/public/triggers/visualize_geo_field_trigger.ts new file mode 100644 index 0000000000000..5582b3b42660c --- /dev/null +++ b/src/plugins/ui_actions/public/triggers/visualize_geo_field_trigger.ts @@ -0,0 +1,27 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Trigger } from '.'; + +export const VISUALIZE_GEO_FIELD_TRIGGER = 'VISUALIZE_GEO_FIELD_TRIGGER'; +export const visualizeGeoFieldTrigger: Trigger<'VISUALIZE_GEO_FIELD_TRIGGER'> = { + id: VISUALIZE_GEO_FIELD_TRIGGER, + title: 'Visualize Geo field', + description: 'Triggered when user wants to visualize a geo field.', +}; diff --git a/src/plugins/ui_actions/public/types.ts b/src/plugins/ui_actions/public/types.ts index dcf0bfb14d538..b00f4628ffb96 100644 --- a/src/plugins/ui_actions/public/types.ts +++ b/src/plugins/ui_actions/public/types.ts @@ -23,6 +23,8 @@ import { SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, APPLY_FILTER_TRIGGER, + VISUALIZE_FIELD_TRIGGER, + VISUALIZE_GEO_FIELD_TRIGGER, DEFAULT_TRIGGER, } from './triggers'; import type { RangeSelectContext, ValueClickContext } from '../../embeddable/public'; @@ -32,6 +34,12 @@ export type TriggerRegistry = Map>; export type ActionRegistry = Map; export type TriggerToActionsRegistry = Map; +export interface VisualizeFieldContext { + fieldName: string; + indexPatternId: string; + contextualFields?: string[]; +} + export type TriggerId = keyof TriggerContextMapping; export type BaseContext = object; @@ -42,11 +50,17 @@ export interface TriggerContextMapping { [SELECT_RANGE_TRIGGER]: RangeSelectContext; [VALUE_CLICK_TRIGGER]: ValueClickContext; [APPLY_FILTER_TRIGGER]: ApplyGlobalFilterActionContext; + [VISUALIZE_FIELD_TRIGGER]: VisualizeFieldContext; + [VISUALIZE_GEO_FIELD_TRIGGER]: VisualizeFieldContext; } const DEFAULT_ACTION = ''; +export const ACTION_VISUALIZE_FIELD = 'ACTION_VISUALIZE_FIELD'; +export const ACTION_VISUALIZE_GEO_FIELD = 'ACTION_VISUALIZE_GEO_FIELD'; export type ActionType = keyof ActionContextMapping; export interface ActionContextMapping { [DEFAULT_ACTION]: BaseContext; + [ACTION_VISUALIZE_FIELD]: VisualizeFieldContext; + [ACTION_VISUALIZE_GEO_FIELD]: VisualizeFieldContext; } diff --git a/src/plugins/visualize/common/constants.ts b/src/plugins/visualize/common/constants.ts new file mode 100644 index 0000000000000..4e33638286a19 --- /dev/null +++ b/src/plugins/visualize/common/constants.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const AGGS_TERMS_SIZE_SETTING = 'discover:aggs:terms:size'; diff --git a/src/plugins/visualize/kibana.json b/src/plugins/visualize/kibana.json index 520d1e1daa6fe..a6cc8d8f8af60 100644 --- a/src/plugins/visualize/kibana.json +++ b/src/plugins/visualize/kibana.json @@ -9,7 +9,8 @@ "navigation", "savedObjects", "visualizations", - "embeddable" + "embeddable", + "uiActions" ], "optionalPlugins": ["home", "share"], "requiredBundles": [ diff --git a/src/plugins/visualize/public/actions/visualize_field_action.ts b/src/plugins/visualize/public/actions/visualize_field_action.ts new file mode 100644 index 0000000000000..6671d2c981910 --- /dev/null +++ b/src/plugins/visualize/public/actions/visualize_field_action.ts @@ -0,0 +1,97 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { i18n } from '@kbn/i18n'; +import { + createAction, + ACTION_VISUALIZE_FIELD, + VisualizeFieldContext, +} from '../../../ui_actions/public'; +import { + getApplication, + getUISettings, + getIndexPatterns, + getQueryService, + getShareService, +} from '../services'; +import { VISUALIZE_APP_URL_GENERATOR, VisualizeUrlGeneratorState } from '../url_generator'; +import { AGGS_TERMS_SIZE_SETTING } from '../../common/constants'; + +export const visualizeFieldAction = createAction({ + type: ACTION_VISUALIZE_FIELD, + getDisplayName: () => + i18n.translate('visualize.discover.visualizeFieldLabel', { + defaultMessage: 'Visualize field', + }), + isCompatible: async () => !!getApplication().capabilities.visualize.show, + getHref: async (context) => { + const url = await getVisualizeUrl(context); + return url; + }, + execute: async (context) => { + const url = await getVisualizeUrl(context); + const hash = url.split('#')[1]; + + getApplication().navigateToApp('visualize', { + path: `/#${hash}`, + }); + }, +}); + +const getVisualizeUrl = async (context: VisualizeFieldContext) => { + const indexPattern = await getIndexPatterns().get(context.indexPatternId); + const field = indexPattern.fields.find((fld) => fld.name === context.fieldName); + const aggsTermSize = getUISettings().get(AGGS_TERMS_SIZE_SETTING); + let agg; + + // If we're visualizing a date field, and our index is time based (and thus has a time filter), + // then run a date histogram + if (field?.type === 'date' && indexPattern.timeFieldName === context.fieldName) { + agg = { + type: 'date_histogram', + schema: 'segment', + params: { + field: context.fieldName, + interval: 'auto', + }, + }; + } else { + agg = { + type: 'terms', + schema: 'segment', + params: { + field: context.fieldName, + size: parseInt(aggsTermSize, 10), + orderBy: '1', + }, + }; + } + const generator = getShareService().urlGenerators.getUrlGenerator(VISUALIZE_APP_URL_GENERATOR); + const urlState: VisualizeUrlGeneratorState = { + filters: getQueryService().filterManager.getFilters(), + query: getQueryService().queryString.getQuery(), + timeRange: getQueryService().timefilter.timefilter.getTime(), + indexPatternId: context.indexPatternId, + type: 'histogram', + vis: { + type: 'histogram', + aggs: [{ schema: 'metric', type: 'count', id: '1' }, agg], + }, + }; + return generator.createUrl(urlState); +}; diff --git a/src/plugins/visualize/public/plugin.ts b/src/plugins/visualize/public/plugin.ts index 3299319e613a0..8794593d6c958 100644 --- a/src/plugins/visualize/public/plugin.ts +++ b/src/plugins/visualize/public/plugin.ts @@ -39,7 +39,7 @@ import { } from '../../kibana_utils/public'; import { DataPublicPluginStart, DataPublicPluginSetup, esFilters } from '../../data/public'; import { NavigationPublicPluginStart as NavigationStart } from '../../navigation/public'; -import { SharePluginStart } from '../../share/public'; +import { SharePluginStart, SharePluginSetup } from '../../share/public'; import { KibanaLegacySetup, KibanaLegacyStart } from '../../kibana_legacy/public'; import { VisualizationsStart } from '../../visualizations/public'; import { VisualizeConstants } from './application/visualize_constants'; @@ -48,6 +48,16 @@ import { VisualizeServices } from './application/types'; import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; import { SavedObjectsStart } from '../../saved_objects/public'; import { EmbeddableStart } from '../../embeddable/public'; +import { UiActionsStart, VISUALIZE_FIELD_TRIGGER } from '../../ui_actions/public'; +import { + setUISettings, + setApplication, + setIndexPatterns, + setQueryService, + setShareService, +} from './services'; +import { visualizeFieldAction } from './actions/visualize_field_action'; +import { createVisualizeUrlGenerator } from './url_generator'; export interface VisualizePluginStartDependencies { data: DataPublicPluginStart; @@ -57,12 +67,14 @@ export interface VisualizePluginStartDependencies { embeddable: EmbeddableStart; kibanaLegacy: KibanaLegacyStart; savedObjects: SavedObjectsStart; + uiActions: UiActionsStart; } export interface VisualizePluginSetupDependencies { home?: HomePublicPluginSetup; kibanaLegacy: KibanaLegacySetup; data: DataPublicPluginSetup; + share?: SharePluginSetup; } export interface FeatureFlagConfig { @@ -80,7 +92,7 @@ export class VisualizePlugin public async setup( core: CoreSetup, - { home, kibanaLegacy, data }: VisualizePluginSetupDependencies + { home, kibanaLegacy, data, share }: VisualizePluginSetupDependencies ) { const { appMounted, @@ -113,6 +125,18 @@ export class VisualizePlugin this.stopUrlTracking = () => { stopUrlTracker(); }; + if (share) { + share.urlGenerators.registerUrlGenerator( + createVisualizeUrlGenerator(async () => { + const [coreStart] = await core.getStartServices(); + return { + appBasePath: coreStart.application.getUrlForApp('visualize'), + useHashedUrl: coreStart.uiSettings.get('state:storeInSessionStorage'), + }; + }) + ); + } + setUISettings(core.uiSettings); core.application.register({ id: 'visualize', @@ -140,7 +164,6 @@ export class VisualizePlugin const unlistenParentHistory = params.history.listen(() => { window.dispatchEvent(new HashChangeEvent('hashchange')); }); - /** * current implementation uses 2 history objects: * 1. the hash history (used for the react hash router) @@ -207,7 +230,15 @@ export class VisualizePlugin } } - public start(core: CoreStart, plugins: VisualizePluginStartDependencies) {} + public start(core: CoreStart, plugins: VisualizePluginStartDependencies) { + setApplication(core.application); + setIndexPatterns(plugins.data.indexPatterns); + setQueryService(plugins.data.query); + if (plugins.share) { + setShareService(plugins.share); + } + plugins.uiActions.addTriggerAction(VISUALIZE_FIELD_TRIGGER, visualizeFieldAction); + } stop() { if (this.stopUrlTracking) { diff --git a/src/plugins/visualize/public/services.ts b/src/plugins/visualize/public/services.ts new file mode 100644 index 0000000000000..8190872ec6508 --- /dev/null +++ b/src/plugins/visualize/public/services.ts @@ -0,0 +1,37 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ApplicationStart, IUiSettingsClient } from '../../../core/public'; +import { createGetterSetter } from '../../../plugins/kibana_utils/public'; +import { IndexPatternsContract, DataPublicPluginStart } from '../../../plugins/data/public'; +import { SharePluginStart } from '../../../plugins/share/public'; + +export const [getUISettings, setUISettings] = createGetterSetter('UISettings'); + +export const [getApplication, setApplication] = createGetterSetter('Application'); + +export const [getShareService, setShareService] = createGetterSetter('Share'); + +export const [getIndexPatterns, setIndexPatterns] = createGetterSetter( + 'IndexPatterns' +); + +export const [getQueryService, setQueryService] = createGetterSetter< + DataPublicPluginStart['query'] +>('Query'); diff --git a/src/plugins/visualize/public/url_generator.test.ts b/src/plugins/visualize/public/url_generator.test.ts new file mode 100644 index 0000000000000..8c8a0f70c15e3 --- /dev/null +++ b/src/plugins/visualize/public/url_generator.test.ts @@ -0,0 +1,100 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createVisualizeUrlGenerator } from './url_generator'; +import { esFilters } from '../../data/public'; + +const APP_BASE_PATH: string = 'test/app/visualize'; +const VISUALIZE_ID: string = '13823000-99b9-11ea-9eb6-d9e8adceb647'; +const INDEXPATTERN_ID: string = '13823000-99b9-11ea-9eb6-d9e8adceb647'; + +describe('visualize url generator', () => { + test('creates a link to a new visualization', async () => { + const generator = createVisualizeUrlGenerator(() => + Promise.resolve({ + appBasePath: APP_BASE_PATH, + useHashedUrl: false, + }) + ); + const url = await generator.createUrl!({ indexPatternId: INDEXPATTERN_ID, type: 'table' }); + expect(url).toMatchInlineSnapshot( + `"test/app/visualize#/create?_g=()&_a=()&indexPattern=${INDEXPATTERN_ID}&type=table"` + ); + }); + + test('creates a link with global time range set up', async () => { + const generator = createVisualizeUrlGenerator(() => + Promise.resolve({ + appBasePath: APP_BASE_PATH, + useHashedUrl: false, + }) + ); + const url = await generator.createUrl!({ + timeRange: { to: 'now', from: 'now-15m', mode: 'relative' }, + indexPatternId: INDEXPATTERN_ID, + type: 'table', + }); + expect(url).toMatchInlineSnapshot( + `"test/app/visualize#/create?_g=(time:(from:now-15m,mode:relative,to:now))&_a=()&indexPattern=${INDEXPATTERN_ID}&type=table"` + ); + }); + + test('creates a link with filters, time range, refresh interval and query to a saved visualization', async () => { + const generator = createVisualizeUrlGenerator(() => + Promise.resolve({ + appBasePath: APP_BASE_PATH, + useHashedUrl: false, + indexPatternId: INDEXPATTERN_ID, + type: 'table', + }) + ); + const url = await generator.createUrl!({ + timeRange: { to: 'now', from: 'now-15m', mode: 'relative' }, + refreshInterval: { pause: false, value: 300 }, + visualizationId: VISUALIZE_ID, + filters: [ + { + meta: { + alias: null, + disabled: false, + negate: false, + }, + query: { query: 'q1' }, + }, + { + meta: { + alias: null, + disabled: false, + negate: false, + }, + query: { query: 'q1' }, + $state: { + store: esFilters.FilterStateStore.GLOBAL_STATE, + }, + }, + ], + query: { query: 'q2', language: 'kuery' }, + indexPatternId: INDEXPATTERN_ID, + type: 'table', + }); + expect(url).toMatchInlineSnapshot( + `"test/app/visualize#/edit/${VISUALIZE_ID}?_g=(filters:!(('$state':(store:globalState),meta:(alias:!n,disabled:!f,negate:!f),query:(query:q1))),refreshInterval:(pause:!f,value:300),time:(from:now-15m,mode:relative,to:now))&_a=(filters:!((meta:(alias:!n,disabled:!f,negate:!f),query:(query:q1))),query:(language:kuery,query:q2))&indexPattern=${INDEXPATTERN_ID}&type=table"` + ); + }); +}); diff --git a/src/plugins/visualize/public/url_generator.ts b/src/plugins/visualize/public/url_generator.ts new file mode 100644 index 0000000000000..38b7633c6fde1 --- /dev/null +++ b/src/plugins/visualize/public/url_generator.ts @@ -0,0 +1,137 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + TimeRange, + Filter, + Query, + esFilters, + QueryState, + RefreshInterval, +} from '../../data/public'; +import { setStateToKbnUrl } from '../../kibana_utils/public'; +import { UrlGeneratorsDefinition } from '../../share/public'; + +const STATE_STORAGE_KEY = '_a'; +const GLOBAL_STATE_STORAGE_KEY = '_g'; + +export const VISUALIZE_APP_URL_GENERATOR = 'VISUALIZE_APP_URL_GENERATOR'; + +export interface VisualizeUrlGeneratorState { + /** + * If given, it will load the given visualization else will load the create a new visualization page. + */ + visualizationId?: string; + /** + * Optionally set the time range in the time picker. + */ + timeRange?: TimeRange; + + /** + * Optional set indexPatternId. + */ + indexPatternId?: string; + + /** + * Optional set visualization type. + */ + type?: string; + + /** + * Optionally set the visualization. + */ + vis?: unknown; + + /** + * Optionally set the refresh interval. + */ + refreshInterval?: RefreshInterval; + + /** + * Optionally apply filers. NOTE: if given and used in conjunction with `dashboardId`, and the + * saved dashboard has filters saved with it, this will _replace_ those filters. + */ + filters?: Filter[]; + /** + * Optionally set a query. NOTE: if given and used in conjunction with `dashboardId`, and the + * saved dashboard has a query saved with it, this will _replace_ that query. + */ + query?: Query; + /** + * If not given, will use the uiSettings configuration for `storeInSessionStorage`. useHash determines + * whether to hash the data in the url to avoid url length issues. + */ + hash?: boolean; +} + +export const createVisualizeUrlGenerator = ( + getStartServices: () => Promise<{ + appBasePath: string; + useHashedUrl: boolean; + }> +): UrlGeneratorsDefinition => ({ + id: VISUALIZE_APP_URL_GENERATOR, + createUrl: async ({ + visualizationId, + filters, + indexPatternId, + query, + refreshInterval, + vis, + type, + timeRange, + hash, + }: VisualizeUrlGeneratorState): Promise => { + const startServices = await getStartServices(); + const useHash = hash ?? startServices.useHashedUrl; + const appBasePath = startServices.appBasePath; + const mode = visualizationId ? `edit/${visualizationId}` : `create`; + + const appState: { + query?: Query; + filters?: Filter[]; + vis?: unknown; + } = {}; + const queryState: QueryState = {}; + + if (query) appState.query = query; + if (filters && filters.length) + appState.filters = filters?.filter((f) => !esFilters.isFilterPinned(f)); + if (vis) appState.vis = vis; + + if (timeRange) queryState.time = timeRange; + if (filters && filters.length) + queryState.filters = filters?.filter((f) => esFilters.isFilterPinned(f)); + if (refreshInterval) queryState.refreshInterval = refreshInterval; + + let url = `${appBasePath}#/${mode}`; + url = setStateToKbnUrl(GLOBAL_STATE_STORAGE_KEY, queryState, { useHash }, url); + url = setStateToKbnUrl(STATE_STORAGE_KEY, appState, { useHash }, url); + + if (indexPatternId) { + url = `${url}&indexPattern=${indexPatternId}`; + } + + if (type) { + url = `${url}&type=${type}`; + } + + return url; + }, +}); diff --git a/test/functional/apps/discover/_field_visualize.ts b/test/functional/apps/discover/_field_visualize.ts index b0db6c149e41a..c95211e98cdba 100644 --- a/test/functional/apps/discover/_field_visualize.ts +++ b/test/functional/apps/discover/_field_visualize.ts @@ -87,6 +87,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await inspector.close(); }); + it('should not show the "Visualize" button for geo field', async () => { + await PageObjects.discover.findFieldByName('geo.coordinates'); + log.debug('visualize a geo field'); + await PageObjects.discover.expectMissingFieldListItemVisualize('geo.coordinates'); + }); + it('should preserve app filters in visualize', async () => { await filterBar.addFilter('bytes', 'is between', '3500', '4000'); await PageObjects.discover.findFieldByName('geo.src'); diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index eec23f95bb17b..363122ac62212 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -26,6 +26,7 @@ export const EMS_TILES_VECTOR_TILE_PATH = 'vector/tile'; export const MAP_SAVED_OBJECT_TYPE = 'map'; export const APP_ID = 'maps'; export const APP_ICON = 'gisApp'; +export const INITIAL_LAYERS_KEY = 'initialLayers'; export const MAPS_APP_PATH = `app/${APP_ID}`; export const MAP_PATH = 'map'; diff --git a/x-pack/plugins/maps/kibana.json b/x-pack/plugins/maps/kibana.json index fbf45aee02125..d554d159a196f 100644 --- a/x-pack/plugins/maps/kibana.json +++ b/x-pack/plugins/maps/kibana.json @@ -15,7 +15,8 @@ "visualizations", "embeddable", "mapsLegacy", - "usageCollection" + "usageCollection", + "share" ], "ui": true, "server": true, diff --git a/x-pack/plugins/maps/public/kibana_services.ts b/x-pack/plugins/maps/public/kibana_services.ts index 3b004e2cda67b..f8f89ebaed102 100644 --- a/x-pack/plugins/maps/public/kibana_services.ts +++ b/x-pack/plugins/maps/public/kibana_services.ts @@ -45,6 +45,7 @@ export const getToasts = () => coreStart.notifications.toasts; export const getSavedObjectsClient = () => coreStart.savedObjects.client; export const getCoreChrome = () => coreStart.chrome; export const getMapsCapabilities = () => coreStart.application.capabilities.maps; +export const getVisualizeCapabilities = () => coreStart.application.capabilities.visualize; export const getDocLinks = () => coreStart.docLinks; export const getCoreOverlays = () => coreStart.overlays; export const getData = () => pluginsStart.data; @@ -81,3 +82,5 @@ export const getProxyElasticMapsServiceInMaps = () => getKibanaCommonConfig().proxyElasticMapsServiceInMaps; export const getRegionmapLayers = () => _.get(getKibanaCommonConfig(), 'regionmap.layers', []); export const getTilemap = () => _.get(getKibanaCommonConfig(), 'tilemap', []); + +export const getShareService = () => pluginsStart.share; diff --git a/x-pack/plugins/maps/public/plugin.ts b/x-pack/plugins/maps/public/plugin.ts index e2b40e22bfe7d..9bb79f2937c68 100644 --- a/x-pack/plugins/maps/public/plugin.ts +++ b/x-pack/plugins/maps/public/plugin.ts @@ -31,6 +31,9 @@ import { getMapsVisTypeAlias } from './maps_vis_type_alias'; import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; import { VisualizationsSetup } from '../../../../src/plugins/visualizations/public'; import { APP_ICON, APP_ID, MAP_SAVED_OBJECT_TYPE } from '../common/constants'; +import { VISUALIZE_GEO_FIELD_TRIGGER } from '../../../../src/plugins/ui_actions/public'; +import { createMapsUrlGenerator } from './url_generator'; +import { visualizeGeoFieldAction } from './trigger_actions/visualize_geo_field_action'; import { MapEmbeddableFactory } from './embeddable/map_embeddable_factory'; import { EmbeddableSetup } from '../../../../src/plugins/embeddable/public'; import { MapsXPackConfig, MapsConfigType } from '../config'; @@ -39,6 +42,7 @@ import { ILicense } from '../../licensing/common/types'; import { lazyLoadMapModules } from './lazy_load_bundle'; import { MapsStartApi } from './api'; import { createSecurityLayerDescriptors, registerLayerWizard, registerSource } from './api'; +import { SharePluginSetup, SharePluginStart } from '../../../../src/plugins/share/public'; import { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; import { MapsLegacyConfigType } from '../../../../src/plugins/maps_legacy/public'; import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; @@ -51,6 +55,7 @@ export interface MapsPluginSetupDependencies { visualizations: VisualizationsSetup; embeddable: EmbeddableSetup; mapsLegacy: { config: MapsLegacyConfigType }; + share: SharePluginSetup; } export interface MapsPluginStartDependencies { @@ -61,6 +66,7 @@ export interface MapsPluginStartDependencies { licensing: LicensingPluginStart; navigation: NavigationPublicPluginStart; uiActions: UiActionsStart; + share: SharePluginStart; } /** @@ -91,6 +97,15 @@ export class MapsPlugin setKibanaCommonConfig(plugins.mapsLegacy.config); setMapAppConfig(config); setKibanaVersion(this._initializerContext.env.packageInfo.version); + plugins.share.urlGenerators.registerUrlGenerator( + createMapsUrlGenerator(async () => { + const [coreStart] = await core.getStartServices(); + return { + appBasePath: coreStart.application.getUrlForApp('maps'), + useHashedUrl: coreStart.uiSettings.get('state:storeInSessionStorage'), + }; + }) + ); plugins.inspector.registerView(MapView); plugins.home.featureCatalogue.register(featureCatalogueEntry); @@ -122,7 +137,7 @@ export class MapsPlugin setLicenseId(license.uid); }); } - + plugins.uiActions.addTriggerAction(VISUALIZE_GEO_FIELD_TRIGGER, visualizeGeoFieldAction); setStartServices(core, plugins); return { diff --git a/x-pack/plugins/maps/public/routing/bootstrap/get_initial_layers.js b/x-pack/plugins/maps/public/routing/bootstrap/get_initial_layers.js index 5d02160fc3eb5..b47f83d5a6664 100644 --- a/x-pack/plugins/maps/public/routing/bootstrap/get_initial_layers.js +++ b/x-pack/plugins/maps/public/routing/bootstrap/get_initial_layers.js @@ -20,6 +20,7 @@ import { TileLayer } from '../../classes/layers/tile_layer/tile_layer'; import { EMSTMSSource } from '../../classes/sources/ems_tms_source'; import { VectorTileLayer } from '../../classes/layers/vector_tile_layer/vector_tile_layer'; import { getIsEmsEnabled, getToasts } from '../../kibana_services'; +import { INITIAL_LAYERS_KEY } from '../../../common/constants'; import { getKibanaTileMap } from '../../meta'; export function getInitialLayers(layerListJSON, initialLayers = []) { @@ -51,12 +52,12 @@ export function getInitialLayersFromUrlParam() { return []; } const mapAppParams = new URLSearchParams(locationSplit[1]); - if (!mapAppParams.has('initialLayers')) { + if (!mapAppParams.has(INITIAL_LAYERS_KEY)) { return []; } try { - let mapInitLayers = mapAppParams.get('initialLayers'); + let mapInitLayers = mapAppParams.get(INITIAL_LAYERS_KEY); if (mapInitLayers[mapInitLayers.length - 1] === '#') { mapInitLayers = mapInitLayers.substr(0, mapInitLayers.length - 1); } diff --git a/x-pack/plugins/maps/public/trigger_actions/visualize_geo_field_action.ts b/x-pack/plugins/maps/public/trigger_actions/visualize_geo_field_action.ts new file mode 100644 index 0000000000000..bdeab292b214c --- /dev/null +++ b/x-pack/plugins/maps/public/trigger_actions/visualize_geo_field_action.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import uuid from 'uuid/v4'; +import { i18n } from '@kbn/i18n'; +import { + createAction, + ACTION_VISUALIZE_GEO_FIELD, + VisualizeFieldContext, +} from '../../../../../src/plugins/ui_actions/public'; +import { + getVisualizeCapabilities, + getIndexPatternService, + getData, + getShareService, + getNavigateToApp, +} from '../kibana_services'; +import { MAPS_APP_URL_GENERATOR, MapsUrlGeneratorState } from '../url_generator'; +import { LAYER_TYPE, SOURCE_TYPES, SCALING_TYPES, APP_ID, MAP_PATH } from '../../common/constants'; + +export const visualizeGeoFieldAction = createAction({ + type: ACTION_VISUALIZE_GEO_FIELD, + getDisplayName: () => + i18n.translate('xpack.maps.discover.visualizeFieldLabel', { + defaultMessage: 'Visualize in Maps', + }), + isCompatible: async () => !!getVisualizeCapabilities().show, + getHref: async (context) => { + const url = await getMapsLink(context); + return url; + }, + execute: async (context) => { + const url = await getMapsLink(context); + const hash = url.split('#')[1]; + + getNavigateToApp()(APP_ID, { + path: `${MAP_PATH}/#${hash}`, + }); + }, +}); + +const getMapsLink = async (context: VisualizeFieldContext) => { + const indexPattern = await getIndexPatternService().get(context.indexPatternId); + const field = indexPattern.fields.find((fld) => fld.name === context.fieldName); + const supportsClustering = field?.aggregatable; + // create initial layer descriptor + const hasTooltips = + context?.contextualFields?.length && context?.contextualFields[0] !== '_source'; + const initialLayers = [ + { + id: uuid(), + visible: true, + type: supportsClustering ? LAYER_TYPE.BLENDED_VECTOR : LAYER_TYPE.VECTOR, + sourceDescriptor: { + id: uuid(), + type: SOURCE_TYPES.ES_SEARCH, + tooltipProperties: hasTooltips ? context.contextualFields : [], + label: indexPattern.title, + indexPatternId: context.indexPatternId, + geoField: context.fieldName, + scalingType: supportsClustering ? SCALING_TYPES.CLUSTERS : SCALING_TYPES.LIMIT, + }, + }, + ]; + + const generator = getShareService().urlGenerators.getUrlGenerator(MAPS_APP_URL_GENERATOR); + const urlState: MapsUrlGeneratorState = { + filters: getData().query.filterManager.getFilters(), + query: getData().query.queryString.getQuery(), + initialLayers, + timeRange: getData().query.timefilter.timefilter.getTime(), + }; + return generator.createUrl(urlState); +}; diff --git a/x-pack/plugins/maps/public/url_generator.test.ts b/x-pack/plugins/maps/public/url_generator.test.ts new file mode 100644 index 0000000000000..a44f8d952fde1 --- /dev/null +++ b/x-pack/plugins/maps/public/url_generator.test.ts @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import rison from 'rison-node'; +import { createMapsUrlGenerator } from './url_generator'; +import { LAYER_TYPE, SOURCE_TYPES, SCALING_TYPES } from '../common/constants'; +import { esFilters } from '../../../../src/plugins/data/public'; + +const APP_BASE_PATH: string = 'test/app/maps'; +const MAP_ID: string = '2c9c1f60-1909-11e9-919b-ffe5949a18d2'; +const LAYER_ID: string = '13823000-99b9-11ea-9eb6-d9e8adceb647'; +const INDEX_PATTERN_ID: string = '90943e30-9a47-11e8-b64d-95841ca0b247'; + +describe('visualize url generator', () => { + test('creates a link to a new visualization', async () => { + const generator = createMapsUrlGenerator(() => + Promise.resolve({ + appBasePath: APP_BASE_PATH, + useHashedUrl: false, + }) + ); + const url = await generator.createUrl!({}); + expect(url).toMatchInlineSnapshot(`"test/app/maps/map#/?_g=()&_a=()"`); + }); + + test('creates a link with global time range set up', async () => { + const generator = createMapsUrlGenerator(() => + Promise.resolve({ + appBasePath: APP_BASE_PATH, + useHashedUrl: false, + }) + ); + const url = await generator.createUrl!({ + timeRange: { to: 'now', from: 'now-15m', mode: 'relative' }, + }); + expect(url).toMatchInlineSnapshot( + `"test/app/maps/map#/?_g=(time:(from:now-15m,mode:relative,to:now))&_a=()"` + ); + }); + + test('creates a link with initialLayers set up', async () => { + const generator = createMapsUrlGenerator(() => + Promise.resolve({ + appBasePath: APP_BASE_PATH, + useHashedUrl: false, + }) + ); + const initialLayers = [ + { + id: LAYER_ID, + visible: true, + type: LAYER_TYPE.VECTOR, + sourceDescriptor: { + id: LAYER_ID, + type: SOURCE_TYPES.ES_SEARCH, + tooltipProperties: [], + label: 'Sample Data', + indexPatternId: INDEX_PATTERN_ID, + geoField: 'test', + scalingType: SCALING_TYPES.LIMIT, + }, + }, + ]; + const encodedLayers = rison.encode_array(initialLayers); + const url = await generator.createUrl!({ + initialLayers, + }); + expect(url).toMatchInlineSnapshot( + `"test/app/maps/map#/?_g=()&_a=()&initialLayers=${encodedLayers}"` + ); + }); + + test('creates a link with filters, time range, refresh interval and query to a saved visualization', async () => { + const generator = createMapsUrlGenerator(() => + Promise.resolve({ + appBasePath: APP_BASE_PATH, + useHashedUrl: false, + }) + ); + const url = await generator.createUrl!({ + timeRange: { to: 'now', from: 'now-15m', mode: 'relative' }, + refreshInterval: { pause: false, value: 300 }, + mapId: MAP_ID, + filters: [ + { + meta: { + alias: null, + disabled: false, + negate: false, + }, + query: { query: 'q1' }, + }, + { + meta: { + alias: null, + disabled: false, + negate: false, + }, + query: { query: 'q1' }, + $state: { + store: esFilters.FilterStateStore.GLOBAL_STATE, + }, + }, + ], + query: { query: 'q2', language: 'kuery' }, + }); + expect(url).toMatchInlineSnapshot( + `"test/app/maps/map#/${MAP_ID}?_g=(filters:!(('$state':(store:globalState),meta:(alias:!n,disabled:!f,negate:!f),query:(query:q1))),refreshInterval:(pause:!f,value:300),time:(from:now-15m,mode:relative,to:now))&_a=(filters:!((meta:(alias:!n,disabled:!f,negate:!f),query:(query:q1))),query:(language:kuery,query:q2))"` + ); + }); +}); diff --git a/x-pack/plugins/maps/public/url_generator.ts b/x-pack/plugins/maps/public/url_generator.ts new file mode 100644 index 0000000000000..3fbb361342c7a --- /dev/null +++ b/x-pack/plugins/maps/public/url_generator.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import rison from 'rison-node'; +import { + TimeRange, + Filter, + Query, + esFilters, + QueryState, + RefreshInterval, +} from '../../../../src/plugins/data/public'; +import { setStateToKbnUrl } from '../../../../src/plugins/kibana_utils/public'; +import { UrlGeneratorsDefinition } from '../../../../src/plugins/share/public'; +import { LayerDescriptor } from '../common/descriptor_types'; +import { INITIAL_LAYERS_KEY } from '../common/constants'; + +const STATE_STORAGE_KEY = '_a'; +const GLOBAL_STATE_STORAGE_KEY = '_g'; + +export const MAPS_APP_URL_GENERATOR = 'MAPS_APP_URL_GENERATOR'; + +export interface MapsUrlGeneratorState { + /** + * If given, it will load the given map else will load the create a new map page. + */ + mapId?: string; + /** + * Optionally set the time range in the time picker. + */ + timeRange?: TimeRange; + + /** + * Optionally set the initial Layers. + */ + initialLayers?: LayerDescriptor[]; + + /** + * Optionally set the refresh interval. + */ + refreshInterval?: RefreshInterval; + + /** + * Optionally apply filers. NOTE: if given and used in conjunction with `mapId`, and the + * saved map has filters saved with it, this will _replace_ those filters. + */ + filters?: Filter[]; + /** + * Optionally set a query. NOTE: if given and used in conjunction with `mapId`, and the + * saved map has a query saved with it, this will _replace_ that query. + */ + query?: Query; + /** + * If not given, will use the uiSettings configuration for `storeInSessionStorage`. useHash determines + * whether to hash the data in the url to avoid url length issues. + */ + hash?: boolean; +} + +export const createMapsUrlGenerator = ( + getStartServices: () => Promise<{ + appBasePath: string; + useHashedUrl: boolean; + }> +): UrlGeneratorsDefinition => ({ + id: MAPS_APP_URL_GENERATOR, + createUrl: async ({ + mapId, + filters, + query, + refreshInterval, + timeRange, + initialLayers, + hash, + }: MapsUrlGeneratorState): Promise => { + const startServices = await getStartServices(); + const useHash = hash ?? startServices.useHashedUrl; + const appBasePath = startServices.appBasePath; + + const appState: { + query?: Query; + filters?: Filter[]; + vis?: unknown; + } = {}; + const queryState: QueryState = {}; + + if (query) appState.query = query; + if (filters && filters.length) + appState.filters = filters?.filter((f) => !esFilters.isFilterPinned(f)); + + if (timeRange) queryState.time = timeRange; + if (filters && filters.length) + queryState.filters = filters?.filter((f) => esFilters.isFilterPinned(f)); + if (refreshInterval) queryState.refreshInterval = refreshInterval; + + let url = `${appBasePath}/map#/${mapId || ''}`; + url = setStateToKbnUrl(GLOBAL_STATE_STORAGE_KEY, queryState, { useHash }, url); + url = setStateToKbnUrl(STATE_STORAGE_KEY, appState, { useHash }, url); + + if (initialLayers && initialLayers.length) { + // @ts-ignore + url = `${url}&${INITIAL_LAYERS_KEY}=${rison.encode_array(initialLayers)}`; + } + + return url; + }, +}); diff --git a/x-pack/typings/rison_node.d.ts b/x-pack/typings/rison_node.d.ts index 295392af2e05b..0e6069147e66f 100644 --- a/x-pack/typings/rison_node.d.ts +++ b/x-pack/typings/rison_node.d.ts @@ -23,4 +23,7 @@ declare module 'rison-node' { // eslint-disable-next-line @typescript-eslint/naming-convention export const encode_object: (input: Input) => string; + + // eslint-disable-next-line @typescript-eslint/naming-convention + export const encode_array: (input: Input) => string; }