Skip to content

Commit

Permalink
[Lens] Warn if leaving with unsaved visualization (#67689)
Browse files Browse the repository at this point in the history
* [Lens] Warn if leaving with unsaved visualization

* Made confirmation logic more robust and add title

Co-authored-by: Elastic Machine <[email protected]>
  • Loading branch information
Wylie Conlon and elasticmachine authored Jun 1, 2020
1 parent add5b11 commit b061d85
Show file tree
Hide file tree
Showing 3 changed files with 208 additions and 66 deletions.
216 changes: 161 additions & 55 deletions x-pack/plugins/lens/public/app_plugin/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ReactWrapper } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { App } from './app';
import { EditorFrameInstance } from '../types';
import { AppMountParameters } from 'kibana/public';
import { Storage } from '../../../../../src/plugins/kibana_utils/public';
import { Document, SavedObjectStore } from '../persistence';
import { mount } from 'enzyme';
Expand Down Expand Up @@ -111,6 +112,7 @@ describe('Lens App', () => {
newlyCreated?: boolean
) => void;
originatingApp: string | undefined;
onAppLeave: AppMountParameters['onAppLeave'];
}> {
return ({
navigation: navigationStartMock,
Expand Down Expand Up @@ -153,6 +155,7 @@ describe('Lens App', () => {
newlyCreated?: boolean
) => {}
),
onAppLeave: jest.fn(),
} as unknown) as jest.Mocked<{
navigation: typeof navigationStartMock;
editorFrame: EditorFrameInstance;
Expand All @@ -168,6 +171,7 @@ describe('Lens App', () => {
newlyCreated?: boolean
) => void;
originatingApp: string | undefined;
onAppLeave: AppMountParameters['onAppLeave'];
}>;
}

Expand Down Expand Up @@ -357,22 +361,7 @@ describe('Lens App', () => {
newTitle: string;
}

let defaultArgs: jest.Mocked<{
editorFrame: EditorFrameInstance;
navigation: typeof navigationStartMock;
data: typeof dataStartMock;
core: typeof core;
storage: Storage;
docId?: string;
docStorage: SavedObjectStore;
redirectTo: (
id?: string,
returnToOrigin?: boolean,
originatingApp?: string | undefined,
newlyCreated?: boolean
) => void;
originatingApp: string | undefined;
}>;
let defaultArgs: ReturnType<typeof makeDefaultArgs>;

beforeEach(() => {
defaultArgs = makeDefaultArgs();
Expand Down Expand Up @@ -486,30 +475,6 @@ describe('Lens App', () => {
expect(getButton(instance).disableButton).toEqual(true);
});

it('shows a disabled save button when there are no changes to the document', async () => {
const args = defaultArgs;
(args.docStorage.load as jest.Mock).mockResolvedValue({
id: '1234',
title: 'My cool doc',
expression: '',
} as jest.ResolvedValue<Document>);
args.editorFrame = frame;

instance = mount(<App {...args} />);
expect(getButton(instance).disableButton).toEqual(true);

const onChange = frame.mount.mock.calls[0][1].onChange;

act(() => {
onChange({
filterableIndexPatterns: [],
doc: ({ id: '1234', expression: 'valid expression' } as unknown) as Document,
});
});
instance.update();
expect(getButton(instance).disableButton).toEqual(false);
});

it('shows a save button that is enabled when the frame has provided its state', async () => {
const args = defaultArgs;
args.editorFrame = frame;
Expand Down Expand Up @@ -691,21 +656,7 @@ describe('Lens App', () => {
});

describe('query bar state management', () => {
let defaultArgs: jest.Mocked<{
editorFrame: EditorFrameInstance;
data: typeof dataStartMock;
navigation: typeof navigationStartMock;
core: typeof core;
storage: Storage;
docId?: string;
docStorage: SavedObjectStore;
redirectTo: (
id?: string,
returnToOrigin?: boolean,
originatingApp?: string | undefined,
newlyCreated?: boolean
) => void;
}>;
let defaultArgs: ReturnType<typeof makeDefaultArgs>;

beforeEach(() => {
defaultArgs = makeDefaultArgs();
Expand Down Expand Up @@ -1001,4 +952,159 @@ describe('Lens App', () => {

expect(args.core.notifications.toasts.addDanger).toHaveBeenCalled();
});

describe('showing a confirm message when leaving', () => {
let defaultArgs: ReturnType<typeof makeDefaultArgs>;
let defaultLeave: jest.Mock;
let confirmLeave: jest.Mock;

beforeEach(() => {
defaultArgs = makeDefaultArgs();
defaultLeave = jest.fn();
confirmLeave = jest.fn();
(defaultArgs.docStorage.load as jest.Mock).mockResolvedValue({
id: '1234',
title: 'My cool doc',
expression: 'valid expression',
state: {
query: 'kuery',
datasourceMetaData: { filterableIndexPatterns: [{ id: '1', title: 'saved' }] },
},
} as jest.ResolvedValue<Document>);
});

it('should not show a confirm message if there is no expression to save', () => {
instance = mount(<App {...defaultArgs} />);

const lastCall =
defaultArgs.onAppLeave.mock.calls[defaultArgs.onAppLeave.mock.calls.length - 1][0];
lastCall({ default: defaultLeave, confirm: confirmLeave });

expect(defaultLeave).toHaveBeenCalled();
expect(confirmLeave).not.toHaveBeenCalled();
});

it('does not confirm if the user is missing save permissions', () => {
const args = defaultArgs;
args.core.application = {
...args.core.application,
capabilities: {
...args.core.application.capabilities,
visualize: { save: false, saveQuery: false, show: true },
},
};
args.editorFrame = frame;

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

const onChange = frame.mount.mock.calls[0][1].onChange;
act(() =>
onChange({
filterableIndexPatterns: [],
doc: ({ id: undefined, expression: 'valid expression' } as unknown) as Document,
})
);
instance.update();

const lastCall =
defaultArgs.onAppLeave.mock.calls[defaultArgs.onAppLeave.mock.calls.length - 1][0];
lastCall({ default: defaultLeave, confirm: confirmLeave });

expect(defaultLeave).toHaveBeenCalled();
expect(confirmLeave).not.toHaveBeenCalled();
});

it('should confirm when leaving with an unsaved doc', () => {
defaultArgs.editorFrame = frame;
instance = mount(<App {...defaultArgs} />);

const onChange = frame.mount.mock.calls[0][1].onChange;
act(() =>
onChange({
filterableIndexPatterns: [],
doc: ({ id: undefined, expression: 'valid expression' } as unknown) as Document,
})
);
instance.update();

const lastCall =
defaultArgs.onAppLeave.mock.calls[defaultArgs.onAppLeave.mock.calls.length - 1][0];
lastCall({ default: defaultLeave, confirm: confirmLeave });

expect(confirmLeave).toHaveBeenCalled();
expect(defaultLeave).not.toHaveBeenCalled();
});

it('should confirm when leaving with unsaved changes to an existing doc', async () => {
defaultArgs.editorFrame = frame;
instance = mount(<App {...defaultArgs} />);
await act(async () => {
instance.setProps({ docId: '1234' });
});

const onChange = frame.mount.mock.calls[0][1].onChange;
act(() =>
onChange({
filterableIndexPatterns: [],
doc: ({ id: '1234', expression: 'different expression' } as unknown) as Document,
})
);
instance.update();

const lastCall =
defaultArgs.onAppLeave.mock.calls[defaultArgs.onAppLeave.mock.calls.length - 1][0];
lastCall({ default: defaultLeave, confirm: confirmLeave });

expect(confirmLeave).toHaveBeenCalled();
expect(defaultLeave).not.toHaveBeenCalled();
});

it('should not confirm when changes are saved', async () => {
defaultArgs.editorFrame = frame;
instance = mount(<App {...defaultArgs} />);
await act(async () => {
instance.setProps({ docId: '1234' });
});

const onChange = frame.mount.mock.calls[0][1].onChange;
act(() =>
onChange({
filterableIndexPatterns: [],
doc: ({ id: '1234', expression: 'valid expression' } as unknown) as Document,
})
);
instance.update();

const lastCall =
defaultArgs.onAppLeave.mock.calls[defaultArgs.onAppLeave.mock.calls.length - 1][0];
lastCall({ default: defaultLeave, confirm: confirmLeave });

expect(defaultLeave).toHaveBeenCalled();
expect(confirmLeave).not.toHaveBeenCalled();
});

it('should confirm when the latest doc is invalid', async () => {
defaultArgs.editorFrame = frame;
instance = mount(<App {...defaultArgs} />);
await act(async () => {
instance.setProps({ docId: '1234' });
});

const onChange = frame.mount.mock.calls[0][1].onChange;
act(() =>
onChange({
filterableIndexPatterns: [],
doc: ({ id: '1234', expression: null } as unknown) as Document,
})
);
instance.update();

const lastCall =
defaultArgs.onAppLeave.mock.calls[defaultArgs.onAppLeave.mock.calls.length - 1][0];
lastCall({ default: defaultLeave, confirm: confirmLeave });

expect(confirmLeave).toHaveBeenCalled();
expect(defaultLeave).not.toHaveBeenCalled();
});
});
});
57 changes: 46 additions & 11 deletions x-pack/plugins/lens/public/app_plugin/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { I18nProvider } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { Query, DataPublicPluginStart } from 'src/plugins/data/public';
import { NavigationPublicPluginStart } from 'src/plugins/navigation/public';
import { AppMountContext, NotificationsStart } from 'kibana/public';
import { AppMountContext, AppMountParameters, NotificationsStart } from 'kibana/public';
import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public';
import {
Expand Down Expand Up @@ -57,6 +57,7 @@ export function App({
redirectTo,
originatingAppFromUrl,
navigation,
onAppLeave,
}: {
editorFrame: EditorFrameInstance;
data: DataPublicPluginStart;
Expand All @@ -72,6 +73,7 @@ export function App({
newlyCreated?: boolean
) => void;
originatingAppFromUrl?: string | undefined;
onAppLeave: AppMountParameters['onAppLeave'];
}) {
const language =
storage.get('kibana.userQueryLanguage') || core.uiSettings.get('search:queryLanguage');
Expand All @@ -94,6 +96,12 @@ export function App({

const { lastKnownDoc } = state;

const isSaveable =
lastKnownDoc &&
lastKnownDoc.expression &&
lastKnownDoc.expression.length > 0 &&
core.application.capabilities.visualize.save;

useEffect(() => {
// Clear app-specific filters when navigating to Lens. Necessary because Lens
// can be loaded without a full page refresh
Expand Down Expand Up @@ -123,7 +131,31 @@ export function App({
filterSubscription.unsubscribe();
timeSubscription.unsubscribe();
};
}, []);
}, [data.query.filterManager, data.query.timefilter.timefilter]);

useEffect(() => {
onAppLeave((actions) => {
// Confirm when the user has made any changes to an existing doc
// or when the user has configured something without saving
if (
core.application.capabilities.visualize.save &&
(state.persistedDoc?.expression
? !_.isEqual(lastKnownDoc?.expression, state.persistedDoc.expression)
: lastKnownDoc?.expression)
) {
return actions.confirm(
i18n.translate('xpack.lens.app.unsavedWorkMessage', {
defaultMessage: 'Leave Lens with unsaved work?',
}),
i18n.translate('xpack.lens.app.unsavedWorkTitle', {
defaultMessage: 'Unsaved changes',
})
);
} else {
return actions.default();
}
});
}, [lastKnownDoc, onAppLeave, state.persistedDoc, core.application.capabilities.visualize.save]);

// Sync Kibana breadcrumbs any time the saved document's title changes
useEffect(() => {
Expand All @@ -144,7 +176,7 @@ export function App({
: i18n.translate('xpack.lens.breadcrumbsCreate', { defaultMessage: 'Create' }),
},
]);
}, [state.persistedDoc && state.persistedDoc.title]);
}, [core.application, core.chrome, core.http.basePath, state.persistedDoc]);

useEffect(() => {
if (docId && (!state.persistedDoc || state.persistedDoc.id !== docId)) {
Expand Down Expand Up @@ -187,13 +219,16 @@ export function App({
redirectTo();
});
}
}, [docId]);

const isSaveable =
lastKnownDoc &&
lastKnownDoc.expression &&
lastKnownDoc.expression.length > 0 &&
core.application.capabilities.visualize.save;
}, [
core.notifications,
data.indexPatterns,
data.query.filterManager,
docId,
// TODO: These dependencies are changing too often
// docStorage,
// redirectTo,
// state.persistedDoc,
]);

const runSave = (
saveProps: Omit<OnSaveProps, 'onTitleDuplicate' | 'newDescription'> & {
Expand Down Expand Up @@ -257,7 +292,7 @@ export function App({
core.notifications.toasts.addDanger({
title: e.message,
}),
[]
[core.notifications.toasts]
);

const { TopNavMenu } = navigation.ui;
Expand Down
1 change: 1 addition & 0 deletions x-pack/plugins/lens/public/app_plugin/mounter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export async function mountApp(
redirectTo(routeProps, id, returnToOrigin, originatingApp, newlyCreated)
}
originatingAppFromUrl={originatingAppFromUrl}
onAppLeave={params.onAppLeave}
/>
);
};
Expand Down

0 comments on commit b061d85

Please sign in to comment.