Skip to content

Commit

Permalink
[Lens] Add save modal to Lens (#48013)
Browse files Browse the repository at this point in the history
  • Loading branch information
chrisdavies authored Oct 15, 2019
1 parent 1354369 commit 2c02458
Show file tree
Hide file tree
Showing 7 changed files with 183 additions and 97 deletions.
132 changes: 110 additions & 22 deletions x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,11 @@ describe('Lens App', () => {
});

describe('save button', () => {
interface SaveProps {
newCopyOnSave: boolean;
newTitle: string;
}

function getButton(instance: ReactWrapper): TopNavMenuData {
return (instance
.find('[data-test-subj="lnsApp_topNav"]')
Expand All @@ -296,6 +301,68 @@ describe('Lens App', () => {
)!;
}

function testSave(instance: ReactWrapper, saveProps: SaveProps) {
act(() => {
getButton(instance).run(instance.getDOMNode());
});

instance.update();

const handler = instance.findWhere(el => el.prop('onSave')).prop('onSave') as ((
p: unknown
) => void);
handler(saveProps);
}

async function save({
initialDocId,
...saveProps
}: SaveProps & {
initialDocId?: string;
}) {
const args = {
...makeDefaultArgs(),
docId: initialDocId,
};
args.editorFrame = frame;
(args.docStorage.load as jest.Mock).mockResolvedValue({
id: '1234',
state: {
query: 'fake query',
datasourceMetaData: { filterableIndexPatterns: [{ id: '1', title: 'saved' }] },
},
});
(args.docStorage.save as jest.Mock).mockImplementation(async ({ id }) => ({
id: id || 'aaa',
}));

const instance = mount(<App {...args} />);

await waitForPromises();

if (initialDocId) {
expect(args.docStorage.load).toHaveBeenCalledTimes(1);
} else {
expect(args.docStorage.load).not.toHaveBeenCalled();
}

const onChange = frame.mount.mock.calls[0][1].onChange;
onChange({
filterableIndexPatterns: [],
doc: ({ id: initialDocId } as unknown) as Document,
});

instance.update();

expect(getButton(instance).disableButton).toEqual(false);

testSave(instance, saveProps);

await waitForPromises();

return { args, instance };
}

it('shows a disabled save button when the user does not have permissions', async () => {
const args = makeDefaultArgs();
args.core.application = {
Expand Down Expand Up @@ -335,37 +402,61 @@ describe('Lens App', () => {
expect(getButton(instance).disableButton).toEqual(false);
});

it('saves the latest doc and then prevents more saving', async () => {
const args = makeDefaultArgs();
args.editorFrame = frame;
(args.docStorage.save as jest.Mock).mockResolvedValue({ id: '1234' });
it('saves new docs', async () => {
const { args, instance } = await save({
initialDocId: undefined,
newCopyOnSave: false,
newTitle: 'hello there',
});

const instance = mount(<App {...args} />);
expect(args.docStorage.save).toHaveBeenCalledWith({
id: undefined,
title: 'hello there',
});

expect(frame.mount).toHaveBeenCalledTimes(1);
expect(args.redirectTo).toHaveBeenCalledWith('aaa');

const onChange = frame.mount.mock.calls[0][1].onChange;
onChange({ filterableIndexPatterns: [], doc: ({ id: undefined } as unknown) as Document });
instance.setProps({ docId: 'aaa' });

instance.update();
expect(args.docStorage.load).not.toHaveBeenCalled();
});

expect(getButton(instance).disableButton).toEqual(false);
it('saves the latest doc as a copy', async () => {
const { args, instance } = await save({
initialDocId: '1234',
newCopyOnSave: true,
newTitle: 'hello there',
});

act(() => {
getButton(instance).run(instance.getDOMNode());
expect(args.docStorage.save).toHaveBeenCalledWith({
id: undefined,
title: 'hello there',
});

expect(args.docStorage.save).toHaveBeenCalledWith({ id: undefined });
expect(args.redirectTo).toHaveBeenCalledWith('aaa');

await waitForPromises();
instance.setProps({ docId: 'aaa' });

expect(args.redirectTo).toHaveBeenCalledWith('1234');
expect(args.docStorage.load).toHaveBeenCalledTimes(1);
});

instance.setProps({ docId: '1234' });
it('saves existing docs', async () => {
const { args, instance } = await save({
initialDocId: '1234',
newCopyOnSave: false,
newTitle: 'hello there',
});

expect(args.docStorage.load).not.toHaveBeenCalled();
expect(args.docStorage.save).toHaveBeenCalledWith({
id: '1234',
title: 'hello there',
});

expect(getButton(instance).disableButton).toEqual(true);
expect(args.redirectTo).not.toHaveBeenCalled();

instance.setProps({ docId: '1234' });

expect(args.docStorage.load).toHaveBeenCalledTimes(1);
});

it('handles save failure by showing a warning, but still allows another save', async () => {
Expand All @@ -380,11 +471,8 @@ describe('Lens App', () => {

instance.update();

act(() => {
getButton(instance).run(instance.getDOMNode());
});
testSave(instance, { newCopyOnSave: false, newTitle: 'hello there' });

await waitForPromises();
await waitForPromises();

expect(args.core.notifications.toasts.addDanger).toHaveBeenCalled();
Expand Down
94 changes: 56 additions & 38 deletions x-pack/legacy/plugins/lens/public/app_plugin/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
*/

import _ from 'lodash';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { I18nProvider } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { Storage } from 'ui/storage';
import { DataPublicPluginStart } from 'src/plugins/data/public';

import { SavedObjectSaveModal } from 'ui/saved_objects/components/saved_object_save_modal';
import { CoreStart, NotificationsStart } from 'src/core/public';
import {
DataStart,
Expand All @@ -28,9 +28,10 @@ import { NativeRenderer } from '../native_renderer';

interface State {
isLoading: boolean;
isDirty: boolean;
isSaveModalVisible: boolean;
indexPatternsForTopNav: IndexPatternInstance[];
persistedDoc?: Document;
lastKnownDoc?: Document;

// Properties needed to interface with TopNav
dateRange: {
Expand Down Expand Up @@ -67,9 +68,8 @@ export function App({

const [state, setState] = useState<State>({
isLoading: !!docId,
isDirty: false,
isSaveModalVisible: false,
indexPatternsForTopNav: [],

query: { query: '', language },
dateRange: {
fromDate: timeDefaults.from,
Expand All @@ -78,7 +78,7 @@ export function App({
filters: [],
});

const lastKnownDocRef = useRef<Document | undefined>(undefined);
const { lastKnownDoc } = state;

useEffect(() => {
const subscription = dataShim.filter.filterManager.getUpdates$().subscribe({
Expand Down Expand Up @@ -124,6 +124,7 @@ export function App({
...s,
isLoading: false,
persistedDoc: doc,
lastKnownDoc: doc,
query: doc.state.query,
filters: doc.state.filters,
indexPatternsForTopNav: indexPatterns,
Expand All @@ -139,7 +140,7 @@ export function App({
setState(s => ({ ...s, isLoading: false }));

core.notifications.toasts.addDanger(
i18n.translate('xpack.lens.editorFrame.docLoadingError', {
i18n.translate('xpack.lens.app.docLoadingError', {
defaultMessage: 'Error loading saved document',
})
);
Expand All @@ -149,10 +150,7 @@ export function App({
}
}, [docId]);

// Can save if the frame has told us what it has, and there is either:
// a) No saved doc
// b) A saved doc that differs from the frame state
const isSaveable = state.isDirty && (core.application.capabilities.visualize.save as boolean);
const isSaveable = lastKnownDoc && core.application.capabilities.visualize.save;

const onError = useCallback(
(e: { message: string }) =>
Expand All @@ -177,32 +175,12 @@ export function App({
<TopNavMenu
config={[
{
label: i18n.translate('xpack.lens.editorFrame.save', {
label: i18n.translate('xpack.lens.app.save', {
defaultMessage: 'Save',
}),
run: () => {
if (isSaveable && lastKnownDocRef.current) {
docStorage
.save(lastKnownDocRef.current)
.then(({ id }) => {
// Prevents unnecessary network request and disables save button
const newDoc = { ...lastKnownDocRef.current!, id };
setState(s => ({
...s,
isDirty: false,
persistedDoc: newDoc,
}));
if (docId !== id) {
redirectTo(id);
}
})
.catch(() => {
core.notifications.toasts.addDanger(
i18n.translate('xpack.lens.editorFrame.docSavingError', {
defaultMessage: 'Error saving document',
})
);
});
if (isSaveable && lastKnownDoc) {
setState(s => ({ ...s, isSaveModalVisible: true }));
}
},
testId: 'lnsApp_saveButton',
Expand Down Expand Up @@ -278,10 +256,8 @@ export function App({
doc: state.persistedDoc,
onError,
onChange: ({ filterableIndexPatterns, doc }) => {
lastKnownDocRef.current = doc;

if (!_.isEqual(state.persistedDoc, doc)) {
setState(s => ({ ...s, isDirty: true }));
setState(s => ({ ...s, lastKnownDoc: doc }));
}

// Update the cached index patterns if the user made a change to any of them
Expand All @@ -307,6 +283,48 @@ export function App({
/>
)}
</div>
{lastKnownDoc && state.isSaveModalVisible && (
<SavedObjectSaveModal
onSave={props => {
const doc = {
...lastKnownDoc,
id: props.newCopyOnSave ? undefined : lastKnownDoc.id,
title: props.newTitle,
};

docStorage
.save(doc)
.then(({ id }) => {
// Prevents unnecessary network request and disables save button
const newDoc = { ...doc, id };
setState(s => ({
...s,
isSaveModalVisible: false,
persistedDoc: newDoc,
lastKnownDoc: newDoc,
}));

if (docId !== id) {
redirectTo(id);
}
})
.catch(() => {
core.notifications.toasts.addDanger(
i18n.translate('xpack.lens.app.docSavingError', {
defaultMessage: 'Error saving document',
})
);
setState(s => ({ ...s, isSaveModalVisible: false }));
});
}}
onClose={() => setState(s => ({ ...s, isSaveModalVisible: false }))}
title={lastKnownDoc.title || ''}
showCopyOnSave={true}
objectType={i18n.translate('xpack.lens.app.saveModalType', {
defaultMessage: 'Lens visualization',
})}
/>
)}
</KibanaContextProvider>
</I18nProvider>
);
Expand All @@ -321,7 +339,7 @@ export async function getAllIndexPatterns(
return await Promise.all(ids.map(({ id }) => indexPatternsService.get(id)));
} catch (e) {
notifications.toasts.addDanger(
i18n.translate('xpack.lens.editorFrame.indexPatternLoadingError', {
i18n.translate('xpack.lens.app.indexPatternLoadingError', {
defaultMessage: 'Error loading index patterns',
})
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
flex-direction: column;

.lnsWorkspacePanelWrapper__pageContentHeader {
padding: $euiSizeS;
@include euiTitle('xs');
padding: $euiSizeM;
border-bottom: $euiBorderThin;
// override EuiPage
margin-bottom: 0 !important; // sass-lint:disable-line no-important
Expand All @@ -31,11 +32,3 @@
}
}
}

.lnsWorkspacePanelWrapper__titleInput {
@include euiTitle('xs');
width: 100%;
max-width: none;
background: transparent;
box-shadow: none;
}
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ export function EditorFrame(props: EditorFrameProps) {
}
workspacePanel={
allLoaded && (
<WorkspacePanelWrapper title={state.title} dispatch={dispatch}>
<WorkspacePanelWrapper title={state.title}>
<WorkspacePanel
activeDatasourceId={state.activeDatasourceId}
activeVisualizationId={state.visualization.activeId}
Expand Down
Loading

0 comments on commit 2c02458

Please sign in to comment.