[Infra][Logs Explorer] Factor out shared custom hook into a package (#182336)

## 📓 Summary

This work is just the extraction of a utility hook implemented in the
`infra` plugin for re-usability, as there are some scenarios where it
becomes handy in the logs explorer plugin too.

For improved reusability in future, I extracted the useBoolean hook into
a react-hooks package, where additional stateful utility hooks can go in
future.

This also replaces the current usage of the hook in the `infra` plugin
and is used to refactor part of the logs_explorer top nav.

---------

Co-authored-by: Marco Antonio Ghiani <marcoantonio.ghiani@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Marco Antonio Ghiani 2024-05-06 16:17:39 +02:00 committed by GitHub
parent 9a320b0507
commit 7a69fdd469
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 264 additions and 81 deletions

1
.github/CODEOWNERS vendored
View file

@ -639,6 +639,7 @@ x-pack/plugins/observability_solution/profiling @elastic/obs-ux-infra_services-t
packages/kbn-profiling-utils @elastic/obs-ux-infra_services-team
x-pack/packages/kbn-random-sampling @elastic/kibana-visualizations
packages/kbn-react-field @elastic/kibana-data-discovery
packages/kbn-react-hooks @elastic/obs-ux-logs-team
packages/react/kibana_context/common @elastic/appex-sharedux
packages/react/kibana_context/render @elastic/appex-sharedux
packages/react/kibana_context/root @elastic/appex-sharedux

View file

@ -650,6 +650,7 @@
"@kbn/profiling-utils": "link:packages/kbn-profiling-utils",
"@kbn/random-sampling": "link:x-pack/packages/kbn-random-sampling",
"@kbn/react-field": "link:packages/kbn-react-field",
"@kbn/react-hooks": "link:packages/kbn-react-hooks",
"@kbn/react-kibana-context-common": "link:packages/react/kibana_context/common",
"@kbn/react-kibana-context-render": "link:packages/react/kibana_context/render",
"@kbn/react-kibana-context-root": "link:packages/react/kibana_context/root",

View file

@ -0,0 +1,28 @@
# @kbn/react-hooks
A utility package, `@kbn/react-hooks`, provides custom react hooks for simple abstractions.
## Custom Hooks
### [useBoolean](./src/useBoolean)
Simplify handling boolean value with predefined handlers.
```tsx
function App() {
const [value, { on, off, toggle }] = useBoolean();
return (
<div>
<EuiText>
The current value is <strong>{value.toString().toUpperCase()}</strong>
</EuiText>
<EuiFlexGroup>
<EuiButton onClick={on}>On</EuiButton>
<EuiButton onClick={off}>Off</EuiButton>
<EuiButton onClick={toggle}>Toggle</EuiButton>
</EuiFlexGroup>
</div>
);
}
```

View file

@ -0,0 +1,10 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { useBoolean } from './src/use_boolean';
export type { UseBooleanHandlers, UseBooleanResult } from './src/use_boolean';

View file

@ -0,0 +1,13 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-react-hooks'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/react-hooks",
"owner": "@elastic/obs-ux-logs-team"
}

View file

@ -0,0 +1,7 @@
{
"name": "@kbn/react-hooks",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0",
"sideEffects": false
}

View file

@ -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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export * from './use_boolean';

View file

@ -0,0 +1,105 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { act, cleanup, renderHook } from '@testing-library/react-hooks';
import { useBoolean } from './use_boolean';
describe('useBoolean hook', () => {
afterEach(cleanup);
it('should return an array. The first element should be a boolean, the second element is an object which should contain three functions: "on", "off" and "toggle"', () => {
const { result } = renderHook(() => useBoolean());
const [value, handlers] = result.current;
expect(typeof value).toBe('boolean');
expect(typeof handlers).toBe('object');
expect(typeof handlers.on).toBe('function');
expect(typeof handlers.off).toBe('function');
expect(typeof handlers.toggle).toBe('function');
});
it('should initialize the value with false by default', () => {
const { result } = renderHook(() => useBoolean());
const [value, handlers] = result.current;
expect(value).toBe(false);
expect(typeof handlers).toBe('object');
});
it('should toggle the value when no value is passed to the updater function', () => {
const { result } = renderHook(() => useBoolean());
const [, { toggle }] = result.current;
expect(result.current[0]).toBe(false);
act(() => {
toggle();
});
expect(result.current[0]).toBe(true);
act(() => {
toggle();
});
expect(result.current[0]).toBe(false);
});
it('should set the value to true the value when the "on" method is invoked, once or multiple times', () => {
const { result } = renderHook(() => useBoolean());
const [, { on }] = result.current;
expect(result.current[0]).toBe(false);
act(() => {
on();
});
expect(result.current[0]).toBe(true);
act(() => {
on();
on();
});
expect(result.current[0]).toBe(true);
});
it('should set the value to false the value when the "off" method is invoked, once or multiple times', () => {
const { result } = renderHook(() => useBoolean(true));
const [, { off }] = result.current;
expect(result.current[0]).toBe(true);
act(() => {
off();
});
expect(result.current[0]).toBe(false);
act(() => {
off();
off();
});
expect(result.current[0]).toBe(false);
});
it('should return the handlers as memoized functions', () => {
const { result } = renderHook(() => useBoolean(true));
const [, { on, off, toggle }] = result.current;
expect(typeof on).toBe('function');
expect(typeof off).toBe('function');
expect(typeof toggle).toBe('function');
act(() => {
toggle();
});
const [, handlers] = result.current;
expect(on).toBe(handlers.on);
expect(off).toBe(handlers.off);
expect(toggle).toBe(handlers.toggle);
});
});

