[7.x] Disable url tracking for dashboard (#55818) (#56878)

This commit is contained in:
Joe Reuter 2020-02-06 11:30:39 +01:00 committed by GitHub
parent c9ede92f4a
commit eebf010924
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 681 additions and 121 deletions

View file

@ -97,13 +97,8 @@ export default function(kibana) {
}),
order: -1001,
url: `${kbnBaseUrl}#/dashboards`,
// The subUrlBase is the common substring of all urls for this app. If not given, it defaults to the url
// above. This app has to use a different subUrlBase, in addition to the url above, because "#/dashboard"
// routes to a page that creates a new dashboard. When we introduced a landing page, we needed to change
// the url above in order to preserve the original url for BWC. The subUrlBase helps the Chrome api nav
// to determine what url to use for the app link.
subUrlBase: `${kbnBaseUrl}#/dashboard`,
euiIconType: 'dashboardApp',
disableSubUrlTracking: true,
category: DEFAULT_APP_CATEGORIES.analyze,
},
{

View file

@ -17,8 +17,15 @@
* under the License.
*/
const topLevelConfig = require('../../../../../.eslintrc.js');
const path = require('path');
const topLevelRestricedZones = topLevelConfig.overrides.find(
override =>
override.files[0] === '**/*.{js,ts,tsx}' &&
Object.keys(override.rules)[0] === '@kbn/eslint/no-restricted-paths'
).rules['@kbn/eslint/no-restricted-paths'][1].zones;
/**
* Builds custom restricted paths configuration for the shimmed plugins within the kibana plugin.
* These custom rules extend the default checks in the top level `eslintrc.js` by also checking two other things:
@ -28,34 +35,37 @@ const path = require('path');
* @returns zones configuration for the no-restricted-paths linter
*/
function buildRestrictedPaths(shimmedPlugins) {
return shimmedPlugins.map(shimmedPlugin => ([{
target: [
`src/legacy/core_plugins/kibana/public/${shimmedPlugin}/np_ready/**/*`,
],
from: [
'ui/**/*',
'src/legacy/ui/**/*',
'src/legacy/core_plugins/kibana/public/**/*',
'src/legacy/core_plugins/data/public/**/*',
'!src/legacy/core_plugins/data/public/index.ts',
`!src/legacy/core_plugins/kibana/public/${shimmedPlugin}/**/*`,
],
allowSameFolder: false,
errorMessage: `${shimmedPlugin} is a shimmed plugin that is not allowed to import modules from the legacy platform. If you need legacy modules for the transition period, import them either in the legacy_imports, kibana_services or index module.`,
}, {
target: [
'src/**/*',
`!src/legacy/core_plugins/kibana/public/${shimmedPlugin}/**/*`,
'x-pack/**/*',
],
from: [
`src/legacy/core_plugins/kibana/public/${shimmedPlugin}/**/*`,
`!src/legacy/core_plugins/kibana/public/${shimmedPlugin}/index.ts`,
`!src/legacy/core_plugins/kibana/public/${shimmedPlugin}/legacy.ts`,
],
allowSameFolder: false,
errorMessage: `kibana/public/${shimmedPlugin} is behaving like a NP plugin and does not allow deep imports. If you need something from within ${shimmedPlugin} in another plugin, consider re-exporting it from the top level index module`,
}])).reduce((acc, part) => [...acc, ...part], []);
return shimmedPlugins
.map(shimmedPlugin => [
{
target: [`src/legacy/core_plugins/kibana/public/${shimmedPlugin}/np_ready/**/*`],
from: [
'ui/**/*',
'src/legacy/ui/**/*',
'src/legacy/core_plugins/kibana/public/**/*',
'src/legacy/core_plugins/data/public/**/*',
'!src/legacy/core_plugins/data/public/index.ts',
`!src/legacy/core_plugins/kibana/public/${shimmedPlugin}/**/*`,
],
allowSameFolder: false,
errorMessage: `${shimmedPlugin} is a shimmed plugin that is not allowed to import modules from the legacy platform. If you need legacy modules for the transition period, import them either in the legacy_imports, kibana_services or index module.`,
},
{
target: [
'src/**/*',
`!src/legacy/core_plugins/kibana/public/${shimmedPlugin}/**/*`,
'x-pack/**/*',
],
from: [
`src/legacy/core_plugins/kibana/public/${shimmedPlugin}/**/*`,
`!src/legacy/core_plugins/kibana/public/${shimmedPlugin}/index.ts`,
`!src/legacy/core_plugins/kibana/public/${shimmedPlugin}/legacy.ts`,
],
allowSameFolder: false,
errorMessage: `kibana/public/${shimmedPlugin} is behaving like a NP plugin and does not allow deep imports. If you need something from within ${shimmedPlugin} in another plugin, consider re-exporting it from the top level index module`,
},
])
.reduce((acc, part) => [...acc, ...part], []);
}
module.exports = {
@ -66,7 +76,9 @@ module.exports = {
'error',
{
basePath: path.resolve(__dirname, '../../../../../'),
zones: buildRestrictedPaths(['visualize', 'discover', 'dashboard', 'devTools', 'home']),
zones: topLevelRestricedZones.concat(
buildRestrictedPaths(['visualize', 'discover', 'dashboard', 'devTools', 'home'])
),
},
],
},

View file

@ -41,6 +41,7 @@ async function getAngularDependencies(): Promise<LegacyAngularInjectedDependenci
const instance = plugin({} as PluginInitializerContext);
instance.setup(npSetup.core, {
...npSetup.plugins,
npData: npSetup.plugins.data,
__LEGACY: {
getAngularDependencies,
},

View file

@ -73,7 +73,6 @@ export const renderApp = (element: HTMLElement, appBasePath: string, deps: Rende
angularModuleInstance = createLocalAngularModule(deps.core, deps.navigation);
// global routing stuff
configureAppAngularModule(angularModuleInstance, deps.core as LegacyCoreStart, true);
// custom routing stuff
initDashboardApp(angularModuleInstance, deps);
}

View file

@ -17,7 +17,7 @@
* under the License.
*/
import { searchSourceMock } from '../../../../../../../plugins/data/public/search/search_source/mocks';
import { searchSourceMock } from '../../../../../../../plugins/data/public/mocks';
import { SavedObjectDashboard } from '../../saved_dashboard/saved_dashboard';
export function getSavedDashboardMock(

View file

@ -17,6 +17,7 @@
* under the License.
*/
import { BehaviorSubject } from 'rxjs';
import {
App,
CoreSetup,
@ -28,7 +29,10 @@ import {
import { i18n } from '@kbn/i18n';
import { RenderDeps } from './np_ready/application';
import { DataStart } from '../../../data/public';
import { DataPublicPluginStart as NpDataStart } from '../../../../../plugins/data/public';
import {
DataPublicPluginStart as NpDataStart,
DataPublicPluginSetup as NpDataSetup,
} from '../../../../../plugins/data/public';
import { IEmbeddableStart } from '../../../../../plugins/embeddable/public';
import { Storage } from '../../../../../plugins/kibana_utils/public';
import { NavigationPublicPluginStart as NavigationStart } from '../../../../../plugins/navigation/public';
@ -38,8 +42,13 @@ import {
HomePublicPluginSetup,
FeatureCatalogueCategory,
} from '../../../../../plugins/home/public';
import { KibanaLegacySetup } from '../../../../../plugins/kibana_legacy/public';
import {
AngularRenderedAppUpdater,
KibanaLegacySetup,
} from '../../../../../plugins/kibana_legacy/public';
import { createSavedDashboardLoader } from './saved_dashboard/saved_dashboards';
import { createKbnUrlTracker } from '../../../../../plugins/kibana_utils/public';
import { getQueryStateContainer } from '../../../../../plugins/data/public';
export interface LegacyAngularInjectedDependencies {
dashboardConfig: any;
@ -59,6 +68,7 @@ export interface DashboardPluginSetupDependencies {
};
home: HomePublicPluginSetup;
kibana_legacy: KibanaLegacySetup;
npData: NpDataSetup;
}
export class DashboardPlugin implements Plugin {
@ -70,10 +80,38 @@ export class DashboardPlugin implements Plugin {
share: SharePluginStart;
} | null = null;
private appStateUpdater = new BehaviorSubject<AngularRenderedAppUpdater>(() => ({}));
private stopUrlTracking: (() => void) | undefined = undefined;
public setup(
core: CoreSetup,
{ __LEGACY: { getAngularDependencies }, home, kibana_legacy }: DashboardPluginSetupDependencies
{
__LEGACY: { getAngularDependencies },
home,
kibana_legacy,
npData,
}: DashboardPluginSetupDependencies
) {
const { querySyncStateContainer, stop: stopQuerySyncStateContainer } = getQueryStateContainer(
npData.query
);
const { appMounted, appUnMounted, stop: stopUrlTracker } = createKbnUrlTracker({
baseUrl: core.http.basePath.prepend('/app/kibana'),
defaultSubUrl: '#/dashboards',
storageKey: 'lastUrl:dashboard',
navLinkUpdater$: this.appStateUpdater,
toastNotifications: core.notifications.toasts,
stateParams: [
{
kbnUrlKey: '_g',
stateUpdate$: querySyncStateContainer.state$,
},
],
});
this.stopUrlTracking = () => {
stopQuerySyncStateContainer();
stopUrlTracker();
};
const app: App = {
id: '',
title: 'Dashboards',
@ -81,6 +119,7 @@ export class DashboardPlugin implements Plugin {
if (this.startDependencies === null) {
throw new Error('not started yet');
}
appMounted();
const {
savedObjectsClient,
embeddables,
@ -114,10 +153,20 @@ export class DashboardPlugin implements Plugin {
localStorage: new Storage(localStorage),
};
const { renderApp } = await import('./np_ready/application');
return renderApp(params.element, params.appBasePath, deps);
const unmount = renderApp(params.element, params.appBasePath, deps);
return () => {
unmount();
appUnMounted();
};
},
};
kibana_legacy.registerLegacyApp({ ...app, id: 'dashboard' });
kibana_legacy.registerLegacyApp({
...app,
id: 'dashboard',
// only register the updater in once app, otherwise all updates would happen twice
updater$: this.appStateUpdater.asObservable(),
navLinkId: 'kibana:dashboard',
});
kibana_legacy.registerLegacyApp({ ...app, id: 'dashboards' });
home.featureCatalogue.register({
@ -147,4 +196,10 @@ export class DashboardPlugin implements Plugin {
share,
};
}
stop() {
if (this.stopUrlTracking) {
this.stopUrlTracking();
}
}
}

View file

@ -22,7 +22,7 @@ import PropTypes from 'prop-types';
import { Instruction } from './instruction';
import { ParameterForm } from './parameter_form';
import { Content } from './content';
import { getDisplayText } from '../../../../../../../../plugins/home/server/tutorials/instructions/instruction_variant';
import { getDisplayText } from '../../../../../../../../plugins/home/public';
import {
EuiTabs,
EuiTab,

View file

@ -79,6 +79,17 @@ export class LocalApplicationService {
})();
},
});
if (app.updater$) {
app.updater$.subscribe(updater => {
const updatedFields = updater(app);
if (updatedFields && updatedFields.activeUrl) {
npStart.core.chrome.navLinks.update(app.navLinkId || app.id, {
url: updatedFields.activeUrl,
});
}
});
}
});
npStart.plugins.kibana_legacy.getForwards().forEach(({ legacyAppId, newAppId, keepPrefix }) => {

View file

@ -146,7 +146,7 @@ export function initChromeNavApi(chrome: any, internals: NavInternals) {
// link.active and link.lastUrl properties
coreNavLinks
.getAll()
.filter(link => link.subUrlBase)
.filter(link => link.subUrlBase && !link.disableSubUrlTracking)
.forEach(link => {
coreNavLinks.update(link.id, {
subUrlBase: relativeToAbsolute(chrome.addBasePath(link.subUrlBase)),

View file

@ -101,6 +101,8 @@ const createStartContract = (): Start => {
return startContract;
};
export { searchSourceMock } from './search/mocks';
export const dataPluginMock = {
createSetupContract,
createStartContract,

View file

@ -17,5 +17,5 @@
* under the License.
*/
export { syncQuery } from './sync_query';
export { syncQuery, getQueryStateContainer } from './sync_query';
export { syncAppFilters } from './sync_app_filters';

View file

@ -31,7 +31,7 @@ import {
import { QueryService, QueryStart } from '../query_service';
import { StubBrowserStorage } from 'test_utils/stub_browser_storage';
import { TimefilterContract } from '../timefilter';
import { QuerySyncState, syncQuery } from './sync_query';
import { getQueryStateContainer, QuerySyncState, syncQuery } from './sync_query';
const setupMock = coreMock.createSetup();
const startMock = coreMock.createStart();
@ -163,4 +163,69 @@ describe('sync_query', () => {
expect(spy).not.toBeCalled();
stop();
});
describe('getQueryStateContainer', () => {
test('state is initialized with state from query service', () => {
const { stop, querySyncStateContainer, initialState } = getQueryStateContainer(
queryServiceStart
);
expect(querySyncStateContainer.getState()).toMatchInlineSnapshot(`
Object {
"filters": Array [],
"refreshInterval": Object {
"pause": true,
"value": 0,
},
"time": Object {
"from": "now-15m",
"to": "now",
},
}
`);
expect(initialState).toEqual(querySyncStateContainer.getState());
stop();
});
test('state takes initial overrides into account', () => {
const { stop, querySyncStateContainer, initialState } = getQueryStateContainer(
queryServiceStart,
{
time: { from: 'now-99d', to: 'now' },
}
);
expect(querySyncStateContainer.getState().time).toEqual({
from: 'now-99d',
to: 'now',
});
expect(initialState).toEqual(querySyncStateContainer.getState());
stop();
});
test('when filters change, state container contains updated global filters', () => {
const { stop, querySyncStateContainer } = getQueryStateContainer(queryServiceStart);
filterManager.setFilters([gF, aF]);
expect(querySyncStateContainer.getState().filters).toHaveLength(1);
stop();
});
test('when time range changes, state container contains updated time range', () => {
const { stop, querySyncStateContainer } = getQueryStateContainer(queryServiceStart);
timefilter.setTime({ from: 'now-30m', to: 'now' });
expect(querySyncStateContainer.getState().time).toEqual({
from: 'now-30m',
to: 'now',
});
stop();
});
test('when refresh interval changes, state container contains updated refresh interval', () => {
const { stop, querySyncStateContainer } = getQueryStateContainer(queryServiceStart);
timefilter.setRefreshInterval({ pause: true, value: 100 });
expect(querySyncStateContainer.getState().refreshInterval).toEqual({
pause: true,
value: 100,
});
stop();
});
});
});

View file

@ -27,7 +27,7 @@ import {
} from '../../../../kibana_utils/public';
import { COMPARE_ALL_OPTIONS, compareFilters } from '../filter_manager/lib/compare_filters';
import { esFilters, RefreshInterval, TimeRange } from '../../../common';
import { QueryStart } from '../query_service';
import { QuerySetup, QueryStart } from '../query_service';
const GLOBAL_STATE_STORAGE_KEY = '_g';
@ -40,16 +40,11 @@ export interface QuerySyncState {
/**
* Helper utility to set up syncing between query services and url's '_g' query param
*/
export const syncQuery = (
{ timefilter: { timefilter }, filterManager }: QueryStart,
urlStateStorage: IKbnUrlStateStorage
) => {
const defaultState: QuerySyncState = {
time: timefilter.getTime(),
refreshInterval: timefilter.getRefreshInterval(),
filters: filterManager.getGlobalFilters(),
};
export const syncQuery = (queryStart: QueryStart, urlStateStorage: IKbnUrlStateStorage) => {
const {
timefilter: { timefilter },
filterManager,
} = queryStart;
// retrieve current state from `_g` url
const initialStateFromUrl = urlStateStorage.get<QuerySyncState>(GLOBAL_STATE_STORAGE_KEY);
@ -58,10 +53,82 @@ export const syncQuery = (
initialStateFromUrl && Object.keys(initialStateFromUrl).length
);
// prepare initial state, whatever was in URL takes precedences over current state in services
const {
querySyncStateContainer,
stop: stopPullQueryState,
initialState,
} = getQueryStateContainer(queryStart, initialStateFromUrl || {});
const pushQueryStateSubscription = querySyncStateContainer.state$.subscribe(
({ time, filters: globalFilters, refreshInterval }) => {
// cloneDeep is required because services are mutating passed objects
// and state in state container is frozen
if (time && !_.isEqual(time, timefilter.getTime())) {
timefilter.setTime(_.cloneDeep(time));
}
if (refreshInterval && !_.isEqual(refreshInterval, timefilter.getRefreshInterval())) {
timefilter.setRefreshInterval(_.cloneDeep(refreshInterval));
}
if (
globalFilters &&
!compareFilters(globalFilters, filterManager.getGlobalFilters(), COMPARE_ALL_OPTIONS)
) {
filterManager.setGlobalFilters(_.cloneDeep(globalFilters));
}
}
);
// if there weren't any initial state in url,
// then put _g key into url
if (!initialStateFromUrl) {
urlStateStorage.set<QuerySyncState>(GLOBAL_STATE_STORAGE_KEY, initialState, {
replace: true,
});
}
// trigger initial syncing from state container to services if needed
querySyncStateContainer.set(initialState);
const { start, stop: stopSyncState } = syncState({
stateStorage: urlStateStorage,
stateContainer: {
...querySyncStateContainer,
set: state => {
if (state) {
// syncState utils requires to handle incoming "null" value
querySyncStateContainer.set(state);
}
},
},
storageKey: GLOBAL_STATE_STORAGE_KEY,
});
start();
return {
stop: () => {
stopSyncState();
pushQueryStateSubscription.unsubscribe();
stopPullQueryState();
},
hasInheritedQueryFromUrl,
};
};
export const getQueryStateContainer = (
{ timefilter: { timefilter }, filterManager }: QuerySetup,
initialStateOverrides: Partial<QuerySyncState> = {}
) => {
const defaultState: QuerySyncState = {
time: timefilter.getTime(),
refreshInterval: timefilter.getRefreshInterval(),
filters: filterManager.getGlobalFilters(),
};
const initialState: QuerySyncState = {
...defaultState,
...initialStateFromUrl,
...initialStateOverrides,
};
// create state container, which will be used for syncing with syncState() util
@ -109,59 +176,13 @@ export const syncQuery = (
.subscribe(newGlobalFilters => {
querySyncStateContainer.transitions.setFilters(newGlobalFilters);
}),
querySyncStateContainer.state$.subscribe(
({ time, filters: globalFilters, refreshInterval }) => {
// cloneDeep is required because services are mutating passed objects
// and state in state container is frozen
if (time && !_.isEqual(time, timefilter.getTime())) {
timefilter.setTime(_.cloneDeep(time));
}
if (refreshInterval && !_.isEqual(refreshInterval, timefilter.getRefreshInterval())) {
timefilter.setRefreshInterval(_.cloneDeep(refreshInterval));
}
if (
globalFilters &&
!compareFilters(globalFilters, filterManager.getGlobalFilters(), COMPARE_ALL_OPTIONS)
) {
filterManager.setGlobalFilters(_.cloneDeep(globalFilters));
}
}
),
];
// if there weren't any initial state in url,
// then put _g key into url
if (!initialStateFromUrl) {
urlStateStorage.set<QuerySyncState>(GLOBAL_STATE_STORAGE_KEY, initialState, {
replace: true,
});
}
// trigger initial syncing from state container to services if needed
querySyncStateContainer.set(initialState);
const { start, stop } = syncState({
stateStorage: urlStateStorage,
stateContainer: {
...querySyncStateContainer,
set: state => {
if (state) {
// syncState utils requires to handle incoming "null" value
querySyncStateContainer.set(state);
}
},
},
storageKey: GLOBAL_STATE_STORAGE_KEY,
});
start();
return {
querySyncStateContainer,
stop: () => {
subs.forEach(s => s.unsubscribe());
stop();
},
hasInheritedQueryFromUrl,
initialState,
};
};

View file

@ -17,6 +17,8 @@
* under the License.
*/
export * from './search_source/mocks';
export const searchSetupMock = {
registerSearchStrategyContext: jest.fn(),
registerSearchStrategyProvider: jest.fn(),

View file

@ -26,6 +26,7 @@ export {
HomePublicPluginStart,
} from './plugin';
export { FeatureCatalogueEntry, FeatureCatalogueCategory, Environment } from './services';
export * from '../common/instruction_variant';
import { HomePublicPlugin } from './plugin';
export const plugin = (initializerContext: PluginInitializerContext) =>

View file

@ -36,5 +36,5 @@ export const config: PluginConfigDescriptor<ConfigSchema> = {
export const plugin = (initContext: PluginInitializerContext) => new HomeServerPlugin(initContext);
export { INSTRUCTION_VARIANT } from './tutorials/instructions/instruction_variant';
export { INSTRUCTION_VARIANT } from '../common/instruction_variant';
export { ArtifactsSchema, TutorialsCategory } from './services/tutorials';

View file

@ -18,7 +18,7 @@
*/
import { i18n } from '@kbn/i18n';
import { INSTRUCTION_VARIANT } from './instruction_variant';
import { INSTRUCTION_VARIANT } from '../../../common/instruction_variant';
import { createTrycloudOption1, createTrycloudOption2 } from './onprem_cloud_instructions';
import { getSpaceIdForBeatsTutorial } from './get_space_id_for_beats_tutorial';
import { Platform, TutorialContext } from '../../services/tutorials/lib/tutorials_registry_types';

View file

@ -18,7 +18,7 @@
*/
import { i18n } from '@kbn/i18n';
import { INSTRUCTION_VARIANT } from './instruction_variant';
import { INSTRUCTION_VARIANT } from '../../../common/instruction_variant';
import { createTrycloudOption1, createTrycloudOption2 } from './onprem_cloud_instructions';
import { getSpaceIdForBeatsTutorial } from './get_space_id_for_beats_tutorial';
import { Platform, TutorialContext } from '../../services/tutorials/lib/tutorials_registry_types';

View file

@ -18,7 +18,7 @@
*/
import { i18n } from '@kbn/i18n';
import { INSTRUCTION_VARIANT } from './instruction_variant';
import { INSTRUCTION_VARIANT } from '../../../common/instruction_variant';
import { createTrycloudOption1, createTrycloudOption2 } from './onprem_cloud_instructions';
import { getSpaceIdForBeatsTutorial } from './get_space_id_for_beats_tutorial';
import { Platform, TutorialContext } from '../../services/tutorials/lib/tutorials_registry_types';

View file

@ -18,7 +18,7 @@
*/
import { i18n } from '@kbn/i18n';
import { INSTRUCTION_VARIANT } from './instruction_variant';
import { INSTRUCTION_VARIANT } from '../../../common/instruction_variant';
import { createTrycloudOption1, createTrycloudOption2 } from './onprem_cloud_instructions';
import { getSpaceIdForBeatsTutorial } from './get_space_id_for_beats_tutorial';
import { Platform, TutorialContext } from '../../services/tutorials/lib/tutorials_registry_types';

View file

@ -18,7 +18,7 @@
*/
import { i18n } from '@kbn/i18n';
import { INSTRUCTION_VARIANT } from './instruction_variant';
import { INSTRUCTION_VARIANT } from '../../../common/instruction_variant';
import { createTrycloudOption1, createTrycloudOption2 } from './onprem_cloud_instructions';
import { getSpaceIdForBeatsTutorial } from './get_space_id_for_beats_tutorial';
import { TutorialContext } from '../../services/tutorials/lib/tutorials_registry_types';

View file

@ -18,7 +18,7 @@
*/
import { i18n } from '@kbn/i18n';
import { INSTRUCTION_VARIANT } from './instruction_variant';
import { INSTRUCTION_VARIANT } from '../../../common/instruction_variant';
import { createTrycloudOption1, createTrycloudOption2 } from './onprem_cloud_instructions';
import { getSpaceIdForBeatsTutorial } from './get_space_id_for_beats_tutorial';
import { TutorialContext } from '../../services/tutorials/lib/tutorials_registry_types';

View file

@ -19,7 +19,7 @@
import { i18n } from '@kbn/i18n';
import { INSTRUCTION_VARIANT } from '../instructions/instruction_variant';
import { INSTRUCTION_VARIANT } from '../../../common/instruction_variant';
import { createLogstashInstructions } from '../instructions/logstash_instructions';
import { createCommonNetflowInstructions } from './common_instructions';

View file

@ -19,7 +19,7 @@
import { i18n } from '@kbn/i18n';
import { INSTRUCTION_VARIANT } from '../instructions/instruction_variant';
import { INSTRUCTION_VARIANT } from '../../../common/instruction_variant';
import { createLogstashInstructions } from '../instructions/logstash_instructions';
import { createCommonNetflowInstructions } from './common_instructions';

View file

@ -19,7 +19,7 @@
import { i18n } from '@kbn/i18n';
import { INSTRUCTION_VARIANT } from '../instructions/instruction_variant';
import { INSTRUCTION_VARIANT } from '../../../common/instruction_variant';
import { createLogstashInstructions } from '../instructions/logstash_instructions';
import {
createTrycloudOption1,

View file

@ -17,8 +17,8 @@
* under the License.
*/
import { App, PluginInitializerContext } from 'kibana/public';
import { App, AppBase, PluginInitializerContext, AppUpdatableFields } from 'kibana/public';
import { Observable } from 'rxjs';
import { ConfigSchema } from '../config';
interface ForwardDefinition {
@ -27,8 +27,26 @@ interface ForwardDefinition {
keepPrefix: boolean;
}
export type AngularRenderedAppUpdater = (
app: AppBase
) => Partial<AppUpdatableFields & { activeUrl: string }> | undefined;
export interface AngularRenderedApp extends App {
/**
* Angular rendered apps are able to update the active url in the nav link (which is currently not
* possible for actual NP apps). When regular applications have the same functionality, this type
* override can be removed.
*/
updater$?: Observable<AngularRenderedAppUpdater>;
/**
* If the active url is updated via the updater$ subject, the app id is assumed to be identical with
* the nav link id. If this is not the case, it is possible to provide another nav link id here.
*/
navLinkId?: string;
}
export class KibanaLegacyPlugin {
private apps: App[] = [];
private apps: AngularRenderedApp[] = [];
private forwards: ForwardDefinition[] = [];
constructor(private readonly initializerContext: PluginInitializerContext<ConfigSchema>) {}
@ -52,7 +70,7 @@ export class KibanaLegacyPlugin {
*
* @param app The app descriptor
*/
registerLegacyApp: (app: App) => {
registerLegacyApp: (app: AngularRenderedApp) => {
this.apps.push(app);
},

View file

@ -40,6 +40,7 @@ export {
unhashUrl,
unhashQuery,
createUrlTracker,
createKbnUrlTracker,
createKbnUrlControls,
getStateFromKbnUrl,
getStatesFromKbnUrl,

View file

@ -25,4 +25,5 @@ export {
getStatesFromKbnUrl,
IKbnUrlControls,
} from './kbn_url_storage';
export { createKbnUrlTracker } from './kbn_url_tracker';
export { createUrlTracker } from './url_tracker';

View file

@ -0,0 +1,184 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { StubBrowserStorage } from 'test_utils/stub_browser_storage';
import { createMemoryHistory, History } from 'history';
import { createKbnUrlTracker, KbnUrlTracker } from './kbn_url_tracker';
import { BehaviorSubject, Subject } from 'rxjs';
import { AppBase, ToastsSetup } from 'kibana/public';
import { coreMock } from '../../../../../core/public/mocks';
import { unhashUrl } from './hash_unhash_url';
jest.mock('./hash_unhash_url', () => ({
unhashUrl: jest.fn(x => x),
}));
describe('kbnUrlTracker', () => {
let storage: StubBrowserStorage;
let history: History;
let urlTracker: KbnUrlTracker;
let state1Subject: Subject<{ key1: string }>;
let state2Subject: Subject<{ key2: string }>;
let navLinkUpdaterSubject: BehaviorSubject<(app: AppBase) => { activeUrl?: string } | undefined>;
let toastService: jest.Mocked<ToastsSetup>;
function createTracker() {
urlTracker = createKbnUrlTracker({
baseUrl: '/app/test',
defaultSubUrl: '#/start',
storageKey: 'storageKey',
history,
storage,
stateParams: [
{
kbnUrlKey: 'state1',
stateUpdate$: state1Subject.asObservable(),
},
{
kbnUrlKey: 'state2',
stateUpdate$: state2Subject.asObservable(),
},
],
navLinkUpdater$: navLinkUpdaterSubject,
toastNotifications: toastService,
});
}
function getActiveNavLinkUrl() {
return navLinkUpdaterSubject.getValue()({} as AppBase)?.activeUrl;
}
beforeEach(() => {
jest.clearAllMocks();
toastService = coreMock.createSetup().notifications.toasts;
storage = new StubBrowserStorage();
history = createMemoryHistory();
state1Subject = new Subject<{ key1: string }>();
state2Subject = new Subject<{ key2: string }>();
navLinkUpdaterSubject = new BehaviorSubject<
(app: AppBase) => { activeUrl?: string } | undefined
>(() => undefined);
});
test('do not touch nav link to default if nothing else is set', () => {
createTracker();
expect(getActiveNavLinkUrl()).toEqual(undefined);
});
test('set nav link to session storage value if defined', () => {
storage.setItem('storageKey', '#/deep/path');
createTracker();
expect(getActiveNavLinkUrl()).toEqual('/app/test#/deep/path');
});
test('set nav link to default if app gets mounted', () => {
storage.setItem('storageKey', '#/deep/path');
createTracker();
urlTracker.appMounted();
expect(getActiveNavLinkUrl()).toEqual('/app/test#/start');
});
test('keep nav link to default if path gets changed while app mounted', () => {
storage.setItem('storageKey', '#/deep/path');
createTracker();
urlTracker.appMounted();
history.push('/deep/path/2');
expect(getActiveNavLinkUrl()).toEqual('/app/test#/start');
});
test('change nav link to last visited url within app after unmount', () => {
createTracker();
urlTracker.appMounted();
history.push('/deep/path/2');
history.push('/deep/path/3');
urlTracker.appUnMounted();
expect(getActiveNavLinkUrl()).toEqual('/app/test#/deep/path/3');
});
test('unhash all urls that are recorded while app is mounted', () => {
(unhashUrl as jest.Mock).mockImplementation(x => x + '?unhashed');
createTracker();
urlTracker.appMounted();
history.push('/deep/path/2');
history.push('/deep/path/3');
urlTracker.appUnMounted();
expect(unhashUrl).toHaveBeenCalledTimes(2);
expect(getActiveNavLinkUrl()).toEqual('/app/test#/deep/path/3?unhashed');
});
test('show warning and use hashed url if unhashing does not work', () => {
(unhashUrl as jest.Mock).mockImplementation(() => {
throw new Error('unhash broke');
});
createTracker();
urlTracker.appMounted();
history.push('/deep/path/2');
urlTracker.appUnMounted();
expect(getActiveNavLinkUrl()).toEqual('/app/test#/deep/path/2');
expect(toastService.addDanger).toHaveBeenCalledWith('unhash broke');
});
test('change nav link back to default if app gets mounted again', () => {
createTracker();
urlTracker.appMounted();
history.push('/deep/path/2');
history.push('/deep/path/3');
urlTracker.appUnMounted();
urlTracker.appMounted();
expect(getActiveNavLinkUrl()).toEqual('/app/test#/start');
});
test('update state param when app is not mounted', () => {
createTracker();
state1Subject.next({ key1: 'abc' });
expect(getActiveNavLinkUrl()).toMatchInlineSnapshot(`"/app/test#/start?state1=(key1:abc)"`);
});
test('update state param without overwriting rest of the url when app is not mounted', () => {
storage.setItem('storageKey', '#/deep/path?extrastate=1');
createTracker();
state1Subject.next({ key1: 'abc' });
expect(getActiveNavLinkUrl()).toMatchInlineSnapshot(
`"/app/test#/deep/path?extrastate=1&state1=(key1:abc)"`
);
});
test('not update state param when app is mounted', () => {
createTracker();
urlTracker.appMounted();
state1Subject.next({ key1: 'abc' });
expect(getActiveNavLinkUrl()).toEqual('/app/test#/start');
});
test('update state param multiple times when app is not mounted', () => {
createTracker();
state1Subject.next({ key1: 'abc' });
state1Subject.next({ key1: 'def' });
expect(getActiveNavLinkUrl()).toMatchInlineSnapshot(`"/app/test#/start?state1=(key1:def)"`);
});
test('update multiple state params when app is not mounted', () => {
createTracker();
state1Subject.next({ key1: 'abc' });
state2Subject.next({ key2: 'def' });
expect(getActiveNavLinkUrl()).toMatchInlineSnapshot(
`"/app/test#/start?state1=(key1:abc)&state2=(key2:def)"`
);
});
});

View file

@ -0,0 +1,192 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { createHashHistory, History, UnregisterCallback } from 'history';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { AppBase, ToastsSetup } from 'kibana/public';
import { setStateToKbnUrl } from './kbn_url_storage';
import { unhashUrl } from './hash_unhash_url';
export interface KbnUrlTracker {
/**
* Callback to invoke when the app is mounted
*/
appMounted: () => void;
/**
* Callback to invoke when the app is unmounted
*/
appUnMounted: () => void;
/**
* Unregistering the url tracker. This won't reset the current state of the nav link
*/
stop: () => void;
}
/**
* Listens to history changes and optionally to global state changes and updates the nav link url of
* a given app to point to the last visited page within the app.
*
* This includes the following parts:
* * When the app is currently active, the nav link points to the configurable default url of the app.
* * When the app is not active the last visited url is set to the nav link.
* * When a provided observable emits a new value, the state parameter in the url of the nav link is updated
* as long as the app is not active.
*/
export function createKbnUrlTracker({
baseUrl,
defaultSubUrl,
storageKey,
stateParams,
navLinkUpdater$,
toastNotifications,
history,
storage,
}: {
/**
* Base url of the current app. This will be used as a prefix for the
* nav link in the side bar
*/
baseUrl: string;
/**
* Default sub url for this app. If the app is currently active or no sub url is already stored in session storage and the app hasn't been visited yet, the nav link will be set to this url.
*/
defaultSubUrl: string;
/**
* List of URL mapped states that should get updated even when the app is not currently active
*/
stateParams: Array<{
/**
* Key of the query parameter containing the state
*/
kbnUrlKey: string;
/**
* Observable providing updates to the state
*/
stateUpdate$: Observable<unknown>;
}>;
/**
* Key used to store the current sub url in session storage. This key should only be used for one active url tracker at any given ntime.
*/
storageKey: string;
/**
* App updater subject passed into the application definition to change nav link url.
*/
navLinkUpdater$: BehaviorSubject<(app: AppBase) => { activeUrl?: string } | undefined>;
/**
* Toast notifications service to show toasts in error cases.
*/
toastNotifications: ToastsSetup;
/**
* History object to use to track url changes. If this isn't provided, a local history instance will be created.
*/
history?: History;
/**
* Storage object to use to persist currently active url. If this isn't provided, the browser wide session storage instance will be used.
*/
storage?: Storage;
}): KbnUrlTracker {
const historyInstance = history || createHashHistory();
const storageInstance = storage || sessionStorage;
// local state storing current listeners and active url
let activeUrl: string = '';
let unsubscribeURLHistory: UnregisterCallback | undefined;
let unsubscribeGlobalState: Subscription[] | undefined;
function setNavLink(hash: string) {
navLinkUpdater$.next(() => ({ activeUrl: baseUrl + hash }));
}
function getActiveSubUrl(url: string) {
// remove baseUrl prefix (just storing the sub url part)
return url.substr(baseUrl.length);
}
function unsubscribe() {
if (unsubscribeURLHistory) {
unsubscribeURLHistory();
unsubscribeURLHistory = undefined;
}
if (unsubscribeGlobalState) {
unsubscribeGlobalState.forEach(sub => sub.unsubscribe());
unsubscribeGlobalState = undefined;
}
}
function onMountApp() {
unsubscribe();
// track current hash when within app
unsubscribeURLHistory = historyInstance.listen(location => {
const urlWithHashes = baseUrl + '#' + location.pathname + location.search;
let urlWithStates = '';
try {
urlWithStates = unhashUrl(urlWithHashes);
} catch (e) {
toastNotifications.addDanger(e.message);
}
activeUrl = getActiveSubUrl(urlWithStates || urlWithHashes);
storageInstance.setItem(storageKey, activeUrl);
});
}
function onUnmountApp() {
unsubscribe();
// propagate state updates when in other apps
unsubscribeGlobalState = stateParams.map(({ stateUpdate$, kbnUrlKey }) =>
stateUpdate$.subscribe(state => {
const updatedUrl = setStateToKbnUrl(
kbnUrlKey,
state,
{ useHash: false },
baseUrl + (activeUrl || defaultSubUrl)
);
// remove baseUrl prefix (just storing the sub url part)
activeUrl = getActiveSubUrl(updatedUrl);
storageInstance.setItem(storageKey, activeUrl);
setNavLink(activeUrl);
})
);
}
// register listeners for unmounted app initially
onUnmountApp();
// initialize nav link and internal state
const storedUrl = storageInstance.getItem(storageKey);
if (storedUrl) {
activeUrl = storedUrl;
setNavLink(storedUrl);
}
return {
appMounted() {
onMountApp();
setNavLink(defaultSubUrl);
},
appUnMounted() {
onUnmountApp();
setNavLink(activeUrl);
},
stop() {
unsubscribe();
},
};
}