Skip to content

Commit

Permalink
Dashboard url generator to preserve saved filters from destination da…
Browse files Browse the repository at this point in the history
…shboard (#64767) (#64875)

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
  • Loading branch information
Dosant and elasticmachine committed Apr 30, 2020
1 parent a49aeee commit e5b9edd
Show file tree
Hide file tree
Showing 3 changed files with 244 additions and 14 deletions.
12 changes: 8 additions & 4 deletions src/plugins/dashboard/public/plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,14 @@ export class DashboardPlugin

if (share) {
share.urlGenerators.registerUrlGenerator(
createDirectAccessDashboardLinkGenerator(async () => ({
appBasePath: (await startServices)[0].application.getUrlForApp('dashboard'),
useHashedUrl: (await startServices)[0].uiSettings.get('state:storeInSessionStorage'),
}))
createDirectAccessDashboardLinkGenerator(async () => {
const [coreStart, , selfStart] = await startServices;
return {
appBasePath: coreStart.application.getUrlForApp('dashboard'),
useHashedUrl: coreStart.uiSettings.get('state:storeInSessionStorage'),
savedDashboardLoader: selfStart.getSavedDashboardLoader(),
};
})
);
}

Expand Down
207 changes: 200 additions & 7 deletions src/plugins/dashboard/public/url_generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,33 @@ import { createDirectAccessDashboardLinkGenerator } from './url_generator';
import { hashedItemStore } from '../../kibana_utils/public';
// eslint-disable-next-line
import { mockStorage } from '../../kibana_utils/public/storage/hashed_item_store/mock';
import { esFilters } from '../../data/public';
import { esFilters, Filter } from '../../data/public';
import { SavedObjectLoader } from '../../saved_objects/public';

const APP_BASE_PATH: string = 'xyz/app/kibana';

const createMockDashboardLoader = (
dashboardToFilters: {
[dashboardId: string]: () => Filter[];
} = {}
) => {
return {
get: async (dashboardId: string) => {
return {
searchSource: {
getField: (field: string) => {
if (field === 'filter')
return dashboardToFilters[dashboardId] ? dashboardToFilters[dashboardId]() : [];
throw new Error(
`createMockDashboardLoader > searchSource > getField > ${field} is not mocked`
);
},
},
};
},
} as SavedObjectLoader;
};

describe('dashboard url generator', () => {
beforeEach(() => {
// @ts-ignore
Expand All @@ -33,15 +56,23 @@ describe('dashboard url generator', () => {

test('creates a link to a saved dashboard', async () => {
const generator = createDirectAccessDashboardLinkGenerator(() =>
Promise.resolve({ appBasePath: APP_BASE_PATH, useHashedUrl: false })
Promise.resolve({
appBasePath: APP_BASE_PATH,
useHashedUrl: false,
savedDashboardLoader: createMockDashboardLoader(),
})
);
const url = await generator.createUrl!({});
expect(url).toMatchInlineSnapshot(`"xyz/app/kibana#/dashboard?_a=()&_g=()"`);
});

test('creates a link with global time range set up', async () => {
const generator = createDirectAccessDashboardLinkGenerator(() =>
Promise.resolve({ appBasePath: APP_BASE_PATH, useHashedUrl: false })
Promise.resolve({
appBasePath: APP_BASE_PATH,
useHashedUrl: false,
savedDashboardLoader: createMockDashboardLoader(),
})
);
const url = await generator.createUrl!({
timeRange: { to: 'now', from: 'now-15m', mode: 'relative' },
Expand All @@ -53,7 +84,11 @@ describe('dashboard url generator', () => {

test('creates a link with filters, time range, refresh interval and query to a saved object', async () => {
const generator = createDirectAccessDashboardLinkGenerator(() =>
Promise.resolve({ appBasePath: APP_BASE_PATH, useHashedUrl: false })
Promise.resolve({
appBasePath: APP_BASE_PATH,
useHashedUrl: false,
savedDashboardLoader: createMockDashboardLoader(),
})
);
const url = await generator.createUrl!({
timeRange: { to: 'now', from: 'now-15m', mode: 'relative' },
Expand Down Expand Up @@ -89,7 +124,11 @@ describe('dashboard url generator', () => {

test('if no useHash setting is given, uses the one was start services', async () => {
const generator = createDirectAccessDashboardLinkGenerator(() =>
Promise.resolve({ appBasePath: APP_BASE_PATH, useHashedUrl: true })
Promise.resolve({
appBasePath: APP_BASE_PATH,
useHashedUrl: true,
savedDashboardLoader: createMockDashboardLoader(),
})
);
const url = await generator.createUrl!({
timeRange: { to: 'now', from: 'now-15m', mode: 'relative' },
Expand All @@ -99,7 +138,11 @@ describe('dashboard url generator', () => {

test('can override a false useHash ui setting', async () => {
const generator = createDirectAccessDashboardLinkGenerator(() =>
Promise.resolve({ appBasePath: APP_BASE_PATH, useHashedUrl: false })
Promise.resolve({
appBasePath: APP_BASE_PATH,
useHashedUrl: false,
savedDashboardLoader: createMockDashboardLoader(),
})
);
const url = await generator.createUrl!({
timeRange: { to: 'now', from: 'now-15m', mode: 'relative' },
Expand All @@ -110,12 +153,162 @@ describe('dashboard url generator', () => {

test('can override a true useHash ui setting', async () => {
const generator = createDirectAccessDashboardLinkGenerator(() =>
Promise.resolve({ appBasePath: APP_BASE_PATH, useHashedUrl: true })
Promise.resolve({
appBasePath: APP_BASE_PATH,
useHashedUrl: true,
savedDashboardLoader: createMockDashboardLoader(),
})
);
const url = await generator.createUrl!({
timeRange: { to: 'now', from: 'now-15m', mode: 'relative' },
useHash: false,
});
expect(url.indexOf('relative')).toBeGreaterThan(1);
});

describe('preserving saved filters', () => {
const savedFilter1 = {
meta: {
alias: null,
disabled: false,
negate: false,
},
query: { query: 'savedfilter1' },
};

const savedFilter2 = {
meta: {
alias: null,
disabled: false,
negate: false,
},
query: { query: 'savedfilter2' },
};

const appliedFilter = {
meta: {
alias: null,
disabled: false,
negate: false,
},
query: { query: 'appliedfilter' },
};

test('attaches filters from destination dashboard', async () => {
const generator = createDirectAccessDashboardLinkGenerator(() =>
Promise.resolve({
appBasePath: APP_BASE_PATH,
useHashedUrl: false,
savedDashboardLoader: createMockDashboardLoader({
['dashboard1']: () => [savedFilter1],
['dashboard2']: () => [savedFilter2],
}),
})
);

const urlToDashboard1 = await generator.createUrl!({
dashboardId: 'dashboard1',
filters: [appliedFilter],
});

expect(urlToDashboard1).toEqual(expect.stringContaining('query:savedfilter1'));
expect(urlToDashboard1).toEqual(expect.stringContaining('query:appliedfilter'));

const urlToDashboard2 = await generator.createUrl!({
dashboardId: 'dashboard2',
filters: [appliedFilter],
});

expect(urlToDashboard2).toEqual(expect.stringContaining('query:savedfilter2'));
expect(urlToDashboard2).toEqual(expect.stringContaining('query:appliedfilter'));
});

test("doesn't fail if can't retrieve filters from destination dashboard", async () => {
const generator = createDirectAccessDashboardLinkGenerator(() =>
Promise.resolve({
appBasePath: APP_BASE_PATH,
useHashedUrl: false,
savedDashboardLoader: createMockDashboardLoader({
['dashboard1']: () => {
throw new Error('Not found');
},
}),
})
);

const url = await generator.createUrl!({
dashboardId: 'dashboard1',
filters: [appliedFilter],
});

expect(url).not.toEqual(expect.stringContaining('query:savedfilter1'));
expect(url).toEqual(expect.stringContaining('query:appliedfilter'));
});

test('can enforce empty filters', async () => {
const generator = createDirectAccessDashboardLinkGenerator(() =>
Promise.resolve({
appBasePath: APP_BASE_PATH,
useHashedUrl: false,
savedDashboardLoader: createMockDashboardLoader({
['dashboard1']: () => [savedFilter1],
}),
})
);

const url = await generator.createUrl!({
dashboardId: 'dashboard1',
filters: [],
preserveSavedFilters: false,
});

expect(url).not.toEqual(expect.stringContaining('query:savedfilter1'));
expect(url).not.toEqual(expect.stringContaining('query:appliedfilter'));
expect(url).toMatchInlineSnapshot(
`"xyz/app/kibana#/dashboard/dashboard1?_a=(filters:!())&_g=(filters:!())"`
);
});

test('no filters in result url if no filters applied', async () => {
const generator = createDirectAccessDashboardLinkGenerator(() =>
Promise.resolve({
appBasePath: APP_BASE_PATH,
useHashedUrl: false,
savedDashboardLoader: createMockDashboardLoader({
['dashboard1']: () => [savedFilter1],
}),
})
);

const url = await generator.createUrl!({
dashboardId: 'dashboard1',
});
expect(url).not.toEqual(expect.stringContaining('filters'));
expect(url).toMatchInlineSnapshot(`"xyz/app/kibana#/dashboard/dashboard1?_a=()&_g=()"`);
});

test('can turn off preserving filters', async () => {
const generator = createDirectAccessDashboardLinkGenerator(() =>
Promise.resolve({
appBasePath: APP_BASE_PATH,
useHashedUrl: false,
savedDashboardLoader: createMockDashboardLoader({
['dashboard1']: () => [savedFilter1],
}),
})
);
const urlWithPreservedFiltersTurnedOff = await generator.createUrl!({
dashboardId: 'dashboard1',
filters: [appliedFilter],
preserveSavedFilters: false,
});

expect(urlWithPreservedFiltersTurnedOff).not.toEqual(
expect.stringContaining('query:savedfilter1')
);
expect(urlWithPreservedFiltersTurnedOff).toEqual(
expect.stringContaining('query:appliedfilter')
);
});
});
});
39 changes: 36 additions & 3 deletions src/plugins/dashboard/public/url_generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
} from '../../data/public';
import { setStateToKbnUrl } from '../../kibana_utils/public';
import { UrlGeneratorsDefinition, UrlGeneratorState } from '../../share/public';
import { SavedObjectLoader } from '../../saved_objects/public';

export const STATE_STORAGE_KEY = '_a';
export const GLOBAL_STATE_STORAGE_KEY = '_g';
Expand Down Expand Up @@ -64,10 +65,22 @@ export type DashboardAppLinkGeneratorState = UrlGeneratorState<{
* whether to hash the data in the url to avoid url length issues.
*/
useHash?: boolean;

/**
* When `true` filters from saved filters from destination dashboard as merged with applied filters
* When `false` applied filters take precedence and override saved filters
*
* true is default
*/
preserveSavedFilters?: boolean;
}>;

export const createDirectAccessDashboardLinkGenerator = (
getStartServices: () => Promise<{ appBasePath: string; useHashedUrl: boolean }>
getStartServices: () => Promise<{
appBasePath: string;
useHashedUrl: boolean;
savedDashboardLoader: SavedObjectLoader;
}>
): UrlGeneratorsDefinition<typeof DASHBOARD_APP_URL_GENERATOR> => ({
id: DASHBOARD_APP_URL_GENERATOR,
createUrl: async state => {
Expand All @@ -76,6 +89,19 @@ export const createDirectAccessDashboardLinkGenerator = (
const appBasePath = startServices.appBasePath;
const hash = state.dashboardId ? `dashboard/${state.dashboardId}` : `dashboard`;

const getSavedFiltersFromDestinationDashboardIfNeeded = async (): Promise<Filter[]> => {
if (state.preserveSavedFilters === false) return [];
if (!state.dashboardId) return [];
try {
const dashboard = await startServices.savedDashboardLoader.get(state.dashboardId);
return dashboard?.searchSource?.getField('filter') ?? [];
} catch (e) {
// in case dashboard is missing, built the url without those filters
// dashboard app will handle redirect to landing page with toast message
return [];
}
};

const cleanEmptyKeys = (stateObj: Record<string, unknown>) => {
Object.keys(stateObj).forEach(key => {
if (stateObj[key] === undefined) {
Expand All @@ -85,11 +111,18 @@ export const createDirectAccessDashboardLinkGenerator = (
return stateObj;
};

// leave filters `undefined` if no filters was applied
// in this case dashboard will restore saved filters on its own
const filters = state.filters && [
...(await getSavedFiltersFromDestinationDashboardIfNeeded()),
...state.filters,
];

const appStateUrl = setStateToKbnUrl(
STATE_STORAGE_KEY,
cleanEmptyKeys({
query: state.query,
filters: state.filters?.filter(f => !esFilters.isFilterPinned(f)),
filters: filters?.filter(f => !esFilters.isFilterPinned(f)),
}),
{ useHash },
`${appBasePath}#/${hash}`
Expand All @@ -99,7 +132,7 @@ export const createDirectAccessDashboardLinkGenerator = (
GLOBAL_STATE_STORAGE_KEY,
cleanEmptyKeys({
time: state.timeRange,
filters: state.filters?.filter(f => esFilters.isFilterPinned(f)),
filters: filters?.filter(f => esFilters.isFilterPinned(f)),
refreshInterval: state.refreshInterval,
}),
{ useHash },
Expand Down

0 comments on commit e5b9edd

Please sign in to comment.