Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,114 @@ describe('KbnUrlStateStorage', () => {
});
});

describe('useHashQuery: false', () => {
let urlStateStorage: IKbnUrlStateStorage;
let history: History;
const getCurrentUrl = () => history.createHref(history.location);
beforeEach(() => {
history = createBrowserHistory();
history.push('/');
urlStateStorage = createKbnUrlStateStorage({ useHash: false, history, useHashQuery: false });
});

it('should persist state to url', async () => {
const state = { test: 'test', ok: 1 };
const key = '_s';
await urlStateStorage.set(key, state);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/?_s=(ok:1,test:test)"`);
expect(urlStateStorage.get(key)).toEqual(state);
});

it('should flush state to url', () => {
const state = { test: 'test', ok: 1 };
const key = '_s';
urlStateStorage.set(key, state);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
expect(!!urlStateStorage.kbnUrlControls.flush()).toBe(true);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/?_s=(ok:1,test:test)"`);
expect(urlStateStorage.get(key)).toEqual(state);

expect(!!urlStateStorage.kbnUrlControls.flush()).toBe(false); // nothing to flush, not update
});

it('should cancel url updates', async () => {
const state = { test: 'test', ok: 1 };
const key = '_s';
const pr = urlStateStorage.set(key, state);
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
urlStateStorage.cancel();
await pr;
expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
expect(urlStateStorage.get(key)).toEqual(null);
});

it('should cancel url updates if synchronously returned to the same state', async () => {
const state1 = { test: 'test', ok: 1 };
const state2 = { test: 'test', ok: 2 };
const key = '_s';
const pr1 = urlStateStorage.set(key, state1);
await pr1;
const historyLength = history.length;
const pr2 = urlStateStorage.set(key, state2);
const pr3 = urlStateStorage.set(key, state1);
await Promise.all([pr2, pr3]);
expect(history.length).toBe(historyLength);
});

it('should notify about url changes', async () => {
expect(urlStateStorage.change$).toBeDefined();
const key = '_s';
const destroy$ = new Subject();
const result = urlStateStorage.change$!(key).pipe(takeUntil(destroy$), toArray()).toPromise();

history.push(`/?${key}=(ok:1,test:test)`);
history.push(`/?query=test&${key}=(ok:2,test:test)&some=test`);
history.push(`/?query=test&some=test`);

destroy$.next();
destroy$.complete();

expect(await result).toEqual([{ test: 'test', ok: 1 }, { test: 'test', ok: 2 }, null]);
});

it("shouldn't throw in case of parsing error", async () => {
const key = '_s';
history.replace(`/?${key}=(ok:2,test:`); // malformed rison
expect(() => urlStateStorage.get(key)).not.toThrow();
expect(urlStateStorage.get(key)).toBeNull();
});

it('should notify about errors', () => {
const cb = jest.fn();
urlStateStorage = createKbnUrlStateStorage({
useHash: false,
useHashQuery: false,
history,
onGetError: cb,
});
const key = '_s';
history.replace(`/?${key}=(ok:2,test:`); // malformed rison
expect(() => urlStateStorage.get(key)).not.toThrow();
expect(cb).toBeCalledWith(expect.any(Error));
});

describe('withNotifyOnErrors integration', () => {
test('toast is shown', () => {
const toasts = coreMock.createStart().notifications.toasts;
urlStateStorage = createKbnUrlStateStorage({
useHash: true,
useHashQuery: false,
history,
...withNotifyOnErrors(toasts),
});
const key = '_s';
history.replace(`/?${key}=(ok:2,test:`); // malformed rison
expect(() => urlStateStorage.get(key)).not.toThrow();
expect(toasts.addError).toBeCalled();
});
});
});

