diff --git a/src/server/saved_objects/service/saved_objects_client.d.ts b/src/server/saved_objects/service/saved_objects_client.d.ts index 2d823276a6f7..6090337639d5 100644 --- a/src/server/saved_objects/service/saved_objects_client.d.ts +++ b/src/server/saved_objects/service/saved_objects_client.d.ts @@ -50,6 +50,7 @@ export interface FindOptions extends BaseOptions { search?: string; searchFields?: string[]; hasReference?: { type: string; id: string }; + defaultSearchOperator?: 'AND' | 'OR'; } export interface FindResponse { diff --git a/src/ui/public/registry/vis_types.d.ts b/src/ui/public/registry/vis_types.d.ts new file mode 100644 index 000000000000..77d5515dc574 --- /dev/null +++ b/src/ui/public/registry/vis_types.d.ts @@ -0,0 +1,25 @@ +/* + * 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 { VisType } from '../vis'; +import { UIRegistry } from './_registry'; + +declare type VisTypesRegistryProvider = UIRegistry & { + byName: { [typeName: string]: VisType }; +}; diff --git a/src/ui/public/saved_objects/components/saved_object_finder.js b/src/ui/public/saved_objects/components/saved_object_finder.tsx similarity index 57% rename from src/ui/public/saved_objects/components/saved_object_finder.js rename to src/ui/public/saved_objects/components/saved_object_finder.tsx index 395a24a3b552..49a775d99b40 100644 --- a/src/ui/public/saved_objects/components/saved_object_finder.js +++ b/src/ui/public/saved_objects/components/saved_object_finder.tsx @@ -18,22 +18,115 @@ */ import _ from 'lodash'; -import React from 'react'; import PropTypes from 'prop-types'; +import React from 'react'; +import { InjectedIntlProps } from 'react-intl'; import chrome from 'ui/chrome'; import { - EuiFieldSearch, EuiBasicTable, - EuiLink, + EuiFieldSearch, EuiFlexGroup, EuiFlexItem, + EuiLink, + EuiTableCriteria, } from '@elastic/eui'; - +import { Direction } from '@elastic/eui/src/services/sort/sort_direction'; import { injectI18n } from '@kbn/i18n/react'; -class SavedObjectFinderUI extends React.Component { - constructor(props) { +import { SavedObjectAttributes } from '../../../../server/saved_objects'; +import { VisTypesRegistryProvider } from '../../registry/vis_types'; +import { SavedObject } from '../saved_object'; + +interface SavedObjectFinderUIState { + items: Array<{ + title: string | null; + id: SavedObject['id']; + type: SavedObject['type']; + }>; + filter: string; + isFetchingItems: boolean; + page: number; + perPage: number; + sortField?: string; + sortDirection?: Direction; +} + +interface SavedObjectFinderUIProps extends InjectedIntlProps { + callToActionButton?: React.ReactNode; + onChoose?: ( + id: SavedObject['id'], + type: SavedObject['type'] + ) => void; + makeUrl?: (id: SavedObject['id']) => void; + noItemsMessage?: React.ReactNode; + savedObjectType: 'visualization' | 'search'; + visTypes?: VisTypesRegistryProvider; +} + +class SavedObjectFinderUI extends React.Component< + SavedObjectFinderUIProps, + SavedObjectFinderUIState +> { + public static propTypes = { + callToActionButton: PropTypes.node, + onChoose: PropTypes.func, + makeUrl: PropTypes.func, + noItemsMessage: PropTypes.node, + savedObjectType: PropTypes.oneOf(['visualization', 'search']).isRequired, + visTypes: PropTypes.object, + }; + + private isComponentMounted: boolean = false; + + private debouncedFetch = _.debounce(async (filter: string) => { + const resp = await chrome.getSavedObjectsClient().find({ + type: this.props.savedObjectType, + fields: ['title', 'visState'], + search: filter ? `${filter}*` : undefined, + page: 1, + perPage: chrome.getUiSettingsClient().get('savedObjects:listingLimit'), + searchFields: ['title^3', 'description'], + defaultSearchOperator: 'AND', + }); + + const { savedObjectType, visTypes } = this.props; + if ( + savedObjectType === 'visualization' && + !chrome.getUiSettingsClient().get('visualize:enableLabs') && + visTypes + ) { + resp.savedObjects = resp.savedObjects.filter(savedObject => { + if (typeof savedObject.attributes.visState !== 'string') { + return false; + } + const typeName: string = JSON.parse(savedObject.attributes.visState).type; + const visType = visTypes.byName[typeName]; + return visType.stage !== 'experimental'; + }); + } + + if (!this.isComponentMounted) { + return; + } + + // We need this check to handle the case where search results come back in a different + // order than they were sent out. Only load results for the most recent search. + if (filter === this.state.filter) { + this.setState({ + isFetchingItems: false, + items: resp.savedObjects.map(({ attributes: { title }, id, type }) => { + return { + title: typeof title === 'string' ? title : '', + id, + type, + }; + }), + }); + } + }, 300); + + constructor(props: SavedObjectFinderUIProps) { super(props); this.state = { @@ -45,29 +138,38 @@ class SavedObjectFinderUI extends React.Component { }; } - componentWillUnmount() { - this._isMounted = false; + public componentWillUnmount() { + this.isComponentMounted = false; this.debouncedFetch.cancel(); } - componentDidMount() { - this._isMounted = true; + public componentDidMount() { + this.isComponentMounted = true; this.fetchItems(); } - onTableChange = ({ page, sort = {} }) => { - let { - field: sortField, - direction: sortDirection, - } = sort; + public render() { + return ( + + {this.renderSearchBar()} + {this.renderTable()} + + ); + } + + private onTableChange = ({ page, sort = {} }: EuiTableCriteria) => { + let sortField: string | undefined = sort.field; + let sortDirection: Direction | undefined = sort.direction; // 3rd sorting state that is not captured by sort - native order (no sort) // when switching from desc to asc for the same field - use native order - if (this.state.sortField === sortField - && this.state.sortDirection === 'desc' - && sortDirection === 'asc') { - sortField = null; - sortDirection = null; + if ( + this.state.sortField === sortField && + this.state.sortDirection === 'desc' && + sortDirection === 'asc' + ) { + sortField = undefined; + sortDirection = undefined; } this.setState({ @@ -76,21 +178,22 @@ class SavedObjectFinderUI extends React.Component { sortField, sortDirection, }); - } + }; // server-side paging not supported // 1) saved object client does not support sorting by title because title is only mapped as analyzed // 2) can not search on anything other than title because all other fields are stored in opaque JSON strings, // for example, visualizations need to be search by isLab but this is not possible in Elasticsearch side // with the current mappings - getPageOfItems = () => { + private getPageOfItems = () => { // do not sort original list to preserve elasticsearch ranking order const items = this.state.items.slice(); + const { sortField } = this.state; - if (this.state.sortField) { + if (sortField) { items.sort((a, b) => { - const fieldA = _.get(a, this.state.sortField, ''); - const fieldB = _.get(b, this.state.sortField, ''); + const fieldA = _.get(a, sortField, ''); + const fieldB = _.get(b, sortField, ''); let order = 1; if (this.state.sortDirection === 'desc') { order = -1; @@ -104,65 +207,18 @@ class SavedObjectFinderUI extends React.Component { // If end is greater than the length of the sequence, slice extracts through to the end of the sequence (arr.length). const lastIndex = startIndex + this.state.perPage; return items.slice(startIndex, lastIndex); - } + }; - debouncedFetch = _.debounce(async (filter) => { - const resp = await chrome.getSavedObjectsClient().find({ - type: this.props.savedObjectType, - fields: ['title', 'visState'], - search: filter ? `${filter}*` : undefined, - page: 1, - perPage: chrome.getUiSettingsClient().get('savedObjects:listingLimit'), - searchFields: ['title^3', 'description'], - defaultSearchOperator: 'AND', - }); - - if (this.props.savedObjectType === 'visualization' - && !chrome.getUiSettingsClient().get('visualize:enableLabs') - && this.props.visTypes) { - resp.savedObjects = resp.savedObjects.filter(savedObject => { - const typeName = JSON.parse(savedObject.attributes.visState).type; - const visType = this.props.visTypes.byName[typeName]; - return visType.stage !== 'experimental'; - }); - } - - if (!this._isMounted) { - return; - } - - // We need this check to handle the case where search results come back in a different - // order than they were sent out. Only load results for the most recent search. - if (filter === this.state.filter) { - this.setState({ - isFetchingItems: false, - items: resp.savedObjects.map(savedObject => { - return { - title: savedObject.attributes.title, - id: savedObject.id, - type: savedObject.type, - }; - }), - }); - } - }, 300); - - fetchItems = () => { - this.setState({ - isFetchingItems: true, - }, this.debouncedFetch.bind(null, this.state.filter)); - } - - renderSearchBar() { - let actionBtn; - if (this.props.callToActionButton) { - actionBtn = ( - - {this.props.callToActionButton} - - ); - } + private fetchItems = () => { + this.setState( + { + isFetchingItems: true, + }, + this.debouncedFetch.bind(null, this.state.filter) + ); + }; + private renderSearchBar() { return ( @@ -173,29 +229,34 @@ class SavedObjectFinderUI extends React.Component { })} fullWidth value={this.state.filter} - onChange={(e) => { - this.setState({ - filter: e.target.value - }, this.fetchItems); + onChange={e => { + this.setState( + { + filter: e.target.value, + }, + this.fetchItems + ); }} data-test-subj="savedObjectFinderSearchInput" /> - {actionBtn} - + {this.props.callToActionButton && ( + {this.props.callToActionButton} + )} ); } - renderTable() { + private renderTable() { const pagination = { pageIndex: this.state.page, pageSize: this.state.perPage, totalItemCount: this.state.items.length, pageSizeOptions: [5, 10], }; - const sorting = {}; + // TODO there should be a Type in EUI for that, replace if it exists + const sorting: { sort?: EuiTableCriteria['sort'] } = {}; if (this.state.sortField) { sorting.sort = { field: this.state.sortField, @@ -210,11 +271,8 @@ class SavedObjectFinderUI extends React.Component { defaultMessage: 'Title', }), sortable: true, - render: (title, record) => { - const { - onChoose, - makeUrl - } = this.props; + render: (title: string, record: SavedObject) => { + const { onChoose, makeUrl } = this.props; if (!onChoose && !makeUrl) { return {title}; @@ -222,15 +280,21 @@ class SavedObjectFinderUI extends React.Component { return ( { onChoose(record.id, record.type); } : undefined} + onClick={ + onChoose + ? () => { + onChoose(record.id, record.type); + } + : undefined + } href={makeUrl ? makeUrl(record.id) : undefined} data-test-subj={`savedObjectTitle${title.split(' ').join('-')}`} > {title} ); - } - } + }, + }, ]; const items = this.state.items.length === 0 ? [] : this.getPageOfItems(); return ( @@ -245,24 +309,6 @@ class SavedObjectFinderUI extends React.Component { /> ); } - - render() { - return ( - - {this.renderSearchBar()} - {this.renderTable()} - - ); - } } -SavedObjectFinderUI.propTypes = { - callToActionButton: PropTypes.node, - onChoose: PropTypes.func, - makeUrl: PropTypes.func, - noItemsMessage: PropTypes.node, - savedObjectType: PropTypes.oneOf(['visualization', 'search']).isRequired, - visTypes: PropTypes.object, -}; - export const SavedObjectFinder = injectI18n(SavedObjectFinderUI); diff --git a/typings/@elastic/eui/index.d.ts b/typings/@elastic/eui/index.d.ts index 7be970db272d..fcdc481c1777 100644 --- a/typings/@elastic/eui/index.d.ts +++ b/typings/@elastic/eui/index.d.ts @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ +import { Direction } from '@elastic/eui/src/services/sort/sort_direction'; // TODO: Remove once typescript definitions are in EUI @@ -24,4 +25,17 @@ declare module '@elastic/eui' { export const EuiCopy: React.SFC; export const EuiOutsideClickDetector: React.SFC; export const EuiSideNav: React.SFC; + + export interface EuiTableCriteria { + page: { index: number; size: number }; + sort?: { + field?: string; + direction?: Direction; + }; + } + export const EuiBasicTable: React.ComponentClass<{ + onTableChange?: (criteria: EuiTableCriteria) => void; + sorting: { sort?: EuiTableCriteria['sort'] }; + [key: string]: any; + }>; }