diff --git a/src/legacy/core_plugins/kibana/public/dashboard/index.js b/src/legacy/core_plugins/kibana/public/dashboard/index.js index 5993c723a81fc..54db02ac90ba2 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/index.js +++ b/src/legacy/core_plugins/kibana/public/dashboard/index.js @@ -99,8 +99,8 @@ uiRoutes $scope.getViewUrl = ({ id }) => { return chrome.addBasePath(`#${createDashboardEditUrl(id)}`); }; - $scope.delete = (ids) => { - return services.dashboards.delete(ids); + $scope.delete = (dashboards) => { + return services.dashboards.delete(dashboards.map(d => d.id)); }; $scope.hideWriteControls = dashboardConfig.getHideWriteControls(); $scope.initialFilter = ($location.search()).filter || EMPTY_FILTER; 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 97bacb45d7ad8..d68e2854ab13f 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.map(id => itemsById[id])); } catch (error) { toastNotifications.addDanger({ title: ( @@ -482,4 +483,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 73afccf2a288a..d221dcfc5ecaf 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,20 @@ 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(selectedItems) { + return Promise.all( + selectedItems.map(item => { + return savedObjectClient.delete(item.savedObjectType, item.id); + }), + ).then(() => { + chrome.untrackNavLinksForDeletedSavedObjects(selectedItems.map(item => 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 6c59c820e0897..a660528cf2477 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 @@ -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,11 +103,11 @@ class VisualizeListingTableUi extends Component { render: (field, record) => ( {this.renderItemTypeIcon(record)} - {record.type.title} + {record.typeTitle} {this.getExperimentalBadge(record)} ) - } + }, ]; return tableColumns; @@ -175,13 +175,13 @@ class VisualizeListingTableUi extends Component { renderItemTypeIcon(item) { let icon; - if (item.type.image) { + if (item.image) { icon = ( ); } else { @@ -199,7 +199,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 0000000000000..ed0f6dc429ef4 --- /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 dee8cd7fda9ab..cfe5eeec3834f 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,9 @@ 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 { createVisualizeEditUrl } from '../visualize_constants'; +import { findListItems } from './find_list_items'; const app = uiModules.get('app/visualize'); @@ -34,7 +37,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 +56,31 @@ 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 = `#${createVisualizeEditUrl(id)}`; + 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 763c34a4240b7..91040cf966567 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,37 @@ * under the License. */ +export interface VisualizationListItem { + editUrl: string; + icon: string; + id: string; + isExperimental: boolean; + savedObjectType: string; + title: string; + typeTitle: 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[] = [];