[Dashboard] Modify state shared in dashboard permalinks (#141985)

* Remove unnecessary global state from share URL

* Clean up

* Add functional tests

* Fix functional tests

* Undo removal of time range from global state in URL

* Clean up code

* Clean up functional tests

* Add warning when snapshot sharing with unsaved panel changes

* Modify how error is passed down

* Fix flakiness of new functional test suite

* Update snapshots + clean up imports

* Change wording of warning + colour of text

* Address first round of feedback

* Switch error state to button
This commit is contained in:
Hannah Mudge 2022-10-17 10:52:24 -06:00 committed by GitHub
parent 6e5f13740c
commit 1eed5311c7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 354 additions and 171 deletions

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { cloneDeep, omit } from 'lodash';
import { cloneDeep } from 'lodash';
import type { KibanaExecutionContext } from '@kbn/core/public';
import { mapAndFlattenFilters } from '@kbn/data-plugin/public';
@ -26,7 +26,7 @@ interface StateToDashboardContainerInputProps {
}
interface StateToRawDashboardStateProps {
state: DashboardState;
state: Partial<DashboardState>;
}
/**
@ -102,13 +102,15 @@ const filtersAreEqual = (first: Filter, second: Filter) =>
*/
export const stateToRawDashboardState = ({
state,
}: StateToRawDashboardStateProps): RawDashboardState => {
}: StateToRawDashboardStateProps): Partial<RawDashboardState> => {
const {
initializerContext: { kibanaVersion },
} = pluginServices.getServices();
const savedDashboardPanels = Object.values(state.panels).map((panel) =>
convertPanelStateToSavedDashboardPanel(panel, kibanaVersion)
);
return { ...omit(state, 'panels'), panels: savedDashboardPanels };
const savedDashboardPanels = state?.panels
? Object.values(state.panels).map((panel) =>
convertPanelStateToSavedDashboardPanel(panel, kibanaVersion)
)
: undefined;
return { ...state, panels: savedDashboardPanels };
};

View file

