From 358ab0da83d54b42d3025463b5b5518e4d6da244 Mon Sep 17 00:00:00 2001 From: Tim Roes Date: Mon, 11 Mar 2019 14:29:49 +0100 Subject: [PATCH] Use embeddable registry in add panel (#31400) (#32885) * Prepare control flow to use embeddable factories in add panel * Rewrite saved object finder and add tests * Fix usages of new saved object finder * fix test failures * fix some functional tests and re-introduce makeUrl * fix tests * remove direct hrefs in saved_object_lists * PR review fixes * update snapshot * overwrite width of viz dialog * Update src/legacy/core_plugins/kibana/public/dashboard/top_nav/add_panel.js Co-Authored-By: flash1293 * Update src/legacy/core_plugins/kibana/public/discover/embeddable/search_embeddable_factory.ts Co-Authored-By: flash1293 * Update src/legacy/core_plugins/kibana/public/discover/top_nav/open_search_panel.js Co-Authored-By: flash1293 * Update src/legacy/core_plugins/kibana/public/visualize/wizard/search_selection/search_selection.tsx Co-Authored-By: flash1293 * Update src/legacy/core_plugins/kibana/public/visualize/wizard/search_selection/search_selection.tsx Co-Authored-By: flash1293 * Update src/legacy/core_plugins/kibana/public/visualize/wizard/search_selection/search_selection.tsx Co-Authored-By: flash1293 * fix tests * review fixes #1 * review fixes #2 * dont use classname in functional test * remove call to action button prop * align buttons correctly * fix tests * remove debugging statement * Update src/legacy/core_plugins/kibana/public/dashboard/top_nav/add_panel.js Co-Authored-By: flash1293 * Update src/legacy/core_plugins/kibana/public/discover/top_nav/open_search_panel.js Co-Authored-By: flash1293 * review fixes #3 * improve filter behavior and enable it for search wizard * adjust functional tests for new filter behavior * Change translation id due to string change * Update Jest snapshot --- .../kibana/public/dashboard/dashboard_app.js | 2 +- .../__snapshots__/add_panel.test.js.snap | 76 ++- .../public/dashboard/top_nav/add_panel.js | 178 ++----- .../dashboard/top_nav/add_panel.test.js | 5 +- .../dashboard/top_nav/show_add_panel.js | 4 +- .../embeddable/search_embeddable_factory.ts | 12 +- .../open_search_panel.test.js.snap | 69 ++- .../discover/top_nav/open_search_panel.js | 109 ++-- .../visualize_embeddable_factory.ts | 37 +- .../visualize_embeddable_factory_provider.ts | 7 +- .../public/visualize/wizard/_dialog.scss | 3 +- .../search_selection/search_selection.tsx | 129 ++--- .../service/saved_objects_client.d.ts | 4 + .../public/embeddable/embeddable_factory.ts | 14 +- src/legacy/ui/public/registry/_registry.d.ts | 16 +- .../registry/chrome_header_nav_controls.ts | 17 +- src/legacy/ui/public/registry/vis_types.js | 26 - .../registry/{vis_types.d.ts => vis_types.ts} | 14 +- .../components/saved_object_finder.test.tsx | 468 ++++++++++++++++++ .../components/saved_object_finder.tsx | 466 ++++++++++++----- .../apps/dashboard/dashboard_filtering.js | 3 - test/functional/page_objects/common_page.js | 8 +- test/functional/page_objects/discover_page.js | 4 +- .../functional/page_objects/visualize_page.js | 1 - .../services/dashboard/add_panel.js | 36 +- typings/@elastic/eui/index.d.ts | 1 + .../public/views/nav_control/nav_control.tsx | 1 - .../translations/translations/zh-CN.json | 7 - 28 files changed, 1164 insertions(+), 553 deletions(-) delete mode 100644 src/legacy/ui/public/registry/vis_types.js rename src/legacy/ui/public/registry/{vis_types.d.ts => vis_types.ts} (72%) create mode 100644 src/legacy/ui/public/saved_objects/components/saved_object_finder.test.tsx diff --git a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.js b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.js index 880455cbd82d7..67ac47d9b1ace 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.js +++ b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.js @@ -478,7 +478,7 @@ app.directive('dashboardApp', function ($injector) { showNewVisModal(visTypes, { editorParams: [DashboardConstants.ADD_VISUALIZATION_TO_DASHBOARD_MODE_PARAM] }); }; - showAddPanel(dashboardStateManager.addNewPanel, addNewVis, visTypes); + showAddPanel(dashboardStateManager.addNewPanel, addNewVis, embeddableFactories); }; navActions[TopNavIds.OPTIONS] = (menuItem, navController, anchorElement) => { showOptionsPopover({ diff --git a/src/legacy/core_plugins/kibana/public/dashboard/top_nav/__snapshots__/add_panel.test.js.snap b/src/legacy/core_plugins/kibana/public/dashboard/top_nav/__snapshots__/add_panel.test.js.snap index 31e5edc76f1c2..32311d82587c4 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/top_nav/__snapshots__/add_panel.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/dashboard/top_nav/__snapshots__/add_panel.test.js.snap @@ -10,68 +10,60 @@ exports[`render 1`] = ` ownFocus={true} size="m" > - + -

+

-

+
- + + + + + - - Visualization - - - Saved Search - - - - - } - key="visSavedObjectFinder" - noItemsMessage="No matching visualizations found." - onChoose={[Function]} - savedObjectType="visualization" - visTypes={Object {}} - /> -
+ + + `; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/top_nav/add_panel.js b/src/legacy/core_plugins/kibana/public/dashboard/top_nav/add_panel.js index e9f4709c7e927..febf53de9670a 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/top_nav/add_panel.js +++ b/src/legacy/core_plugins/kibana/public/dashboard/top_nav/add_panel.js @@ -19,109 +19,24 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import { toastNotifications } from 'ui/notify'; import { SavedObjectFinder } from 'ui/saved_objects/components/saved_object_finder'; import { + EuiFlexGroup, + EuiFlexItem, EuiFlyout, + EuiFlyoutHeader, + EuiFlyoutFooter, EuiFlyoutBody, EuiButton, - EuiTabs, - EuiTab, - EuiSpacer, EuiTitle, } from '@elastic/eui'; -const VIS_TAB_ID = 'vis'; -const SAVED_SEARCH_TAB_ID = 'search'; - -class DashboardAddPanelUi extends React.Component { - constructor(props) { - super(props); - - const addNewVisBtn = ( - - - - ); - - const tabs = [{ - id: VIS_TAB_ID, - name: props.intl.formatMessage({ - id: 'kbn.dashboard.topNav.addPanel.visualizationTabName', - defaultMessage: 'Visualization', - }), - dataTestSubj: 'addVisualizationTab', - toastDataTestSubj: 'addVisualizationToDashboardSuccess', - savedObjectFinder: ( - - ) - }, { - id: SAVED_SEARCH_TAB_ID, - name: props.intl.formatMessage({ - id: 'kbn.dashboard.topNav.addPanel.savedSearchTabName', - defaultMessage: 'Saved Search', - }), - dataTestSubj: 'addSavedSearchTab', - toastDataTestSubj: 'addSavedSearchToDashboardSuccess', - savedObjectFinder: ( - - ) - }]; - - this.state = { - tabs: tabs, - selectedTab: tabs[0], - }; - } - - onSelectedTabChanged = tab => { - this.setState({ - selectedTab: tab, - }); - } - - renderTabs() { - return this.state.tabs.map((tab) => { - return ( - this.onSelectedTabChanged(tab)} - isSelected={tab.id === this.state.selectedTab.id} - key={tab.id} - data-test-subj={tab.dataTestSubj} - > - {tab.name} - - ); - }); - } - - onAddPanel = (id, type) => { +export class DashboardAddPanel extends React.Component { + onAddPanel = (id, type, name) => { this.props.addNewPanel(id, type); // To avoid the clutter of having toast messages cover flyout @@ -131,53 +46,66 @@ class DashboardAddPanelUi extends React.Component { } this.lastToast = toastNotifications.addSuccess({ - title: this.props.intl.formatMessage({ - id: 'kbn.dashboard.topNav.addPanel.selectedTabAddedToDashboardSuccessMessageTitle', - defaultMessage: '{selectedTabName} was added to your dashboard', - }, { - selectedTabName: this.state.selectedTab.name, - }), - 'data-test-subj': this.state.selectedTab.toastDataTestSubj, + title: i18n.translate( + 'kbn.dashboard.topNav.addPanel.savedObjectAddedToDashboardSuccessMessageTitle', + { + defaultMessage: '{savedObjectName} was added to your dashboard', + values: { + savedObjectName: name, + }, + } + ), + 'data-test-subj': 'addObjectToDashboardSuccess', }); - } + }; render() { return ( - - - - -

+ + + +

-

+

- - - {this.renderTabs()} - - - - - {this.state.selectedTab.savedObjectFinder} - + + + Boolean(embeddableFactory.savedObjectMetaData)) + .map(({ savedObjectMetaData }) => savedObjectMetaData)} + showFilter={true} + noItemsMessage={i18n.translate( + 'kbn.dashboard.topNav.addPanel.noMatchingObjectsMessage', + { + defaultMessage: 'No matching objects found.', + } + )} + /> + + + + + + + + +
); } } -DashboardAddPanelUi.propTypes = { +DashboardAddPanel.propTypes = { onClose: PropTypes.func.isRequired, - visTypes: PropTypes.object.isRequired, addNewPanel: PropTypes.func.isRequired, addNewVis: PropTypes.func.isRequired, }; - -export const DashboardAddPanel = injectI18n(DashboardAddPanelUi); diff --git a/src/legacy/core_plugins/kibana/public/dashboard/top_nav/add_panel.test.js b/src/legacy/core_plugins/kibana/public/dashboard/top_nav/add_panel.test.js index 3f233eed6b100..eccf9198939e3 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/top_nav/add_panel.test.js +++ b/src/legacy/core_plugins/kibana/public/dashboard/top_nav/add_panel.test.js @@ -19,7 +19,7 @@ import React from 'react'; import sinon from 'sinon'; -import { shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { shallow } from 'enzyme'; import { DashboardAddPanel, @@ -38,11 +38,12 @@ beforeEach(() => { }); test('render', () => { - const component = shallowWithIntl( {}} addNewVis={() => {}} + embeddableFactories={[]} />); expect(component).toMatchSnapshot(); }); diff --git a/src/legacy/core_plugins/kibana/public/dashboard/top_nav/show_add_panel.js b/src/legacy/core_plugins/kibana/public/dashboard/top_nav/show_add_panel.js index adb0908a623eb..ede1432d10480 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/top_nav/show_add_panel.js +++ b/src/legacy/core_plugins/kibana/public/dashboard/top_nav/show_add_panel.js @@ -24,7 +24,7 @@ import ReactDOM from 'react-dom'; let isOpen = false; -export function showAddPanel(addNewPanel, addNewVis, visTypes) { +export function showAddPanel(addNewPanel, addNewVis, embeddableFactories) { if (isOpen) { return; } @@ -47,9 +47,9 @@ export function showAddPanel(addNewPanel, addNewVis, visTypes) { ); diff --git a/src/legacy/core_plugins/kibana/public/discover/embeddable/search_embeddable_factory.ts b/src/legacy/core_plugins/kibana/public/discover/embeddable/search_embeddable_factory.ts index 83028ea62d881..17a5afb749f2e 100644 --- a/src/legacy/core_plugins/kibana/public/discover/embeddable/search_embeddable_factory.ts +++ b/src/legacy/core_plugins/kibana/public/discover/embeddable/search_embeddable_factory.ts @@ -19,6 +19,7 @@ import 'ui/doc_table'; +import { i18n } from '@kbn/i18n'; import { EmbeddableFactory } from 'ui/embeddable'; import { EmbeddableInstanceConfiguration, @@ -33,7 +34,16 @@ export class SearchEmbeddableFactory extends EmbeddableFactory { private $rootScope: ng.IRootScopeService, private searchLoader: SavedSearchLoader ) { - super({ name: 'search' }); + super({ + name: 'search', + savedObjectMetaData: { + name: i18n.translate('kbn.discover.savedSearch.savedObjectName', { + defaultMessage: 'Saved search', + }), + type: 'search', + getIconForSavedObject: () => 'search', + }, + }); } public getEditPath(panelId: string) { diff --git a/src/legacy/core_plugins/kibana/public/discover/top_nav/__snapshots__/open_search_panel.test.js.snap b/src/legacy/core_plugins/kibana/public/discover/top_nav/__snapshots__/open_search_panel.test.js.snap index 05b8fae9ab1dd..6c4eaa7f2f4c7 100644 --- a/src/legacy/core_plugins/kibana/public/discover/top_nav/__snapshots__/open_search_panel.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/discover/top_nav/__snapshots__/open_search_panel.test.js.snap @@ -10,27 +10,60 @@ exports[`render 1`] = ` ownFocus={true} size="m" > - + -