View file

@ -0,0 +1,35 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { useMemo } from 'react';
import useToggle from 'react-use/lib/useToggle';
export type VoidHandler = () => void;
export interface UseBooleanHandlers {
on: VoidHandler;
off: VoidHandler;
toggle: ReturnType<typeof useToggle>[1];
}
export type UseBooleanResult = [boolean, UseBooleanHandlers];
export const useBoolean = (initialValue: boolean = false): UseBooleanResult => {
const [value, toggle] = useToggle(initialValue);
const handlers = useMemo(
() => ({
toggle,
on: () => toggle(true),
off: () => toggle(false),
}),
[toggle]
);
return [value, handlers];
};

View file

@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"react",
]
},
"include": [
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"target/**/*"
],
"kbn_references": []
}

View file

@ -1272,6 +1272,8 @@
"@kbn/random-sampling/*": ["x-pack/packages/kbn-random-sampling/*"],
"@kbn/react-field": ["packages/kbn-react-field"],
"@kbn/react-field/*": ["packages/kbn-react-field/*"],
"@kbn/react-hooks": ["packages/kbn-react-hooks"],
"@kbn/react-hooks/*": ["packages/kbn-react-hooks/*"],
"@kbn/react-kibana-context-common": ["packages/react/kibana_context/common"],
"@kbn/react-kibana-context-common/*": ["packages/react/kibana_context/common/*"],
"@kbn/react-kibana-context-render": ["packages/react/kibana_context/render"],

View file

@ -8,7 +8,7 @@
import { EuiPopover, EuiIcon, type IconType, type IconColor, type IconSize } from '@elastic/eui';
import { css } from '@emotion/react';
import React from 'react';
import { useBoolean } from '../../../../hooks/use_boolean';
import { useBoolean } from '@kbn/react-hooks';
export const Popover = ({
children,

View file

@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n';
import React from 'react';
import { EuiButtonIcon, EuiContextMenuPanel, EuiContextMenuItem, EuiPopover } from '@elastic/eui';
import { useBoolean } from '../../../../hooks/use_boolean';
import { useBoolean } from '@kbn/react-hooks';
interface Props {
items: React.ReactNode[];

View file

@ -10,9 +10,9 @@ import { EuiFlexGroup, EuiFlexItem, type EuiAccordionProps } from '@elastic/eui'
import type { TimeRange } from '@kbn/es-query';
import type { InventoryItemType } from '@kbn/metrics-data-access-plugin/common';
import { findInventoryFields } from '@kbn/metrics-data-access-plugin/common';
import { useBoolean } from '@kbn/react-hooks';
import { usePluginConfig } from '../../../../../containers/plugin_config_context';
import { AlertFlyout } from '../../../../../alerting/inventory/components/alert_flyout';
import { useBoolean } from '../../../../../hooks/use_boolean';
import { AlertsSectionTitle } from '../section_titles';
import { useAssetDetailsRenderPropsContext } from '../../../hooks/use_asset_details_render_props';
import { Section } from '../../../components/section';

View file

@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n';
import { EuiButton, EuiPopover, EuiListGroup, EuiListGroupItem } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { NonEmptyString } from '@kbn/io-ts-utils';
import { useBoolean } from '@kbn/react-hooks';
import {
SavedViewState,
SavedViewOperations,
@ -17,7 +18,6 @@ import {
BasicAttributes,
} from '../../../common/saved_views';
import { ManageViewsFlyout } from './manage_views_flyout';
import { useBoolean } from '../../hooks/use_boolean';
import { UpsertViewModal } from './upsert_modal';
interface Props<TSingleSavedViewState extends SavedViewItem, TViewState>

View file

@ -7,7 +7,7 @@
import React, { useCallback } from 'react';
import { EuiPopover, EuiIcon } from '@elastic/eui';
import { useBoolean } from '../../../../../hooks/use_boolean';
import { useBoolean } from '@kbn/react-hooks';
export const Popover = ({ children }: { children: React.ReactNode }) => {
const [isPopoverOpen, { off: closePopover, toggle: togglePopover }] = useBoolean(false);

View file

@ -9,8 +9,7 @@ import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiPopover, EuiButtonEmpty, useEuiTheme, euiCanAnimate } from '@elastic/eui';
import { cx, css } from '@emotion/css';
import { useBoolean } from '../../../../../hooks/use_boolean';
import { useBoolean } from '@kbn/react-hooks';
const selectedHostsLabel = (selectedHostsCount: number) => {
return i18n.translate('xpack.infra.hostsViewPage.table.selectedHostsButton', {

View file

@ -9,6 +9,7 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { AlertConsumers, ALERT_RULE_PRODUCER } from '@kbn/rule-data-utils';
import { BrushEndListener, type XYBrushEvent } from '@elastic/charts';
import { useSummaryTimeRange } from '@kbn/observability-plugin/public';
import { useBoolean } from '@kbn/react-hooks';
import { useKibanaContextForPlugin } from '../../../../../../hooks/use_kibana';
import { HeightRetainer } from '../../../../../../components/height_retainer';
import { useUnifiedSearchContext } from '../../../hooks/use_unified_search';
@ -25,7 +26,6 @@ import { CreateAlertRuleButton } from '../../../../../../components/shared/alert
import { LinkToAlertsPage } from '../../../../../../components/shared/alerts/links/link_to_alerts_page';
import { INFRA_ALERT_FEATURE_ID } from '../../../../../../../common/constants';
import { AlertFlyout } from '../../../../../../alerting/inventory/components/alert_flyout';
import { useBoolean } from '../../../../../../hooks/use_boolean';
import { usePluginConfig } from '../../../../../../containers/plugin_config_context';
export const AlertsTabContent = () => {

View file

@ -10,7 +10,7 @@ import React from 'react';
import { first } from 'lodash';
import { EuiPopover, EuiToolTip } from '@elastic/eui';
import { InventoryItemType } from '@kbn/metrics-data-access-plugin/common';
import { useBoolean } from '../../../../../hooks/use_boolean';
import { useBoolean } from '@kbn/react-hooks';
import {
InfraWaffleMapBounds,
InfraWaffleMapNode,

View file

@ -12,7 +12,7 @@ import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import { DispatchWithOptionalAction } from '../../../../../hooks/use_boolean';
import { UseBooleanHandlers } from '@kbn/react-hooks';
type NodeProps<T = HTMLDivElement> = React.DetailedHTMLProps<React.HTMLAttributes<T>, T> & {
'data-test-subj'?: string;
@ -162,7 +162,7 @@ export const NodeSquare = ({
showBorder,
}: {
squareSize: number;
togglePopover: DispatchWithOptionalAction<boolean>;
togglePopover: UseBooleanHandlers['toggle'];
showToolTip: () => void;
hideToolTip: () => void;
color: string;
@ -203,6 +203,7 @@ export const NodeSquare = ({
) : (
ellipsisMode && (
<ValueInner aria-label={nodeAriaLabel}>
{/* eslint-disable-next-line @kbn/i18n/strings_should_be_translated_with_i18n */}
<Label color={color}>...</Label>
</ValueInner>
)

View file

@ -97,6 +97,7 @@
"@kbn/dashboard-plugin",
"@kbn/shared-svg",
"@kbn/aiops-log-rate-analysis",
"@kbn/react-hooks",
"@kbn/search-types"
],
"exclude": [

View file

@ -12,7 +12,7 @@ import {
EuiContextMenuItem,
EuiHorizontalRule,
} from '@elastic/eui';
import React, { useMemo, useReducer } from 'react';
import React, { useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { OBSERVABILITY_THRESHOLD_RULE_TYPE_ID } from '@kbn/rule-data-utils';
import { useActor } from '@xstate/react';
@ -24,59 +24,12 @@ import { useLinkProps } from '@kbn/observability-shared-plugin/public';
import { sloFeatureId } from '@kbn/observability-shared-plugin/common';
import { loadRuleTypes } from '@kbn/triggers-actions-ui-plugin/public';
import useAsync from 'react-use/lib/useAsync';
import { useBoolean } from '@kbn/react-hooks';
import { useKibanaContextForPlugin } from '../utils/use_kibana';
import { useObservabilityLogsExplorerPageStateContext } from '../state_machines/observability_logs_explorer/src';
type ThresholdRuleTypeParams = Pick<AlertParams, 'searchConfiguration'>;
interface AlertsPopoverState {
isPopoverOpen: boolean;
isAddRuleFlyoutOpen: boolean;
isCreateSLOFlyoutOpen: boolean;
}
type AlertsPopoverAction =
| {
type: 'togglePopover';
isOpen?: boolean;
}
| {
type: 'toggleAddRuleFlyout';
isOpen?: boolean;
}
| {
type: 'toggleCreateSLOFlyout';
isOpen?: boolean;
};
function alertsPopoverReducer(state: AlertsPopoverState, action: AlertsPopoverAction) {
switch (action.type) {
case 'togglePopover':
return {
isPopoverOpen: action.isOpen ?? !state.isPopoverOpen,
isAddRuleFlyoutOpen: state.isAddRuleFlyoutOpen,
isCreateSLOFlyoutOpen: state.isCreateSLOFlyoutOpen,
};
case 'toggleAddRuleFlyout':
return {
isPopoverOpen: false,
isAddRuleFlyoutOpen: action.isOpen ?? !state.isAddRuleFlyoutOpen,
isCreateSLOFlyoutOpen: false,
};
case 'toggleCreateSLOFlyout':
return {
isPopoverOpen: false,
isAddRuleFlyoutOpen: false,
isCreateSLOFlyoutOpen: action.isOpen ?? !state.isAddRuleFlyoutOpen,
};
default:
return state;
}
}
const defaultQuery: Query = {
language: 'kuery',
query: '',
@ -97,22 +50,14 @@ export const AlertsPopover = () => {
const [pageState] = useActor(useObservabilityLogsExplorerPageStateContext());
const [state, dispatch] = useReducer(alertsPopoverReducer, {
isPopoverOpen: false,
isAddRuleFlyoutOpen: false,
isCreateSLOFlyoutOpen: false,
});
const togglePopover = () => dispatch({ type: 'togglePopover' });
const closePopover = () => dispatch({ type: 'togglePopover', isOpen: false });
const openAddRuleFlyout = () => dispatch({ type: 'toggleAddRuleFlyout', isOpen: true });
const closeAddRuleFlyout = () => dispatch({ type: 'toggleAddRuleFlyout', isOpen: false });
const openCreateSLOFlyout = () => dispatch({ type: 'toggleCreateSLOFlyout', isOpen: true });
const closeCreateSLOFlyout = () => dispatch({ type: 'toggleCreateSLOFlyout', isOpen: false });
const [isPopoverOpen, { toggle: togglePopover, off: closePopover }] = useBoolean();
const [isAddRuleFlyoutOpen, { on: openAddRuleFlyout, off: closeAddRuleFlyout }] = useBoolean();
const [isCreateSLOFlyoutOpen, { on: openCreateSLOFlyout, off: closeCreateSLOFlyout }] =
useBoolean();
const addRuleFlyout = useMemo(() => {
if (
state.isAddRuleFlyoutOpen &&
isAddRuleFlyoutOpen &&
triggersActionsUi &&
pageState.matches({ initialized: 'validLogsExplorerState' })
) {
@ -141,13 +86,10 @@ export const AlertsPopover = () => {
onClose: closeAddRuleFlyout,
});
}
}, [triggersActionsUi, pageState, state.isAddRuleFlyoutOpen]);
}, [closeAddRuleFlyout, triggersActionsUi, pageState, isAddRuleFlyoutOpen]);
const createSLOFlyout = useMemo(() => {
if (
state.isCreateSLOFlyoutOpen &&
pageState.matches({ initialized: 'validLogsExplorerState' })
) {
if (isCreateSLOFlyoutOpen && pageState.matches({ initialized: 'validLogsExplorerState' })) {
const { logsExplorerState } = pageState.context;
const dataView = hydrateDataSourceSelection(
logsExplorerState.dataSourceSelection
@ -178,7 +120,7 @@ export const AlertsPopover = () => {
onClose: closeCreateSLOFlyout,
});
}
}, [slo, pageState, state.isCreateSLOFlyoutOpen]);
}, [isCreateSLOFlyoutOpen, pageState, slo, closeCreateSLOFlyout]);
// Check whether the user has the necessary permissions to create an SLO
const canCreateSLOs = !!application.capabilities[sloFeatureId]?.write;
@ -252,12 +194,12 @@ export const AlertsPopover = () => {
/>
</EuiButtonEmpty>
}
isOpen={state.isPopoverOpen}
isOpen={isPopoverOpen}
closePopover={closePopover}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenuPanel items={items} />
<EuiContextMenuPanel onClick={closePopover} items={items} />
</EuiPopover>
</>
);

View file

@ -50,6 +50,7 @@
"@kbn/es-query",
"@kbn/analytics-client",
"@kbn/core-analytics-browser",
"@kbn/react-hooks",
],
"exclude": [
"target/**/*"

View file

@ -5594,6 +5594,10 @@
version "0.0.0"
uid ""
"@kbn/react-hooks@link:packages/kbn-react-hooks":
version "0.0.0"
uid ""
"@kbn/react-kibana-context-common@link:packages/react/kibana_context/common":
version "0.0.0"
uid ""