[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:
Matthias Wilhelm 2020-02-18 14:24:22 +01:00 committed by GitHub
parent 8bc3fa4042
commit 23306d8097
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 506 additions and 41 deletions

View file

@ -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),
};
}

View file

@ -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);

View file

@ -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

View file

@ -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)"`
);
});
});

View file

@ -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,
};
}