diff --git a/src/legacy/core_plugins/kibana/public/table_list_view/table_list_view.js b/src/legacy/core_plugins/kibana/public/table_list_view/table_list_view.js index 97bacb45d7ad8a..5cfbca49d9a496 100644 --- a/src/legacy/core_plugins/kibana/public/table_list_view/table_list_view.js +++ b/src/legacy/core_plugins/kibana/public/table_list_view/table_list_view.js @@ -116,7 +116,8 @@ class TableListViewUi extends React.Component { isDeletingItems: true }); try { - await this.props.deleteItems(this.state.selectedIds); + const itemsById = _.indexBy(this.state.items, 'id'); + await this.props.deleteItems(this.state.selectedIds, this.state.selectedIds.map(id => itemsById[id])); } catch (error) { toastNotifications.addDanger({ title: ( @@ -316,6 +317,7 @@ class TableListViewUi extends React.Component { }; const selection = this.props.deleteItems ? { + selectable: this.props.selectable, onSelectionChange: (selection) => { this.setState({ selectedIds: selection.map(item => { return item.id; }) @@ -468,6 +470,7 @@ TableListViewUi.propTypes = { deleteItems: PropTypes.func, createItem: PropTypes.func, editItem: PropTypes.func, + selectable: PropTypes.func, listingLimit: PropTypes.number, initialFilter: PropTypes.string, @@ -482,4 +485,3 @@ TableListViewUi.defaultProps = { }; export const TableListView = injectI18n(TableListViewUi); - diff --git a/src/legacy/core_plugins/kibana/public/visualize/listing/visualize_listing.js b/src/legacy/core_plugins/kibana/public/visualize/listing/visualize_listing.js index 73afccf2a288ad..c47a301b24c37c 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/listing/visualize_listing.js +++ b/src/legacy/core_plugins/kibana/public/visualize/listing/visualize_listing.js @@ -26,12 +26,11 @@ import chrome from 'ui/chrome'; import { wrapInI18nContext } from 'ui/i18n'; import { toastNotifications } from 'ui/notify'; import { addHelpMenuToAppChrome } from '../help_menu/help_menu_util'; - +import { SavedObjectsClientProvider } from 'ui/saved_objects'; import { VisualizeListingTable } from './visualize_listing_table'; import { NewVisModal } from '../wizard/new_vis_modal'; -import { createVisualizeEditUrl, VisualizeConstants } from '../visualize_constants'; +import { VisualizeConstants } from '../visualize_constants'; import { visualizations } from 'plugins/visualizations'; - import { i18n } from '@kbn/i18n'; const app = uiModules.get('app/visualize', ['ngRoute', 'react']); @@ -42,6 +41,7 @@ export function VisualizeListingController($injector, createNewVis) { const Private = $injector.get('Private'); const config = $injector.get('config'); const kbnUrl = $injector.get('kbnUrl'); + const savedObjectClient = Private(SavedObjectsClientProvider); this.visTypeRegistry = Private(VisTypesRegistryProvider); this.visTypeAliases = visualizations.types.visTypeAliasRegistry.get(); @@ -55,13 +55,13 @@ export function VisualizeListingController($injector, createNewVis) { this.showNewVisModal = true; }; - this.editItem = ({ id }) => { + this.editItem = ({ editURL }) => { // for visualizations the edit and view URLs are the same - kbnUrl.change(createVisualizeEditUrl(id)); + window.location = chrome.addBasePath(editURL); }; - this.getViewUrl = ({ id }) => { - return chrome.addBasePath(`#${createVisualizeEditUrl(id)}`); + this.getViewUrl = ({ editURL }) => { + return chrome.addBasePath(editURL); }; this.closeNewVisModal = () => { @@ -83,7 +83,7 @@ export function VisualizeListingController($injector, createNewVis) { this.fetchItems = (filter) => { const isLabsEnabled = config.get('visualize:enableLabs'); - return visualizationService.find(filter, config.get('savedObjects:listingLimit')) + return visualizationService.findListItems(filter, config.get('savedObjects:listingLimit')) .then(result => { this.totalItems = result.total; @@ -94,15 +94,18 @@ export function VisualizeListingController($injector, createNewVis) { }); }; - this.deleteSelectedItems = function deleteSelectedItems(selectedIds) { - return visualizationService.delete(selectedIds) - .catch(error => { - toastNotifications.addError(error, { - title: i18n.translate('kbn.visualize.visualizeListingDeleteErrorTitle', { - defaultMessage: 'Error deleting visualization', - }), - }); + this.deleteSelectedItems = function deleteSelectedItems(_ids, selectedItems) { + return Promise.all( + selectedItems.map(item => { + return savedObjectClient.delete(item.savedObjectType, item.id); + }), + ).catch(error => { + toastNotifications.addError(error, { + title: i18n.translate('kbn.visualize.visualizeListingDeleteErrorTitle', { + defaultMessage: 'Error deleting visualization', + }), }); + }); }; chrome.breadcrumbs.set([{ diff --git a/src/legacy/core_plugins/kibana/public/visualize/listing/visualize_listing_table.js b/src/legacy/core_plugins/kibana/public/visualize/listing/visualize_listing_table.js index 6c59c820e0897b..acc8ce7a8c2d48 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/listing/visualize_listing_table.js +++ b/src/legacy/core_plugins/kibana/public/visualize/listing/visualize_listing_table.js @@ -21,12 +21,12 @@ import React, { Component, Fragment } from 'react'; import PropTypes from 'prop-types'; import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { capabilities } from 'ui/capabilities'; import { TableListView } from './../../table_list_view'; import { EuiIcon, EuiBetaBadge, + EuiButtonIcon, EuiLink, EuiButton, EuiEmptyPrompt, @@ -46,10 +46,10 @@ class VisualizeListingTableUi extends Component { // for data exploration purposes createItem={this.props.createItem} findItems={this.props.findItems} - deleteItems={capabilities.get().visualize.delete ? this.props.deleteItems : null} - editItem={capabilities.get().visualize.save ? this.props.editItem : null} + deleteItems={this.props.deleteItems} tableColumns={this.getTableColumns()} listingLimit={this.props.listingLimit} + selectable={item => item.canDelete} initialFilter={''} noItemsFragment={this.getNoItemsMessage()} entityName={ @@ -94,7 +94,7 @@ class VisualizeListingTableUi extends Component { ) }, { - field: 'type.title', + field: 'typeTitle', name: intl.formatMessage({ id: 'kbn.visualize.listing.table.typeColumnName', defaultMessage: 'Type', @@ -103,10 +103,31 @@ class VisualizeListingTableUi extends Component { render: (field, record) => ( {this.renderItemTypeIcon(record)} - {record.type.title} + {record.typeTitle} {this.getExperimentalBadge(record)} ) + }, + { + field: 'canEdit', + name: intl.formatMessage({ + id: 'kbn.visualize.listing.table.actionsColumnName', + defaultMessage: 'Actions', + }), + align: 'right', + width: '100px', + render: (_field, record) => ( + this.props.editItem(record)} + iconType="pencil" + aria-label={intl.formatMessage({ + id: 'kbn.visualize.listing.table.editActionDescription', + defaultMessage: 'Edit', + })} + disabled={!record.canEdit} + /> + ), } ]; @@ -175,13 +196,13 @@ class VisualizeListingTableUi extends Component { renderItemTypeIcon(item) { let icon; - if (item.type.image) { + if (item.image) { icon = ( ); } else { @@ -199,7 +220,7 @@ class VisualizeListingTableUi extends Component { } getExperimentalBadge(item) { - return item.type.shouldMarkAsExperimentalInUI() && ( + return item.isExperimental && ( v.appExtensions && v.appExtensions.visualizations)); + const extensionByType = extensions.reduce((acc, m) => { + return m.docTypes.reduce((_acc, type) => { + acc[type] = m; + return acc; + }, acc); + }, {}); + const searchOption = (field, ...defaults) => + _(extensions) + .pluck(field) + .concat(defaults) + .compact() + .flatten() + .uniq() + .value(); + const searchOptions = { + type: searchOption('docTypes', 'visualization'), + searchFields: searchOption('searchFields', 'title^3', 'description'), + search: search ? `${search}*` : undefined, + perPage: size, + page: 1, + defaultSearchOperator: 'AND' + }; + + const { total, savedObjects } = await savedObjectsClient.find(searchOptions); + + return { + total, + hits: savedObjects + .map((savedObject) => { + const config = extensionByType[savedObject.type]; + + if (config) { + return config.toListItem(savedObject); + } else { + return mapSavedObjectApiHits(savedObject); + } + }) + }; +} diff --git a/src/legacy/core_plugins/kibana/public/visualize/saved_visualizations/find_list_items.test.js b/src/legacy/core_plugins/kibana/public/visualize/saved_visualizations/find_list_items.test.js new file mode 100644 index 00000000000000..ed0f6dc429ef45 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/visualize/saved_visualizations/find_list_items.test.js @@ -0,0 +1,185 @@ +/* + * 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 { findListItems } from './find_list_items'; + +describe('saved_visualizations', () => { + function testProps() { + return { + visTypes: [], + search: '', + size: 10, + savedObjectsClient: { + find: jest.fn(async () => ({ + total: 0, + savedObjects: [], + })), + }, + mapSavedObjectApiHits: jest.fn(), + }; + } + + it('searches visualization title and description', async () => { + const props = testProps(); + const { find } = props.savedObjectsClient; + await findListItems(props); + expect(find.mock.calls).toMatchObject([ + [ + { + type: ['visualization'], + searchFields: ['title^3', 'description'], + }, + ], + ]); + }); + + it('searches searchFields and types specified by app extensions', async () => { + const props = { + ...testProps(), + visTypes: [ + { + appExtensions: { + visualizations: { + docTypes: ['bazdoc', 'etc'], + searchFields: ['baz', 'bing'], + }, + }, + }, + ], + }; + const { find } = props.savedObjectsClient; + await findListItems(props); + expect(find.mock.calls).toMatchObject([ + [ + { + type: ['bazdoc', 'etc', 'visualization'], + searchFields: ['baz', 'bing', 'title^3', 'description'], + }, + ], + ]); + }); + + it('deduplicates types and search fields', async () => { + const props = { + ...testProps(), + visTypes: [ + { + appExtensions: { + visualizations: { + docTypes: ['bazdoc', 'bar'], + searchFields: ['baz', 'bing', 'barfield'], + }, + }, + }, + { + appExtensions: { + visualizations: { + docTypes: ['visualization', 'foo', 'bazdoc'], + searchFields: ['baz', 'bing', 'foofield'], + }, + }, + }, + ], + }; + const { find } = props.savedObjectsClient; + await findListItems(props); + expect(find.mock.calls).toMatchObject([ + [ + { + type: ['bazdoc', 'bar', 'visualization', 'foo'], + searchFields: ['baz', 'bing', 'barfield', 'foofield', 'title^3', 'description'], + }, + ], + ]); + }); + + it('searches the search term prefix', async () => { + const props = { + ...testProps(), + search: 'ahoythere', + }; + const { find } = props.savedObjectsClient; + await findListItems(props); + expect(find.mock.calls).toMatchObject([ + [ + { + search: 'ahoythere*', + }, + ], + ]); + }); + + it('uses type-specific toListItem function, if available', async () => { + const props = { + ...testProps(), + savedObjectsClient: { + find: jest.fn(async () => ({ + total: 2, + savedObjects: [ + { + id: 'lotr', + type: 'wizard', + attributes: { label: 'Gandalf' }, + }, + { + id: 'wat', + type: 'visualization', + attributes: { title: 'WATEVER' }, + }, + ], + })), + }, + mapSavedObjectApiHits(savedObject) { + return { + id: savedObject.id, + title: `DEFAULT ${savedObject.attributes.title}`, + }; + }, + visTypes: [ + { + appExtensions: { + visualizations: { + docTypes: ['wizard'], + toListItem(savedObject) { + return { + id: savedObject.id, + title: `${savedObject.attributes.label} THE GRAY`, + }; + }, + }, + }, + }, + ], + }; + const items = await findListItems(props); + expect(items).toEqual({ + total: 2, + hits: [ + { + id: 'lotr', + title: 'Gandalf THE GRAY', + }, + { + id: 'wat', + title: 'DEFAULT WATEVER', + }, + ], + }); + }); +}); diff --git a/src/legacy/core_plugins/kibana/public/visualize/saved_visualizations/saved_visualizations.js b/src/legacy/core_plugins/kibana/public/visualize/saved_visualizations/saved_visualizations.js index dee8cd7fda9ab2..c3c3abbb215da5 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/saved_visualizations/saved_visualizations.js +++ b/src/legacy/core_plugins/kibana/public/visualize/saved_visualizations/saved_visualizations.js @@ -22,6 +22,10 @@ import { VisTypesRegistryProvider } from 'ui/registry/vis_types'; import { uiModules } from 'ui/modules'; import { SavedObjectLoader, SavedObjectsClientProvider } from 'ui/saved_objects'; import { savedObjectManagementRegistry } from '../../management/saved_object_registry'; +import { visualizations } from 'plugins/visualizations'; +import { capabilities } from 'ui/capabilities'; +import { createVisualizeEditUrl } from '../visualize_constants'; +import { findListItems } from './find_list_items'; const app = uiModules.get('app/visualize'); @@ -34,7 +38,6 @@ savedObjectManagementRegistry.register({ app.service('savedVisualizations', function (SavedVis, Private, kbnUrl, chrome) { const visTypes = Private(VisTypesRegistryProvider); - const savedObjectClient = Private(SavedObjectsClientProvider); const saveVisualizationLoader = new SavedObjectLoader(SavedVis, kbnUrl, chrome, savedObjectClient); @@ -54,12 +57,33 @@ app.service('savedVisualizations', function (SavedVis, Private, kbnUrl, chrome) } source.type = visTypes.byName[typeName]; + source.savedObjectType = 'visualization'; source.icon = source.type.icon; + source.image = source.type.image; + source.typeTitle = source.type.title; + source.isExperimental = source.type.shouldMarkAsExperimentalInUI(); + source.editURL = `/app/kibana#${createVisualizeEditUrl(id)}`; + source.canDelete = capabilities.get().visualize.delete; + source.canEdit = capabilities.get().visualize.save; + return source; }; saveVisualizationLoader.urlFor = function (id) { return kbnUrl.eval('#/visualize/edit/{{id}}', { id: id }); }; + + // This behaves similarly to find, except it returns visualizations that are + // defined as appExtensions and which may not conform to type: visualization + saveVisualizationLoader.findListItems = function (search = '', size = 100) { + return findListItems({ + search, + size, + mapSavedObjectApiHits: this.mapSavedObjectApiHits.bind(this), + savedObjectsClient: this.savedObjectsClient, + visTypes: visualizations.types.visTypeAliasRegistry.get(), + }); + }; + return saveVisualizationLoader; }); diff --git a/src/legacy/core_plugins/visualizations/public/types/vis_type_alias_registry.ts b/src/legacy/core_plugins/visualizations/public/types/vis_type_alias_registry.ts index 763c34a4240b78..2fb178dd09648b 100644 --- a/src/legacy/core_plugins/visualizations/public/types/vis_type_alias_registry.ts +++ b/src/legacy/core_plugins/visualizations/public/types/vis_type_alias_registry.ts @@ -17,12 +17,39 @@ * under the License. */ +export interface VisualizationListItem { + canDelete?: boolean; + canEdit?: boolean; + editURL: string; + icon: string; + id: string; + isExperimental: boolean; + title: string; + typeTitle: string; + savedObjectType: string; +} + +export interface VisualizationsAppExtension { + docTypes: string[]; + searchFields?: string[]; + toListItem: (savedObject: { + id: string; + type: string; + attributes: object; + }) => VisualizationListItem; +} + export interface VisTypeAlias { aliasUrl: string; name: string; title: string; icon: string; description: string; + + appExtensions?: { + visualizations: VisualizationsAppExtension; + [appName: string]: unknown; + }; } const registry: VisTypeAlias[] = []; diff --git a/src/legacy/plugin_discovery/types.ts b/src/legacy/plugin_discovery/types.ts index 76b62b7eb693c5..6d7c59893dfe6c 100644 --- a/src/legacy/plugin_discovery/types.ts +++ b/src/legacy/plugin_discovery/types.ts @@ -58,6 +58,7 @@ export interface LegacyPluginOptions { icon: string; euiIconType: string; order: number; + listed: boolean; }>; apps: any; hacks: string[]; diff --git a/x-pack/legacy/plugins/lens/index.ts b/x-pack/legacy/plugins/lens/index.ts index 36cc18c86b5496..175c65eccd7da3 100644 --- a/x-pack/legacy/plugins/lens/index.ts +++ b/x-pack/legacy/plugins/lens/index.ts @@ -25,6 +25,7 @@ export const lens: LegacyPluginInitializer = kibana => { title: NOT_INTERNATIONALIZED_PRODUCT_NAME, description: 'Explore and visualize data.', main: `plugins/${PLUGIN_ID}/index`, + listed: false, }, embeddableFactories: ['plugins/lens/register_embeddable'], styleSheetPaths: resolve(__dirname, 'public/index.scss'), @@ -64,7 +65,7 @@ export const lens: LegacyPluginInitializer = kibana => { all: [], read: [], }, - ui: ['save', 'show'], + ui: ['save', 'show', 'delete'], }, read: { api: [PLUGIN_ID], diff --git a/x-pack/legacy/plugins/lens/mappings.json b/x-pack/legacy/plugins/lens/mappings.json index 9136447531be8d..673197a8b1247a 100644 --- a/x-pack/legacy/plugins/lens/mappings.json +++ b/x-pack/legacy/plugins/lens/mappings.json @@ -11,7 +11,8 @@ "type": "keyword" }, "state": { - "type": "text" + "enabled": false, + "type": "object" }, "expression": { "type": "text" diff --git a/x-pack/legacy/plugins/lens/public/persistence/saved_object_store.test.ts b/x-pack/legacy/plugins/lens/public/persistence/saved_object_store.test.ts index 12dff938d9be13..6952d1254ee00a 100644 --- a/x-pack/legacy/plugins/lens/public/persistence/saved_object_store.test.ts +++ b/x-pack/legacy/plugins/lens/public/persistence/saved_object_store.test.ts @@ -67,7 +67,7 @@ describe('LensStore', () => { expression: '', activeDatasourceId: 'indexpattern', - state: JSON.stringify({ + state: { datasourceMetaData: { filterableIndexPatterns: [] }, datasourceStates: { indexpattern: { type: 'index_pattern', indexPattern: '.kibana_test' }, @@ -75,7 +75,7 @@ describe('LensStore', () => { visualization: { x: 'foo', y: 'baz' }, query: { query: '', language: 'lucene' }, filters: [], - }), + }, }); }); @@ -117,45 +117,18 @@ describe('LensStore', () => { visualizationType: 'line', expression: '', activeDatasourceId: 'indexpattern', - state: JSON.stringify({ + state: { datasourceMetaData: { filterableIndexPatterns: [] }, datasourceStates: { indexpattern: { type: 'index_pattern', indexPattern: 'lotr' } }, visualization: { gear: ['staff', 'pointy hat'] }, query: { query: '', language: 'lucene' }, filters: [], - }), + }, }); }); }); describe('load', () => { - test('parses the visState', async () => { - const { client, store } = testStore(); - client.get = jest.fn(async () => ({ - id: 'Paul', - type: 'lens', - attributes: { - title: 'Hope clouds observation.', - visualizationType: 'dune', - state: '{ "datasource": { "giantWorms": true } }', - }, - })); - const doc = await store.load('Paul'); - - expect(doc).toEqual({ - id: 'Paul', - type: 'lens', - title: 'Hope clouds observation.', - visualizationType: 'dune', - state: { - datasource: { giantWorms: true }, - }, - }); - - expect(client.get).toHaveBeenCalledTimes(1); - expect(client.get).toHaveBeenCalledWith('lens', 'Paul'); - }); - test('throws if an error is returned', async () => { const { client, store } = testStore(); client.get = jest.fn(async () => ({ diff --git a/x-pack/legacy/plugins/lens/public/persistence/saved_object_store.ts b/x-pack/legacy/plugins/lens/public/persistence/saved_object_store.ts index 681042ed34ffe4..e047e8e57c4dd0 100644 --- a/x-pack/legacy/plugins/lens/public/persistence/saved_object_store.ts +++ b/x-pack/legacy/plugins/lens/public/persistence/saved_object_store.ts @@ -62,18 +62,17 @@ export class SavedObjectIndexStore implements SavedObjectStore { async save(vis: Document) { const { id, type, ...rest } = vis; - const attributes = { - ...rest, - state: JSON.stringify(rest.state), - }; + + // Any is the most straighforward way out here, since the saved + // object client doesn't allow unknowns, and we have them in + // our object. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const attributes: any = rest; const result = await (id ? this.client.update(DOC_TYPE, id, attributes) : this.client.create(DOC_TYPE, attributes)); - return { - ...vis, - id: result.id, - }; + return { ...vis, id: result.id }; } async load(id: string): Promise { @@ -87,7 +86,6 @@ export class SavedObjectIndexStore implements SavedObjectStore { ...attributes, id, type, - state: JSON.parse(((attributes as unknown) as { state: string }).state as string), } as Document; } } diff --git a/x-pack/legacy/plugins/lens/public/register_vis_type_alias.ts b/x-pack/legacy/plugins/lens/public/register_vis_type_alias.ts index 595eb4d0e350bd..ab6b8b63deb493 100644 --- a/x-pack/legacy/plugins/lens/public/register_vis_type_alias.ts +++ b/x-pack/legacy/plugins/lens/public/register_vis_type_alias.ts @@ -5,12 +5,13 @@ */ import { i18n } from '@kbn/i18n'; +import { capabilities } from 'ui/capabilities'; import { visualizations } from '../../../../../src/legacy/core_plugins/visualizations/public'; const NOT_INTERNATIONALIZED_PRODUCT_NAME = 'Lens Visualizations'; visualizations.types.visTypeAliasRegistry.add({ - aliasUrl: '/app/lens/', + aliasUrl: '/app/lens', name: NOT_INTERNATIONALIZED_PRODUCT_NAME, title: i18n.translate('xpack.lens.visTypeAlias.title', { defaultMessage: 'Lens Visualizations', @@ -19,4 +20,25 @@ visualizations.types.visTypeAliasRegistry.add({ defaultMessage: `Lens is a simpler way to create basic visualizations`, }), icon: 'faceHappy', + appExtensions: { + visualizations: { + docTypes: ['lens'], + searchFields: ['title^3'], + toListItem(savedObject) { + const { id, type, attributes } = savedObject; + const { title } = attributes as { title: string }; + return { + id, + title, + canDelete: !!capabilities.get().visualize.delete, + canEdit: !!capabilities.get().visualize.save, + editURL: `/app/lens#/edit/${id}`, + icon: 'faceHappy', + isExperimental: true, + savedObjectType: type, + typeTitle: i18n.translate('xpack.lens.visTypeAlias.type', { defaultMessage: 'Lens' }), + }; + }, + }, + }, });