describe('ScopedHistory integration', () => {
let urlStateStorage: IKbnUrlStateStorage;
let history: ScopedHistory;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,19 @@ export interface IKbnUrlStateStorage extends IStateStorage {
export const createKbnUrlStateStorage = (
{
useHash = false,
useHashQuery = true,
history,
onGetError,
onSetError,
}: {
useHash: boolean;
useHashQuery?: boolean;
history?: History;
onGetError?: (error: Error) => void;
onSetError?: (error: Error) => void;
} = {
useHash: false,
useHashQuery: true,
}
): IKbnUrlStateStorage => {
const url = createKbnUrlControls(history);
Expand All @@ -80,7 +83,12 @@ export const createKbnUrlStateStorage = (
// syncState() utils doesn't wait for this promise
return url.updateAsync((currentUrl) => {
try {
return setStateToKbnUrl(key, state, { useHash }, currentUrl);
return setStateToKbnUrl(
key,
state,
{ useHash, storeInHashQuery: useHashQuery },
currentUrl
);
} catch (error) {
if (onSetError) onSetError(error);
}
Expand All @@ -90,7 +98,7 @@ export const createKbnUrlStateStorage = (
// if there is a pending url update, then state will be extracted from that pending url,
// otherwise current url will be used to retrieve state from
try {
return getStateFromKbnUrl(key, url.getPendingUrl());
return getStateFromKbnUrl(key, url.getPendingUrl(), { getFromHashQuery: useHashQuery });
} catch (e) {
if (onGetError) onGetError(e);
return null;
Expand All @@ -106,7 +114,7 @@ export const createKbnUrlStateStorage = (
unlisten();
};
}).pipe(
map(() => getStateFromKbnUrl<State>(key)),
map(() => getStateFromKbnUrl<State>(key, undefined, { getFromHashQuery: useHashQuery })),
catchError((error) => {
if (onGetError) onGetError(error);
return of(null);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { useKibana } from '../../../../../src/plugins/kibana_react/public';
import { ObservabilityPublicPluginsStart } from '../';

export function useTimefilterService() {
const { services } = useKibana<ObservabilityPublicPluginsStart>();
return services.data.query.timefilter.timefilter;
}
59 changes: 31 additions & 28 deletions x-pack/plugins/observability/public/pages/alerts/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,18 @@ import { EuiButtonEmpty, EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiLink } from '
import { IndexPatternBase } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import React, { useCallback, useRef } from 'react';
import { useHistory } from 'react-router-dom';
import useAsync from 'react-use/lib/useAsync';
import { ParsedTechnicalFields } from '../../../../rule_registry/common/parse_technical_fields';
import type { AlertWorkflowStatus } from '../../../common/typings';
import { ExperimentalBadge } from '../../components/shared/experimental_badge';
import { useBreadcrumbs } from '../../hooks/use_breadcrumbs';
import { useFetcher } from '../../hooks/use_fetcher';
import { usePluginContext } from '../../hooks/use_plugin_context';
import { RouteParams } from '../../routes';
import { useTimefilterService } from '../../hooks/use_timefilter_service';
import { callObservabilityApi } from '../../services/call_observability_api';
import { AlertsSearchBar } from './alerts_search_bar';
import { AlertsTableTGrid } from './alerts_table_t_grid';
import { Provider, alertsPageStateContainer, useAlertsPageStateContainer } from './state_container';
import './styles.scss';
import { WorkflowStatusFilter } from './workflow_status_filter';

Expand All @@ -32,18 +32,24 @@ export interface TopAlert {
active: boolean;
}

interface AlertsPageProps {
routeParams: RouteParams<'/alerts'>;
}
const NO_INDEX_NAMES: string[] = [];
const NO_INDEX_PATTERNS: IndexPatternBase[] = [];

export function AlertsPage({ routeParams }: AlertsPageProps) {
function AlertsPage() {
const { core, plugins, ObservabilityPageTemplate } = usePluginContext();
const { prepend } = core.http.basePath;
const history = useHistory();
const refetch = useRef<() => void>();
const timefilterService = useTimefilterService();
const {
query: { rangeFrom = 'now-15m', rangeTo = 'now', kuery = '', workflowStatus = 'open' },
} = routeParams;
rangeFrom,
setRangeFrom,
rangeTo,
setRangeTo,
kuery,
setKuery,
workflowStatus,
setWorkflowStatus,
} = useAlertsPageStateContainer();

useBreadcrumbs([
{
Expand Down Expand Up @@ -94,33 +100,23 @@ export function AlertsPage({ routeParams }: AlertsPageProps) {

const setWorkflowStatusFilter = useCallback(
(value: AlertWorkflowStatus) => {
const nextSearchParams = new URLSearchParams(history.location.search);
nextSearchParams.set('workflowStatus', value);
history.push({
...history.location,
search: nextSearchParams.toString(),
});
setWorkflowStatus(value);
},
[history]
[setWorkflowStatus]
);

const onQueryChange = useCallback(
({ dateRange, query }) => {
if (rangeFrom === dateRange.from && rangeTo === dateRange.to && kuery === (query ?? '')) {
return refetch.current && refetch.current();
}
const nextSearchParams = new URLSearchParams(history.location.search);

nextSearchParams.set('rangeFrom', dateRange.from);
nextSearchParams.set('rangeTo', dateRange.to);
nextSearchParams.set('kuery', query ?? '');

history.push({
...history.location,
search: nextSearchParams.toString(),
});
timefilterService.setTime(dateRange);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is also possible to subscribe to state container changes and update global time there. This way timefilterService will stay in sync with state container even if dateRange is updated from other places

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good to know, there are some business requirements around defaults on page load that prevents us from doing that in this case though!

setRangeFrom(dateRange.from);
setRangeTo(dateRange.to);
setKuery(query);
Comment on lines +114 to +117
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Much cleaner 👍

},
[history, rangeFrom, rangeTo, kuery]
[rangeFrom, setRangeFrom, rangeTo, setRangeTo, kuery, setKuery, timefilterService]
);

const addToQuery = useCallback(
Expand Down Expand Up @@ -215,5 +211,12 @@ export function AlertsPage({ routeParams }: AlertsPageProps) {
);
}

const NO_INDEX_NAMES: string[] = [];
const NO_INDEX_PATTERNS: IndexPatternBase[] = [];
function WrappedAlertsPage() {
return (
<Provider value={alertsPageStateContainer}>
<AlertsPage />
</Provider>
);
}

export { WrappedAlertsPage as AlertsPage };
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

export { Provider, alertsPageStateContainer } from './state_container';
export { useAlertsPageStateContainer } from './use_alerts_page_state_container';
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import {
createStateContainer,
createStateContainerReactHelpers,
} from '../../../../../../../src/plugins/kibana_utils/public';
import type { AlertWorkflowStatus } from '../../../../common/typings';

interface AlertsPageContainerState {
rangeFrom: string;
rangeTo: string;
kuery: string;
workflowStatus: AlertWorkflowStatus;
}

interface AlertsPageStateTransitions {
setRangeFrom: (
state: AlertsPageContainerState
) => (rangeFrom: string) => AlertsPageContainerState;
setRangeTo: (state: AlertsPageContainerState) => (rangeTo: string) => AlertsPageContainerState;
setKuery: (state: AlertsPageContainerState) => (kuery: string) => AlertsPageContainerState;
setWorkflowStatus: (
state: AlertsPageContainerState
) => (workflowStatus: AlertWorkflowStatus) => AlertsPageContainerState;
}

const defaultState: AlertsPageContainerState = {
rangeFrom: 'now-15m',
rangeTo: 'now',
kuery: '',
workflowStatus: 'open',
};

const transitions: AlertsPageStateTransitions = {
setRangeFrom: (state) => (rangeFrom) => ({ ...state, rangeFrom }),
setRangeTo: (state) => (rangeTo) => ({ ...state, rangeTo }),
setKuery: (state) => (kuery) => ({ ...state, kuery }),
setWorkflowStatus: (state) => (workflowStatus) => ({ ...state, workflowStatus }),
};

const alertsPageStateContainer = createStateContainer(defaultState, transitions);

type AlertsPageStateContainer = typeof alertsPageStateContainer;

const { Provider, useContainer } = createStateContainerReactHelpers<AlertsPageStateContainer>();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice 👍

(It's my first time reviewing the state container in a real world example, so it's nice to see there's some helpers and so on)


export { Provider, alertsPageStateContainer, useContainer, defaultState };
export type { AlertsPageStateContainer, AlertsPageContainerState, AlertsPageStateTransitions };
Loading