Allow other apps to list their custom visualizations in the Visualize app (#43386)

This commit is contained in:
Chris Davies 2019-08-26 13:15:29 -04:00 committed by GitHub
parent dedfd62717
commit 007fa0a4a0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 328 additions and 27 deletions

View file

@ -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;

View file

@ -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);

View file

@ -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([{

View file

@ -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) => (
<span>
{this.renderItemTypeIcon(record)}
{record.type.title}
{record.typeTitle}
{this.getExperimentalBadge(record)}
</span>
)
}
},
];
return tableColumns;
@ -175,13 +175,13 @@ class VisualizeListingTableUi extends Component {
renderItemTypeIcon(item) {
let icon;
if (item.type.image) {
if (item.image) {
icon = (
<img
className="visListingTable__typeImage"
aria-hidden="true"
alt=""
src={item.type.image}
src={item.image}
/>
);
} else {
@ -199,7 +199,7 @@ class VisualizeListingTableUi extends Component {
}
getExperimentalBadge(item) {
return item.type.shouldMarkAsExperimentalInUI() && (
return item.isExperimental && (
<EuiBetaBadge
className="visListingTable__experimentalIcon"
label="E"

View file

@ -0,0 +1,65 @@
/*
* 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 _ from 'lodash';
/**
* Search for visualizations and convert them into a list display-friendly format.
*/
export async function findListItems({ visTypes, search, size, savedObjectsClient, mapSavedObjectApiHits }) {
const extensions = _.compact(visTypes.map(v => 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);
}
})
};
}

View file

@ -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',
},
],
});
});
});

View file

@ -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;
});

View file

@ -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[] = [];