mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[Discover] Migrate context AppState / GlobalState to use new app state helpers (#57078)
* Remove globalState, migrate to the new helpers * Remove appState, migrate to the new helpers * Add tests
This commit is contained in:
parent
8bc3fa4042
commit
23306d8097
5 changed files with 506 additions and 41 deletions
|
@ -19,13 +19,11 @@
|
|||
|
||||
import _ from 'lodash';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { getAngularModule, getServices, subscribeWithScope } from '../../kibana_services';
|
||||
|
||||
import { getAngularModule, getServices } from '../../kibana_services';
|
||||
import './context_app';
|
||||
import { getState } from './context_state';
|
||||
import contextAppRouteTemplate from './context.html';
|
||||
import { getRootBreadcrumbs } from '../helpers/breadcrumbs';
|
||||
import { FilterStateManager } from '../../../../../data/public';
|
||||
const { chrome } = getServices();
|
||||
|
||||
const k7Breadcrumbs = $route => {
|
||||
const { indexPattern } = $route.current.locals;
|
||||
|
@ -68,53 +66,50 @@ getAngularModule().config($routeProvider => {
|
|||
});
|
||||
});
|
||||
|
||||
function ContextAppRouteController(
|
||||
$routeParams,
|
||||
$scope,
|
||||
AppState,
|
||||
config,
|
||||
$route,
|
||||
getAppState,
|
||||
globalState
|
||||
) {
|
||||
function ContextAppRouteController($routeParams, $scope, config, $route) {
|
||||
const filterManager = getServices().filterManager;
|
||||
const filterStateManager = new FilterStateManager(globalState, getAppState, filterManager);
|
||||
const indexPattern = $route.current.locals.indexPattern.ip;
|
||||
const {
|
||||
startSync: startStateSync,
|
||||
stopSync: stopStateSync,
|
||||
appState,
|
||||
getFilters,
|
||||
setFilters,
|
||||
setAppState,
|
||||
} = getState({
|
||||
defaultStepSize: config.get('context:defaultSize'),
|
||||
timeFieldName: indexPattern.timeFieldName,
|
||||
storeInSessionStorage: config.get('state:storeInSessionStorage'),
|
||||
});
|
||||
this.state = { ...appState.getState() };
|
||||
this.anchorId = $routeParams.id;
|
||||
this.indexPattern = indexPattern;
|
||||
this.discoverUrl = getServices().chrome.navLinks.get('kibana:discover').url;
|
||||
filterManager.setFilters(_.cloneDeep(getFilters()));
|
||||
startStateSync();
|
||||
|
||||
this.state = new AppState(createDefaultAppState(config, indexPattern));
|
||||
this.state.save(true);
|
||||
|
||||
// take care of parameter changes in UI
|
||||
$scope.$watchGroup(
|
||||
[
|
||||
'contextAppRoute.state.columns',
|
||||
'contextAppRoute.state.predecessorCount',
|
||||
'contextAppRoute.state.successorCount',
|
||||
],
|
||||
() => this.state.save(true)
|
||||
newValues => {
|
||||
const [columns, predecessorCount, successorCount] = newValues;
|
||||
if (Array.isArray(columns) && predecessorCount >= 0 && successorCount >= 0) {
|
||||
setAppState({ columns, predecessorCount, successorCount });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const updateSubsciption = subscribeWithScope($scope, filterManager.getUpdates$(), {
|
||||
next: () => {
|
||||
this.filters = _.cloneDeep(filterManager.getFilters());
|
||||
},
|
||||
// take care of parameter filter changes
|
||||
const filterObservable = filterManager.getUpdates$().subscribe(() => {
|
||||
setFilters(filterManager);
|
||||
$route.reload();
|
||||
});
|
||||
|
||||
$scope.$on('$destroy', () => {
|
||||
filterStateManager.destroy();
|
||||
updateSubsciption.unsubscribe();
|
||||
stopStateSync();
|
||||
filterObservable.unsubscribe();
|
||||
});
|
||||
this.anchorId = $routeParams.id;
|
||||
this.indexPattern = indexPattern;
|
||||
this.discoverUrl = chrome.navLinks.get('kibana:discover').url;
|
||||
this.filters = _.cloneDeep(filterManager.getFilters());
|
||||
}
|
||||
|
||||
function createDefaultAppState(config, indexPattern) {
|
||||
return {
|
||||
columns: ['_source'],
|
||||
filters: [],
|
||||
predecessorCount: parseInt(config.get('context:defaultSize'), 10),
|
||||
sort: [indexPattern.timeFieldName, 'desc'],
|
||||
successorCount: parseInt(config.get('context:defaultSize'), 10),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -67,7 +67,7 @@ function fetchContextProvider(indexPatterns: IndexPatternsContract) {
|
|||
size: number,
|
||||
filters: Filter[]
|
||||
) {
|
||||
if (typeof anchor !== 'object' || anchor === null) {
|
||||
if (typeof anchor !== 'object' || anchor === null || !size) {
|
||||
return [];
|
||||
}
|
||||
const indexPattern = await indexPatterns.get(indexPatternId);
|
||||
|
|
|
@ -88,9 +88,11 @@ export function QueryActionsProvider(Promise) {
|
|||
|
||||
const fetchSurroundingRows = (type, state) => {
|
||||
const {
|
||||
queryParameters: { indexPatternId, filters, sort, tieBreakerField },
|
||||
queryParameters: { indexPatternId, sort, tieBreakerField },
|
||||
rows: { anchor },
|
||||
} = state;
|
||||
const filters = getServices().filterManager.getFilters();
|
||||
|
||||
const count =
|
||||
type === 'successors'
|
||||
? state.queryParameters.successorCount
|
||||
|
|
|
@ -0,0 +1,193 @@
|
|||
/*
|
||||
* 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 { getState } from './context_state';
|
||||
import { createBrowserHistory, History } from 'history';
|
||||
import { FilterManager, Filter } from '../../../../../../../plugins/data/public';
|
||||
import { coreMock } from '../../../../../../../core/public/mocks';
|
||||
const setupMock = coreMock.createSetup();
|
||||
|
||||
describe('Test Discover Context State', () => {
|
||||
let history: History;
|
||||
let state: any;
|
||||
const getCurrentUrl = () => history.createHref(history.location);
|
||||
beforeEach(async () => {
|
||||
history = createBrowserHistory();
|
||||
history.push('/');
|
||||
state = await getState({
|
||||
defaultStepSize: '4',
|
||||
timeFieldName: 'time',
|
||||
history,
|
||||
});
|
||||
state.startSync();
|
||||
});
|
||||
afterEach(() => {
|
||||
state.stopSync();
|
||||
});
|
||||
test('getState function default return', () => {
|
||||
expect(state.appState.getState()).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"columns": Array [
|
||||
"_source",
|
||||
],
|
||||
"filters": Array [],
|
||||
"predecessorCount": 4,
|
||||
"sort": Array [
|
||||
"time",
|
||||
"desc",
|
||||
],
|
||||
"successorCount": 4,
|
||||
}
|
||||
`);
|
||||
expect(state.globalState.getState()).toMatchInlineSnapshot(`null`);
|
||||
expect(state.startSync).toBeDefined();
|
||||
expect(state.stopSync).toBeDefined();
|
||||
expect(state.getFilters()).toStrictEqual([]);
|
||||
});
|
||||
test('getState -> setAppState syncing to url', async () => {
|
||||
state.setAppState({ predecessorCount: 10 });
|
||||
state.flushToUrl();
|
||||
expect(getCurrentUrl()).toMatchInlineSnapshot(
|
||||
`"/#?_a=(columns:!(_source),filters:!(),predecessorCount:10,sort:!(time,desc),successorCount:4)"`
|
||||
);
|
||||
});
|
||||
test('getState -> url to appState syncing', async () => {
|
||||
history.push(
|
||||
'/#?_a=(columns:!(_source),predecessorCount:1,sort:!(time,desc),successorCount:1)'
|
||||
);
|
||||
expect(state.appState.getState()).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"columns": Array [
|
||||
"_source",
|
||||
],
|
||||
"predecessorCount": 1,
|
||||
"sort": Array [
|
||||
"time",
|
||||
"desc",
|
||||
],
|
||||
"successorCount": 1,
|
||||
}
|
||||
`);
|
||||
});
|
||||
test('getState -> url to appState syncing with return to a url without state', async () => {
|
||||
history.push(
|
||||
'/#?_a=(columns:!(_source),predecessorCount:1,sort:!(time,desc),successorCount:1)'
|
||||
);
|
||||
expect(state.appState.getState()).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"columns": Array [
|
||||
"_source",
|
||||
],
|
||||
"predecessorCount": 1,
|
||||
"sort": Array [
|
||||
"time",
|
||||
"desc",
|
||||
],
|
||||
"successorCount": 1,
|
||||
}
|
||||
`);
|
||||
history.push('/');
|
||||
expect(state.appState.getState()).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"columns": Array [
|
||||
"_source",
|
||||
],
|
||||
"predecessorCount": 1,
|
||||
"sort": Array [
|
||||
"time",
|
||||
"desc",
|
||||
],
|
||||
"successorCount": 1,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('getState -> filters', async () => {
|
||||
const filterManager = new FilterManager(setupMock.uiSettings);
|
||||
const filterGlobal = {
|
||||
query: { match: { extension: { query: 'jpg', type: 'phrase' } } },
|
||||
meta: { index: 'logstash-*', negate: false, disabled: false, alias: null },
|
||||
} as Filter;
|
||||
filterManager.setGlobalFilters([filterGlobal]);
|
||||
const filterApp = {
|
||||
query: { match: { extension: { query: 'png', type: 'phrase' } } },
|
||||
meta: { index: 'logstash-*', negate: true, disabled: false, alias: null },
|
||||
} as Filter;
|
||||
filterManager.setAppFilters([filterApp]);
|
||||
state.setFilters(filterManager);
|
||||
expect(state.getFilters()).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"$state": Object {
|
||||
"store": "globalState",
|
||||
},
|
||||
"meta": Object {
|
||||
"alias": null,
|
||||
"disabled": false,
|
||||
"index": "logstash-*",
|
||||
"key": "extension",
|
||||
"negate": false,
|
||||
"params": Object {
|
||||
"query": "jpg",
|
||||
},
|
||||
"type": "phrase",
|
||||
"value": [Function],
|
||||
},
|
||||
"query": Object {
|
||||
"match": Object {
|
||||
"extension": Object {
|
||||
"query": "jpg",
|
||||
"type": "phrase",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"$state": Object {
|
||||
"store": "appState",
|
||||
},
|
||||
"meta": Object {
|
||||
"alias": null,
|
||||
"disabled": false,
|
||||
"index": "logstash-*",
|
||||
"key": "extension",
|
||||
"negate": true,
|
||||
"params": Object {
|
||||
"query": "png",
|
||||
},
|
||||
"type": "phrase",
|
||||
"value": [Function],
|
||||
},
|
||||
"query": Object {
|
||||
"match": Object {
|
||||
"extension": Object {
|
||||
"query": "png",
|
||||
"type": "phrase",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
`);
|
||||
state.flushToUrl();
|
||||
expect(getCurrentUrl()).toMatchInlineSnapshot(
|
||||
`"/#?_g=(filters:!(('$state':(store:globalState),meta:(alias:!n,disabled:!f,index:'logstash-*',key:extension,negate:!f,params:(query:jpg),type:phrase),query:(match:(extension:(query:jpg,type:phrase))))))&_a=(columns:!(_source),filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:'logstash-*',key:extension,negate:!t,params:(query:png),type:phrase),query:(match:(extension:(query:png,type:phrase))))),predecessorCount:4,sort:!(time,desc),successorCount:4)"`
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,275 @@
|
|||
/*
|
||||
* 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 _ from 'lodash';
|
||||
import { createBrowserHistory, History } from 'history';
|
||||
import {
|
||||
createStateContainer,
|
||||
createKbnUrlStateStorage,
|
||||
syncStates,
|
||||
BaseStateContainer,
|
||||
} from '../../../../../../../plugins/kibana_utils/public';
|
||||
import { esFilters, FilterManager, Filter } from '../../../../../../../plugins/data/public';
|
||||
|
||||
interface AppState {
|
||||
/**
|
||||
* Columns displayed in the table, cannot be changed by UI, just in discover's main app
|
||||
*/
|
||||
columns: string[];
|
||||
/**
|
||||
* Array of filters
|
||||
*/
|
||||
filters: Filter[];
|
||||
/**
|
||||
* Number of records to be fetched before anchor records (newer records)
|
||||
*/
|
||||
predecessorCount: number;
|
||||
/**
|
||||
* Sorting of the records to be fetched, assumed to be a legacy parameter
|
||||
*/
|
||||
sort: string[];
|
||||
/**
|
||||
* Number of records to be fetched after the anchor records (older records)
|
||||
*/
|
||||
successorCount: number;
|
||||
}
|
||||
|
||||
interface GlobalState {
|
||||
/**
|
||||
* Array of filters
|
||||
*/
|
||||
filters: Filter[];
|
||||
}
|
||||
|
||||
interface GetStateParams {
|
||||
/**
|
||||
* Number of records to be fetched when 'Load' link/button is clicked
|
||||
*/
|
||||
defaultStepSize: string;
|
||||
/**
|
||||
* The timefield used for sorting
|
||||
*/
|
||||
timeFieldName: string;
|
||||
/**
|
||||
* Determins the use of long vs. short/hashed urls
|
||||
*/
|
||||
storeInSessionStorage?: boolean;
|
||||
/**
|
||||
* Browser history used for testing
|
||||
*/
|
||||
history?: History;
|
||||
}
|
||||
|
||||
interface GetStateReturn {
|
||||
/**
|
||||
* Global state, the _g part of the URL
|
||||
*/
|
||||
globalState: BaseStateContainer<GlobalState>;
|
||||
/**
|
||||
* App state, the _a part of the URL
|
||||
*/
|
||||
appState: BaseStateContainer<AppState>;
|
||||
/**
|
||||
* Start sync between state and URL
|
||||
*/
|
||||
startSync: () => void;
|
||||
/**
|
||||
* Stop sync between state and URL
|
||||
*/
|
||||
stopSync: () => void;
|
||||
/**
|
||||
* Set app state to with a partial new app state
|
||||
*/
|
||||
setAppState: (newState: Partial<AppState>) => void;
|
||||
/**
|
||||
* Get all filters, global and app state
|
||||
*/
|
||||
getFilters: () => Filter[];
|
||||
/**
|
||||
* Set global state and app state filters by the given FilterManager instance
|
||||
* @param filterManager
|
||||
*/
|
||||
setFilters: (filterManager: FilterManager) => void;
|
||||
/**
|
||||
* sync state to URL, used for testing
|
||||
*/
|
||||
flushToUrl: () => void;
|
||||
}
|
||||
const GLOBAL_STATE_URL_KEY = '_g';
|
||||
const APP_STATE_URL_KEY = '_a';
|
||||
|
||||
/**
|
||||
* Builds and returns appState and globalState containers
|
||||
* provides helper functions to start/stop syncing with URL
|
||||
*/
|
||||
export function getState({
|
||||
defaultStepSize,
|
||||
timeFieldName,
|
||||
storeInSessionStorage = false,
|
||||
history,
|
||||
}: GetStateParams): GetStateReturn {
|
||||
const stateStorage = createKbnUrlStateStorage({
|
||||
useHash: storeInSessionStorage,
|
||||
history: history ? history : createBrowserHistory(),
|
||||
});
|
||||
|
||||
const globalStateInitial = stateStorage.get(GLOBAL_STATE_URL_KEY) as GlobalState;
|
||||
const globalStateContainer = createStateContainer<GlobalState>(globalStateInitial);
|
||||
|
||||
const appStateFromUrl = stateStorage.get(APP_STATE_URL_KEY) as AppState;
|
||||
const appStateInitial = createInitialAppState(defaultStepSize, timeFieldName, appStateFromUrl);
|
||||
const appStateContainer = createStateContainer<AppState>(appStateInitial);
|
||||
|
||||
const { start, stop } = syncStates([
|
||||
{
|
||||
storageKey: GLOBAL_STATE_URL_KEY,
|
||||
stateContainer: {
|
||||
...globalStateContainer,
|
||||
...{
|
||||
set: (value: GlobalState | null) => {
|
||||
if (value) {
|
||||
globalStateContainer.set(value);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
stateStorage,
|
||||
},
|
||||
{
|
||||
storageKey: APP_STATE_URL_KEY,
|
||||
stateContainer: {
|
||||
...appStateContainer,
|
||||
...{
|
||||
set: (value: AppState | null) => {
|
||||
if (value) {
|
||||
appStateContainer.set(value);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
stateStorage,
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
globalState: globalStateContainer,
|
||||
appState: appStateContainer,
|
||||
startSync: start,
|
||||
stopSync: stop,
|
||||
setAppState: (newState: Partial<AppState>) => {
|
||||
const oldState = appStateContainer.getState();
|
||||
const mergedState = { ...oldState, ...newState };
|
||||
|
||||
if (!isEqualState(oldState, mergedState)) {
|
||||
appStateContainer.set(mergedState);
|
||||
}
|
||||
},
|
||||
getFilters: () => [
|
||||
...getFilters(globalStateContainer.getState()),
|
||||
...getFilters(appStateContainer.getState()),
|
||||
],
|
||||
setFilters: (filterManager: FilterManager) => {
|
||||
// global state filters
|
||||
const globalFilters = filterManager.getGlobalFilters();
|
||||
const globalFilterChanged = !isEqualFilters(
|
||||
globalFilters,
|
||||
getFilters(globalStateContainer.getState())
|
||||
);
|
||||
if (globalFilterChanged) {
|
||||
globalStateContainer.set({ filters: globalFilters });
|
||||
}
|
||||
// app state filters
|
||||
const appFilters = filterManager.getAppFilters();
|
||||
const appFilterChanged = !isEqualFilters(
|
||||
appFilters,
|
||||
getFilters(appStateContainer.getState())
|
||||
);
|
||||
if (appFilterChanged) {
|
||||
appStateContainer.set({ ...appStateContainer.getState(), ...{ filters: appFilters } });
|
||||
}
|
||||
},
|
||||
// helper function just needed for testing
|
||||
flushToUrl: () => stateStorage.flush(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to compare 2 different filter states
|
||||
*/
|
||||
export function isEqualFilters(filtersA: Filter[], filtersB: Filter[]) {
|
||||
if (!filtersA && !filtersB) {
|
||||
return true;
|
||||
} else if (!filtersA || !filtersB) {
|
||||
return false;
|
||||
}
|
||||
return esFilters.compareFilters(filtersA, filtersB, esFilters.COMPARE_ALL_OPTIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to compare 2 different states, is needed since comparing filters
|
||||
* works differently, doesn't work with _.isEqual
|
||||
*/
|
||||
function isEqualState(stateA: AppState | GlobalState, stateB: AppState | GlobalState) {
|
||||
if (!stateA && !stateB) {
|
||||
return true;
|
||||
} else if (!stateA || !stateB) {
|
||||
return false;
|
||||
}
|
||||
const { filters: stateAFilters = [], ...stateAPartial } = stateA;
|
||||
const { filters: stateBFilters = [], ...stateBPartial } = stateB;
|
||||
return (
|
||||
_.isEqual(stateAPartial, stateBPartial) &&
|
||||
esFilters.compareFilters(stateAFilters, stateBFilters, esFilters.COMPARE_ALL_OPTIONS)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to return array of filter object of a given state
|
||||
*/
|
||||
function getFilters(state: AppState | GlobalState): Filter[] {
|
||||
if (!state || !Array.isArray(state.filters)) {
|
||||
return [];
|
||||
}
|
||||
return state.filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to return the initial app state, which is a merged object of url state and
|
||||
* default state. The default size is the default number of successor/predecessor records to fetch
|
||||
*/
|
||||
function createInitialAppState(
|
||||
defaultSize: string,
|
||||
timeFieldName: string,
|
||||
urlState: AppState
|
||||
): AppState {
|
||||
const defaultState = {
|
||||
columns: ['_source'],
|
||||
filters: [],
|
||||
predecessorCount: parseInt(defaultSize, 10),
|
||||
sort: [timeFieldName, 'desc'],
|
||||
successorCount: parseInt(defaultSize, 10),
|
||||
};
|
||||
if (typeof urlState !== 'object') {
|
||||
return defaultState;
|
||||
}
|
||||
|
||||
return {
|
||||
...defaultState,
|
||||
...urlState,
|
||||
};
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue