Skip to content

Commit dcc4081

Browse files
authored
Dashboard url generator to preserve saved filters from destination dashboard (#64767)
1 parent 6e26913 commit dcc4081

File tree

3 files changed

+244
-14
lines changed

3 files changed

+244
-14
lines changed

src/plugins/dashboard/public/plugin.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,10 +141,14 @@ export class DashboardPlugin
141141

142142
if (share) {
143143
share.urlGenerators.registerUrlGenerator(
144-
createDirectAccessDashboardLinkGenerator(async () => ({
145-
appBasePath: (await startServices)[0].application.getUrlForApp('dashboard'),
146-
useHashedUrl: (await startServices)[0].uiSettings.get('state:storeInSessionStorage'),
147-
}))
144+
createDirectAccessDashboardLinkGenerator(async () => {
145+
const [coreStart, , selfStart] = await startServices;
146+
return {
147+
appBasePath: coreStart.application.getUrlForApp('dashboard'),
148+
useHashedUrl: coreStart.uiSettings.get('state:storeInSessionStorage'),
149+
savedDashboardLoader: selfStart.getSavedDashboardLoader(),
150+
};
151+
})
148152
);
149153
}
150154

src/plugins/dashboard/public/url_generator.test.ts

Lines changed: 200 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,33 @@ import { createDirectAccessDashboardLinkGenerator } from './url_generator';
2121
import { hashedItemStore } from '../../kibana_utils/public';
2222
// eslint-disable-next-line
2323
import { mockStorage } from '../../kibana_utils/public/storage/hashed_item_store/mock';
24-
import { esFilters } from '../../data/public';
24+
import { esFilters, Filter } from '../../data/public';
25+
import { SavedObjectLoader } from '../../saved_objects/public';
2526

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

29+
const createMockDashboardLoader = (
30+
dashboardToFilters: {
31+
[dashboardId: string]: () => Filter[];
32+
} = {}
33+
) => {
34+
return {
35+
get: async (dashboardId: string) => {
36+
return {
37+
searchSource: {
38+
getField: (field: string) => {
39+
if (field === 'filter')
40+
return dashboardToFilters[dashboardId] ? dashboardToFilters[dashboardId]() : [];
41+
throw new Error(
42+
`createMockDashboardLoader > searchSource > getField > ${field} is not mocked`
43+
);
44+
},
45+
},
46+
};
47+
},
48+
} as SavedObjectLoader;
49+
};
50+
2851
describe('dashboard url generator', () => {
2952
beforeEach(() => {
3053
// @ts-ignore
@@ -33,15 +56,23 @@ describe('dashboard url generator', () => {
3356

3457
test('creates a link to a saved dashboard', async () => {
3558
const generator = createDirectAccessDashboardLinkGenerator(() =>
36-
Promise.resolve({ appBasePath: APP_BASE_PATH, useHashedUrl: false })
59+
Promise.resolve({
60+
appBasePath: APP_BASE_PATH,
61+
useHashedUrl: false,
62+
savedDashboardLoader: createMockDashboardLoader(),
63+
})
3764
);
3865
const url = await generator.createUrl!({});
3966
expect(url).toMatchInlineSnapshot(`"xyz/app/kibana#/dashboard?_a=()&_g=()"`);
4067
});
4168

4269
test('creates a link with global time range set up', async () => {
4370
const generator = createDirectAccessDashboardLinkGenerator(() =>
44-
Promise.resolve({ appBasePath: APP_BASE_PATH, useHashedUrl: false })
71+
Promise.resolve({
72+
appBasePath: APP_BASE_PATH,
73+
useHashedUrl: false,
74+
savedDashboardLoader: createMockDashboardLoader(),
75+
})
4576
);
4677
const url = await generator.createUrl!({
4778
timeRange: { to: 'now', from: 'now-15m', mode: 'relative' },
@@ -53,7 +84,11 @@ describe('dashboard url generator', () => {
5384

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

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

100139
test('can override a false useHash ui setting', async () => {
101140
const generator = createDirectAccessDashboardLinkGenerator(() =>
102-
Promise.resolve({ appBasePath: APP_BASE_PATH, useHashedUrl: false })
141+
Promise.resolve({
142+
appBasePath: APP_BASE_PATH,
143+
useHashedUrl: false,
144+
savedDashboardLoader: createMockDashboardLoader(),
145+
})
103146
);
104147
const url = await generator.createUrl!({
105148
timeRange: { to: 'now', from: 'now-15m', mode: 'relative' },
@@ -110,12 +153,162 @@ describe('dashboard url generator', () => {
110153

111154
test('can override a true useHash ui setting', async () => {
112155
const generator = createDirectAccessDashboardLinkGenerator(() =>
113-
Promise.resolve({ appBasePath: APP_BASE_PATH, useHashedUrl: true })
156+
Promise.resolve({
157+
appBasePath: APP_BASE_PATH,
158+
useHashedUrl: true,
159+
savedDashboardLoader: createMockDashboardLoader(),
160+
})
114161
);
115162
const url = await generator.createUrl!({
116163
timeRange: { to: 'now', from: 'now-15m', mode: 'relative' },
117164
useHash: false,
118165
});
119166
expect(url.indexOf('relative')).toBeGreaterThan(1);
120167
});
168+
169+
describe('preserving saved filters', () => {
170+
const savedFilter1 = {
171+
meta: {
172+
alias: null,
173+
disabled: false,
174+
negate: false,
175+
},
176+
query: { query: 'savedfilter1' },
177+
};
178+
179+
const savedFilter2 = {
180+
meta: {
181+
alias: null,
182+
disabled: false,
183+
negate: false,
184+
},
185+
query: { query: 'savedfilter2' },
186+
};
187+
188+
const appliedFilter = {
189+
meta: {
190+
alias: null,
191+
disabled: false,
192+
negate: false,
193+
},
194+
query: { query: 'appliedfilter' },
195+
};
196+
197+
test('attaches filters from destination dashboard', async () => {
198+
const generator = createDirectAccessDashboardLinkGenerator(() =>
199+
Promise.resolve({
200+
appBasePath: APP_BASE_PATH,
201+
useHashedUrl: false,
202+
savedDashboardLoader: createMockDashboardLoader({
203+
['dashboard1']: () => [savedFilter1],
204+
['dashboard2']: () => [savedFilter2],
205+
}),
206+
})
207+
);
208+
209+
const urlToDashboard1 = await generator.createUrl!({
210+
dashboardId: 'dashboard1',
211+
filters: [appliedFilter],
212+
});
213+
214+
expect(urlToDashboard1).toEqual(expect.stringContaining('query:savedfilter1'));
215+
expect(urlToDashboard1).toEqual(expect.stringContaining('query:appliedfilter'));
216+
217+
const urlToDashboard2 = await generator.createUrl!({
218+
dashboardId: 'dashboard2',
219+
filters: [appliedFilter],
220+
});
221+
222+
expect(urlToDashboard2).toEqual(expect.stringContaining('query:savedfilter2'));
223+
expect(urlToDashboard2).toEqual(expect.stringContaining('query:appliedfilter'));
224+
});
225+
226+
test("doesn't fail if can't retrieve filters from destination dashboard", async () => {
227+
const generator = createDirectAccessDashboardLinkGenerator(() =>
228+
Promise.resolve({
229+
appBasePath: APP_BASE_PATH,
230+
useHashedUrl: false,
231+
savedDashboardLoader: createMockDashboardLoader({
232+
['dashboard1']: () => {
233+
throw new Error('Not found');
234+
},
235+
}),
236+
})
237+
);
238+
239+
const url = await generator.createUrl!({
240+
dashboardId: 'dashboard1',
241+
filters: [appliedFilter],
242+
});
243+
244+
expect(url).not.toEqual(expect.stringContaining('query:savedfilter1'));
245+
expect(url).toEqual(expect.stringContaining('query:appliedfilter'));
246+
});
247+
248+
test('can enforce empty filters', async () => {
249+
const generator = createDirectAccessDashboardLinkGenerator(() =>
250+
Promise.resolve({
251+
appBasePath: APP_BASE_PATH,
252+
useHashedUrl: false,
253+
savedDashboardLoader: createMockDashboardLoader({
254+
['dashboard1']: () => [savedFilter1],
255+
}),
256+
})
257+
);
258+
259+
const url = await generator.createUrl!({
260+
dashboardId: 'dashboard1',
261+
filters: [],
262+
preserveSavedFilters: false,
263+
});
264+
265+
expect(url).not.toEqual(expect.stringContaining('query:savedfilter1'));
266+
expect(url).not.toEqual(expect.stringContaining('query:appliedfilter'));
267+
expect(url).toMatchInlineSnapshot(
268+
`"xyz/app/kibana#/dashboard/dashboard1?_a=(filters:!())&_g=(filters:!())"`
269+
);
270+
});
271+
272+
test('no filters in result url if no filters applied', async () => {
273+
const generator = createDirectAccessDashboardLinkGenerator(() =>
274+
Promise.resolve({
275+
appBasePath: APP_BASE_PATH,
276+
useHashedUrl: false,
277+
savedDashboardLoader: createMockDashboardLoader({
278+
['dashboard1']: () => [savedFilter1],
279+
}),
280+
})
281+
);
282+
283+
const url = await generator.createUrl!({
284+
dashboardId: 'dashboard1',
285+
});
286+
expect(url).not.toEqual(expect.stringContaining('filters'));
287+
expect(url).toMatchInlineSnapshot(`"xyz/app/kibana#/dashboard/dashboard1?_a=()&_g=()"`);
288+
});
289+
290+
test('can turn off preserving filters', async () => {
291+
const generator = createDirectAccessDashboardLinkGenerator(() =>
292+
Promise.resolve({
293+
appBasePath: APP_BASE_PATH,
294+
useHashedUrl: false,
295+
savedDashboardLoader: createMockDashboardLoader({
296+
['dashboard1']: () => [savedFilter1],
297+
}),
298+
})
299+
);
300+
const urlWithPreservedFiltersTurnedOff = await generator.createUrl!({
301+
dashboardId: 'dashboard1',
302+
filters: [appliedFilter],
303+
preserveSavedFilters: false,
304+
});
305+
306+
expect(urlWithPreservedFiltersTurnedOff).not.toEqual(
307+
expect.stringContaining('query:savedfilter1')
308+
);
309+
expect(urlWithPreservedFiltersTurnedOff).toEqual(
310+
expect.stringContaining('query:appliedfilter')
311+
);
312+
});
313+
});
121314
});

src/plugins/dashboard/public/url_generator.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
} from '../../data/public';
2828
import { setStateToKbnUrl } from '../../kibana_utils/public';
2929
import { UrlGeneratorsDefinition, UrlGeneratorState } from '../../share/public';
30+
import { SavedObjectLoader } from '../../saved_objects/public';
3031

3132
export const STATE_STORAGE_KEY = '_a';
3233
export const GLOBAL_STATE_STORAGE_KEY = '_g';
@@ -64,10 +65,22 @@ export type DashboardAppLinkGeneratorState = UrlGeneratorState<{
6465
* whether to hash the data in the url to avoid url length issues.
6566
*/
6667
useHash?: boolean;
68+
69+
/**
70+
* When `true` filters from saved filters from destination dashboard as merged with applied filters
71+
* When `false` applied filters take precedence and override saved filters
72+
*
73+
* true is default
74+
*/
75+
preserveSavedFilters?: boolean;
6776
}>;
6877

6978
export const createDirectAccessDashboardLinkGenerator = (
70-
getStartServices: () => Promise<{ appBasePath: string; useHashedUrl: boolean }>
79+
getStartServices: () => Promise<{
80+
appBasePath: string;
81+
useHashedUrl: boolean;
82+
savedDashboardLoader: SavedObjectLoader;
83+
}>
7184
): UrlGeneratorsDefinition<typeof DASHBOARD_APP_URL_GENERATOR> => ({
7285
id: DASHBOARD_APP_URL_GENERATOR,
7386
createUrl: async state => {
@@ -76,6 +89,19 @@ export const createDirectAccessDashboardLinkGenerator = (
7689
const appBasePath = startServices.appBasePath;
7790
const hash = state.dashboardId ? `dashboard/${state.dashboardId}` : `dashboard`;
7891

92+
const getSavedFiltersFromDestinationDashboardIfNeeded = async (): Promise<Filter[]> => {
93+
if (state.preserveSavedFilters === false) return [];
94+
if (!state.dashboardId) return [];
95+
try {
96+
const dashboard = await startServices.savedDashboardLoader.get(state.dashboardId);
97+
return dashboard?.searchSource?.getField('filter') ?? [];
98+
} catch (e) {
99+
// in case dashboard is missing, built the url without those filters
100+
// dashboard app will handle redirect to landing page with toast message
101+
return [];
102+
}
103+
};
104+
79105
const cleanEmptyKeys = (stateObj: Record<string, unknown>) => {
80106
Object.keys(stateObj).forEach(key => {
81107
if (stateObj[key] === undefined) {
@@ -85,11 +111,18 @@ export const createDirectAccessDashboardLinkGenerator = (
85111
return stateObj;
86112
};
87113

114+
// leave filters `undefined` if no filters was applied
115+
// in this case dashboard will restore saved filters on its own
116+
const filters = state.filters && [
117+
...(await getSavedFiltersFromDestinationDashboardIfNeeded()),
118+
...state.filters,
119+
];
120+
88121
const appStateUrl = setStateToKbnUrl(
89122
STATE_STORAGE_KEY,
90123
cleanEmptyKeys({
91124
query: state.query,
92-
filters: state.filters?.filter(f => !esFilters.isFilterPinned(f)),
125+
filters: filters?.filter(f => !esFilters.isFilterPinned(f)),
93126
}),
94127
{ useHash },
95128
`${appBasePath}#/${hash}`
@@ -99,7 +132,7 @@ export const createDirectAccessDashboardLinkGenerator = (
99132
GLOBAL_STATE_STORAGE_KEY,
100133
cleanEmptyKeys({
101134
time: state.timeRange,
102-
filters: state.filters?.filter(f => esFilters.isFilterPinned(f)),
135+
filters: filters?.filter(f => esFilters.isFilterPinned(f)),
103136
refreshInterval: state.refreshInterval,
104137
}),
105138
{ useHash },

0 commit comments

Comments
 (0)