@ -8,11 +8,14 @@
import moment from 'moment';
import React, { ReactElement, useState } from 'react';
import { omit } from 'lodash';
import { i18n } from '@kbn/i18n';
import { EuiCheckboxGroup } from '@elastic/eui';
import { QueryState } from '@kbn/data-plugin/common';
import type { Capabilities } from '@kbn/core/public';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { getStateFromKbnUrl } from '@kbn/kibana-utils-plugin/public';
import { setStateToKbnUrl, unhashUrl } from '@kbn/kibana-utils-plugin/public';
import type { SerializableControlGroupInput } from '@kbn/controls-plugin/common';
@ -21,8 +24,8 @@ import { dashboardUrlParams } from '../dashboard_router';
import { shareModalStrings } from '../../dashboard_strings';
import { convertPanelMapToSavedPanels } from '../../../common';
import { pluginServices } from '../../services/plugin_services';
import { stateToRawDashboardState } from '../lib/convert_dashboard_state';
import { DashboardAppLocatorParams, DASHBOARD_APP_LOCATOR } from '../../locator';
import { stateToRawDashboardState } from '../lib/convert_dashboard_state';
const showFilterBarId = 'showFilterBar';
@ -55,8 +58,8 @@ export function ShowShareModal({
},
},
},
share: { toggleShareContextMenu },
initializerContext: { kibanaVersion },
share: { toggleShareContextMenu },
} = pluginServices.getServices();
if (!toggleShareContextMenu) return; // TODO: Make this logic cleaner once share is an optional service
@ -151,19 +154,25 @@ export function ShowShareModal({
...unsavedStateForLocator,
};
let _g = getStateFromKbnUrl<QueryState>('_g', window.location.href);
if (_g?.filters && _g.filters.length === 0) {
_g = omit(_g, 'filters');
}
const baseUrl = setStateToKbnUrl('_g', _g);
const shareableUrl = setStateToKbnUrl(
'_a',
stateToRawDashboardState({ state: unsavedDashboardState ?? {} }),
{ useHash: false, storeInHashQuery: true },
unhashUrl(baseUrl)
);
toggleShareContextMenu({
isDirty,
anchorElement,
allowEmbed: true,
allowShortUrl,
shareableUrl: setStateToKbnUrl(
'_a',
stateToRawDashboardState({
state: currentDashboardState,
}),
{ useHash: false, storeInHashQuery: true },
unhashUrl(window.location.href)
),
shareableUrl,
objectId: savedObjectId,
objectType: 'dashboard',
sharingData: {
@ -185,5 +194,8 @@ export function ShowShareModal({
},
],
showPublicUrlSwitch,
snapshotShareWarning: Boolean(unsavedDashboardState?.panels)
? shareModalStrings.getSnapshotShareWarning()
: undefined,
});
}

View file

@ -278,6 +278,11 @@ export const shareModalStrings = {
i18n.translate('dashboard.embedUrlParamExtension.include', {
defaultMessage: 'Include',
}),
getSnapshotShareWarning: () =>
i18n.translate('dashboard.snapshotShare.longUrlWarning', {
defaultMessage:
'One or more panels on this dashboard have changed. Before you generate a snapshot, save the dashboard.',
}),
};
export const leaveConfirmStrings = {

View file

@ -42,38 +42,40 @@ exports[`share url panel content render 1`] = `
Object {
"data-test-subj": "exportAsSnapshot",
"id": "snapshot",
"label": <EuiFlexGroup
gutterSize="none"
responsive={false}
>
<EuiFlexItem
grow={false}
"label": <React.Fragment>
<EuiFlexGroup
gutterSize="none"
responsive={false}
>
<FormattedMessage
defaultMessage="Snapshot"
id="share.urlPanel.snapshotLabel"
values={Object {}}
/>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiIconTip
content={
<FormattedMessage
defaultMessage="Snapshot URLs encode the current state of the {objectType} in the URL itself. Edits to the saved {objectType} won't be visible via this URL."
id="share.urlPanel.snapshotDescription"
values={
Object {
"objectType": "dashboard",
<EuiFlexItem
grow={false}
>
<FormattedMessage
defaultMessage="Snapshot"
id="share.urlPanel.snapshotLabel"
values={Object {}}
/>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiIconTip
content={
<FormattedMessage
defaultMessage="Snapshot URLs encode the current state of the {objectType} in the URL itself. Edits to the saved {objectType} won't be visible via this URL."
id="share.urlPanel.snapshotDescription"
values={
Object {
"objectType": "dashboard",
}
}
}
/>
}
position="bottom"
/>
</EuiFlexItem>
</EuiFlexGroup>,
/>
}
position="bottom"
/>
</EuiFlexItem>
</EuiFlexGroup>
</React.Fragment>,
},
Object {
"data-test-subj": "exportAsSavedObject",
@ -225,38 +227,40 @@ exports[`share url panel content should enable saved object export option when o
Object {
"data-test-subj": "exportAsSnapshot",
"id": "snapshot",
"label": <EuiFlexGroup
gutterSize="none"
responsive={false}
>
<EuiFlexItem
grow={false}
"label": <React.Fragment>
<EuiFlexGroup
gutterSize="none"
responsive={false}
>
<FormattedMessage
defaultMessage="Snapshot"
id="share.urlPanel.snapshotLabel"
values={Object {}}
/>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiIconTip
content={
<FormattedMessage
defaultMessage="Snapshot URLs encode the current state of the {objectType} in the URL itself. Edits to the saved {objectType} won't be visible via this URL."
id="share.urlPanel.snapshotDescription"
values={
Object {
"objectType": "dashboard",
<EuiFlexItem
grow={false}
>
<FormattedMessage
defaultMessage="Snapshot"
id="share.urlPanel.snapshotLabel"
values={Object {}}
/>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiIconTip
content={
<FormattedMessage
defaultMessage="Snapshot URLs encode the current state of the {objectType} in the URL itself. Edits to the saved {objectType} won't be visible via this URL."
id="share.urlPanel.snapshotDescription"
values={
Object {
"objectType": "dashboard",
}
}
}
/>
}
position="bottom"
/>
</EuiFlexItem>
</EuiFlexGroup>,
/>
}
position="bottom"
/>
</EuiFlexItem>
</EuiFlexGroup>
</React.Fragment>,
},
Object {
"data-test-subj": "exportAsSavedObject",
@ -408,38 +412,40 @@ exports[`share url panel content should hide short url section when allowShortUr
Object {
"data-test-subj": "exportAsSnapshot",
"id": "snapshot",
"label": <EuiFlexGroup
gutterSize="none"
responsive={false}
>
<EuiFlexItem
grow={false}
"label": <React.Fragment>
<EuiFlexGroup
gutterSize="none"
responsive={false}
>
<FormattedMessage
defaultMessage="Snapshot"
id="share.urlPanel.snapshotLabel"
values={Object {}}
/>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiIconTip
content={
<FormattedMessage
defaultMessage="Snapshot URLs encode the current state of the {objectType} in the URL itself. Edits to the saved {objectType} won't be visible via this URL."
id="share.urlPanel.snapshotDescription"
values={
Object {
"objectType": "dashboard",
<EuiFlexItem
grow={false}
>
<FormattedMessage
defaultMessage="Snapshot"
id="share.urlPanel.snapshotLabel"
values={Object {}}
/>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiIconTip
content={
<FormattedMessage
defaultMessage="Snapshot URLs encode the current state of the {objectType} in the URL itself. Edits to the saved {objectType} won't be visible via this URL."
id="share.urlPanel.snapshotDescription"
values={
Object {
"objectType": "dashboard",
}
}
}
/>
}
position="bottom"
/>
</EuiFlexItem>
</EuiFlexGroup>,
/>
}
position="bottom"
/>
</EuiFlexItem>
</EuiFlexGroup>
</React.Fragment>,
},
Object {
"data-test-subj": "exportAsSavedObject",
@ -527,38 +533,40 @@ exports[`should show url param extensions 1`] = `
Object {
"data-test-subj": "exportAsSnapshot",
"id": "snapshot",
"label": <EuiFlexGroup
gutterSize="none"
responsive={false}
>
<EuiFlexItem
grow={false}
"label": <React.Fragment>
<EuiFlexGroup
gutterSize="none"
responsive={false}
>
<FormattedMessage
defaultMessage="Snapshot"
id="share.urlPanel.snapshotLabel"
values={Object {}}
/>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiIconTip
content={
<FormattedMessage
defaultMessage="Snapshot URLs encode the current state of the {objectType} in the URL itself. Edits to the saved {objectType} won't be visible via this URL."
id="share.urlPanel.snapshotDescription"
values={
Object {
"objectType": "dashboard",
<EuiFlexItem
grow={false}
>
<FormattedMessage
defaultMessage="Snapshot"
id="share.urlPanel.snapshotLabel"
values={Object {}}
/>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiIconTip
content={
<FormattedMessage
defaultMessage="Snapshot URLs encode the current state of the {objectType} in the URL itself. Edits to the saved {objectType} won't be visible via this URL."
id="share.urlPanel.snapshotDescription"
values={
Object {
"objectType": "dashboard",
}
}
}
/>
}
position="bottom"
/>
</EuiFlexItem>
</EuiFlexGroup>,
/>
}
position="bottom"
/>
</EuiFlexItem>
</EuiFlexGroup>
</React.Fragment>,
},
Object {
"data-test-subj": "exportAsSavedObject",

View file

@ -32,6 +32,7 @@ export interface ShareContextMenuProps {
anonymousAccess?: AnonymousAccessServiceContract;
showPublicUrlSwitch?: (anonymousUserCapabilities: Capabilities) => boolean;
urlService: BrowserUrlService;
snapshotShareWarning?: string;
}
export class ShareContextMenu extends Component<ShareContextMenuProps> {
@ -66,6 +67,7 @@ export class ShareContextMenu extends Component<ShareContextMenuProps> {
anonymousAccess={this.props.anonymousAccess}
showPublicUrlSwitch={this.props.showPublicUrlSwitch}
urlService={this.props.urlService}
snapshotShareWarning={this.props.snapshotShareWarning}
/>
),
};
@ -96,6 +98,7 @@ export class ShareContextMenu extends Component<ShareContextMenuProps> {
anonymousAccess={this.props.anonymousAccess}
showPublicUrlSwitch={this.props.showPublicUrlSwitch}
urlService={this.props.urlService}
snapshotShareWarning={this.props.snapshotShareWarning}
/>
),
};

View file

@ -20,6 +20,7 @@ import {
EuiRadioGroup,
EuiSwitch,
EuiSwitchEvent,
EuiToolTip,
} from '@elastic/eui';
import { format as formatUrl, parse as parseUrl } from 'url';
@ -45,6 +46,7 @@ export interface UrlPanelContentProps {
anonymousAccess?: AnonymousAccessServiceContract;
showPublicUrlSwitch?: (anonymousUserCapabilities: Capabilities) => boolean;
urlService: BrowserUrlService;
snapshotShareWarning?: string;
}
export enum ExportUrlAsType {
@ -78,7 +80,6 @@ export class UrlPanelContent extends Component<UrlPanelContentProps, State> {
super(props);
this.shortUrlCache = undefined;
this.state = {
exportUrlAs: ExportUrlAsType.EXPORT_URL_AS_SNAPSHOT,
useShortUrl: false,
@ -155,6 +156,33 @@ export class UrlPanelContent extends Component<UrlPanelContentProps, State> {
</EuiFormRow>
);
const showWarningButton =
this.props.snapshotShareWarning &&
this.state.exportUrlAs === ExportUrlAsType.EXPORT_URL_AS_SNAPSHOT;
const copyButton = (copy: () => void) => (
<EuiButton
fill
fullWidth
onClick={copy}
disabled={this.state.isCreatingShortUrl || this.state.url === ''}
data-share-url={this.state.url}
data-test-subj="copyShareUrlButton"
size="s"
iconType={showWarningButton ? 'alert' : undefined}
color={showWarningButton ? 'warning' : 'primary'}
>
{this.props.isEmbedded ? (
<FormattedMessage
id="share.urlPanel.copyIframeCodeButtonLabel"
defaultMessage="Copy iFrame code"
/>
) : (
<FormattedMessage id="share.urlPanel.copyLinkButtonLabel" defaultMessage="Copy link" />
)}
</EuiButton>
);
return (
<I18nProvider>
<EuiForm className="kbnShareContextMenu__finalPanel" data-test-subj="shareUrlForm">
@ -166,27 +194,19 @@ export class UrlPanelContent extends Component<UrlPanelContentProps, State> {
<EuiCopy textToCopy={this.state.url || ''} anchorClassName="eui-displayBlock">
{(copy: () => void) => (
<EuiButton
fill
fullWidth
onClick={copy}
disabled={this.state.isCreatingShortUrl || this.state.url === ''}
data-share-url={this.state.url}
data-test-subj="copyShareUrlButton"
size="s"
>
{this.props.isEmbedded ? (
<FormattedMessage
id="share.urlPanel.copyIframeCodeButtonLabel"
defaultMessage="Copy iFrame code"
/>
<>
{showWarningButton ? (
<EuiToolTip
position="bottom"
content={this.props.snapshotShareWarning}
display="block"
>
{copyButton(copy)}
</EuiToolTip>
) : (
<FormattedMessage
id="share.urlPanel.copyLinkButtonLabel"
defaultMessage="Copy link"
/>
copyButton(copy)
)}
</EuiButton>
</>
)}
</EuiCopy>
</EuiForm>
@ -246,13 +266,11 @@ export class UrlPanelContent extends Component<UrlPanelContentProps, State> {
},
}),
});
return this.updateUrlParams(formattedUrl);
};
private getSnapshotUrl = () => {
const url = this.props.shareableUrl || window.location.href;
return this.updateUrlParams(url);
};
@ -404,17 +422,24 @@ export class UrlPanelContent extends Component<UrlPanelContentProps, State> {
};
private renderExportUrlAsOptions = () => {
const snapshotLabel = (
<FormattedMessage id="share.urlPanel.snapshotLabel" defaultMessage="Snapshot" />
);
return [
{
id: ExportUrlAsType.EXPORT_URL_AS_SNAPSHOT,
label: this.renderWithIconTip(
<FormattedMessage id="share.urlPanel.snapshotLabel" defaultMessage="Snapshot" />,
<FormattedMessage
id="share.urlPanel.snapshotDescription"
defaultMessage="Snapshot URLs encode the current state of the {objectType} in the URL itself.
label: (
<>
{this.renderWithIconTip(
snapshotLabel,
<FormattedMessage
id="share.urlPanel.snapshotDescription"
defaultMessage="Snapshot URLs encode the current state of the {objectType} in the URL itself.
Edits to the saved {objectType} won't be visible via this URL."
values={{ objectType: this.props.objectType }}
/>
values={{ objectType: this.props.objectType }}
/>
)}
</>
),
['data-test-subj']: 'exportAsSnapshot',
},

View file

@ -74,6 +74,7 @@ export class ShareMenuManager {
showPublicUrlSwitch,
urlService,
anonymousAccess,
snapshotShareWarning,
onClose,
}: ShowShareMenuOptions & {
menuItems: ShareMenuItem[];
@ -114,6 +115,7 @@ export class ShareMenuManager {
anonymousAccess={anonymousAccess}
showPublicUrlSwitch={showPublicUrlSwitch}
urlService={urlService}
snapshotShareWarning={snapshotShareWarning}
/>
</EuiWrappingPopover>
</KibanaThemeProvider>

View file

@ -97,5 +97,6 @@ export interface ShowShareMenuOptions extends Omit<ShareContext, 'onClose'> {
allowEmbed: boolean;
allowShortUrl: boolean;
embedUrlParamExtensions?: UrlParamExtension[];
snapshotShareWarning?: string;
onClose?: () => void;
}

View file

@ -9,11 +9,94 @@
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
type TestingModes = 'snapshot' | 'savedObject';
type AppState = string | undefined;
interface UrlState {
globalState: string;
appState: AppState;
}
const getStateFromUrl = (url: string): UrlState => {
const globalStateStart = url.indexOf('_g');
const appStateStart = url.indexOf('_a');
// global state is always part of the URL, but app state is *not* - so, need to
// modify the logic depending on whether app state exists or not
if (appStateStart === -1) {
return {
globalState: url.substring(globalStateStart + 3),
appState: undefined,
};
}
return {
globalState: url.substring(globalStateStart + 3, appStateStart - 1),
appState: url.substring(appStateStart + 3, url.length),
};
};
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const retry = getService('retry');
const filterBar = getService('filterBar');
const kibanaServer = getService('kibanaServer');
const PageObjects = getPageObjects(['dashboard', 'common', 'share']);
const testSubjects = getService('testSubjects');
const dashboardPanelActions = getService('dashboardPanelActions');
const PageObjects = getPageObjects(['dashboard', 'common', 'share', 'timePicker']);
const getSharedUrl = async (mode: TestingModes): Promise<string> => {
await retry.waitFor('share menu to open', async () => {
await PageObjects.share.clickShareTopNavButton();
return await PageObjects.share.isShareMenuOpen();
});
if (mode === 'savedObject') {
await PageObjects.share.exportAsSavedObject();
}
const sharedUrl = await PageObjects.share.getSharedUrl();
return sharedUrl;
};
describe('share dashboard', () => {
const testFilterState = async (mode: TestingModes) => {
it('should not have "filters" state in either app or global state when no filters', async () => {
expect(await getSharedUrl(mode)).to.not.contain('filters');
});
it('unpinned filter should show up only in app state when dashboard is unsaved', async () => {
await filterBar.addFilter('geo.src', 'is', 'AE');
await PageObjects.dashboard.waitForRenderComplete();
const sharedUrl = await getSharedUrl(mode);
const { globalState, appState } = getStateFromUrl(sharedUrl);
expect(globalState).to.not.contain('filters');
if (mode === 'snapshot') {
expect(appState).to.contain('filters');
} else {
expect(sharedUrl).to.not.contain('appState');
}
});
it('unpinned filters should be removed from app state when dashboard is saved', async () => {
await PageObjects.dashboard.clickQuickSave();
await PageObjects.dashboard.waitForRenderComplete();
const sharedUrl = await getSharedUrl(mode);
expect(sharedUrl).to.not.contain('appState');
});
it('pinned filter should show up only in global state', async () => {
await filterBar.toggleFilterPinned('geo.src');
await PageObjects.dashboard.clickQuickSave();
await PageObjects.dashboard.waitForRenderComplete();
const sharedUrl = await getSharedUrl(mode);
const { globalState, appState } = getStateFromUrl(sharedUrl);
expect(globalState).to.contain('filters');
if (mode === 'snapshot') {
expect(appState).to.not.contain('filters');
}
});
};
before(async () => {
await kibanaServer.savedObjects.cleanStandardList();
await kibanaServer.importExport.load(
@ -25,16 +108,58 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.common.navigateToApp('dashboard');
await PageObjects.dashboard.preserveCrossAppState();
await PageObjects.dashboard.loadSavedDashboard('few panels');
await PageObjects.dashboard.switchToEditMode();
const from = 'Sep 19, 2017 @ 06:31:44.000';
const to = 'Sep 23, 2018 @ 18:31:44.000';
await PageObjects.timePicker.setAbsoluteRange(from, to);
await PageObjects.dashboard.waitForRenderComplete();
});
after(async () => {
await kibanaServer.savedObjects.cleanStandardList();
});
it('has "panels" state when sharing a snapshot', async () => {
await PageObjects.share.clickShareTopNavButton();
const sharedUrl = await PageObjects.share.getSharedUrl();
expect(sharedUrl).to.contain('panels');
describe('snapshot share', async () => {
describe('test local state', async () => {
it('should not have "panels" state when not in unsaved changes state', async () => {
await testSubjects.missingOrFail('dashboardUnsavedChangesBadge');
expect(await getSharedUrl('snapshot')).to.not.contain('panels');
});
it('should have "panels" in app state when a panel has been modified', async () => {
await dashboardPanelActions.setCustomPanelTitle('Test New Title');
await PageObjects.dashboard.waitForRenderComplete();
await testSubjects.existOrFail('dashboardUnsavedChangesBadge');
const sharedUrl = await getSharedUrl('snapshot');
const { appState } = getStateFromUrl(sharedUrl);
expect(appState).to.contain('panels');
});
it('should once again not have "panels" state when save is clicked', async () => {
await PageObjects.dashboard.clickQuickSave();
await PageObjects.dashboard.waitForRenderComplete();
await testSubjects.missingOrFail('dashboardUnsavedChangesBadge');
expect(await getSharedUrl('snapshot')).to.not.contain('panels');
});
});
describe('test filter state', async () => {
await testFilterState('snapshot');
});
after(async () => {
await filterBar.removeAllFilters();
await PageObjects.dashboard.clickQuickSave();
await PageObjects.dashboard.waitForRenderComplete();
});
});
describe('saved object share', async () => {
describe('test filter state', async () => {
await testFilterState('savedObject');
});
});
});
}