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 <email@johannes-reuter.de>

* Update src/legacy/core_plugins/kibana/public/discover/embeddable/search_embeddable_factory.ts

Co-Authored-By: flash1293 <email@johannes-reuter.de>

* Update src/legacy/core_plugins/kibana/public/discover/top_nav/open_search_panel.js

Co-Authored-By: flash1293 <email@johannes-reuter.de>

* Update src/legacy/core_plugins/kibana/public/visualize/wizard/search_selection/search_selection.tsx

Co-Authored-By: flash1293 <email@johannes-reuter.de>

* Update src/legacy/core_plugins/kibana/public/visualize/wizard/search_selection/search_selection.tsx

Co-Authored-By: flash1293 <email@johannes-reuter.de>

* Update src/legacy/core_plugins/kibana/public/visualize/wizard/search_selection/search_selection.tsx

Co-Authored-By: flash1293 <email@johannes-reuter.de>

* 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 <email@johannes-reuter.de>

* Update src/legacy/core_plugins/kibana/public/discover/top_nav/open_search_panel.js

Co-Authored-By: flash1293 <email@johannes-reuter.de>

* 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
This commit is contained in:
Tim Roes 2019-03-11 14:29:49 +01:00 committed by GitHub
parent d3d76ce574
commit 358ab0da83
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 1165 additions and 554 deletions

View file

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

View file

@ -10,68 +10,60 @@ exports[`render 1`] = `
ownFocus={true}
size="m"
>
<EuiFlyoutBody>
<EuiFlyoutHeader
hasBorder={true}
>
<EuiTitle
size="s"
size="m"
textTransform="none"
>
<h1>
<h2>
<FormattedMessage
defaultMessage="Add Panels"
defaultMessage="Add panels"
id="kbn.dashboard.topNav.addPanelsTitle"
values={Object {}}
/>
</h1>
</h2>
</EuiTitle>
<EuiTabs
expand={false}
size="m"
>
<EuiTab
data-test-subj="addVisualizationTab"
disabled={false}
isSelected={true}
key="vis"
onClick={[Function]}
>
Visualization
</EuiTab>
<EuiTab
data-test-subj="addSavedSearchTab"
disabled={false}
isSelected={false}
key="search"
onClick={[Function]}
>
Saved Search
</EuiTab>
</EuiTabs>
<EuiSpacer
size="s"
/>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<SavedObjectFinder
callToActionButton={
noItemsMessage="No matching objects found."
onChoose={[Function]}
savedObjectMetaData={Array []}
showFilter={true}
/>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup
alignItems="stretch"
component="div"
direction="row"
gutterSize="l"
justifyContent="flexEnd"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={false}
>
<EuiButton
color="primary"
data-test-subj="addNewSavedObjectLink"
fill={false}
fill={true}
iconSide="left"
onClick={[Function]}
type="button"
>
<FormattedMessage
defaultMessage="Add new Visualization"
id="kbn.dashboard.topNav.addPanel.addNewVisualizationButtonLabel"
defaultMessage="Create new visualization"
id="kbn.dashboard.topNav.addPanel.createNewVisualizationButtonLabel"
values={Object {}}
/>
</EuiButton>
}
key="visSavedObjectFinder"
noItemsMessage="No matching visualizations found."
onChoose={[Function]}
savedObjectType="visualization"
visTypes={Object {}}
/>
</EuiFlyoutBody>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
`;

View file

