Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Drilldowns] Dashboard url generator to preserve saved filters from destination dashboard #64767

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this is not a new change, but for my own understanding - why do we need to filter pinned filters here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

url_generator accepts array of filters to apply and to make pinned state work in the url properly we have to split those into _a and _g :(

refreshInterval: state.refreshInterval,
}),
{ useHash },
Expand Down