/* * SPDX-License-Identifier: Apache-2.0 * * The OpenSearch Contributors require contributions made to * this file be licensed under the Apache-2.0 license or a * compatible open source license. * * Any modifications Copyright OpenSearch Contributors. See * GitHub history for details. */ /* * 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'; import PropTypes from 'prop-types'; import React from 'react'; import { EuiContextMenuItem, EuiContextMenuPanel, EuiEmptyPrompt, EuiFieldSearch, EuiFilterButton, EuiFilterGroup, EuiFlexGroup, EuiFlexItem, EuiListGroup, EuiListGroupItem, EuiLoadingSpinner, EuiPagination, EuiPopover, EuiSpacer, EuiTablePagination, IconType, } from '@elastic/eui'; import { Direction } from '@elastic/eui/src/services/sort/sort_direction'; import { i18n } from '@osd/i18n'; import { SimpleSavedObject, CoreStart, IUiSettingsClient, SavedObjectsStart, } from 'src/core/public'; import { DataSourceAttributes } from 'src/plugins/data_source/common/data_sources'; import { getIndexPatternTitle } from '../../../data/common/index_patterns/utils'; import { LISTING_LIMIT_SETTING } from '../../common'; export interface SavedObjectMetaData { type: string; name: string; getIconForSavedObject(savedObject: SimpleSavedObject): IconType; getTooltipForSavedObject?(savedObject: SimpleSavedObject): string; showSavedObject?(savedObject: SimpleSavedObject): boolean; includeFields?: string[]; } interface FinderAttributes { title?: string; type: string; } interface SavedObjectFinderState { items: Array<{ title: string | null; id: SimpleSavedObject['id']; type: SimpleSavedObject['type']; savedObject: SimpleSavedObject; }>; query: string; isFetchingItems: boolean; page: number; perPage: number; sortDirection?: Direction; sortOpen: boolean; filterOpen: boolean; filteredTypes: string[]; } interface BaseSavedObjectFinder { onChoose?: ( id: SimpleSavedObject['id'], type: SimpleSavedObject['type'], name: string, savedObject: SimpleSavedObject ) => void; noItemsMessage?: React.ReactNode; savedObjectMetaData: Array>; showFilter?: boolean; } interface SavedObjectFinderFixedPage extends BaseSavedObjectFinder { initialPageSize?: undefined; fixedPageSize: number; } interface SavedObjectFinderInitialPageSize extends BaseSavedObjectFinder { initialPageSize?: 5 | 10 | 15 | 25; fixedPageSize?: undefined; } export type SavedObjectFinderProps = SavedObjectFinderFixedPage | SavedObjectFinderInitialPageSize; export type SavedObjectFinderUiProps = { savedObjects: CoreStart['savedObjects']; uiSettings: CoreStart['uiSettings']; } & SavedObjectFinderProps; class SavedObjectFinderUi extends React.Component< SavedObjectFinderUiProps, SavedObjectFinderState > { public static propTypes = { onChoose: PropTypes.func, noItemsMessage: PropTypes.node, savedObjectMetaData: PropTypes.array.isRequired, initialPageSize: PropTypes.oneOf([5, 10, 15, 25]), fixedPageSize: PropTypes.number, showFilter: PropTypes.bool, }; private isComponentMounted: boolean = false; private debouncedFetch = _.debounce(async (query: string) => { const metaDataMap = this.getSavedObjectMetaDataMap(); const fields = Object.values(metaDataMap) .map((metaData) => metaData.includeFields || []) .reduce((allFields, currentFields) => allFields.concat(currentFields), ['title']); const perPage = this.props.uiSettings.get(LISTING_LIMIT_SETTING); const resp = await this.props.savedObjects.client.find({ type: Object.keys(metaDataMap), fields: [...new Set(fields)], search: query ? `${query}*` : undefined, page: 1, perPage, searchFields: ['title^3', 'description'], defaultSearchOperator: 'AND', }); const getDataSource = async (id: string) => { const client = this.props.savedObjects.client; return await client.get('data-source', id); }; const savedObjects = await Promise.all( resp.savedObjects.map(async (obj) => { if (obj.type === 'index-pattern') { const result = { ...obj }; result.attributes.title = await getIndexPatternTitle( obj.attributes.title!, obj.references, getDataSource ); return result; } else { return obj; } }) ); resp.savedObjects = savedObjects.filter((savedObject) => { const metaData = metaDataMap[savedObject.type]; if (metaData.showSavedObject) { return metaData.showSavedObject(savedObject); } else { return true; } }); 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 (query === this.state.query) { this.setState({ isFetchingItems: false, page: 0, items: resp.savedObjects.map((savedObject) => { const { attributes: { title }, id, type, } = savedObject; return { title: typeof title === 'string' ? title : '', id, type, savedObject, }; }), }); } }, 300); constructor(props: SavedObjectFinderUiProps) { super(props); this.state = { items: [], isFetchingItems: false, page: 0, perPage: props.initialPageSize || props.fixedPageSize || 10, query: '', filterOpen: false, filteredTypes: [], sortOpen: false, }; } public componentWillUnmount() { this.isComponentMounted = false; this.debouncedFetch.cancel(); } public componentDidMount() { this.isComponentMounted = true; this.fetchItems(); } public render() { return ( {this.renderSearchBar()} {this.renderListing()} ); } private getSavedObjectMetaDataMap(): Record { return this.props.savedObjectMetaData.reduce( (map, metaData) => ({ ...map, [metaData.type]: metaData }), {} ); } private getPageCount() { return Math.ceil( (this.state.filteredTypes.length === 0 ? this.state.items.length : this.state.items.filter( (item) => this.state.filteredTypes.length === 0 || this.state.filteredTypes.includes(item.type) ).length) / this.state.perPage ); } // 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 OpenSearch side // with the current mappings private getPageOfItems = () => { // do not sort original list to preserve opensearch ranking order const items = this.state.items.slice(); const { sortDirection } = this.state; if (sortDirection || !this.state.query) { items.sort(({ title: titleA }, { title: titleB }) => { let order = 1; if (sortDirection === 'desc') { order = -1; } return order * (titleA || '').toLowerCase().localeCompare((titleB || '').toLowerCase()); }); } // If begin is greater than the length of the sequence, an empty array is returned. const startIndex = this.state.page * this.state.perPage; // 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 .filter( (item) => this.state.filteredTypes.length === 0 || this.state.filteredTypes.includes(item.type) ) .slice(startIndex, lastIndex); }; private fetchItems = () => { this.setState( { isFetchingItems: true, }, this.debouncedFetch.bind(null, this.state.query) ); }; private getAvailableSavedObjectMetaData() { const typesInItems = new Set(); this.state.items.forEach((item) => { typesInItems.add(item.type); }); return this.props.savedObjectMetaData.filter((metaData) => typesInItems.has(metaData.type)); } private getSortOptions() { const sortOptions = [ { this.setState({ sortDirection: 'asc', }); }} > {i18n.translate('savedObjects.finder.sortAsc', { defaultMessage: 'Ascending', })} , { this.setState({ sortDirection: 'desc', }); }} > {i18n.translate('savedObjects.finder.sortDesc', { defaultMessage: 'Descending', })} , ]; if (this.state.query) { sortOptions.push( { this.setState({ sortDirection: undefined, }); }} > {i18n.translate('savedObjects.finder.sortAuto', { defaultMessage: 'Best match', })} ); } return sortOptions; } private renderSearchBar() { const availableSavedObjectMetaData = this.getAvailableSavedObjectMetaData(); return ( { this.setState( { query: e.target.value, }, this.fetchItems ); }} data-test-subj="savedObjectFinderSearchInput" isLoading={this.state.isFetchingItems} /> this.setState({ sortOpen: false })} button={ this.setState(({ sortOpen }) => ({ sortOpen: !sortOpen, })) } iconType="arrowDown" isSelected={this.state.sortOpen} data-test-subj="savedObjectFinderSortButton" > {i18n.translate('savedObjects.finder.sortButtonLabel', { defaultMessage: 'Sort', })} } > {this.props.showFilter && ( this.setState({ filterOpen: false })} button={ this.setState(({ filterOpen }) => ({ filterOpen: !filterOpen, })) } iconType="arrowDown" data-test-subj="savedObjectFinderFilterButton" isSelected={this.state.filterOpen} numFilters={this.props.savedObjectMetaData.length} hasActiveFilters={this.state.filteredTypes.length > 0} numActiveFilters={this.state.filteredTypes.length} > {i18n.translate('savedObjects.finder.filterButtonLabel', { defaultMessage: 'Types', })} } > ( { this.setState(({ filteredTypes }) => ({ filteredTypes: filteredTypes.includes(metaData.type) ? filteredTypes.filter((t) => t !== metaData.type) : [...filteredTypes, metaData.type], page: 0, })); }} > {metaData.name} ))} /> )} {this.props.children ? {this.props.children} : null} ); } private renderListing() { const items = this.state.items.length === 0 ? [] : this.getPageOfItems(); const { onChoose, savedObjectMetaData } = this.props; return ( <> {this.state.isFetchingItems && this.state.items.length === 0 && ( )} {items.length > 0 ? ( {items.map((item) => { const currentSavedObjectMetaData = savedObjectMetaData.find( (metaData) => metaData.type === item.type )!; const fullName = currentSavedObjectMetaData.getTooltipForSavedObject ? currentSavedObjectMetaData.getTooltipForSavedObject(item.savedObject) : `${item.title} (${currentSavedObjectMetaData!.name})`; const iconType = ( currentSavedObjectMetaData || ({ getIconForSavedObject: () => 'document', } as Pick, 'getIconForSavedObject'>) ).getIconForSavedObject(item.savedObject); return ( { onChoose(item.id, item.type, fullName, item.savedObject); } : undefined } title={fullName} data-test-subj={`savedObjectTitle${(item.title || '').split(' ').join('-')}`} /> ); })} ) : ( !this.state.isFetchingItems && )} {this.getPageCount() > 1 && (this.props.fixedPageSize ? ( { this.setState({ page, }); }} /> ) : ( { this.setState({ page, }); }} onChangeItemsPerPage={(perPage) => { this.setState({ perPage, }); }} itemsPerPage={this.state.perPage} itemsPerPageOptions={[5, 10, 15, 25]} /> ))} ); } } const getSavedObjectFinder = (savedObject: SavedObjectsStart, uiSettings: IUiSettingsClient) => { return (props: SavedObjectFinderProps) => ( ); }; export { getSavedObjectFinder, SavedObjectFinderUi };