@ -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 = (
<EuiButton
onClick={this.props.addNewVis}
data-test-subj="addNewSavedObjectLink"
>
<FormattedMessage
id="kbn.dashboard.topNav.addPanel.addNewVisualizationButtonLabel"
defaultMessage="Add new Visualization"
/>
</EuiButton>
);
const tabs = [{
id: VIS_TAB_ID,
name: props.intl.formatMessage({
id: 'kbn.dashboard.topNav.addPanel.visualizationTabName',
defaultMessage: 'Visualization',
}),
dataTestSubj: 'addVisualizationTab',
toastDataTestSubj: 'addVisualizationToDashboardSuccess',
savedObjectFinder: (
<SavedObjectFinder
key="visSavedObjectFinder"
callToActionButton={addNewVisBtn}
onChoose={this.onAddPanel}
visTypes={this.props.visTypes}
noItemsMessage={props.intl.formatMessage({
id: 'kbn.dashboard.topNav.addPanel.visSavedObjectFinder.noMatchingVisualizationsMessage',
defaultMessage: 'No matching visualizations found.',
})}
savedObjectType="visualization"
/>
)
}, {
id: SAVED_SEARCH_TAB_ID,
name: props.intl.formatMessage({
id: 'kbn.dashboard.topNav.addPanel.savedSearchTabName',
defaultMessage: 'Saved Search',
}),
dataTestSubj: 'addSavedSearchTab',
toastDataTestSubj: 'addSavedSearchToDashboardSuccess',
savedObjectFinder: (
<SavedObjectFinder
key="searchSavedObjectFinder"
onChoose={this.onAddPanel}
noItemsMessage={props.intl.formatMessage({
id: 'kbn.dashboard.topNav.addPanel.searchSavedObjectFinder.noMatchingVisualizationsMessage',
defaultMessage: 'No matching saved searches found.',
})}
savedObjectType="search"
/>
)
}];
this.state = {
tabs: tabs,
selectedTab: tabs[0],
};
}
onSelectedTabChanged = tab => {
this.setState({
selectedTab: tab,
});
}
renderTabs() {
return this.state.tabs.map((tab) => {
return (
<EuiTab
onClick={() => this.onSelectedTabChanged(tab)}
isSelected={tab.id === this.state.selectedTab.id}
key={tab.id}
data-test-subj={tab.dataTestSubj}
>
{tab.name}
</EuiTab>
);
});
}
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 (
<EuiFlyout
ownFocus
onClose={this.props.onClose}
data-test-subj="dashboardAddPanel"
>
<EuiFlyoutBody>
<EuiTitle size="s">
<h1>
<EuiFlyout ownFocus onClose={this.props.onClose} data-test-subj="dashboardAddPanel">
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>
<FormattedMessage
id="kbn.dashboard.topNav.addPanelsTitle"
defaultMessage="Add Panels"
defaultMessage="Add panels"
/>
</h1>
</h2>
</EuiTitle>
<EuiTabs>
{this.renderTabs()}
</EuiTabs>
<EuiSpacer size="s" />
{this.state.selectedTab.savedObjectFinder}
</EuiFlyoutHeader>
<EuiFlyoutBody>
<SavedObjectFinder
onChoose={this.onAddPanel}
savedObjectMetaData={this.props.embeddableFactories
.filter(embeddableFactory => Boolean(embeddableFactory.savedObjectMetaData))
.map(({ savedObjectMetaData }) => savedObjectMetaData)}
showFilter={true}
noItemsMessage={i18n.translate(
'kbn.dashboard.topNav.addPanel.noMatchingObjectsMessage',
{
defaultMessage: 'No matching objects found.',
}
)}
/>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButton fill onClick={this.props.addNewVis} data-test-subj="addNewSavedObjectLink">
<FormattedMessage
id="kbn.dashboard.topNav.addPanel.createNewVisualizationButtonLabel"
defaultMessage="Create new visualization"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
}
}
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);

View file

@ -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(<DashboardAddPanel.WrappedComponent
const component = shallow(<DashboardAddPanel
onClose={onClose}
visTypes={{}}
addNewPanel={() => {}}
addNewVis={() => {}}
embeddableFactories={[]}
/>);
expect(component).toMatchSnapshot();
});

View file

@ -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) {
<I18nContext>
<DashboardAddPanel
onClose={onClose}
visTypes={visTypes}
addNewPanel={addNewPanel}
addNewVis={addNewVisWithCleanup}
embeddableFactories={embeddableFactories}
/>
</I18nContext>
);

View file

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

View file

@ -10,27 +10,60 @@ exports[`render 1`] = `
ownFocus={true}
size="m"
>
<EuiFlyoutBody>
<EuiFlyoutHeader
hasBorder={true}
>
<EuiTitle
size="s"
size="m"
textTransform="none"
>
<h1>
<h2>
<FormattedMessage
defaultMessage="Open Search"
defaultMessage="Open search"
id="kbn.discover.topNav.openSearchPanel.openSearchTitle"
values={Object {}}
/>
</h1>
</h2>
</EuiTitle>
<EuiSpacer
size="m"
/>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<SavedObjectFinder
callToActionButton={
noItemsMessage={
<FormattedMessage
defaultMessage="No matching searches found."
id="kbn.discover.topNav.openSearchPanel.noSearchesFoundDescription"
values={Object {}}
/>
}
onChoose={[Function]}
savedObjectMetaData={
Array [
Object {
"getIconForSavedObject": [Function],
"name": "Saved search",
"type": "search",
},
]
}
/>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup
alignItems="stretch"
component="div"
direction="row"
gutterSize="l"
justifyContent="flexEnd"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={false}
>
<EuiButton
color="primary"
fill={false}
fill={true}
href="#/management/kibana/objects?_a=(tab:search)"
iconSide="left"
onClick={[Function]}
@ -42,18 +75,8 @@ exports[`render 1`] = `
values={Object {}}
/>
</EuiButton>
}
makeUrl={[Function]}
noItemsMessage={
<FormattedMessage
defaultMessage="No matching searches found."
id="kbn.discover.topNav.openSearchPanel.noSearchesFoundDescription"
values={Object {}}
/>
}
onChoose={[Function]}
savedObjectType="search"
/>
</EuiFlyoutBody>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
`;

View file

@ -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 (
<EuiButton
onClick={this.props.onClose}
href={`#/management/kibana/objects?_a=${rison.encode({ tab: SEARCH_OBJECT_TYPE })}`}
>
<FormattedMessage
id="kbn.discover.topNav.openSearchPanel.manageSearchesButtonLabel"
defaultMessage="Manage searches"
export function OpenSearchPanel(props) {
return (
<EuiFlyout ownFocus onClose={props.onClose} data-test-subj="loadSearchForm">
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>
<FormattedMessage
id="kbn.discover.topNav.openSearchPanel.openSearchTitle"
defaultMessage="Open search"
/>
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<SavedObjectFinder
noItemsMessage={
<FormattedMessage
id="kbn.discover.topNav.openSearchPanel.noSearchesFoundDescription"
defaultMessage="No matching searches found."
/>
}
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();
}}
/>
</EuiButton>
);
}
render() {
return (
<EuiFlyout
ownFocus
onClose={this.props.onClose}
data-test-subj="loadSearchForm"
>
<EuiFlyoutBody>
<EuiTitle size="s">
<h1>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButton
fill
onClick={props.onClose}
href={`#/management/kibana/objects?_a=${rison.encode({ tab: SEARCH_OBJECT_TYPE })}`}
>
<FormattedMessage
id="kbn.discover.topNav.openSearchPanel.openSearchTitle"
defaultMessage="Open Search"
id="kbn.discover.topNav.openSearchPanel.manageSearchesButtonLabel"
defaultMessage="Manage searches"
/>
</h1>
</EuiTitle>
<EuiSpacer size="m" />
<SavedObjectFinder
noItemsMessage={
<FormattedMessage
id="kbn.discover.topNav.openSearchPanel.noSearchesFoundDescription"
defaultMessage="No matching searches found."
/>
}
savedObjectType={SEARCH_OBJECT_TYPE}
makeUrl={this.props.makeUrl}
onChoose={this.props.onClose}
callToActionButton={this.renderMangageSearchesButton()}
/>
</EuiFlyoutBody>
</EuiFlyout>
);
}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
}
OpenSearchPanel.propTypes = {

View file

@ -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<VisualizationAttributes> {
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;
}

View file

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

View file

@ -7,7 +7,8 @@
}
.visNewVisSearchDialog {
min-height: $euiSizeL * 20;
width: $euiSizeL * 30;
min-height: $euiSizeL * 25;
}
.visNewVisDialog__body {

View file

@ -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<SearchSelectionProps, SearchSelectionState> {
public state = {
selectedTabId: INDEX_PATTERNS_TAB_ID,
};
export class SearchSelection extends React.Component<SearchSelectionProps> {
private fixedPageSize: number = 8;
public render() {
@ -74,77 +52,42 @@ export class SearchSelection extends React.Component<SearchSelectionProps, Searc
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiTabs size="m">{this.renderTabs()}</EuiTabs>
<EuiSpacer size="m" />
{this.renderTab()}
<SavedObjectFinder
key="searchSavedObjectFinder"
onChoose={this.props.onSearchSelected}
showFilter
noItemsMessage={i18n.translate(
'kbn.visualize.newVisWizard.searchSelection.notFoundLabel',
{
defaultMessage: 'No matching indices or saved searches found.',
}
)}
savedObjectMetaData={[
{
type: 'search',
getIconForSavedObject: () => '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}
/>
</EuiModalBody>
</React.Fragment>
);
}
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 => (
<EuiTab
onClick={() => this.onSelectedTabChanged(tab)}
isSelected={tab.id === selectedTabId}
key={tab.id}
data-test-subj={`${tab.id}Tab`}
>
{tab.name}
</EuiTab>
));
}
private renderTab() {
if (this.state.selectedTabId === SAVED_SEARCHES_TAB_ID) {
return (
<SavedObjectFinder
key="searchSavedObjectFinder"
onChoose={this.props.onSearchSelected}
noItemsMessage={i18n.translate(
'kbn.visualize.newVisWizard.savedSearchTab.notFoundLabel',
{ defaultMessage: 'No matching saved searches found.' }
)}
savedObjectType="search"
fixedPageSize={this.fixedPageSize}
/>
);
}
return (
<SavedObjectFinder
key="visSavedObjectFinder"
onChoose={this.props.onSearchSelected}
noItemsMessage={i18n.translate('kbn.visualize.newVisWizard.indexPatternTab.notFoundLabel', {
defaultMessage: 'No matching index patterns found.',
})}
savedObjectType="index-pattern"
fixedPageSize={this.fixedPageSize}
/>
);
}
}

View file

@ -83,6 +83,10 @@ export interface SavedObjectAttributes {
[key: string]: SavedObjectAttributes | string | number | boolean | null;
}
export interface VisualizationAttributes extends SavedObjectAttributes {
visState: string;
}
export interface SavedObject<T extends SavedObjectAttributes = any> {
id: string;
type: string;

View file

@ -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<T extends SavedObjectAttributes = SavedObjectAttributes> {
public readonly name: string;
public readonly savedObjectMetaData?: SavedObjectMetaData<T>;
/**
*
* @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<T>;
}) {
this.name = name;
this.savedObjectMetaData = savedObjectMetaData;
}
/**

View file

@ -19,13 +19,21 @@
import { IndexedArray, IndexedArrayConfig } from '../indexed_array';
interface UIRegistry<T> extends IndexedArray<T> {
register<T>(privateModule: T): UIRegistry<T>;
}
interface UIRegistry<T> extends IndexedArray<T> {}
interface UIRegistrySpec<T> extends IndexedArrayConfig<T> {
name: string;
filter?(item: T): boolean;
}
declare function uiRegistry<T>(spec: UIRegistrySpec<T>): UIRegistry<T>;
/**
* 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<T, A = {}>(
spec: UIRegistrySpec<T>
): { (): UIRegistry<T> & A; register<T>(privateModule: T): UIRegistry<T> & A };

View file

@ -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<NavControl>;
interface ChromeHeaderNavControlsRegistryAccessors {
bySide: { [typeName: string]: IndexedArray<NavControl> };
}
export interface ChromeHeaderNavControlsRegistry extends UIRegistry<NavControl> {
bySide: BySideDictionary;
}
export type ChromeHeaderNavControlsRegistry = UIRegistry<NavControl> &
ChromeHeaderNavControlsRegistryAccessors;
export const chromeHeaderNavControlsRegistry: ChromeHeaderNavControlsRegistry = uiRegistry({
export const chromeHeaderNavControlsRegistry = uiRegistry<
NavControl,
ChromeHeaderNavControlsRegistryAccessors
>({
name: 'chromeHeaderNavControls',
order: ['order'],
group: ['side'],
}) as ChromeHeaderNavControlsRegistry;
});

View file

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

View file

@ -18,8 +18,16 @@
*/
import { VisType } from '../vis';
import { UIRegistry } from './_registry';
import { uiRegistry, UIRegistry } from './_registry';
declare type VisTypesRegistryProvider = UIRegistry<VisType> & {
interface VisTypesRegistryAccessors {
byName: { [typeName: string]: VisType };
};
}
export type VisTypesRegistry = UIRegistry<VisType> & VisTypesRegistryAccessors;
export const VisTypesRegistryProvider = uiRegistry<VisType, VisTypesRegistryAccessors>({
name: 'visTypes',
index: ['name'],
order: ['title'],
});

View file

@ -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(<SavedObjectFinder savedObjectMetaData={searchMetaData} />);
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(<SavedObjectFinder savedObjectMetaData={searchMetaData} />);
wrapper.instance().componentDidMount!();
await nextTick();
expect(
wrapper.containsMatchingElement(<EuiListGroupItem iconType="search" label="Example title" />)
).toEqual(true);
});
it('should call onChoose on item click', async () => {
const chooseStub = sinon.stub();
objectsClientStub.returns(Promise.resolve({ savedObjects: [doc] }));
const wrapper = shallow(
<SavedObjectFinder onChoose={chooseStub} savedObjectMetaData={searchMetaData} />
);
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(<SavedObjectFinder savedObjectMetaData={searchMetaData} />);
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(<SavedObjectFinder savedObjectMetaData={searchMetaData} />);
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(
<SavedObjectFinder
savedObjectMetaData={[
{
type: 'search',
name: 'Search',
getIconForSavedObject: () => '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(<SavedObjectFinder savedObjectMetaData={searchMetaData} />);
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(<SavedObjectFinder savedObjectMetaData={searchMetaData} />);
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(
<SavedObjectFinder
savedObjectMetaData={[
{
type: 'search',
name: 'Search',
getIconForSavedObject: () => '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(
<SavedObjectFinder showFilter={false} savedObjectMetaData={metaDataConfig} />
);
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(
<SavedObjectFinder showFilter={true} savedObjectMetaData={metaDataConfig} />
);
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(
<SavedObjectFinder showFilter={true} savedObjectMetaData={metaDataConfig} />
);
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 = <span id="myNoItemsMessage" />;
const wrapper = shallow(
<SavedObjectFinder noItemsMessage={noItemsMessage} savedObjectMetaData={searchMetaData} />
);
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(
<SavedObjectFinder initialPageSize={15} savedObjectMetaData={searchMetaData} />
);
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(
<SavedObjectFinder initialPageSize={15} savedObjectMetaData={searchMetaData} />
);
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(
<SavedObjectFinder initialPageSize={15} savedObjectMetaData={searchMetaData} />
);
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(
<SavedObjectFinder fixedPageSize={33} savedObjectMetaData={searchMetaData} />
);
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(
<SavedObjectFinder fixedPageSize={33} savedObjectMetaData={searchMetaData} />
);
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(<SavedObjectFinder savedObjectMetaData={searchMetaData} />);
expect(wrapper.containsMatchingElement(<EuiLoadingSpinner />)).toBe(true);
});
it('should hide the spinner if data is shown', async () => {
objectsClientStub.returns(Promise.resolve({ savedObjects: [doc] }));
const wrapper = shallow(
<SavedObjectFinder
savedObjectMetaData={[
{
type: 'search',
name: 'Search',
getIconForSavedObject: () => 'search',
},
]}
/>
);
wrapper.instance().componentDidMount!();
await nextTick();
expect(wrapper.containsMatchingElement(<EuiLoadingSpinner />)).toBe(false);
});
it('should not show the spinner if there are already items', async () => {
objectsClientStub.returns(Promise.resolve({ savedObjects: [doc] }));
const wrapper = shallow(<SavedObjectFinder savedObjectMetaData={searchMetaData} />);
wrapper.instance().componentDidMount!();
await nextTick();
wrapper
.find('[data-test-subj="savedObjectFinderSearchInput"]')
.first()
.simulate('change', { target: { value: 'abc' } });
wrapper.update();
expect(wrapper.containsMatchingElement(<EuiLoadingSpinner />)).toBe(false);
});
});
});

View file

@ -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<T extends SavedObjectAttributes> {
type: string;
name: string;
getIconForSavedObject(savedObject: SimpleSavedObject<T>): string | undefined;
getTooltipForSavedObject?(savedObject: SimpleSavedObject<T>): string;
showSavedObject?(savedObject: SimpleSavedObject<T>): boolean;
}
interface SavedObjectFinderState {
items: Array<{
title: string | null;
id: SimpleSavedObject<SavedObjectAttributes>['id'];
type: SimpleSavedObject<SavedObjectAttributes>['type'];
savedObject: SimpleSavedObject<SavedObjectAttributes>;
}>;
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<SavedObjectAttributes>['id'],
type: SimpleSavedObject<SavedObjectAttributes>['type']
type: SimpleSavedObject<SavedObjectAttributes>['type'],
name: string
) => void;
makeUrl?: (id: SimpleSavedObject<SavedObjectAttributes>['id']) => void;
noItemsMessage?: React.ReactNode;
savedObjectType: 'visualization' | 'search' | 'index-pattern';
visTypes?: VisTypesRegistryProvider;
savedObjectMetaData: Array<SavedObjectMetaData<SavedObjectAttributes>>;
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<SavedObjectFinderProps, SavedObjectFinderUIState> {
class SavedObjectFinder extends React.Component<SavedObjectFinderProps, SavedObjectFinderState> {
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<SavedObjectFinderProps, SavedObj
// 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) {
if (query === this.state.query) {
this.setState({
isFetchingItems: false,
items: resp.savedObjects.map(({ attributes: { title }, id, type }) => {
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<SavedObjectFinderProps, SavedObj
items: [],
isFetchingItems: false,
page: 0,
perPage: props.initialPageSize || props.fixedPageSize || 15,
filter: '',
perPage: props.initialPageSize || props.fixedPageSize || 10,
query: '',
filterOpen: false,
filteredTypes: [],
sortOpen: false,
};
}
@ -161,33 +193,28 @@ class SavedObjectFinder extends React.Component<SavedObjectFinderProps, SavedObj
return (
<React.Fragment>
{this.renderSearchBar()}
{this.renderTable()}
{this.renderListing()}
</React.Fragment>
);
}
private onTableChange = ({ page, sort = {} }: EuiTableCriteria) => {
let sortField: string | undefined = sort.field;
let sortDirection: Direction | undefined = sort.direction;
private getSavedObjectMetaDataMap(): Record<string, SavedObjectMetaData<SavedObjectAttributes>> {
return this.props.savedObjectMetaData.reduce(
(map, metaData) => ({ ...map, [metaData.type]: metaData }),
{}
);
}
// 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;
}
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<SavedObjectFinderProps, SavedObj
private getPageOfItems = () => {
// 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<SavedObjectFinderProps, SavedObj
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.slice(startIndex, lastIndex);
return items
.filter(
item =>
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<SavedObjectFinderProps, SavedObj
{
isFetchingItems: true,
},
this.debouncedFetch.bind(null, this.state.filter)
this.debouncedFetch.bind(null, this.state.query)
);
};
private getAvailableSavedObjectMetaData() {
const typesInItems = new Set<string>();
this.state.items.forEach(item => {
typesInItems.add(item.type);
});
return this.props.savedObjectMetaData.filter(metaData => typesInItems.has(metaData.type));
}
private getSortOptions() {
const sortOptions = [
<EuiContextMenuItem
key="asc"
icon={
this.state.sortDirection === 'asc' ||
(this.state.query === '' && this.state.sortDirection !== 'desc')
? 'check'
: 'empty'
}
onClick={() => {
this.setState({
sortDirection: 'asc',
});
}}
>
{i18n.translate('common.ui.savedObjects.finder.sortAsc', {
defaultMessage: 'Ascending',
})}
</EuiContextMenuItem>,
<EuiContextMenuItem
key="desc"
icon={this.state.sortDirection === 'desc' ? 'check' : 'empty'}
onClick={() => {
this.setState({
sortDirection: 'desc',
});
}}
>
{i18n.translate('common.ui.savedObjects.finder.sortDesc', {
defaultMessage: 'Descending',
})}
</EuiContextMenuItem>,
];
if (this.state.query) {
sortOptions.push(
<EuiContextMenuItem
key="auto"
icon={!this.state.sortDirection ? 'check' : 'empty'}
onClick={() => {
this.setState({
sortDirection: undefined,
});
}}
>
{i18n.translate('common.ui.savedObjects.finder.sortAuto', {
defaultMessage: 'Best match',
})}
</EuiContextMenuItem>
);
}
return sortOptions;
}
private renderSearchBar() {
const availableSavedObjectMetaData = this.getAvailableSavedObjectMetaData();
return (
<EuiFlexGroup>
<EuiFlexGroup gutterSize="m">
<EuiFlexItem grow={true}>
<EuiFieldSearch
placeholder={i18n.translate('common.ui.savedObjects.finder.searchPlaceholder', {
defaultMessage: 'Search…',
})}
fullWidth
value={this.state.filter}
value={this.state.query}
onChange={e => {
this.setState(
{
filter: e.target.value,
query: e.target.value,
},
this.fetchItems
);
}}
data-test-subj="savedObjectFinderSearchInput"
isLoading={this.state.isFetchingItems}
/>
</EuiFlexItem>
{this.props.callToActionButton && (
<EuiFlexItem grow={false}>{this.props.callToActionButton}</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<EuiFilterGroup>
<EuiPopover
id="addPanelSortPopover"
panelClassName="euiFilterGroup__popoverPanel"
panelPaddingSize="none"
isOpen={this.state.sortOpen}
closePopover={() => this.setState({ sortOpen: false })}
button={
<EuiFilterButton
onClick={() =>
this.setState(({ sortOpen }) => ({
sortOpen: !sortOpen,
}))
}
iconType="arrowDown"
isSelected={this.state.sortOpen}
data-test-subj="savedObjectFinderSortButton"
>
{i18n.translate('common.ui.savedObjects.finder.sortButtonLabel', {
defaultMessage: 'Sort',
})}
</EuiFilterButton>
}
>
<FixedEuiContextMenuPanel
watchedItemProps={['icon', 'disabled']}
items={this.getSortOptions()}
/>
</EuiPopover>
{this.props.showFilter && (
<EuiPopover
id="addPanelFilterPopover"
panelClassName="euiFilterGroup__popoverPanel"
panelPaddingSize="none"
isOpen={this.state.filterOpen}
closePopover={() => this.setState({ filterOpen: false })}
button={
<EuiFilterButton
onClick={() =>
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',
})}
</EuiFilterButton>
}
>
<FixedEuiContextMenuPanel
watchedItemProps={['icon', 'disabled']}
items={this.props.savedObjectMetaData.map(metaData => (
<EuiContextMenuItem
key={metaData.type}
disabled={!availableSavedObjectMetaData.includes(metaData)}
icon={this.state.filteredTypes.includes(metaData.type) ? 'check' : 'empty'}
data-test-subj={`savedObjectFinderFilter-${metaData.type}`}
onClick={() => {
this.setState(({ filteredTypes }) => ({
filteredTypes: filteredTypes.includes(metaData.type)
? filteredTypes.filter(t => t !== metaData.type)
: [...filteredTypes, metaData.type],
page: 0,
}));
}}
>
{metaData.name}
</EuiContextMenuItem>
))}
/>
</EuiPopover>
)}
</EuiFilterGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
}
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<SavedObjectAttributes>) => {
const { onChoose, makeUrl } = this.props;
if (!onChoose && !makeUrl) {
return <span>{title}</span>;
}
return (
<EuiLink
onClick={
onChoose
? () => {
onChoose(record.id, record.type);
}
: undefined
}
href={makeUrl ? makeUrl(record.id) : undefined}
data-test-subj={`savedObjectTitle${title.split(' ').join('-')}`}
>
{title}
</EuiLink>
);
},
},
];
private renderListing() {
const items = this.state.items.length === 0 ? [] : this.getPageOfItems();
const { onChoose, savedObjectMetaData } = this.props;
return (
<EuiBasicTable
items={items}
loading={this.state.isFetchingItems}
columns={tableColumns}
pagination={pagination}
sorting={sorting}
onChange={this.onTableChange}
noItemsMessage={this.props.noItemsMessage}
/>
<>
{this.state.isFetchingItems && this.state.items.length === 0 && (
<EuiFlexGroup justifyContent="center">
<EuiFlexItem grow={false}>
<EuiSpacer />
<EuiLoadingSpinner data-test-subj="savedObjectFinderLoadingIndicator" />
</EuiFlexItem>
</EuiFlexGroup>
)}
{items.length > 0 ? (
<FixedEuiListGroup data-test-subj="savedObjectFinderItemList" maxWidth={false}>
{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<SavedObjectMetaData<SavedObjectAttributes>, 'getIconForSavedObject'>)
).getIconForSavedObject(item.savedObject);
return (
<EuiListGroupItem
key={item.id}
iconType={iconType}
label={item.title}
onClick={
onChoose
? () => {
onChoose(item.id, item.type, fullName);
}
: undefined
}
title={fullName}
data-test-subj={`savedObjectTitle${(item.title || '').split(' ').join('-')}`}
/>
);
})}
</FixedEuiListGroup>
) : (
!this.state.isFetchingItems && <EuiEmptyPrompt body={this.props.noItemsMessage} />
)}
{this.getPageCount() > 1 &&
(this.props.fixedPageSize ? (
<EuiPagination
activePage={this.state.page}
pageCount={this.getPageCount()}
onPageClick={page => {
this.setState({
page,
});
}}
/>
) : (
<EuiTablePagination
activePage={this.state.page}
pageCount={this.getPageCount()}
onChangePage={page => {
this.setState({
page,
});
}}
onChangeItemsPerPage={perPage => {
this.setState({
perPage,
});
}}
itemsPerPage={this.state.perPage}
itemsPerPageOptions={[5, 10, 15, 25]}
/>
))}
</>
);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -25,6 +25,7 @@ declare module '@elastic/eui' {
export const EuiCopy: React.SFC<any>;
export const EuiOutsideClickDetector: React.SFC<any>;
export const EuiSideNav: React.SFC<any>;
export const EuiListGroupItem: React.FunctionComponent<any>;
export interface EuiTableCriteria {
page: { index: number; size: number };

View file

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

View file

@ -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": "克隆面板",