mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
* 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:
parent
d3d76ce574
commit
358ab0da83
28 changed files with 1165 additions and 554 deletions
|
@ -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({
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -7,7 +7,8 @@
|
|||
}
|
||||
|
||||
.visNewVisSearchDialog {
|
||||
min-height: $euiSizeL * 20;
|
||||
width: $euiSizeL * 30;
|
||||
min-height: $euiSizeL * 25;
|
||||
}
|
||||
|
||||
.visNewVisDialog__body {
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
16
src/legacy/ui/public/registry/_registry.d.ts
vendored
16
src/legacy/ui/public/registry/_registry.d.ts
vendored
|
@ -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 };
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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']
|
||||
});
|
|
@ -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'],
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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]}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
1
typings/@elastic/eui/index.d.ts
vendored
1
typings/@elastic/eui/index.d.ts
vendored
|
@ -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 };
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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": "克隆面板",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue