[Time to Visualize] Add visualizations to dashboard from save modal (#83140) (#85345)

Co-authored-by: Ryan Keairns <contactryank@gmail.com>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com>
# Conflicts:
#	packages/kbn-optimizer/limits.yml
This commit is contained in:
Poff Poffenberger 2020-12-09 10:37:25 -06:00 committed by GitHub
parent 1094b751fd
commit a6e71edbf0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1237 additions and 116 deletions

View file

@ -28,6 +28,7 @@
],
"maps_legacy": "src/plugins/maps_legacy",
"monaco": "packages/kbn-monaco/src",
"presentationUtil": "src/plugins/presentation_util",
"indexPatternManagement": "src/plugins/index_pattern_management",
"advancedSettings": "src/plugins/advanced_settings",
"kibana_legacy": "src/plugins/kibana_legacy",

View file

@ -156,6 +156,10 @@ It also provides a stateful version of it on the start contract.
Content is fetched from the remote (https://feeds.elastic.co and https://feeds-staging.elastic.co in dev mode) once a day, with periodic checks if the content needs to be refreshed. All newsfeed content is hosted remotely.
|{kib-repo}blob/{branch}/src/plugins/presentation_util/README.md[presentationUtil]
|Utilities and components used by the presentation-related plugins
|{kib-repo}blob/{branch}/src/plugins/region_map/README.md[regionMap]
|Create choropleth maps. Display the results of a term-aggregation as e.g. countries, zip-codes, states.

View file

@ -104,3 +104,4 @@ pageLoadAssetSize:
watcher: 43742
stackAlerts: 29684
runtimeFields: 41752
presentationUtil: 28545

View file

@ -549,6 +549,7 @@ export class DashboardAppController {
incomingEmbeddable.type,
incomingEmbeddable.input
);
updateViewMode(ViewMode.EDIT);
}
}

View file

@ -0,0 +1,3 @@
# presentationUtil
Utilities and components used by the presentation-related plugins

View file

@ -0,0 +1,21 @@
/*
* 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.
*/
export const PLUGIN_ID = 'presentationUtil';
export const PLUGIN_NAME = 'presentationUtil';

View file

@ -0,0 +1,9 @@
{
"id": "presentationUtil",
"version": "1.0.0",
"kibanaVersion": "kibana",
"server": false,
"ui": true,
"requiredPlugins": ["dashboard", "savedObjects"],
"optionalPlugins": []
}

View file

@ -0,0 +1,92 @@
/*
* 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 React, { useState, useEffect, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiComboBox } from '@elastic/eui';
import { SavedObjectsClientContract } from '../../../../core/public';
import { SavedObjectDashboard } from '../../../../plugins/dashboard/public';
export interface DashboardPickerProps {
onChange: (dashboard: { name: string; id: string } | null) => void;
isDisabled: boolean;
savedObjectsClient: SavedObjectsClientContract;
}
interface DashboardOption {
label: string;
value: string;
}
export function DashboardPicker(props: DashboardPickerProps) {
const [dashboards, setDashboards] = useState<DashboardOption[]>([]);
const [isLoadingDashboards, setIsLoadingDashboards] = useState(true);
const [selectedDashboard, setSelectedDashboard] = useState<DashboardOption | null>(null);
const { savedObjectsClient, isDisabled, onChange } = props;
const fetchDashboards = useCallback(
async (query) => {
setIsLoadingDashboards(true);
setDashboards([]);
const { savedObjects } = await savedObjectsClient.find<SavedObjectDashboard>({
type: 'dashboard',
search: query ? `${query}*` : '',
searchFields: ['title'],
});
if (savedObjects) {
setDashboards(savedObjects.map((d) => ({ value: d.id, label: d.attributes.title })));
}
setIsLoadingDashboards(false);
},
[savedObjectsClient]
);
// Initial dashboard load
useEffect(() => {
fetchDashboards('');
}, [fetchDashboards]);
return (
<EuiComboBox
placeholder={i18n.translate('presentationUtil.dashboardPicker.searchDashboardPlaceholder', {
defaultMessage: 'Search dashboards...',
})}
singleSelection={{ asPlainText: true }}
options={dashboards || []}
selectedOptions={!!selectedDashboard ? [selectedDashboard] : undefined}
onChange={(e) => {
if (e.length) {
setSelectedDashboard({ value: e[0].value || '', label: e[0].label });
onChange({ name: e[0].label, id: e[0].value || '' });
} else {
setSelectedDashboard(null);
onChange(null);
}
}}
onSearchChange={fetchDashboards}
isDisabled={isDisabled}
isLoading={isLoadingDashboards}
compressed={true}
/>
);
}

View file

@ -0,0 +1,5 @@
.savAddDashboard__searchDashboards {
margin-left: $euiSizeL;
margin-top: $euiSizeXS;
width: 300px;
}

View file

@ -0,0 +1,220 @@
/*
* 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 React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiRadio,
EuiIconTip,
EuiPanel,
EuiSpacer,
} from '@elastic/eui';
import { SavedObjectsClientContract } from '../../../../core/public';
import {
OnSaveProps,
SaveModalState,
SavedObjectSaveModal,
} from '../../../../plugins/saved_objects/public';
import { DashboardPicker } from './dashboard_picker';
import './saved_object_save_modal_dashboard.scss';
interface SaveModalDocumentInfo {
id?: string;
title: string;
description?: string;
}
export interface DashboardSaveModalProps {
documentInfo: SaveModalDocumentInfo;
objectType: string;
onClose: () => void;
onSave: (props: OnSaveProps & { dashboardId: string | null }) => void;
savedObjectsClient: SavedObjectsClientContract;
tagOptions?: React.ReactNode | ((state: SaveModalState) => React.ReactNode);
}
export function SavedObjectSaveModalDashboard(props: DashboardSaveModalProps) {
const { documentInfo, savedObjectsClient, tagOptions } = props;
const initialCopyOnSave = !Boolean(documentInfo.id);
const [dashboardOption, setDashboardOption] = useState<'new' | 'existing' | null>(
documentInfo.id ? null : 'existing'
);
const [selectedDashboard, setSelectedDashboard] = useState<{ id: string; name: string } | null>(
null
);
const [copyOnSave, setCopyOnSave] = useState<boolean>(initialCopyOnSave);
const renderDashboardSelect = (state: SaveModalState) => {
const isDisabled = Boolean(!state.copyOnSave && documentInfo.id);
return (
<>
<EuiFormRow
label={
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
<FormattedMessage
id="presentationUtil.saveModalDashboard.addToDashboardLabel"
defaultMessage="Add to dashboard"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiIconTip
type="iInCircle"
content={
<FormattedMessage
id="presentationUtil.saveModalDashboard.dashboardInfoTooltip"
defaultMessage="Items added to a dashboard will not appear in the library and must be edited from the dashboard."
/>
}
/>
</EuiFlexItem>
</EuiFlexGroup>
}
hasChildLabel={false}
>
<EuiPanel color="subdued" hasShadow={false}>
<div>
<EuiRadio
checked={dashboardOption === 'existing'}
id="existing"
name="dashboard-option"
label={i18n.translate(
'presentationUtil.saveModalDashboard.existingDashboardOptionLabel',
{
defaultMessage: 'Existing',
}
)}
onChange={() => setDashboardOption('existing')}
disabled={isDisabled}
/>
<div className="savAddDashboard__searchDashboards">
<DashboardPicker
savedObjectsClient={savedObjectsClient}
isDisabled={dashboardOption !== 'existing'}
onChange={(dash) => {
setSelectedDashboard(dash);
}}
/>
</div>
<EuiSpacer size="s" />
<EuiRadio
checked={dashboardOption === 'new'}
id="new"
name="dashboard-option"
label={i18n.translate(
'presentationUtil.saveModalDashboard.newDashboardOptionLabel',
{
defaultMessage: 'New',
}
)}
onChange={() => setDashboardOption('new')}
disabled={isDisabled}
/>
<EuiSpacer size="s" />
<EuiRadio
checked={dashboardOption === null}
id="library"
name="dashboard-option"
label={i18n.translate('presentationUtil.saveModalDashboard.libraryOptionLabel', {
defaultMessage: 'No dashboard, but add to library',
})}
onChange={() => setDashboardOption(null)}
disabled={isDisabled}
/>
</div>
</EuiPanel>
</EuiFormRow>
</>
);
};
const onCopyOnSaveChange = (newCopyOnSave: boolean) => {
setDashboardOption(null);
setCopyOnSave(newCopyOnSave);
};
const onModalSave = (onSaveProps: OnSaveProps) => {
let dashboardId = null;
// Don't save with a dashboard ID if we're
// just updating an existing visualization
if (!(!onSaveProps.newCopyOnSave && documentInfo.id)) {
if (dashboardOption === 'existing') {
dashboardId = selectedDashboard?.id || null;
} else {
dashboardId = dashboardOption;
}
}
props.onSave({ ...onSaveProps, dashboardId });
};
const saveLibraryLabel =
!copyOnSave && documentInfo.id
? i18n.translate('presentationUtil.saveModalDashboard.saveLabel', {
defaultMessage: 'Save',
})
: i18n.translate('presentationUtil.saveModalDashboard.saveToLibraryLabel', {
defaultMessage: 'Save and add to library',
});
const saveDashboardLabel = i18n.translate(
'presentationUtil.saveModalDashboard.saveAndGoToDashboardLabel',
{
defaultMessage: 'Save and go to Dashboard',
}
);
const confirmButtonLabel = dashboardOption === null ? saveLibraryLabel : saveDashboardLabel;
const isValid = !(dashboardOption === 'existing' && selectedDashboard === null);
return (
<SavedObjectSaveModal
onSave={onModalSave}
onClose={props.onClose}
title={documentInfo.title}
showCopyOnSave={documentInfo.id ? true : false}
initialCopyOnSave={initialCopyOnSave}
confirmButtonLabel={confirmButtonLabel}
objectType={props.objectType}
options={dashboardOption === null ? tagOptions : undefined} // Show tags when not adding to dashboard
rightOptions={renderDashboardSelect}
description={documentInfo.description}
showDescription={true}
isValid={isValid}
onCopyOnSaveChange={onCopyOnSaveChange}
/>
);
}

View file

@ -0,0 +1,30 @@
/*
* 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 { PresentationUtilPlugin } from './plugin';
export {
SavedObjectSaveModalDashboard,
DashboardSaveModalProps,
} from './components/saved_object_save_modal_dashboard';
export function plugin() {
return new PresentationUtilPlugin();
}
export { PresentationUtilPluginSetup, PresentationUtilPluginStart } from './types';

View file

@ -0,0 +1,34 @@
/*
* 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 { CoreSetup, CoreStart, Plugin } from '../../../core/public';
import { PresentationUtilPluginSetup, PresentationUtilPluginStart } from './types';
export class PresentationUtilPlugin
implements Plugin<PresentationUtilPluginSetup, PresentationUtilPluginStart> {
public setup(core: CoreSetup): PresentationUtilPluginSetup {
return {};
}
public start(core: CoreStart): PresentationUtilPluginStart {
return {};
}
public stop() {}
}

View file

@ -0,0 +1,23 @@
/*
* 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.
*/
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface PresentationUtilPluginSetup {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface PresentationUtilPluginStart {}

View file

@ -25,7 +25,6 @@ exports[`SavedObjectSaveModal should render matching snapshot 1`] = `
</EuiModalHeader>
<EuiModalBody>
<EuiForm>
<EuiSpacer />
<EuiFormRow
describedByIds={Array []}
display="row"
@ -98,3 +97,311 @@ exports[`SavedObjectSaveModal should render matching snapshot 1`] = `
</form>
</EuiOverlayMask>
`;
exports[`SavedObjectSaveModal should render matching snapshot when custom isValid is set 1`] = `
<EuiOverlayMask>
<form
onSubmit={[Function]}
>
<EuiModal
className="kbnSavedObjectSaveModal"
data-test-subj="savedObjectSaveModal"
onClose={[Function]}
>
<EuiModalHeader>
<EuiModalHeaderTitle>
<FormattedMessage
defaultMessage="Save {objectType}"
id="savedObjects.saveModal.saveTitle"
values={
Object {
"objectType": "visualization",
}
}
/>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiForm>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
label={
<FormattedMessage
defaultMessage="Title"
id="savedObjects.saveModal.titleLabel"
values={Object {}}
/>
}
labelType="label"
>
<EuiFieldText
autoFocus={true}
data-test-subj="savedObjectTitle"
fullWidth={true}
isInvalid={false}
onChange={[Function]}
value="Saved Object title"
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
label={
<FormattedMessage
defaultMessage="Description"
id="savedObjects.saveModal.descriptionLabel"
values={Object {}}
/>
}
labelType="label"
>
<EuiTextArea
data-test-subj="viewDescription"
onChange={[Function]}
value=""
/>
</EuiFormRow>
</EuiForm>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty
data-test-subj="saveCancelButton"
onClick={[Function]}
>
<FormattedMessage
defaultMessage="Cancel"
id="savedObjects.saveModal.cancelButtonLabel"
values={Object {}}
/>
</EuiButtonEmpty>
<EuiButton
data-test-subj="confirmSaveSavedObjectButton"
fill={true}
isDisabled={true}
isLoading={false}
type="submit"
>
Save
</EuiButton>
</EuiModalFooter>
</EuiModal>
</form>
</EuiOverlayMask>
`;
exports[`SavedObjectSaveModal should render matching snapshot when custom isValid is set 2`] = `
<EuiOverlayMask>
<form
onSubmit={[Function]}
>
<EuiModal
className="kbnSavedObjectSaveModal"
data-test-subj="savedObjectSaveModal"
onClose={[Function]}
>
<EuiModalHeader>
<EuiModalHeaderTitle>
<FormattedMessage
defaultMessage="Save {objectType}"
id="savedObjects.saveModal.saveTitle"
values={
Object {
"objectType": "visualization",
}
}
/>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiForm>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
label={
<FormattedMessage
defaultMessage="Title"
id="savedObjects.saveModal.titleLabel"
values={Object {}}
/>
}
labelType="label"
>
<EuiFieldText
autoFocus={true}
data-test-subj="savedObjectTitle"
fullWidth={true}
isInvalid={false}
onChange={[Function]}
value="Saved Object title"
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
label={
<FormattedMessage
defaultMessage="Description"
id="savedObjects.saveModal.descriptionLabel"
values={Object {}}
/>
}
labelType="label"
>
<EuiTextArea
data-test-subj="viewDescription"
onChange={[Function]}
value=""
/>
</EuiFormRow>
</EuiForm>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty
data-test-subj="saveCancelButton"
onClick={[Function]}
>
<FormattedMessage
defaultMessage="Cancel"
id="savedObjects.saveModal.cancelButtonLabel"
values={Object {}}
/>
</EuiButtonEmpty>
<EuiButton
data-test-subj="confirmSaveSavedObjectButton"
fill={true}
isDisabled={false}
isLoading={false}
type="submit"
>
Save
</EuiButton>
</EuiModalFooter>
</EuiModal>
</form>
</EuiOverlayMask>
`;
exports[`SavedObjectSaveModal should render matching snapshot when given options 1`] = `
<EuiOverlayMask>
<form
onSubmit={[Function]}
>
<EuiModal
className="kbnSavedObjectSaveModal kbnSavedObjectsSaveModal--wide"
data-test-subj="savedObjectSaveModal"
onClose={[Function]}
>
<EuiModalHeader>
<EuiModalHeaderTitle>
<FormattedMessage
defaultMessage="Save {objectType}"
id="savedObjects.saveModal.saveTitle"
values={
Object {
"objectType": "visualization",
}
}
/>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiForm>
<EuiFlexGroup
gutterSize="m"
>
<EuiFlexItem>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
label={
<FormattedMessage
defaultMessage="Title"
id="savedObjects.saveModal.titleLabel"
values={Object {}}
/>
}
labelType="label"
>
<EuiFieldText
autoFocus={true}
data-test-subj="savedObjectTitle"
fullWidth={true}
isInvalid={false}
onChange={[Function]}
value="Saved Object title"
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
label={
<FormattedMessage
defaultMessage="Description"
id="savedObjects.saveModal.descriptionLabel"
values={Object {}}
/>
}
labelType="label"
>
<EuiTextArea
data-test-subj="viewDescription"
onChange={[Function]}
value=""
/>
</EuiFormRow>
<div>
Hello! Main options
</div>
</EuiFlexItem>
<EuiFlexItem>
<div>
Hey there! Options on the right
</div>
</EuiFlexItem>
</EuiFlexGroup>
</EuiForm>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty
data-test-subj="saveCancelButton"
onClick={[Function]}
>
<FormattedMessage
defaultMessage="Cancel"
id="savedObjects.saveModal.cancelButtonLabel"
values={Object {}}
/>
</EuiButtonEmpty>
<EuiButton
data-test-subj="confirmSaveSavedObjectButton"
fill={true}
isDisabled={false}
isLoading={false}
type="submit"
>
Save
</EuiButton>
</EuiModalFooter>
</EuiModal>
</form>
</EuiOverlayMask>
`;

View file

@ -1,3 +1,7 @@
.kbnSavedObjectSaveModal {
width: $euiSizeXXL * 10;
}
}
.kbnSavedObjectsSaveModal--wide {
width: 800px;
}

View file

@ -37,6 +37,50 @@ describe('SavedObjectSaveModal', () => {
expect(wrapper).toMatchSnapshot();
});
it('should render matching snapshot when given options', () => {
const wrapper = shallow(
<SavedObjectSaveModal
onSave={() => void 0}
onClose={() => void 0}
title={'Saved Object title'}
showCopyOnSave={false}
objectType="visualization"
showDescription={true}
options={<div>Hello! Main options</div>}
rightOptions={<div>Hey there! Options on the right</div>}
/>
);
expect(wrapper).toMatchSnapshot();
});
it('should render matching snapshot when custom isValid is set', () => {
const falseWrapper = shallow(
<SavedObjectSaveModal
onSave={() => void 0}
onClose={() => void 0}
title={'Saved Object title'}
showCopyOnSave={false}
objectType="visualization"
showDescription={true}
isValid={false}
/>
);
expect(falseWrapper).toMatchSnapshot();
const trueWrapper = shallow(
<SavedObjectSaveModal
onSave={() => void 0}
onClose={() => void 0}
title={'Saved Object title'}
showCopyOnSave={false}
objectType="visualization"
showDescription={true}
isValid={true}
/>
);
expect(trueWrapper).toMatchSnapshot();
});
it('allows specifying custom save button label', () => {
const wrapper = mountWithIntl(
<SavedObjectSaveModal

View file

@ -22,6 +22,8 @@ import {
EuiButtonEmpty,
EuiCallOut,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormRow,
EuiModal,
@ -53,12 +55,15 @@ interface Props {
onClose: () => void;
title: string;
showCopyOnSave: boolean;
onCopyOnSaveChange?: (copyOnChange: boolean) => void;
initialCopyOnSave?: boolean;
objectType: string;
confirmButtonLabel?: React.ReactNode;
options?: React.ReactNode | ((state: SaveModalState) => React.ReactNode);
rightOptions?: React.ReactNode | ((state: SaveModalState) => React.ReactNode);
description?: string;
showDescription: boolean;
isValid?: boolean;
}
export interface SaveModalState {
@ -87,12 +92,54 @@ export class SavedObjectSaveModal extends React.Component<Props, SaveModalState>
const { isTitleDuplicateConfirmed, hasTitleDuplicate, title } = this.state;
const duplicateWarningId = generateId();
const hasColumns = !!this.props.rightOptions;
const formBodyContent = (
<>
<EuiFormRow
fullWidth
label={<FormattedMessage id="savedObjects.saveModal.titleLabel" defaultMessage="Title" />}
>
<EuiFieldText
fullWidth
autoFocus
data-test-subj="savedObjectTitle"
value={title}
onChange={this.onTitleChange}
isInvalid={(!isTitleDuplicateConfirmed && hasTitleDuplicate) || title.length === 0}
aria-describedby={this.state.hasTitleDuplicate ? duplicateWarningId : undefined}
/>
</EuiFormRow>
{this.renderViewDescription()}
{typeof this.props.options === 'function'
? this.props.options(this.state)
: this.props.options}
</>
);
const formBody = hasColumns ? (
<EuiFlexGroup gutterSize="m">
<EuiFlexItem>{formBodyContent}</EuiFlexItem>
<EuiFlexItem>
{typeof this.props.rightOptions === 'function'
? this.props.rightOptions(this.state)
: this.props.rightOptions}
</EuiFlexItem>
</EuiFlexGroup>
) : (
formBodyContent
);
return (
<EuiOverlayMask>
<form onSubmit={this.onFormSubmit}>
<EuiModal
data-test-subj="savedObjectSaveModal"
className="kbnSavedObjectSaveModal"
className={`kbnSavedObjectSaveModal${
hasColumns ? ' kbnSavedObjectsSaveModal--wide' : ''
}`}
onClose={this.props.onClose}
>
<EuiModalHeader>
@ -114,38 +161,8 @@ export class SavedObjectSaveModal extends React.Component<Props, SaveModalState>
{this.props.description}
</EuiText>
)}
<EuiSpacer />
{formBody}
{this.renderCopyOnSave()}
<EuiFormRow
fullWidth
label={
<FormattedMessage
id="savedObjects.saveModal.titleLabel"
defaultMessage="Title"
/>
}
>
<EuiFieldText
fullWidth
autoFocus
data-test-subj="savedObjectTitle"
value={title}
onChange={this.onTitleChange}
isInvalid={
(!isTitleDuplicateConfirmed && hasTitleDuplicate) || title.length === 0
}
aria-describedby={this.state.hasTitleDuplicate ? duplicateWarningId : undefined}
/>
</EuiFormRow>
{this.renderViewDescription()}
{typeof this.props.options === 'function'
? this.props.options(this.state)
: this.props.options}
</EuiForm>
</EuiModalBody>
@ -238,6 +255,10 @@ export class SavedObjectSaveModal extends React.Component<Props, SaveModalState>
this.setState({
copyOnSave: event.target.checked,
});
if (this.props.onCopyOnSaveChange) {
this.props.onCopyOnSaveChange(event.target.checked);
}
};
private onFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
@ -259,12 +280,14 @@ export class SavedObjectSaveModal extends React.Component<Props, SaveModalState>
confirmLabel = this.props.confirmButtonLabel;
}
const isValid = this.props.isValid !== undefined ? this.props.isValid : true;
return (
<EuiButton
fill
data-test-subj="confirmSaveSavedObjectButton"
isLoading={isLoading}
isDisabled={title.length === 0}
isDisabled={title.length === 0 || !isValid}
type="submit"
>
{confirmLabel}
@ -315,6 +338,7 @@ export class SavedObjectSaveModal extends React.Component<Props, SaveModalState>
return (
<>
<EuiSpacer />
<EuiSwitch
data-test-subj="saveAsNewCheckbox"
checked={this.state.copyOnSave}
@ -327,7 +351,6 @@ export class SavedObjectSaveModal extends React.Component<Props, SaveModalState>
/>
}
/>
<EuiSpacer />
</>
);
};

View file

@ -23,6 +23,7 @@
"kibanaReact",
"home",
"discover",
"visDefaultEditor"
"visDefaultEditor",
"presentationUtil"
]
}

View file

@ -14,4 +14,18 @@
vertical-align: baseline;
padding: 0 $euiSizeS;
margin-left: $euiSizeS;
}
}
.visListingCallout {
max-width: 1000px;
width: 100%;
margin-left: auto;
margin-right: auto;
padding: $euiSize $euiSize 0 $euiSize;
}
.visListingCallout__link {
text-decoration: underline;
}

View file

@ -19,8 +19,10 @@
import './visualize_listing.scss';
import React, { useCallback, useRef, useMemo, useEffect } from 'react';
import React, { useCallback, useRef, useMemo, useEffect, MouseEvent } from 'react';
import { EuiCallOut, EuiLink } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import useUnmount from 'react-use/lib/useUnmount';
import useMount from 'react-use/lib/useMount';
@ -150,35 +152,65 @@ export const VisualizeListing = () => {
: [];
}, [savedObjectsTagging]);
const calloutMessage = (
<>
<FormattedMessage
id="visualize.visualizeListingDashboardFlowDescription"
defaultMessage="Building a dashboard? Create content directly from the {dashboardApp} using a new integrated workflow."
values={{
dashboardApp: (
<EuiLink
className="visListingCallout__link"
onClick={(event: MouseEvent) => {
event.preventDefault();
application.navigateToUrl(application.getUrlForApp('dashboards'));
}}
>
<FormattedMessage
id="visualize.visualizeListingDashboardAppName"
defaultMessage="Dashboard application"
/>
</EuiLink>
),
}}
/>
</>
);
return (
<TableListView
headingId="visualizeListingHeading"
// we allow users to create visualizations even if they can't save them
// for data exploration purposes
createItem={createNewVis}
tableCaption={i18n.translate('visualize.listing.table.listTitle', {
defaultMessage: 'Visualizations',
})}
findItems={fetchItems}
deleteItems={visualizeCapabilities.delete ? deleteItems : undefined}
editItem={visualizeCapabilities.save ? editItem : undefined}
tableColumns={tableColumns}
listingLimit={listingLimit}
initialPageSize={savedObjectsPublic.settings.getPerPage()}
initialFilter={''}
rowHeader="title"
noItemsFragment={noItemsFragment}
entityName={i18n.translate('visualize.listing.table.entityName', {
defaultMessage: 'visualization',
})}
entityNamePlural={i18n.translate('visualize.listing.table.entityNamePlural', {
defaultMessage: 'visualizations',
})}
tableListTitle={i18n.translate('visualize.listing.table.listTitle', {
defaultMessage: 'Visualizations',
})}
toastNotifications={toastNotifications}
searchFilters={searchFilters}
/>
<>
<div className="visListingCallout">
<EuiCallOut size="s" title={calloutMessage} iconType="iInCircle" />
</div>
<TableListView
headingId="visualizeListingHeading"
// we allow users to create visualizations even if they can't save them
// for data exploration purposes
createItem={createNewVis}
tableCaption={i18n.translate('visualize.listing.table.listTitle', {
defaultMessage: 'Visualizations',
})}
findItems={fetchItems}
deleteItems={visualizeCapabilities.delete ? deleteItems : undefined}
editItem={visualizeCapabilities.save ? editItem : undefined}
tableColumns={tableColumns}
listingLimit={listingLimit}
initialPageSize={savedObjectsPublic.settings.getPerPage()}
initialFilter={''}
rowHeader="title"
noItemsFragment={noItemsFragment}
entityName={i18n.translate('visualize.listing.table.entityName', {
defaultMessage: 'visualization',
})}
entityNamePlural={i18n.translate('visualize.listing.table.entityNamePlural', {
defaultMessage: 'visualizations',
})}
tableListTitle={i18n.translate('visualize.listing.table.listTitle', {
defaultMessage: 'Visualizations',
})}
toastNotifications={toastNotifications}
searchFilters={searchFilters}
/>
</>
);
};

View file

@ -81,6 +81,7 @@ const TopNav = ({
[visInstance.embeddableHandler]
);
const stateTransfer = services.embeddable.getStateTransfer();
const savedObjectsClient = services.savedObjects.client;
const config = useMemo(() => {
if (isEmbeddableRendered) {
@ -96,6 +97,7 @@ const TopNav = ({
stateContainer,
visualizationIdFromUrl,
stateTransfer,
savedObjectsClient,
embeddableId,
onAppLeave,
},
@ -116,6 +118,7 @@ const TopNav = ({
services,
embeddableId,
stateTransfer,
savedObjectsClient,
onAppLeave,
]);
const [indexPatterns, setIndexPatterns] = useState<IndexPattern[]>(

View file

@ -29,7 +29,9 @@ import {
SavedObjectSaveOpts,
OnSaveProps,
} from '../../../../saved_objects/public';
import { SavedObjectSaveModalDashboard } from '../../../../presentation_util/public';
import { unhashUrl } from '../../../../kibana_utils/public';
import { SavedObjectsClientContract } from '../../../../../core/public';
import {
VisualizeServices,
@ -51,6 +53,7 @@ interface TopNavConfigParams {
stateContainer: VisualizeAppStateContainer;
visualizationIdFromUrl?: string;
stateTransfer: EmbeddableStateTransfer;
savedObjectsClient: SavedObjectsClientContract;
embeddableId?: string;
onAppLeave: AppMountParameters['onAppLeave'];
}
@ -65,6 +68,7 @@ export const getTopNavConfig = (
hasUnappliedChanges,
visInstance,
stateContainer,
savedObjectsClient,
visualizationIdFromUrl,
stateTransfer,
embeddableId,
@ -168,6 +172,7 @@ export const getTopNavConfig = (
if (!originatingApp) {
return;
}
const state = {
input: {
savedVis: vis.serialize(),
@ -298,10 +303,12 @@ export const getTopNavConfig = (
onTitleDuplicate,
newDescription,
returnToOrigin,
}: OnSaveProps & { returnToOrigin: boolean }) => {
dashboardId,
}: OnSaveProps & { returnToOrigin?: boolean } & { dashboardId?: string | null }) => {
if (!savedVis) {
return;
}
const currentTitle = savedVis.title;
savedVis.title = newTitle;
embeddableHandler.updateInput({ title: newTitle });
@ -318,16 +325,48 @@ export const getTopNavConfig = (
onTitleDuplicate,
returnToOrigin,
};
if (dashboardId) {
const appPath = `${VisualizeConstants.LANDING_PAGE_PATH}`;
// Manually insert a new url so the back button will open the saved visualization.
history.replace(appPath);
setActiveUrl(appPath);
const state = {
input: {
savedVis: {
...vis.serialize(),
title: newTitle,
description: newDescription,
},
} as VisualizeInput,
embeddableId,
type: VISUALIZE_EMBEDDABLE_TYPE,
};
const path = dashboardId === 'new' ? '#/create' : `#/view/${dashboardId}`;
stateTransfer.navigateToWithEmbeddablePackage('dashboards', {
state,
path,
});
// TODO: Saved Object Modal requires `id` to be defined so this is a workaround
return { id: true };
}
const response = await doSave(saveOptions);
// If the save wasn't successful, put the original values back.
if (!response.id || response.error) {
savedVis.title = currentTitle;
}
return response;
};
let selectedTags: string[] = [];
let options: React.ReactNode | undefined;
let tagOptions: React.ReactNode | undefined;
if (
savedVis &&
@ -335,7 +374,7 @@ export const getTopNavConfig = (
savedObjectsTagging.ui.hasTagDecoration(savedVis)
) {
selectedTags = savedVis.getTags();
options = (
tagOptions = (
<savedObjectsTagging.ui.components.SavedObjectSaveModalTagSelector
initialSelection={selectedTags}
onTagsSelected={(newSelection) => {
@ -345,17 +384,29 @@ export const getTopNavConfig = (
);
}
const saveModal = (
<SavedObjectSaveModalOrigin
documentInfo={savedVis || { title: '' }}
onSave={onSave}
options={options}
getAppNameFromId={stateTransfer.getAppNameFromId}
objectType={'visualization'}
onClose={() => {}}
originatingApp={originatingApp}
/>
);
const saveModal =
!!originatingApp ||
!dashboard.dashboardFeatureFlagConfig.allowByValueEmbeddables ? (
<SavedObjectSaveModalOrigin
documentInfo={savedVis || { title: '' }}
onSave={onSave}
options={tagOptions}
getAppNameFromId={stateTransfer.getAppNameFromId}
objectType={'visualization'}
onClose={() => {}}
originatingApp={originatingApp}
/>
) : (
<SavedObjectSaveModalDashboard
documentInfo={savedVis || { title: '' }}
onSave={onSave}
tagOptions={tagOptions}
objectType={'visualization'}
onClose={() => {}}
savedObjectsClient={savedObjectsClient}
/>
);
const isSaveAsButton = anchorElement.classList.contains('saveAsButton');
onAppLeave((actions) => {
return actions.default();

View file

@ -20,5 +20,5 @@
"optionalPlugins": ["usageCollection", "taskManager", "globalSearch", "savedObjectsTagging"],
"configPath": ["xpack", "lens"],
"extraPublicDirs": ["common/constants"],
"requiredBundles": ["savedObjects", "kibanaUtils", "kibanaReact", "embeddable", "lensOss"]
"requiredBundles": ["savedObjects", "kibanaUtils", "kibanaReact", "embeddable", "lensOss", "presentationUtil"]
}

View file

@ -630,7 +630,7 @@ describe('Lens App', () => {
});
});
it('Shows Save and Return and Save As buttons in create by value mode', async () => {
it('Shows Save and Return and Save As buttons in create by value mode with originating app', async () => {
const props = makeDefaultProps();
const services = makeDefaultServices();
services.dashboardFeatureFlag = { allowByValueEmbeddables: true };

View file

@ -34,7 +34,7 @@ import {
import { LENS_EMBEDDABLE_TYPE, getFullPath } from '../../common';
import { LensAppProps, LensAppServices, LensAppState } from './types';
import { getLensTopNavConfig } from './lens_top_nav';
import { TagEnhancedSavedObjectSaveModalOrigin } from './tags_saved_object_save_modal_origin_wrapper';
import { SaveModal } from './save_modal';
import {
LensByReferenceInput,
LensEmbeddableInput,
@ -48,6 +48,7 @@ export function App({
initialInput,
incomingState,
redirectToOrigin,
redirectToDashboard,
setHeaderActionMenu,
initialContext,
}: LensAppProps) {
@ -355,6 +356,7 @@ export function App({
const runSave = async (
saveProps: Omit<OnSaveProps, 'onTitleDuplicate' | 'newDescription'> & {
returnToOrigin: boolean;
dashboardId?: string | null;
onTitleDuplicate?: OnSaveProps['onTitleDuplicate'];
newDescription?: string;
newTags?: string[];
@ -429,6 +431,13 @@ export function App({
});
redirectToOrigin({ input: newInput, isCopied: saveProps.newCopyOnSave });
return;
} else if (saveProps.dashboardId && redirectToDashboard) {
// disabling the validation on app leave because the document has been saved.
onAppLeave((actions) => {
return actions.default();
});
redirectToDashboard(newInput, saveProps.dashboardId);
return;
}
notifications.toasts.addSuccess(
@ -679,35 +688,28 @@ export function App({
/>
)}
</div>
{lastKnownDoc && state.isSaveModalVisible && (
<TagEnhancedSavedObjectSaveModalOrigin
savedObjectsTagging={savedObjectsTagging}
initialTags={tagsIds}
originatingApp={incomingState?.originatingApp}
onSave={(props) => runSave(props, { saveToLibrary: true })}
onClose={() => {
setState((s) => ({ ...s, isSaveModalVisible: false }));
}}
getAppNameFromId={() => getOriginatingAppName()}
documentInfo={{
id: lastKnownDoc.savedObjectId,
title: lastKnownDoc.title || '',
description: lastKnownDoc.description || '',
}}
returnToOriginSwitchLabel={
getIsByValueMode() && initialInput
? i18n.translate('xpack.lens.app.updatePanel', {
defaultMessage: 'Update panel on {originatingAppName}',
values: { originatingAppName: getOriginatingAppName() },
})
: undefined
}
objectType={i18n.translate('xpack.lens.app.saveModalType', {
defaultMessage: 'Lens visualization',
})}
data-test-subj="lnsApp_saveModalOrigin"
/>
)}
<SaveModal
isVisible={state.isSaveModalVisible}
originatingApp={incomingState?.originatingApp}
allowByValueEmbeddables={dashboardFeatureFlag.allowByValueEmbeddables}
savedObjectsClient={savedObjectsClient}
savedObjectsTagging={savedObjectsTagging}
tagsIds={tagsIds}
onSave={runSave}
onClose={() => {
setState((s) => ({ ...s, isSaveModalVisible: false }));
}}
getAppNameFromId={() => getOriginatingAppName()}
lastKnownDoc={lastKnownDoc}
returnToOriginSwitchLabel={
getIsByValueMode() && initialInput
? i18n.translate('xpack.lens.app.updatePanel', {
defaultMessage: 'Update panel on {originatingAppName}',
values: { originatingAppName: getOriginatingAppName() },
})
: undefined
}
/>
</>
);
}

View file

@ -107,6 +107,23 @@ export async function mountApp(
}
};
const redirectToDashboard = (embeddableInput: LensEmbeddableInput, dashboardId: string) => {
if (!lensServices.dashboardFeatureFlag.allowByValueEmbeddables) {
throw new Error('redirectToDashboard called with by-value embeddables disabled');
}
const state = {
input: embeddableInput,
type: LENS_EMBEDDABLE_TYPE,
};
const path = dashboardId === 'new' ? '#/create' : `#/view/${dashboardId}`;
stateTransfer.navigateToWithEmbeddablePackage('dashboards', {
state,
path,
});
};
const redirectToOrigin = (props?: RedirectToOriginProps) => {
if (!embeddableEditorIncomingState?.originatingApp) {
throw new Error('redirectToOrigin called without an originating app');
@ -135,6 +152,7 @@ export async function mountApp(
initialInput={getInitialInput(routeProps)}
redirectTo={(savedObjectId?: string) => redirectTo(routeProps, savedObjectId)}
redirectToOrigin={redirectToOrigin}
redirectToDashboard={redirectToDashboard}
onAppLeave={params.onAppLeave}
setHeaderActionMenu={params.setHeaderActionMenu}
history={routeProps.history}

View file

@ -0,0 +1,106 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { SavedObjectsStart } from '../../../../../src/core/public';
import { Document } from '../persistence';
import type { SavedObjectTaggingPluginStart } from '../../../saved_objects_tagging/public';
import {
TagEnhancedSavedObjectSaveModalOrigin,
OriginSaveProps,
} from './tags_saved_object_save_modal_origin_wrapper';
import {
TagEnhancedSavedObjectSaveModalDashboard,
DashboardSaveProps,
} from './tags_saved_object_save_modal_dashboard_wrapper';
export type SaveProps = OriginSaveProps | DashboardSaveProps;
export interface Props {
isVisible: boolean;
originatingApp?: string;
allowByValueEmbeddables: boolean;
savedObjectsClient: SavedObjectsStart['client'];
savedObjectsTagging?: SavedObjectTaggingPluginStart;
tagsIds: string[];
lastKnownDoc?: Document;
getAppNameFromId: () => string | undefined;
returnToOriginSwitchLabel?: string;
onClose: () => void;
onSave: (props: SaveProps, options: { saveToLibrary: boolean }) => void;
}
export const SaveModal = (props: Props) => {
if (!props.isVisible || !props.lastKnownDoc) {
return null;
}
const {
originatingApp,
savedObjectsTagging,
savedObjectsClient,
tagsIds,
lastKnownDoc,
allowByValueEmbeddables,
returnToOriginSwitchLabel,
getAppNameFromId,
onClose,
onSave,
} = props;
// Use the modal with return-to-origin features if we're in an app's edit flow or if by-value embeddables are disabled
if (originatingApp || !allowByValueEmbeddables) {
return (
<TagEnhancedSavedObjectSaveModalOrigin
savedObjectsTagging={savedObjectsTagging}
initialTags={tagsIds}
originatingApp={originatingApp}
onClose={onClose}
onSave={(saveProps) => onSave(saveProps, { saveToLibrary: true })}
getAppNameFromId={getAppNameFromId}
documentInfo={{
id: lastKnownDoc.savedObjectId,
title: lastKnownDoc.title || '',
description: lastKnownDoc.description || '',
}}
returnToOriginSwitchLabel={returnToOriginSwitchLabel}
objectType={i18n.translate('xpack.lens.app.saveModalType', {
defaultMessage: 'Lens visualization',
})}
data-test-subj="lnsApp_saveModalOrigin"
/>
);
}
return (
<TagEnhancedSavedObjectSaveModalDashboard
savedObjectsTagging={savedObjectsTagging}
savedObjectsClient={savedObjectsClient}
initialTags={tagsIds}
onSave={(saveProps) => onSave(saveProps, { saveToLibrary: false })}
onClose={onClose}
documentInfo={{
id: lastKnownDoc.savedObjectId,
title: lastKnownDoc.title || '',
description: lastKnownDoc.description || '',
}}
objectType={i18n.translate('xpack.lens.app.saveModalType', {
defaultMessage: 'Lens visualization',
})}
data-test-subj="lnsApp_saveModalDashboard"
/>
);
};

View file

@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC, useState, useMemo, useCallback } from 'react';
import { OnSaveProps } from '../../../../../src/plugins/saved_objects/public';
import {
DashboardSaveModalProps,
SavedObjectSaveModalDashboard,
} from '../../../../../src/plugins/presentation_util/public';
import { SavedObjectTaggingPluginStart } from '../../../saved_objects_tagging/public';
export type DashboardSaveProps = OnSaveProps & {
returnToOrigin: boolean;
dashboardId?: string | null;
newTags?: string[];
};
export type TagEnhancedSavedObjectSaveModalDashboardProps = Omit<
DashboardSaveModalProps,
'onSave'
> & {
initialTags: string[];
savedObjectsTagging?: SavedObjectTaggingPluginStart;
onSave: (props: DashboardSaveProps) => void;
};
export const TagEnhancedSavedObjectSaveModalDashboard: FC<TagEnhancedSavedObjectSaveModalDashboardProps> = ({
initialTags,
onSave,
savedObjectsTagging,
...otherProps
}) => {
const [selectedTags, setSelectedTags] = useState(initialTags);
const tagSelectorOption = useMemo(
() =>
savedObjectsTagging ? (
<savedObjectsTagging.ui.components.SavedObjectSaveModalTagSelector
initialSelection={initialTags}
onTagsSelected={setSelectedTags}
/>
) : undefined,
[savedObjectsTagging, initialTags]
);
const tagEnhancedOptions = <>{tagSelectorOption}</>;
const tagEnhancedOnSave: DashboardSaveModalProps['onSave'] = useCallback(
(saveOptions) => {
onSave({
...saveOptions,
returnToOrigin: false,
newTags: selectedTags,
});
},
[onSave, selectedTags]
);
return (
<SavedObjectSaveModalDashboard
{...otherProps}
onSave={tagEnhancedOnSave}
tagOptions={tagEnhancedOptions}
/>
);
};

View file

@ -13,10 +13,12 @@ import {
} from '../../../../../src/plugins/saved_objects/public';
import { SavedObjectTaggingPluginStart } from '../../../saved_objects_tagging/public';
type TagEnhancedSavedObjectSaveModalOriginProps = Omit<OriginSaveModalProps, 'onSave'> & {
export type OriginSaveProps = OnSaveProps & { returnToOrigin: boolean; newTags?: string[] };
export type TagEnhancedSavedObjectSaveModalOriginProps = Omit<OriginSaveModalProps, 'onSave'> & {
initialTags: string[];
savedObjectsTagging?: SavedObjectTaggingPluginStart;
onSave: (props: OnSaveProps & { returnToOrigin: boolean; newTags?: string[] }) => void;
onSave: (props: OriginSaveProps) => void;
};
export const TagEnhancedSavedObjectSaveModalOrigin: FC<TagEnhancedSavedObjectSaveModalOriginProps> = ({

View file

@ -76,6 +76,7 @@ export interface LensAppProps {
setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
redirectTo: (savedObjectId?: string) => void;
redirectToOrigin?: (props?: RedirectToOriginProps) => void;
redirectToDashboard?: (input: LensEmbeddableInput, dashboardId: string) => void;
// The initial input passed in by the container when editing. Can be either by reference or by value.
initialInput?: LensEmbeddableInput;