diff --git a/src/legacy/core_plugins/kibana/index.js b/src/legacy/core_plugins/kibana/index.js index 8c35044b52c9e..395e0da218307 100644 --- a/src/legacy/core_plugins/kibana/index.js +++ b/src/legacy/core_plugins/kibana/index.js @@ -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, }, { diff --git a/src/legacy/core_plugins/kibana/public/.eslintrc.js b/src/legacy/core_plugins/kibana/public/.eslintrc.js index 9b45217287dc8..b3ee0a8fa7b04 100644 --- a/src/legacy/core_plugins/kibana/public/.eslintrc.js +++ b/src/legacy/core_plugins/kibana/public/.eslintrc.js @@ -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']) + ), }, ], }, diff --git a/src/legacy/core_plugins/kibana/public/dashboard/legacy.ts b/src/legacy/core_plugins/kibana/public/dashboard/legacy.ts index acbc4c4b6c47f..ca2dc9d5fb4f5 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/legacy.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/legacy.ts @@ -41,6 +41,7 @@ async function getAngularDependencies(): Promise(() => ({})); + 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(); + } + } } diff --git a/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial/instruction_set.js b/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial/instruction_set.js index 15bda33534185..631ef1d6e0e42 100644 --- a/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial/instruction_set.js +++ b/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial/instruction_set.js @@ -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, diff --git a/src/legacy/core_plugins/kibana/public/local_application_service/local_application_service.ts b/src/legacy/core_plugins/kibana/public/local_application_service/local_application_service.ts index d52bec8304ff9..c84a3e1eacbd2 100644 --- a/src/legacy/core_plugins/kibana/public/local_application_service/local_application_service.ts +++ b/src/legacy/core_plugins/kibana/public/local_application_service/local_application_service.ts @@ -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 }) => { diff --git a/src/legacy/ui/public/chrome/api/nav.ts b/src/legacy/ui/public/chrome/api/nav.ts index 771314d9e1481..ae32473e451b7 100644 --- a/src/legacy/ui/public/chrome/api/nav.ts +++ b/src/legacy/ui/public/chrome/api/nav.ts @@ -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)), diff --git a/src/plugins/data/public/mocks.ts b/src/plugins/data/public/mocks.ts index 847d79fdc87d1..726cd6cfb18f0 100644 --- a/src/plugins/data/public/mocks.ts +++ b/src/plugins/data/public/mocks.ts @@ -101,6 +101,8 @@ const createStartContract = (): Start => { return startContract; }; +export { searchSourceMock } from './search/mocks'; + export const dataPluginMock = { createSetupContract, createStartContract, diff --git a/src/plugins/data/public/query/state_sync/index.ts b/src/plugins/data/public/query/state_sync/index.ts index 7eefda0d0aec1..27e02940765cf 100644 --- a/src/plugins/data/public/query/state_sync/index.ts +++ b/src/plugins/data/public/query/state_sync/index.ts @@ -17,5 +17,5 @@ * under the License. */ -export { syncQuery } from './sync_query'; +export { syncQuery, getQueryStateContainer } from './sync_query'; export { syncAppFilters } from './sync_app_filters'; diff --git a/src/plugins/data/public/query/state_sync/sync_query.test.ts b/src/plugins/data/public/query/state_sync/sync_query.test.ts index 0973af13cacd5..4796da4f5fd4b 100644 --- a/src/plugins/data/public/query/state_sync/sync_query.test.ts +++ b/src/plugins/data/public/query/state_sync/sync_query.test.ts @@ -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(); + }); + }); }); diff --git a/src/plugins/data/public/query/state_sync/sync_query.ts b/src/plugins/data/public/query/state_sync/sync_query.ts index be641e89f9b76..9a4e9cbba2990 100644 --- a/src/plugins/data/public/query/state_sync/sync_query.ts +++ b/src/plugins/data/public/query/state_sync/sync_query.ts @@ -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(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(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 = {} +) => { + 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(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, }; }; diff --git a/src/plugins/data/public/search/mocks.ts b/src/plugins/data/public/search/mocks.ts index 81a028007bc94..821bd45f731e8 100644 --- a/src/plugins/data/public/search/mocks.ts +++ b/src/plugins/data/public/search/mocks.ts @@ -17,6 +17,8 @@ * under the License. */ +export * from './search_source/mocks'; + export const searchSetupMock = { registerSearchStrategyContext: jest.fn(), registerSearchStrategyProvider: jest.fn(), diff --git a/src/plugins/home/server/tutorials/instructions/instruction_variant.ts b/src/plugins/home/common/instruction_variant.ts similarity index 100% rename from src/plugins/home/server/tutorials/instructions/instruction_variant.ts rename to src/plugins/home/common/instruction_variant.ts diff --git a/src/plugins/home/public/index.ts b/src/plugins/home/public/index.ts index 114d442b40943..2a445cf242729 100644 --- a/src/plugins/home/public/index.ts +++ b/src/plugins/home/public/index.ts @@ -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) => diff --git a/src/plugins/home/server/index.ts b/src/plugins/home/server/index.ts index 02f4c91a414cc..75ace84344216 100644 --- a/src/plugins/home/server/index.ts +++ b/src/plugins/home/server/index.ts @@ -36,5 +36,5 @@ export const config: PluginConfigDescriptor = { 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'; diff --git a/src/plugins/home/server/tutorials/instructions/auditbeat_instructions.ts b/src/plugins/home/server/tutorials/instructions/auditbeat_instructions.ts index 6a9dba69b193f..4c85ad3985b3d 100644 --- a/src/plugins/home/server/tutorials/instructions/auditbeat_instructions.ts +++ b/src/plugins/home/server/tutorials/instructions/auditbeat_instructions.ts @@ -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'; diff --git a/src/plugins/home/server/tutorials/instructions/filebeat_instructions.ts b/src/plugins/home/server/tutorials/instructions/filebeat_instructions.ts index 176a3901821f1..66efa36ec9bcd 100644 --- a/src/plugins/home/server/tutorials/instructions/filebeat_instructions.ts +++ b/src/plugins/home/server/tutorials/instructions/filebeat_instructions.ts @@ -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'; diff --git a/src/plugins/home/server/tutorials/instructions/functionbeat_instructions.ts b/src/plugins/home/server/tutorials/instructions/functionbeat_instructions.ts index 385880ba9780f..ee13b9c5eefd8 100644 --- a/src/plugins/home/server/tutorials/instructions/functionbeat_instructions.ts +++ b/src/plugins/home/server/tutorials/instructions/functionbeat_instructions.ts @@ -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'; diff --git a/src/plugins/home/server/tutorials/instructions/heartbeat_instructions.ts b/src/plugins/home/server/tutorials/instructions/heartbeat_instructions.ts index 406bf55da4321..33f5defc0273f 100644 --- a/src/plugins/home/server/tutorials/instructions/heartbeat_instructions.ts +++ b/src/plugins/home/server/tutorials/instructions/heartbeat_instructions.ts @@ -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'; diff --git a/src/plugins/home/server/tutorials/instructions/metricbeat_instructions.ts b/src/plugins/home/server/tutorials/instructions/metricbeat_instructions.ts index 77efe0958a615..9fdc70e0703a4 100644 --- a/src/plugins/home/server/tutorials/instructions/metricbeat_instructions.ts +++ b/src/plugins/home/server/tutorials/instructions/metricbeat_instructions.ts @@ -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'; diff --git a/src/plugins/home/server/tutorials/instructions/winlogbeat_instructions.ts b/src/plugins/home/server/tutorials/instructions/winlogbeat_instructions.ts index cc18f2ce9705d..9d7d0660d3d6c 100644 --- a/src/plugins/home/server/tutorials/instructions/winlogbeat_instructions.ts +++ b/src/plugins/home/server/tutorials/instructions/winlogbeat_instructions.ts @@ -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'; diff --git a/src/plugins/home/server/tutorials/netflow/elastic_cloud.ts b/src/plugins/home/server/tutorials/netflow/elastic_cloud.ts index ac64aef730004..fbedc6abfbb8a 100644 --- a/src/plugins/home/server/tutorials/netflow/elastic_cloud.ts +++ b/src/plugins/home/server/tutorials/netflow/elastic_cloud.ts @@ -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'; diff --git a/src/plugins/home/server/tutorials/netflow/on_prem.ts b/src/plugins/home/server/tutorials/netflow/on_prem.ts index c7cd36d073632..ef8c3e172af87 100644 --- a/src/plugins/home/server/tutorials/netflow/on_prem.ts +++ b/src/plugins/home/server/tutorials/netflow/on_prem.ts @@ -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'; diff --git a/src/plugins/home/server/tutorials/netflow/on_prem_elastic_cloud.ts b/src/plugins/home/server/tutorials/netflow/on_prem_elastic_cloud.ts index c01a9a5382f88..85aa694970491 100644 --- a/src/plugins/home/server/tutorials/netflow/on_prem_elastic_cloud.ts +++ b/src/plugins/home/server/tutorials/netflow/on_prem_elastic_cloud.ts @@ -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, diff --git a/src/plugins/kibana_legacy/public/plugin.ts b/src/plugins/kibana_legacy/public/plugin.ts index b9a61a1c9b200..7c4b3428cbb6d 100644 --- a/src/plugins/kibana_legacy/public/plugin.ts +++ b/src/plugins/kibana_legacy/public/plugin.ts @@ -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 | 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; + /** + * 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) {} @@ -52,7 +70,7 @@ export class KibanaLegacyPlugin { * * @param app The app descriptor */ - registerLegacyApp: (app: App) => { + registerLegacyApp: (app: AngularRenderedApp) => { this.apps.push(app); }, diff --git a/src/plugins/kibana_utils/public/index.ts b/src/plugins/kibana_utils/public/index.ts index 5b6d304e14c2e..b1d0d6dea7a4a 100644 --- a/src/plugins/kibana_utils/public/index.ts +++ b/src/plugins/kibana_utils/public/index.ts @@ -40,6 +40,7 @@ export { unhashUrl, unhashQuery, createUrlTracker, + createKbnUrlTracker, createKbnUrlControls, getStateFromKbnUrl, getStatesFromKbnUrl, diff --git a/src/plugins/kibana_utils/public/state_management/url/index.ts b/src/plugins/kibana_utils/public/state_management/url/index.ts index 40491bf7a274b..e28d183c6560a 100644 --- a/src/plugins/kibana_utils/public/state_management/url/index.ts +++ b/src/plugins/kibana_utils/public/state_management/url/index.ts @@ -25,4 +25,5 @@ export { getStatesFromKbnUrl, IKbnUrlControls, } from './kbn_url_storage'; +export { createKbnUrlTracker } from './kbn_url_tracker'; export { createUrlTracker } from './url_tracker'; diff --git a/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.test.ts b/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.test.ts new file mode 100644 index 0000000000000..4b17d8517328b --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.test.ts @@ -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; + + 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)"` + ); + }); +}); diff --git a/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.ts b/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.ts new file mode 100644 index 0000000000000..6f3f64ea7b941 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.ts @@ -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; + }>; + /** + * 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(); + }, + }; +}