+

-

+
- +
+ + } + onChoose={[Function]} + savedObjectMetaData={ + Array [ + Object { + "getIconForSavedObject": [Function], + "name": "Saved search", + "type": "search", + }, + ] + } + /> + + + + - } - makeUrl={[Function]} - noItemsMessage={ - - } - onChoose={[Function]} - savedObjectType="search" - /> -
+ + + `; diff --git a/src/legacy/core_plugins/kibana/public/discover/top_nav/open_search_panel.js b/src/legacy/core_plugins/kibana/public/discover/top_nav/open_search_panel.js index 1235272588fcb..aee33d9dcb604 100644 --- a/src/legacy/core_plugins/kibana/public/discover/top_nav/open_search_panel.js +++ b/src/legacy/core_plugins/kibana/public/discover/top_nav/open_search_panel.js @@ -21,71 +21,76 @@ import React from 'react'; import PropTypes from 'prop-types'; import { SavedObjectFinder } from 'ui/saved_objects/components/saved_object_finder'; import rison from 'rison-node'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { - EuiSpacer, + EuiButton, + EuiFlexGroup, + EuiFlexItem, EuiFlyout, + EuiFlyoutHeader, + EuiFlyoutFooter, EuiFlyoutBody, EuiTitle, - EuiButton, } from '@elastic/eui'; const SEARCH_OBJECT_TYPE = 'search'; -export class OpenSearchPanel extends React.Component { - - renderMangageSearchesButton() { - return ( - - + + +

+ +

+
+
+ + + } + savedObjectMetaData={[ + { + type: SEARCH_OBJECT_TYPE, + getIconForSavedObject: () => 'search', + name: i18n.translate('kbn.discover.savedSearch.savedObjectName', { + defaultMessage: 'Saved search', + }), + }, + ]} + onChoose={id => { + window.location.assign(props.makeUrl(id)); + props.onClose(); + }} /> -
- ); - } - - render() { - return ( - - - - -

+ + + + + -

-
- - - - - } - savedObjectType={SEARCH_OBJECT_TYPE} - makeUrl={this.props.makeUrl} - onChoose={this.props.onClose} - callToActionButton={this.renderMangageSearchesButton()} - /> - -
-
- ); - } + + + + + + ); } OpenSearchPanel.propTypes = { diff --git a/src/legacy/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable_factory.ts b/src/legacy/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable_factory.ts index e7a81a61b9831..ef1debdb218e9 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable_factory.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable_factory.ts @@ -17,6 +17,8 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; +import chrome from 'ui/chrome'; import { EmbeddableFactory } from 'ui/embeddable'; import { getVisualizeLoader } from 'ui/visualize/loader'; import { VisualizeEmbeddable } from './visualize_embeddable'; @@ -26,16 +28,45 @@ import { EmbeddableInstanceConfiguration, OnEmbeddableStateChanged, } from 'ui/embeddable/embeddable_factory'; +import { VisTypesRegistry } from 'ui/registry/vis_types'; +import { VisualizationAttributes } from '../../../../../server/saved_objects/service/saved_objects_client'; import { SavedVisualizations } from '../types'; import { DisabledLabEmbeddable } from './disabled_lab_embeddable'; import { getIndexPattern } from './get_index_pattern'; -export class VisualizeEmbeddableFactory extends EmbeddableFactory { +export class VisualizeEmbeddableFactory extends EmbeddableFactory { private savedVisualizations: SavedVisualizations; private config: Legacy.KibanaConfig; - constructor(savedVisualizations: SavedVisualizations, config: Legacy.KibanaConfig) { - super({ name: 'visualization' }); + constructor( + savedVisualizations: SavedVisualizations, + config: Legacy.KibanaConfig, + visTypes: VisTypesRegistry + ) { + super({ + name: 'visualization', + savedObjectMetaData: { + name: i18n.translate('kbn.visualize.savedObjectName', { defaultMessage: 'Visualization' }), + type: 'visualization', + getIconForSavedObject: savedObject => { + return ( + visTypes.byName[JSON.parse(savedObject.attributes.visState).type].icon || 'visualizeApp' + ); + }, + getTooltipForSavedObject: savedObject => { + const visType = visTypes.byName[JSON.parse(savedObject.attributes.visState).type].title; + return `${savedObject.attributes.title} (${visType})`; + }, + showSavedObject: savedObject => { + if (chrome.getUiSettingsClient().get('visualize:enableLabs')) { + return true; + } + const typeName: string = JSON.parse(savedObject.attributes.visState).type; + const visType = visTypes.byName[typeName]; + return visType.stage !== 'experimental'; + }, + }, + }); this.config = config; this.savedVisualizations = savedVisualizations; } diff --git a/src/legacy/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable_factory_provider.ts b/src/legacy/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable_factory_provider.ts index da30433d11d72..dcdf58a52d918 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable_factory_provider.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable_factory_provider.ts @@ -20,6 +20,7 @@ import { Legacy } from 'kibana'; import { EmbeddableFactoriesRegistryProvider } from 'ui/embeddable/embeddable_factories_registry'; import { IPrivate } from 'ui/private'; +import { VisTypesRegistryProvider } from 'ui/registry/vis_types'; import { SavedVisualizations } from '../types'; import { VisualizeEmbeddableFactory } from './visualize_embeddable_factory'; @@ -28,7 +29,11 @@ export function visualizeEmbeddableFactoryProvider(Private: IPrivate) { savedVisualizations: SavedVisualizations, config: Legacy.KibanaConfig ) => { - return new VisualizeEmbeddableFactory(savedVisualizations, config); + return new VisualizeEmbeddableFactory( + savedVisualizations, + config, + Private(VisTypesRegistryProvider) + ); }; return Private(VisualizeEmbeddableFactoryProvider); } diff --git a/src/legacy/core_plugins/kibana/public/visualize/wizard/_dialog.scss b/src/legacy/core_plugins/kibana/public/visualize/wizard/_dialog.scss index 01ecbd9ff5197..829c18c3644f0 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/wizard/_dialog.scss +++ b/src/legacy/core_plugins/kibana/public/visualize/wizard/_dialog.scss @@ -7,7 +7,8 @@ } .visNewVisSearchDialog { - min-height: $euiSizeL * 20; + width: $euiSizeL * 30; + min-height: $euiSizeL * 25; } .visNewVisDialog__body { diff --git a/src/legacy/core_plugins/kibana/public/visualize/wizard/search_selection/search_selection.tsx b/src/legacy/core_plugins/kibana/public/visualize/wizard/search_selection/search_selection.tsx index 2c945a5ccccbf..34c95b43991e8 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/wizard/search_selection/search_selection.tsx +++ b/src/legacy/core_plugins/kibana/public/visualize/wizard/search_selection/search_selection.tsx @@ -17,14 +17,7 @@ * under the License. */ -import { - EuiModalBody, - EuiModalHeader, - EuiModalHeaderTitle, - EuiSpacer, - EuiTab, - EuiTabs, -} from '@elastic/eui'; +import { EuiModalBody, EuiModalHeader, EuiModalHeaderTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; @@ -38,22 +31,7 @@ interface SearchSelectionProps { visType: VisType; } -interface SearchSelectionState { - selectedTabId: string; -} - -interface TabProps { - id: string; - name: string; -} - -const INDEX_PATTERNS_TAB_ID = 'indexPatterns'; -const SAVED_SEARCHES_TAB_ID = 'savedSearches'; - -export class SearchSelection extends React.Component { - public state = { - selectedTabId: INDEX_PATTERNS_TAB_ID, - }; +export class SearchSelection extends React.Component { private fixedPageSize: number = 8; public render() { @@ -74,77 +52,42 @@ export class SearchSelection extends React.Component - {this.renderTabs()} - - - - {this.renderTab()} + 'search', + name: i18n.translate( + 'kbn.visualize.newVisWizard.searchSelection.savedObjectType.search', + { + defaultMessage: 'Saved search', + } + ), + }, + { + type: 'index-pattern', + getIconForSavedObject: () => 'indexPatternApp', + name: i18n.translate( + 'kbn.visualize.newVisWizard.searchSelection.savedObjectType.indexPattern', + { + defaultMessage: 'Index pattern', + } + ), + }, + ]} + fixedPageSize={this.fixedPageSize} + /> ); } - - private onSelectedTabChanged = (tab: TabProps) => { - this.setState({ - selectedTabId: tab.id, - }); - }; - - private renderTabs() { - const tabs = [ - { - id: INDEX_PATTERNS_TAB_ID, - name: i18n.translate('kbn.visualize.newVisWizard.indexPatternTabLabel', { - defaultMessage: 'Index pattern', - }), - }, - { - id: SAVED_SEARCHES_TAB_ID, - name: i18n.translate('kbn.visualize.newVisWizard.savedSearchTabLabel', { - defaultMessage: 'Saved search', - }), - }, - ]; - const { selectedTabId } = this.state; - - return tabs.map(tab => ( - this.onSelectedTabChanged(tab)} - isSelected={tab.id === selectedTabId} - key={tab.id} - data-test-subj={`${tab.id}Tab`} - > - {tab.name} - - )); - } - - private renderTab() { - if (this.state.selectedTabId === SAVED_SEARCHES_TAB_ID) { - return ( - - ); - } - - return ( - - ); - } } diff --git a/src/legacy/server/saved_objects/service/saved_objects_client.d.ts b/src/legacy/server/saved_objects/service/saved_objects_client.d.ts index 2b9db99dc7275..41340f8448576 100644 --- a/src/legacy/server/saved_objects/service/saved_objects_client.d.ts +++ b/src/legacy/server/saved_objects/service/saved_objects_client.d.ts @@ -83,6 +83,10 @@ export interface SavedObjectAttributes { [key: string]: SavedObjectAttributes | string | number | boolean | null; } +export interface VisualizationAttributes extends SavedObjectAttributes { + visState: string; +} + export interface SavedObject { id: string; type: string; diff --git a/src/legacy/ui/public/embeddable/embeddable_factory.ts b/src/legacy/ui/public/embeddable/embeddable_factory.ts index 07bfd2a2b33f0..e71813b22995f 100644 --- a/src/legacy/ui/public/embeddable/embeddable_factory.ts +++ b/src/legacy/ui/public/embeddable/embeddable_factory.ts @@ -17,6 +17,8 @@ * under the License. */ +import { SavedObjectAttributes } from '../../../server/saved_objects'; +import { SavedObjectMetaData } from '../saved_objects/components/saved_object_finder'; import { Embeddable } from './embeddable'; import { EmbeddableState } from './types'; export interface EmbeddableInstanceConfiguration { @@ -28,16 +30,24 @@ export type OnEmbeddableStateChanged = (embeddableStateChanges: EmbeddableState) /** * The EmbeddableFactory creates and initializes an embeddable instance */ -export abstract class EmbeddableFactory { +export abstract class EmbeddableFactory { public readonly name: string; + public readonly savedObjectMetaData?: SavedObjectMetaData; /** * * @param name - a unique identified for this factory, which will be used to map an embeddable spec to * a factory that can generate an instance of it. */ - constructor({ name }: { name: string }) { + constructor({ + name, + savedObjectMetaData, + }: { + name: string; + savedObjectMetaData?: SavedObjectMetaData; + }) { this.name = name; + this.savedObjectMetaData = savedObjectMetaData; } /** diff --git a/src/legacy/ui/public/registry/_registry.d.ts b/src/legacy/ui/public/registry/_registry.d.ts index 425ab45036519..9b95a2e02ee90 100644 --- a/src/legacy/ui/public/registry/_registry.d.ts +++ b/src/legacy/ui/public/registry/_registry.d.ts @@ -19,13 +19,21 @@ import { IndexedArray, IndexedArrayConfig } from '../indexed_array'; -interface UIRegistry extends IndexedArray { - register(privateModule: T): UIRegistry; -} +interface UIRegistry extends IndexedArray {} interface UIRegistrySpec extends IndexedArrayConfig { name: string; filter?(item: T): boolean; } -declare function uiRegistry(spec: UIRegistrySpec): UIRegistry; +/** + * Creates a new UiRegistry (See js method for detailed documentation) + * The generic type T is the type of objects which are stored in the registry. + * The generic type A is an interface of accessors which depend on the + * fields of the objects stored in the registry. + * Example: if there is a string field "name" in type T, then A should be + * `{ byName: { [typeName: string]: T }; }` + */ +declare function uiRegistry( + spec: UIRegistrySpec +): { (): UIRegistry & A; register(privateModule: T): UIRegistry & A }; diff --git a/src/legacy/ui/public/registry/chrome_header_nav_controls.ts b/src/legacy/ui/public/registry/chrome_header_nav_controls.ts index 5207113db6899..a626d013dde7b 100644 --- a/src/legacy/ui/public/registry/chrome_header_nav_controls.ts +++ b/src/legacy/ui/public/registry/chrome_header_nav_controls.ts @@ -21,17 +21,18 @@ import { NavControl } from '../chrome/directives/header_global_nav'; import { IndexedArray } from '../indexed_array'; import { uiRegistry, UIRegistry } from './_registry'; -interface BySideDictionary { - // this key should be from NavControlSide - [side: string]: IndexedArray; +interface ChromeHeaderNavControlsRegistryAccessors { + bySide: { [typeName: string]: IndexedArray }; } -export interface ChromeHeaderNavControlsRegistry extends UIRegistry { - bySide: BySideDictionary; -} +export type ChromeHeaderNavControlsRegistry = UIRegistry & + ChromeHeaderNavControlsRegistryAccessors; -export const chromeHeaderNavControlsRegistry: ChromeHeaderNavControlsRegistry = uiRegistry({ +export const chromeHeaderNavControlsRegistry = uiRegistry< + NavControl, + ChromeHeaderNavControlsRegistryAccessors +>({ name: 'chromeHeaderNavControls', order: ['order'], group: ['side'], -}) as ChromeHeaderNavControlsRegistry; +}); diff --git a/src/legacy/ui/public/registry/vis_types.js b/src/legacy/ui/public/registry/vis_types.js deleted file mode 100644 index a6e95f5576d57..0000000000000 --- a/src/legacy/ui/public/registry/vis_types.js +++ /dev/null @@ -1,26 +0,0 @@ -/* - * 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 { uiRegistry } from './_registry'; - -export const VisTypesRegistryProvider = uiRegistry({ - name: 'visTypes', - index: ['name'], - order: ['title'] -}); diff --git a/src/legacy/ui/public/registry/vis_types.d.ts b/src/legacy/ui/public/registry/vis_types.ts similarity index 72% rename from src/legacy/ui/public/registry/vis_types.d.ts rename to src/legacy/ui/public/registry/vis_types.ts index 77d5515dc5745..7f4c2e96eee44 100644 --- a/src/legacy/ui/public/registry/vis_types.d.ts +++ b/src/legacy/ui/public/registry/vis_types.ts @@ -18,8 +18,16 @@ */ import { VisType } from '../vis'; -import { UIRegistry } from './_registry'; +import { uiRegistry, UIRegistry } from './_registry'; -declare type VisTypesRegistryProvider = UIRegistry & { +interface VisTypesRegistryAccessors { byName: { [typeName: string]: VisType }; -}; +} + +export type VisTypesRegistry = UIRegistry & VisTypesRegistryAccessors; + +export const VisTypesRegistryProvider = uiRegistry({ + name: 'visTypes', + index: ['name'], + order: ['title'], +}); diff --git a/src/legacy/ui/public/saved_objects/components/saved_object_finder.test.tsx b/src/legacy/ui/public/saved_objects/components/saved_object_finder.test.tsx new file mode 100644 index 0000000000000..09412322a5558 --- /dev/null +++ b/src/legacy/ui/public/saved_objects/components/saved_object_finder.test.tsx @@ -0,0 +1,468 @@ +/* + * 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. + */ + +jest.mock('ui/chrome', () => ({ + getUiSettingsClient: () => ({ + get: () => 10, + }), +})); + +jest.mock('lodash', () => ({ + debounce: (fn: any) => fn, +})); + +const nextTick = () => new Promise(res => process.nextTick(res)); + +import { + EuiEmptyPrompt, + EuiListGroup, + EuiListGroupItem, + EuiLoadingSpinner, + EuiPagination, + EuiTablePagination, +} from '@elastic/eui'; +import { shallow } from 'enzyme'; +import React from 'react'; +import * as sinon from 'sinon'; +import { SavedObjectFinder } from './saved_object_finder'; + +describe('SavedObjectsFinder', () => { + let objectsClientStub: sinon.SinonStub; + + const doc = { + id: '1', + type: 'search', + attributes: { title: 'Example title' }, + }; + + const doc2 = { + id: '2', + type: 'search', + attributes: { title: 'Another title' }, + }; + + const doc3 = { type: 'vis', id: '3', attributes: { title: 'Vis' } }; + + const searchMetaData = [ + { + type: 'search', + name: 'Search', + getIconForSavedObject: () => 'search', + showSavedObject: () => true, + }, + ]; + + beforeEach(() => { + objectsClientStub = sinon.stub(); + objectsClientStub.returns(Promise.resolve({ savedObjects: [] })); + require('ui/chrome').getSavedObjectsClient = () => ({ + find: async (...args: any[]) => { + return objectsClientStub(...args); + }, + }); + }); + + it('should call saved object client on startup', async () => { + objectsClientStub.returns(Promise.resolve({ savedObjects: [doc] })); + + const wrapper = shallow(); + wrapper.instance().componentDidMount!(); + expect( + objectsClientStub.calledWith({ + type: ['search'], + fields: ['title', 'visState'], + search: undefined, + page: 1, + perPage: 10, + searchFields: ['title^3', 'description'], + defaultSearchOperator: 'AND', + }) + ).toBe(true); + }); + + it('should list initial items', async () => { + objectsClientStub.returns(Promise.resolve({ savedObjects: [doc] })); + + const wrapper = shallow(); + wrapper.instance().componentDidMount!(); + await nextTick(); + expect( + wrapper.containsMatchingElement() + ).toEqual(true); + }); + + it('should call onChoose on item click', async () => { + const chooseStub = sinon.stub(); + objectsClientStub.returns(Promise.resolve({ savedObjects: [doc] })); + + const wrapper = shallow( + + ); + wrapper.instance().componentDidMount!(); + await nextTick(); + wrapper + .find(EuiListGroupItem) + .first() + .simulate('click'); + expect(chooseStub.calledWith('1', 'search', `${doc.attributes.title} (Search)`)).toEqual(true); + }); + + describe('sorting', () => { + it('should list items ascending', async () => { + objectsClientStub.returns(Promise.resolve({ savedObjects: [doc, doc2] })); + + const wrapper = shallow(); + wrapper.instance().componentDidMount!(); + await nextTick(); + const list = wrapper.find(EuiListGroup); + expect(list.childAt(0).key()).toBe('2'); + expect(list.childAt(1).key()).toBe('1'); + }); + + it('should list items descending', async () => { + objectsClientStub.returns(Promise.resolve({ savedObjects: [doc, doc2] })); + + const wrapper = shallow(); + wrapper.instance().componentDidMount!(); + await nextTick(); + wrapper.setState({ sortDirection: 'desc' }); + const list = wrapper.find(EuiListGroup); + expect(list.childAt(0).key()).toBe('1'); + expect(list.childAt(1).key()).toBe('2'); + }); + }); + + it('should not show the saved objects which get filtered by showSavedObject', async () => { + objectsClientStub.returns(Promise.resolve({ savedObjects: [doc, doc2] })); + + const wrapper = shallow( + 'search', + showSavedObject: ({ id }) => id !== '1', + }, + ]} + /> + ); + wrapper.instance().componentDidMount!(); + await nextTick(); + const list = wrapper.find(EuiListGroup); + expect(list.childAt(0).key()).toBe('2'); + expect(list.children().length).toBe(1); + }); + + describe('search', () => { + it('should request filtered list on search input', async () => { + objectsClientStub.returns(Promise.resolve({ savedObjects: [doc, doc2] })); + + const wrapper = shallow(); + wrapper.instance().componentDidMount!(); + await nextTick(); + wrapper + .find('[data-test-subj="savedObjectFinderSearchInput"]') + .first() + .simulate('change', { target: { value: 'abc' } }); + + expect( + objectsClientStub.calledWith({ + type: ['search'], + fields: ['title', 'visState'], + search: 'abc*', + page: 1, + perPage: 10, + searchFields: ['title^3', 'description'], + defaultSearchOperator: 'AND', + }) + ).toBe(true); + }); + + it('should respect response order on search input', async () => { + objectsClientStub.returns(Promise.resolve({ savedObjects: [doc, doc2] })); + + const wrapper = shallow(); + wrapper.instance().componentDidMount!(); + await nextTick(); + wrapper + .find('[data-test-subj="savedObjectFinderSearchInput"]') + .first() + .simulate('change', { target: { value: 'abc' } }); + await nextTick(); + const list = wrapper.find(EuiListGroup); + expect(list.childAt(0).key()).toBe('1'); + expect(list.childAt(1).key()).toBe('2'); + }); + }); + + it('should request multiple saved object types at once', async () => { + const wrapper = shallow( + 'search', + }, + { + type: 'vis', + name: 'Vis', + getIconForSavedObject: () => 'visualization', + }, + ]} + /> + ); + wrapper.instance().componentDidMount!(); + + expect( + objectsClientStub.calledWith({ + type: ['search', 'vis'], + fields: ['title', 'visState'], + search: undefined, + page: 1, + perPage: 10, + searchFields: ['title^3', 'description'], + defaultSearchOperator: 'AND', + }) + ).toBe(true); + }); + + describe('filter', () => { + const metaDataConfig = [ + { + type: 'search', + name: 'Search', + getIconForSavedObject: () => 'search', + }, + { + type: 'vis', + name: 'Vis', + getIconForSavedObject: () => 'document', + }, + ]; + + it('should not render filter buttons if disabled', async () => { + objectsClientStub.returns( + Promise.resolve({ + savedObjects: [doc, doc2, doc3], + }) + ); + const wrapper = shallow( + + ); + wrapper.instance().componentDidMount!(); + await nextTick(); + expect(wrapper.find('[data-test-subj="savedObjectFinderFilter-search"]').exists()).toBe( + false + ); + }); + + it('should not render filter buttons if there is only one type in the list', async () => { + objectsClientStub.returns( + Promise.resolve({ + savedObjects: [doc, doc2], + }) + ); + const wrapper = shallow( + + ); + wrapper.instance().componentDidMount!(); + await nextTick(); + expect(wrapper.find('[data-test-subj="savedObjectFinderFilter-search"]').exists()).toBe( + false + ); + }); + + it('should apply filter if selected', async () => { + objectsClientStub.returns( + Promise.resolve({ + savedObjects: [doc, doc2, doc3], + }) + ); + const wrapper = shallow( + + ); + wrapper.instance().componentDidMount!(); + await nextTick(); + wrapper.setState({ filteredTypes: ['vis'] }); + const list = wrapper.find(EuiListGroup); + expect(list.childAt(0).key()).toBe('3'); + expect(list.children().length).toBe(1); + + wrapper.setState({ filteredTypes: ['vis', 'search'] }); + expect(wrapper.find(EuiListGroup).children().length).toBe(3); + }); + }); + + it('should display no items message if there are no items', async () => { + objectsClientStub.returns(Promise.resolve({ savedObjects: [] })); + const noItemsMessage = ; + const wrapper = shallow( + + ); + wrapper.instance().componentDidMount!(); + await nextTick(); + + expect( + wrapper + .find(EuiEmptyPrompt) + .first() + .prop('body') + ).toEqual(noItemsMessage); + }); + + describe('pagination', () => { + const longItemList = new Array(50).fill(undefined).map((_, i) => ({ + id: String(i), + type: 'search', + attributes: { + title: `Title ${i < 10 ? '0' : ''}${i}`, + }, + })); + + beforeEach(() => { + objectsClientStub.returns(Promise.resolve({ savedObjects: longItemList })); + }); + + it('should show a table pagination with initial per page', async () => { + const wrapper = shallow( + + ); + wrapper.instance().componentDidMount!(); + await nextTick(); + expect( + wrapper + .find(EuiTablePagination) + .first() + .prop('itemsPerPage') + ).toEqual(15); + expect(wrapper.find(EuiListGroup).children().length).toBe(15); + }); + + it('should allow switching the page size', async () => { + const wrapper = shallow( + + ); + wrapper.instance().componentDidMount!(); + await nextTick(); + wrapper + .find(EuiTablePagination) + .first() + .prop('onChangeItemsPerPage')!(5); + expect(wrapper.find(EuiListGroup).children().length).toBe(5); + }); + + it('should switch page correctly', async () => { + const wrapper = shallow( + + ); + wrapper.instance().componentDidMount!(); + await nextTick(); + wrapper + .find(EuiTablePagination) + .first() + .prop('onChangePage')!(1); + expect( + wrapper + .find(EuiListGroup) + .children() + .first() + .key() + ).toBe('15'); + }); + + it('should show an ordinary pagination for fixed page sizes', async () => { + const wrapper = shallow( + + ); + wrapper.instance().componentDidMount!(); + await nextTick(); + expect( + wrapper + .find(EuiPagination) + .first() + .prop('pageCount') + ).toEqual(2); + expect(wrapper.find(EuiListGroup).children().length).toBe(33); + }); + + it('should switch page correctly for fixed page sizes', async () => { + const wrapper = shallow( + + ); + wrapper.instance().componentDidMount!(); + await nextTick(); + wrapper + .find(EuiPagination) + .first() + .prop('onPageClick')!(1); + expect( + wrapper + .find(EuiListGroup) + .children() + .first() + .key() + ).toBe('33'); + }); + }); + + describe('loading state', () => { + it('should display a spinner during initial loading', () => { + const wrapper = shallow(); + + expect(wrapper.containsMatchingElement()).toBe(true); + }); + + it('should hide the spinner if data is shown', async () => { + objectsClientStub.returns(Promise.resolve({ savedObjects: [doc] })); + + const wrapper = shallow( + 'search', + }, + ]} + /> + ); + wrapper.instance().componentDidMount!(); + await nextTick(); + expect(wrapper.containsMatchingElement()).toBe(false); + }); + + it('should not show the spinner if there are already items', async () => { + objectsClientStub.returns(Promise.resolve({ savedObjects: [doc] })); + + const wrapper = shallow(); + wrapper.instance().componentDidMount!(); + await nextTick(); + wrapper + .find('[data-test-subj="savedObjectFinderSearchInput"]') + .first() + .simulate('change', { target: { value: 'abc' } }); + + wrapper.update(); + + expect(wrapper.containsMatchingElement()).toBe(false); + }); + }); +}); diff --git a/src/legacy/ui/public/saved_objects/components/saved_object_finder.tsx b/src/legacy/ui/public/saved_objects/components/saved_object_finder.tsx index 481eca0edaf67..8575fa66ee747 100644 --- a/src/legacy/ui/public/saved_objects/components/saved_object_finder.tsx +++ b/src/legacy/ui/public/saved_objects/components/saved_object_finder.tsx @@ -23,44 +23,74 @@ import React from 'react'; import chrome from 'ui/chrome'; import { - EuiBasicTable, + CommonProps, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiContextMenuPanelProps, + EuiEmptyPrompt, EuiFieldSearch, + EuiFilterButton, + EuiFilterGroup, EuiFlexGroup, EuiFlexItem, - EuiLink, - EuiTableCriteria, + EuiListGroup, + EuiListGroupItem, + EuiLoadingSpinner, + EuiPagination, + EuiPopover, + EuiSpacer, + EuiTablePagination, } from '@elastic/eui'; import { Direction } from '@elastic/eui/src/services/sort/sort_direction'; import { i18n } from '@kbn/i18n'; import { SavedObjectAttributes } from '../../../../server/saved_objects'; -import { VisTypesRegistryProvider } from '../../registry/vis_types'; import { SimpleSavedObject } from '../simple_saved_object'; -interface SavedObjectFinderUIState { +// TODO the typings for EuiListGroup are incorrect - maxWidth is missing. This can be removed when the types are adjusted +const FixedEuiListGroup = (EuiListGroup as any) as React.FunctionComponent< + CommonProps & { maxWidth: boolean } +>; + +// TODO the typings for EuiContextMenuPanel are incorrect - watchedItemProps is missing. This can be removed when the types are adjusted +const FixedEuiContextMenuPanel = (EuiContextMenuPanel as any) as React.FunctionComponent< + EuiContextMenuPanelProps & { watchedItemProps: string[] } +>; + +export interface SavedObjectMetaData { + type: string; + name: string; + getIconForSavedObject(savedObject: SimpleSavedObject): string | undefined; + getTooltipForSavedObject?(savedObject: SimpleSavedObject): string; + showSavedObject?(savedObject: SimpleSavedObject): boolean; +} + +interface SavedObjectFinderState { items: Array<{ title: string | null; id: SimpleSavedObject['id']; type: SimpleSavedObject['type']; + savedObject: SimpleSavedObject; }>; - filter: string; + query: string; isFetchingItems: boolean; page: number; perPage: number; - sortField?: string; sortDirection?: Direction; + sortOpen: boolean; + filterOpen: boolean; + filteredTypes: string[]; } interface BaseSavedObjectFinder { - callToActionButton?: React.ReactNode; onChoose?: ( id: SimpleSavedObject['id'], - type: SimpleSavedObject['type'] + type: SimpleSavedObject['type'], + name: string ) => void; - makeUrl?: (id: SimpleSavedObject['id']) => void; noItemsMessage?: React.ReactNode; - savedObjectType: 'visualization' | 'search' | 'index-pattern'; - visTypes?: VisTypesRegistryProvider; + savedObjectMetaData: Array>; + showFilter?: boolean; } interface SavedObjectFinderFixedPage extends BaseSavedObjectFinder { @@ -69,51 +99,44 @@ interface SavedObjectFinderFixedPage extends BaseSavedObjectFinder { } interface SavedObjectFinderInitialPageSize extends BaseSavedObjectFinder { - initialPageSize?: 5 | 10 | 15; + initialPageSize?: 5 | 10 | 15 | 25; fixedPageSize?: undefined; } type SavedObjectFinderProps = SavedObjectFinderFixedPage | SavedObjectFinderInitialPageSize; -class SavedObjectFinder extends React.Component { +class SavedObjectFinder extends React.Component { public static propTypes = { - callToActionButton: PropTypes.node, onChoose: PropTypes.func, - makeUrl: PropTypes.func, noItemsMessage: PropTypes.node, - savedObjectType: PropTypes.oneOf(['visualization', 'search', 'index-pattern']).isRequired, - visTypes: PropTypes.object, - initialPageSize: PropTypes.oneOf([5, 10, 15]), + 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 (filter: string) => { + private debouncedFetch = _.debounce(async (query: string) => { + const metaDataMap = this.getSavedObjectMetaDataMap(); + const resp = await chrome.getSavedObjectsClient().find({ - type: this.props.savedObjectType, + type: Object.keys(metaDataMap), fields: ['title', 'visState'], - search: filter ? `${filter}*` : undefined, + search: query ? `${query}*` : 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'; - }); - } + resp.savedObjects = resp.savedObjects.filter(savedObject => { + const metaData = metaDataMap[savedObject.type]; + if (metaData.showSavedObject) { + return metaData.showSavedObject(savedObject); + } else { + return true; + } + }); if (!this.isComponentMounted) { return; @@ -121,14 +144,20 @@ class SavedObjectFinder extends React.Component { + items: resp.savedObjects.map(savedObject => { + const { + attributes: { title }, + id, + type, + } = savedObject; return { title: typeof title === 'string' ? title : '', id, type, + savedObject, }; }), }); @@ -142,8 +171,11 @@ class SavedObjectFinder extends React.Component {this.renderSearchBar()} - {this.renderTable()} + {this.renderListing()} ); } - 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 = undefined; - sortDirection = undefined; - } + private getSavedObjectMetaDataMap(): Record> { + return this.props.savedObjectMetaData.reduce( + (map, metaData) => ({ ...map, [metaData.type]: metaData }), + {} + ); + } - this.setState({ - page: page.index, - perPage: page.size, - sortField, - sortDirection, - }); - }; + 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 @@ -197,17 +224,15 @@ class SavedObjectFinder extends React.Component { // do not sort original list to preserve elasticsearch ranking order const items = this.state.items.slice(); - const { sortField } = this.state; + const { sortDirection } = this.state; - if (sortField) { - items.sort((a, b) => { - const fieldA = _.get(a, sortField, ''); - const fieldB = _.get(b, sortField, ''); + if (sortDirection || !this.state.query) { + items.sort(({ title: titleA }, { title: titleB }) => { let order = 1; - if (this.state.sortDirection === 'desc') { + if (sortDirection === 'desc') { order = -1; } - return order * fieldA.toLowerCase().localeCompare(fieldB.toLowerCase()); + return order * (titleA || '').toLowerCase().localeCompare((titleB || '').toLowerCase()); }); } @@ -215,7 +240,12 @@ class SavedObjectFinder extends React.Component + this.state.filteredTypes.length === 0 || this.state.filteredTypes.includes(item.type) + ) + .slice(startIndex, lastIndex); }; private fetchItems = () => { @@ -223,98 +253,262 @@ class SavedObjectFinder extends React.Component(); + 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('common.ui.savedObjects.finder.sortAsc', { + defaultMessage: 'Ascending', + })} + , + { + this.setState({ + sortDirection: 'desc', + }); + }} + > + {i18n.translate('common.ui.savedObjects.finder.sortDesc', { + defaultMessage: 'Descending', + })} + , + ]; + if (this.state.query) { + sortOptions.push( + { + this.setState({ + sortDirection: undefined, + }); + }} + > + {i18n.translate('common.ui.savedObjects.finder.sortAuto', { + defaultMessage: 'Best match', + })} + + ); + } + return sortOptions; + } + private renderSearchBar() { + const availableSavedObjectMetaData = this.getAvailableSavedObjectMetaData(); + return ( - + { this.setState( { - filter: e.target.value, + query: e.target.value, }, this.fetchItems ); }} data-test-subj="savedObjectFinderSearchInput" + isLoading={this.state.isFetchingItems} /> - - {this.props.callToActionButton && ( - {this.props.callToActionButton} - )} + + + this.setState({ sortOpen: false })} + button={ + + this.setState(({ sortOpen }) => ({ + sortOpen: !sortOpen, + })) + } + iconType="arrowDown" + isSelected={this.state.sortOpen} + data-test-subj="savedObjectFinderSortButton" + > + {i18n.translate('common.ui.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('common.ui.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} + + ))} + /> + + )} + + ); } - private renderTable() { - const pagination = { - pageIndex: this.state.page, - pageSize: this.state.perPage, - totalItemCount: this.state.items.length, - hidePerPageOptions: Boolean(this.props.fixedPageSize), - pageSizeOptions: [5, 10, 15], - }; - // 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, - direction: this.state.sortDirection, - }; - } - const tableColumns = [ - { - field: 'title', - name: i18n.translate('common.ui.savedObjects.finder.titleLabel', { - defaultMessage: 'Title', - }), - sortable: true, - render: (title: string, record: SimpleSavedObject) => { - const { onChoose, makeUrl } = this.props; - - if (!onChoose && !makeUrl) { - return {title}; - } - - return ( - { - onChoose(record.id, record.type); - } - : undefined - } - href={makeUrl ? makeUrl(record.id) : undefined} - data-test-subj={`savedObjectTitle${title.split(' ').join('-')}`} - > - {title} - - ); - }, - }, - ]; + 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); + } + : 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]} + /> + ))} + ); } } diff --git a/test/functional/apps/dashboard/dashboard_filtering.js b/test/functional/apps/dashboard/dashboard_filtering.js index 8742851249b7b..eca064ef04646 100644 --- a/test/functional/apps/dashboard/dashboard_filtering.js +++ b/test/functional/apps/dashboard/dashboard_filtering.js @@ -46,9 +46,6 @@ export default function ({ getService, getPageObjects }) { await dashboardAddPanel.addEveryVisualization('"Filter Bytes Test"'); await dashboardAddPanel.addEverySavedSearch('"Filter Bytes Test"'); - // TODO: Remove once https://github.com/elastic/kibana/issues/22561 is fixed - await dashboardPanelActions.removePanelByTitle('Filter Bytes Test: timelion split 5 on bytes'); - await dashboardAddPanel.closeAddPanel(); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.dashboard.waitForRenderComplete(); diff --git a/test/functional/page_objects/common_page.js b/test/functional/page_objects/common_page.js index 1c9ce3972c9c1..9962aa2049431 100644 --- a/test/functional/page_objects/common_page.js +++ b/test/functional/page_objects/common_page.js @@ -301,7 +301,13 @@ export function CommonPageProvider({ getService, getPageObjects }) { } async closeToast() { - const toast = await find.byCssSelector('.euiToast'); + let toast; + await retry.try(async () => { + toast = await find.byCssSelector('.euiToast'); + if (!toast) { + throw new Error('Toast is not visible yet'); + } + }); await browser.moveMouseTo(toast); const title = await (await find.byCssSelector('.euiToastHeader__title')).getVisibleText(); log.debug(title); diff --git a/test/functional/page_objects/discover_page.js b/test/functional/page_objects/discover_page.js index 3c2f36df21823..531fd9f984a0b 100644 --- a/test/functional/page_objects/discover_page.js +++ b/test/functional/page_objects/discover_page.js @@ -86,13 +86,13 @@ export function DiscoverPageProvider({ getService, getPageObjects }) { } async hasSavedSearch(searchName) { - const searchLink = await find.byPartialLinkText(searchName); + const searchLink = await find.byButtonText(searchName); return searchLink.isDisplayed(); } async loadSavedSearch(searchName) { await this.openLoadSavedSearchPanel(); - const searchLink = await find.byPartialLinkText(searchName); + const searchLink = await find.byButtonText(searchName); await searchLink.click(); await PageObjects.header.waitUntilLoadingHasFinished(); } diff --git a/test/functional/page_objects/visualize_page.js b/test/functional/page_objects/visualize_page.js index 7e7726a050405..c14e34687ddac 100644 --- a/test/functional/page_objects/visualize_page.js +++ b/test/functional/page_objects/visualize_page.js @@ -397,7 +397,6 @@ export function VisualizePageProvider({ getService, getPageObjects }) { } async clickSavedSearch(savedSearchName) { - await testSubjects.click('savedSearchesTab'); await testSubjects.click(`savedObjectTitle${savedSearchName.split(' ').join('-')}`); await PageObjects.header.waitUntilLoadingHasFinished(); } diff --git a/test/functional/services/dashboard/add_panel.js b/test/functional/services/dashboard/add_panel.js index 18f5bea91d1a5..c8572125ac7c3 100644 --- a/test/functional/services/dashboard/add_panel.js +++ b/test/functional/services/dashboard/add_panel.js @@ -24,7 +24,6 @@ export function DashboardAddPanelProvider({ getService, getPageObjects }) { const testSubjects = getService('testSubjects'); const flyout = getService('flyout'); const PageObjects = getPageObjects(['header', 'common']); - const find = getService('find'); return new class DashboardAddPanel { async clickOpenAddPanel() { @@ -36,14 +35,23 @@ export function DashboardAddPanelProvider({ getService, getPageObjects }) { await testSubjects.click('addNewSavedObjectLink'); } - async clickSavedSearchTab() { - await testSubjects.click('addSavedSearchTab'); + async toggleFilterPopover() { + log.debug('DashboardAddPanel.toggleFilter'); + await testSubjects.click('savedObjectFinderFilterButton'); + } + + async toggleFilter(type) { + log.debug(`DashboardAddPanel.addToFilter(${type})`); + await this.waitForListLoading(); + await this.toggleFilterPopover(); + await testSubjects.click(`savedObjectFinderFilter-${type}`); + await this.toggleFilterPopover(); } async addEveryEmbeddableOnCurrentPage() { log.debug('addEveryEmbeddableOnCurrentPage'); - const addPanel = await testSubjects.find('dashboardAddPanel'); - const embeddableRows = await addPanel.findAllByClassName('euiLink'); + const itemList = await testSubjects.find('savedObjectFinderItemList'); + const embeddableRows = await itemList.findAllByCssSelector('li'); for (let i = 0; i < embeddableRows.length; i++) { await embeddableRows[i].click(); await PageObjects.common.closeToast(); @@ -95,10 +103,9 @@ export function DashboardAddPanelProvider({ getService, getPageObjects }) { } } - async waitForEuiTableLoading() { + async waitForListLoading() { await retry.waitFor('dashboard add panel loading to complete', async () => { - const table = await find.byClassName('euiBasicTable'); - return !((await table.getAttribute('class')).includes('loading')); + return !(await testSubjects.exists('savedObjectFinderLoadingIndicator')); }); } @@ -109,6 +116,7 @@ export function DashboardAddPanelProvider({ getService, getPageObjects }) { async addEveryVisualization(filter) { log.debug('DashboardAddPanel.addEveryVisualization'); await this.ensureAddPanelIsShowing(); + await this.toggleFilter('visualization'); if (filter) { await this.filterEmbeddableNames(filter.replace('-', ' ')); } @@ -123,7 +131,7 @@ export function DashboardAddPanelProvider({ getService, getPageObjects }) { async addEverySavedSearch(filter) { log.debug('DashboardAddPanel.addEverySavedSearch'); await this.ensureAddPanelIsShowing(); - await this.clickSavedSearchTab(); + await this.toggleFilter('search'); if (filter) { await this.filterEmbeddableNames(filter.replace('-', ' ')); } @@ -139,11 +147,11 @@ export function DashboardAddPanelProvider({ getService, getPageObjects }) { log.debug(`addSavedSearch(${searchName})`); await this.ensureAddPanelIsShowing(); - await this.clickSavedSearchTab(); + await this.toggleFilter('search'); await this.filterEmbeddableNames(searchName); await testSubjects.click(`savedObjectTitle${searchName.split(' ').join('-')}`); - await testSubjects.exists('addSavedSearchToDashboardSuccess'); + await testSubjects.exists('addObjectToDashboardSuccess'); await this.closeAddPanel(); } @@ -163,16 +171,18 @@ export function DashboardAddPanelProvider({ getService, getPageObjects }) { async addVisualization(vizName) { log.debug(`DashboardAddPanel.addVisualization(${vizName})`); await this.ensureAddPanelIsShowing(); + await this.toggleFilter('visualization'); await this.filterEmbeddableNames(`"${vizName.replace('-', ' ')}"`); await testSubjects.click(`savedObjectTitle${vizName.split(' ').join('-')}`); + await testSubjects.exists('addObjectToDashboardSuccess'); await this.closeAddPanel(); } async filterEmbeddableNames(name) { // The search input field may be disabled while the table is loading so wait for it - await this.waitForEuiTableLoading(); + await this.waitForListLoading(); await testSubjects.setValue('savedObjectFinderSearchInput', name); - await this.waitForEuiTableLoading(); + await this.waitForListLoading(); } async panelAddLinkExists(name) { diff --git a/typings/@elastic/eui/index.d.ts b/typings/@elastic/eui/index.d.ts index fcdc481c17770..bdd6d4d72fbd2 100644 --- a/typings/@elastic/eui/index.d.ts +++ b/typings/@elastic/eui/index.d.ts @@ -25,6 +25,7 @@ declare module '@elastic/eui' { export const EuiCopy: React.SFC; export const EuiOutsideClickDetector: React.SFC; export const EuiSideNav: React.SFC; + export const EuiListGroupItem: React.FunctionComponent; export interface EuiTableCriteria { page: { index: number; size: number }; diff --git a/x-pack/plugins/spaces/public/views/nav_control/nav_control.tsx b/x-pack/plugins/spaces/public/views/nav_control/nav_control.tsx index 93efcdce0295a..6385e74280a12 100644 --- a/x-pack/plugins/spaces/public/views/nav_control/nav_control.tsx +++ b/x-pack/plugins/spaces/public/views/nav_control/nav_control.tsx @@ -19,7 +19,6 @@ import { NavControlSide } from 'ui/chrome/directives/header_global_nav'; import { I18nContext } from 'ui/i18n'; // @ts-ignore import { uiModules } from 'ui/modules'; -// @ts-ignore import { chromeHeaderNavControlsRegistry } from 'ui/registry/chrome_header_nav_controls'; // @ts-ignore import { chromeNavControlsRegistry } from 'ui/registry/chrome_nav_controls'; diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 02a1bfc8d2b93..5f40f3bf0b4cf 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -577,7 +577,6 @@ "common.ui.savedObjects.confirmModal.saveDuplicateButtonLabel": "保存“{name}”", "common.ui.savedObjects.confirmModal.saveDuplicateConfirmationMessage": "具有标题 “{title}” 的 “{name}” 已存在。是否确定要保存?", "common.ui.savedObjects.finder.searchPlaceholder": "搜索……", - "common.ui.savedObjects.finder.titleLabel": "标题", "common.ui.savedObjects.howToSaveAsNewDescription": "在 Kibana 的以前版本中,更改 {savedObjectName} 的名称将创建具有新名称的副本。使用“另存为新的 {savedObjectName}” 复选框可立即达到此目的。", "common.ui.savedObjects.overwriteRejectedDescription": "已拒绝覆盖确认", "common.ui.savedObjects.saveAsNewLabel": "另存为新的 {savedObjectName}", @@ -1219,12 +1218,6 @@ "kbn.dashboard.stateManager.timeNotSavedWithDashboardErrorMessage": "时间未随此仪表板保存,因此无法同步。", "kbn.dashboard.strings.dashboardEditTitle": "编辑 {title}", "kbn.dashboard.strings.dashboardUnsavedEditTitle": "编辑 {title}(未保存)", - "kbn.dashboard.topNav.addPanel.addNewVisualizationButtonLabel": "添加新的可视化", - "kbn.dashboard.topNav.addPanel.savedSearchTabName": "已保存搜索", - "kbn.dashboard.topNav.addPanel.searchSavedObjectFinder.noMatchingVisualizationsMessage": "未找到匹配的已保存搜索。", - "kbn.dashboard.topNav.addPanel.selectedTabAddedToDashboardSuccessMessageTitle": "“{selectedTabName}” 已添加到您的仪表板", - "kbn.dashboard.topNav.addPanel.visSavedObjectFinder.noMatchingVisualizationsMessage": "未找到任何匹配的可视化。", - "kbn.dashboard.topNav.addPanel.visualizationTabName": "可视化", "kbn.dashboard.topNav.addPanelsTitle": "添加面板", "kbn.dashboard.topNav.cloneModal.cancelButtonLabel": "取消", "kbn.dashboard.topNav.cloneModal.cloneDashboardModalHeaderTitle": "克隆面板",