[Security Solution] kbn package for generic hook utils (#101976)

* Adds boilerplate for new hook-utils package

* Move existing, identified utils into our hook-utils package

Updates references, and fixes a few missing config that were preventing
packages from building.

* Extracts a common type and adds a little more JSdoc for clarity

* Adds new useObservable hook

Similar to useAsync (a nearly identical interface), this is meant to
wrap a thunk returning an observable, allowing conditional invocation
and progressive updates as the observable continues to emit.

* Remove orphaned test

This function (and its tests) were moved to the hook-utils package; this
was simply missed.

* Remove optional chaining from kbn package

The build system does not currently support these typescript features.
While a valid fix would also have been to build separate browser and
node targets a la #99390, the use here was very minimal and so changing
to a supported syntax was the most pragmatic fix.

* Update old reference in test file

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Ryland Herrick 2021-06-17 12:09:17 -05:00 committed by GitHub
parent a0effa1ebd
commit ac07ebba87
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 424 additions and 97 deletions

View file

@ -87,6 +87,7 @@ yarn kbn watch-bazel
- @kbn/monaco
- @kbn/rule-data-utils
- @kbn/securitysolution-es-utils
- @kbn/securitysolution-hook-utils
- @kbn/securitysolution-io-ts-alerting-types
- @kbn/securitysolution-io-ts-list-types
- @kbn/securitysolution-io-ts-types

View file

@ -141,6 +141,7 @@
"@kbn/rule-data-utils": "link:bazel-bin/packages/kbn-rule-data-utils",
"@kbn/securitysolution-list-constants": "link:bazel-bin/packages/kbn-securitysolution-list-constants",
"@kbn/securitysolution-es-utils": "link:bazel-bin/packages/kbn-securitysolution-es-utils",
"@kbn/securitysolution-hook-utils": "link:bazel-bin/packages/kbn-securitysolution-hook-utils",
"@kbn/securitysolution-io-ts-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-types",
"@kbn/securitysolution-io-ts-alerting-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-alerting-types",
"@kbn/securitysolution-io-ts-list-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-list-types",

View file

@ -40,6 +40,7 @@ filegroup(
"//packages/kbn-securitysolution-list-utils:build",
"//packages/kbn-securitysolution-utils:build",
"//packages/kbn-securitysolution-es-utils:build",
"//packages/kbn-securitysolution-hook-utils:build",
"//packages/kbn-server-http-tools:build",
"//packages/kbn-server-route-repository:build",
"//packages/kbn-std:build",

View file

@ -0,0 +1,87 @@
load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project")
load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm")
PKG_BASE_NAME = "kbn-securitysolution-hook-utils"
PKG_REQUIRE_NAME = "@kbn/securitysolution-hook-utils"
SOURCE_FILES = glob(
[
"src/**/*.ts",
],
exclude = [
"**/*.test.*",
"**/*.mock.*",
],
)
SRCS = SOURCE_FILES
filegroup(
name = "srcs",
srcs = SRCS,
)
NPM_MODULE_EXTRA_FILES = [
"package.json",
"README.md",
]
SRC_DEPS = [
"@npm//react",
"@npm//rxjs",
"@npm//tslib",
]
TYPES_DEPS = [
"@npm//@types/jest",
"@npm//@types/node",
"@npm//@types/react",
]
DEPS = SRC_DEPS + TYPES_DEPS
ts_config(
name = "tsconfig",
src = "tsconfig.json",
deps = [
"//:tsconfig.base.json",
],
)
ts_project(
name = "tsc",
srcs = SRCS,
args = ["--pretty"],
declaration = True,
declaration_map = True,
incremental = True,
out_dir = "target",
root_dir = "src",
source_map = True,
tsconfig = ":tsconfig",
deps = DEPS,
)
js_library(
name = PKG_BASE_NAME,
package_name = PKG_REQUIRE_NAME,
srcs = NPM_MODULE_EXTRA_FILES,
visibility = ["//visibility:public"],
deps = DEPS + [":tsc"],
)
pkg_npm(
name = "npm_module",
deps = [
":%s" % PKG_BASE_NAME,
],
)
filegroup(
name = "build",
srcs = [
":npm_module",
],
visibility = ["//visibility:public"],
)

View file

@ -0,0 +1,3 @@
# kbn-securitysolution-hook-utils
This package contains shared utilities for React hooks.

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-securitysolution-hook-utils'],
};

View file

@ -0,0 +1,9 @@
{
"name": "@kbn/securitysolution-hook-utils",
"version": "1.0.0",
"description": "Security Solution utilities for React hooks",
"license": "SSPL-1.0 OR Elastic License 2.0",
"main": "./target/index.js",
"types": "./target/index.d.ts",
"private": true
}

View file

@ -0,0 +1,12 @@
/*
* 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_async';
export * from './use_is_mounted';
export * from './use_observable';
export * from './with_optional_signal';

View file

@ -0,0 +1,18 @@
/*
* 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.
*/
/**
* Represents the state of an asynchronous task, along with an initiator
* function to kick off the work.
*/
export interface Task<Args extends unknown[], Result> {
loading: boolean;
error: unknown | undefined;
result: Result | undefined;
start: (...args: Args) => void;
}

View file

@ -8,26 +8,26 @@
import { useCallback, useState } from 'react';
import { Task } from '../types';
import { useIsMounted } from '../use_is_mounted';
// TODO: This is probably better off in another package such as kbn-securitysolution-hook-utils
export interface Async<Args extends unknown[], Result> {
loading: boolean;
error: unknown | undefined;
result: Result | undefined;
start: (...args: Args) => void;
}
/**
*
* @param fn Async function
* This hook wraps a promise-returning thunk (task) in order to conditionally
* initiate the work, and automatically provide state corresponding to the
* task's status.
*
* @returns An {@link AsyncTask} containing the underlying task's state along with a start callback
* In order to function properly and not rerender unnecessarily, ensure that
* your task is a stable function reference.
*
* @param fn a function returning a promise.
*
* @returns An {@link Task} containing the task's current state along with a
* start callback
*/
export const useAsync = <Args extends unknown[], Result>(
fn: (...args: Args) => Promise<Result>
): Async<Args, Result> => {
): Task<Args, Result> => {
const isMounted = useIsMounted();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<unknown | undefined>();

View file

@ -10,8 +10,6 @@ import { useCallback, useEffect, useRef } from 'react';
type GetIsMounted = () => boolean;
// TODO: This is probably better off in another package such as kbn-securitysolution-hook-utils
/**
*
* @returns A {@link GetIsMounted} getter function returning whether the component is currently mounted

View file

@ -0,0 +1,160 @@
/*
* 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, renderHook } from '@testing-library/react-hooks';
import { Subject, throwError } from 'rxjs';
import { useObservable } from '.';
interface TestArgs {
n: number;
s: string;
}
type TestReturn = Subject<unknown>;
describe('useObservable', () => {
let fn: jest.Mock<TestReturn, TestArgs[]>;
let subject: TestReturn;
let args: TestArgs;
beforeEach(() => {
args = { n: 1, s: 's' };
subject = new Subject();
fn = jest.fn().mockReturnValue(subject);
});
it('does not invoke fn if start was not called', () => {
renderHook(() => useObservable(fn));
expect(fn).not.toHaveBeenCalled();
});
it('invokes the function when start is called', () => {
const { result } = renderHook(() => useObservable(fn));
act(() => {
result.current.start(args);
});
expect(fn).toHaveBeenCalled();
});
it('invokes the function with start args', () => {
const { result } = renderHook(() => useObservable(fn));
const expectedArgs = { ...args };
act(() => {
result.current.start(args);
});
expect(fn).toHaveBeenCalledWith(expectedArgs);
});
it('populates result with the next value of the fn', () => {
const { result } = renderHook(() => useObservable(fn));
act(() => {
result.current.start(args);
});
act(() => subject.next('value'));
expect(result.current.result).toEqual('value');
expect(result.current.error).toBeUndefined();
});
it('populates error if observable throws an error', () => {
const error = new Error('whoops');
const errorFn = () => throwError(error);
const { result } = renderHook(() => useObservable(errorFn));
act(() => {
result.current.start();
});
expect(result.current.result).toBeUndefined();
expect(result.current.error).toEqual(error);
});
it('populates the loading state while no value has resolved', () => {
const { result } = renderHook(() => useObservable(fn));
act(() => {
result.current.start(args);
});
expect(result.current.loading).toBe(true);
act(() => subject.next('a value'));
expect(result.current.loading).toBe(false);
});
it('updates result with each resolved value', () => {
const { result } = renderHook(() => useObservable(fn));
act(() => {
result.current.start(args);
});
act(() => subject.next('a value'));
expect(result.current.result).toEqual('a value');
act(() => subject.next('a subsequent value'));
expect(result.current.result).toEqual('a subsequent value');
});
it('does not update result with values if start has not been called', () => {
const { result } = renderHook(() => useObservable(fn));
act(() => subject.next('a value'));
expect(result.current.result).toBeUndefined();
act(() => subject.next('a subsequent value'));
expect(result.current.result).toBeUndefined();
});
it('unsubscribes on unmount', () => {
const { result, unmount } = renderHook(() => useObservable(fn));
act(() => {
result.current.start(args);
});
expect(subject.observers).toHaveLength(1);
unmount();
expect(subject.observers).toHaveLength(0);
});
it('multiple start calls reset state', () => {
const { result } = renderHook(() => useObservable(fn));
act(() => {
result.current.start(args);
});
expect(result.current.loading).toBe(true);
act(() => subject.next('one value'));
expect(result.current.loading).toBe(false);
expect(result.current.result).toBe('one value');
act(() => {
result.current.start(args);
});
expect(result.current.loading).toBe(true);
expect(result.current.result).toBe(undefined);
act(() => subject.next('another value'));
expect(result.current.loading).toBe(false);
expect(result.current.result).toBe('another value');
});
});

View file

@ -0,0 +1,72 @@
/*
* 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 { useCallback, useEffect, useRef, useState } from 'react';
import { Observable, Subscription } from 'rxjs';
import { useIsMounted } from '../use_is_mounted';
import { Task } from '../types';
/**
*
* @param fn function returning an observable
*
* @returns An {@link Async} containing the underlying task's state along with a start callback
*/
export const useObservable = <Args extends unknown[], Result>(
fn: (...args: Args) => Observable<Result>
): Task<Args, Result> => {
const isMounted = useIsMounted();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<unknown | undefined>();
const [result, setResult] = useState<Result | undefined>();
const subRef = useRef<Subscription | undefined>();
const start = useCallback(
(...args: Args) => {
if (subRef.current) {
subRef.current.unsubscribe();
}
setLoading(true);
setResult(undefined);
setError(undefined);
subRef.current = fn(...args).subscribe(
(r) => {
if (isMounted()) {
setResult(r);
setLoading(false);
}
},
(e) => {
if (isMounted()) {
setError(e);
setLoading(false);
}
}
);
},
[fn, isMounted]
);
useEffect(
() => () => {
if (subRef.current) {
subRef.current.unsubscribe();
}
},
[]
);
return {
error,
loading,
result,
start,
};
};

View file

@ -12,8 +12,6 @@ interface SignalArgs {
export type OptionalSignalArgs<Args> = Omit<Args, 'signal'> & Partial<SignalArgs>;
// TODO: This is probably better off in another package such as kbn-securitysolution-hook-utils
/**
*
* @param fn an async function receiving an AbortSignal argument

View file

@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"incremental": true,
"outDir": "target",
"rootDir": "src",
"sourceMap": true,
"sourceRoot": "../../../../packages/kbn-securitysolution-hook-utils/src",
"types": ["jest", "node"]
},
"include": ["src/**/*"]
}

View file

@ -28,6 +28,7 @@ NPM_MODULE_EXTRA_FILES = [
]
SRC_DEPS = [
"//packages/kbn-securitysolution-hook-utils",
"//packages/kbn-securitysolution-io-ts-list-types",
"//packages/kbn-securitysolution-list-api",
"//packages/kbn-securitysolution-list-constants",

View file

@ -7,7 +7,6 @@
*/
export * from './transforms';
export * from './use_api';
export * from './use_async';
export * from './use_create_list_index';
export * from './use_cursor';
export * from './use_delete_list';
@ -16,9 +15,7 @@ export * from './use_exception_lists';
export * from './use_export_list';
export * from './use_find_lists';
export * from './use_import_list';
export * from './use_is_mounted';
export * from './use_persist_exception_item';
export * from './use_persist_exception_list';
export * from './use_read_list_index';
export * from './use_read_list_privileges';
export * from './with_optional_signal';

View file

@ -7,8 +7,7 @@
*/
import { createListIndex } from '@kbn/securitysolution-list-api';
import { withOptionalSignal } from '../with_optional_signal';
import { useAsync } from '../use_async';
import { useAsync, withOptionalSignal } from '@kbn/securitysolution-hook-utils';
const createListIndexWithOptionalSignal = withOptionalSignal(createListIndex);

View file

@ -7,8 +7,7 @@
*/
import { deleteList } from '@kbn/securitysolution-list-api';
import { withOptionalSignal } from '../with_optional_signal';
import { useAsync } from '../use_async';
import { useAsync, withOptionalSignal } from '@kbn/securitysolution-hook-utils';
const deleteListWithOptionalSignal = withOptionalSignal(deleteList);

View file

@ -7,8 +7,7 @@
*/
import { exportList } from '@kbn/securitysolution-list-api';
import { withOptionalSignal } from '../with_optional_signal';
import { useAsync } from '../use_async';
import { useAsync, withOptionalSignal } from '@kbn/securitysolution-hook-utils';
const exportListWithOptionalSignal = withOptionalSignal(exportList);

View file

@ -7,8 +7,7 @@
*/
import { findLists } from '@kbn/securitysolution-list-api';
import { withOptionalSignal } from '../with_optional_signal';
import { useAsync } from '../use_async';
import { useAsync, withOptionalSignal } from '@kbn/securitysolution-hook-utils';
const findListsWithOptionalSignal = withOptionalSignal(findLists);

View file

@ -7,8 +7,7 @@
*/
import { importList } from '@kbn/securitysolution-list-api';
import { withOptionalSignal } from '../with_optional_signal';
import { useAsync } from '../use_async';
import { useAsync, withOptionalSignal } from '@kbn/securitysolution-hook-utils';
const importListWithOptionalSignal = withOptionalSignal(importList);

View file

@ -7,8 +7,7 @@
*/
import { readListIndex } from '@kbn/securitysolution-list-api';
import { withOptionalSignal } from '../with_optional_signal';
import { useAsync } from '../use_async';
import { useAsync, withOptionalSignal } from '@kbn/securitysolution-hook-utils';
const readListIndexWithOptionalSignal = withOptionalSignal(readListIndex);

View file

@ -7,8 +7,7 @@
*/
import { readListPrivileges } from '@kbn/securitysolution-list-api';
import { withOptionalSignal } from '../with_optional_signal';
import { useAsync } from '../use_async';
import { useAsync, withOptionalSignal } from '@kbn/securitysolution-hook-utils';
const readListPrivilegesWithOptionalSignal = withOptionalSignal(readListPrivileges);

View file

@ -1,30 +0,0 @@
/*
* 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 { withOptionalSignal } from './with_optional_signal';
type TestFn = ({ number, signal }: { number: number; signal: AbortSignal }) => boolean;
describe('withOptionalSignal', () => {
it('does not require a signal on the returned function', () => {
const fn = jest.fn().mockReturnValue('hello') as TestFn;
const wrappedFn = withOptionalSignal(fn);
expect(wrappedFn({ number: 1 })).toEqual('hello');
});
it('will pass a given signal to the wrapped function', () => {
const fn = jest.fn().mockReturnValue('hello') as TestFn;
const { signal } = new AbortController();
const wrappedFn = withOptionalSignal(fn);
wrappedFn({ number: 1, signal });
expect(fn).toHaveBeenCalledWith({ number: 1, signal });
});
});

View file

@ -1,25 +0,0 @@
/*
* 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.
*/
interface SignalArgs {
signal: AbortSignal;
}
export type OptionalSignalArgs<Args> = Omit<Args, 'signal'> & Partial<SignalArgs>;
/**
*
* @param fn an async function receiving an AbortSignal argument
*
* @returns An async function where the AbortSignal argument is optional
*/
export const withOptionalSignal = <Args extends SignalArgs, Result>(fn: (args: Args) => Result) => (
args: OptionalSignalArgs<Args>
): Result => {
const signal = args.signal ?? new AbortController().signal;
return fn({ ...args, signal } as Args);
};

View file

@ -13,7 +13,7 @@ import { waitFor } from '@testing-library/react';
import { AddExceptionModal } from './';
import { useCurrentUser } from '../../../../common/lib/kibana';
import { ExceptionBuilder } from '../../../../shared_imports';
import { useAsync } from '@kbn/securitysolution-list-hooks';
import { useAsync } from '@kbn/securitysolution-hook-utils';
import { getExceptionListSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_schema.mock';
import { useFetchIndex } from '../../../containers/source';
import { stubIndexPattern } from 'src/plugins/data/common/index_patterns/index_pattern.stub';
@ -49,8 +49,8 @@ jest.mock('../../../containers/source');
jest.mock('../../../../detections/containers/detection_engine/rules');
jest.mock('../use_add_exception');
jest.mock('../use_fetch_or_create_rule_exception_list');
jest.mock('@kbn/securitysolution-list-hooks', () => ({
...jest.requireActual('@kbn/securitysolution-list-hooks'),
jest.mock('@kbn/securitysolution-hook-utils', () => ({
...jest.requireActual('@kbn/securitysolution-hook-utils'),
useAsync: jest.fn(),
}));
jest.mock('../../../../detections/containers/detection_engine/rules/use_rule_async');

View file

@ -6,7 +6,7 @@
*/
import { useEffect, useState } from 'react';
import { useAsync, withOptionalSignal } from '@kbn/securitysolution-list-hooks';
import { useAsync, withOptionalSignal } from '@kbn/securitysolution-hook-utils';
import { getJobs } from '../api/get_jobs';
import { CombinedJobWithStats } from '../../../../../../ml/common/types/anomaly_detection_jobs';

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { useAsync, withOptionalSignal } from '@kbn/securitysolution-list-hooks';
import { useAsync, withOptionalSignal } from '@kbn/securitysolution-hook-utils';
import { getJobsSummary } from '../api/get_jobs_summary';
const _getJobsSummary = withOptionalSignal(getJobsSummary);

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { useAsync, withOptionalSignal } from '@kbn/securitysolution-list-hooks';
import { useAsync, withOptionalSignal } from '@kbn/securitysolution-hook-utils';
import { getMlCapabilities } from '../api/get_ml_capabilities';
const _getMlCapabilities = withOptionalSignal(getMlCapabilities);

View file

@ -6,7 +6,7 @@
*/
import { useEffect, useRef } from 'react';
import { useAsync, withOptionalSignal } from '@kbn/securitysolution-list-hooks';
import { useAsync, withOptionalSignal } from '@kbn/securitysolution-hook-utils';
import { useAppToasts } from '../../../common/hooks/use_app_toasts';
import { getUserPrivilege } from '../../containers/detection_engine/alerts/api';
import * as i18n from './translations';

View file

@ -8,7 +8,7 @@
import { useEffect, useCallback } from 'react';
import { flow } from 'fp-ts/lib/function';
import { useAsync, withOptionalSignal } from '@kbn/securitysolution-list-hooks';
import { useAsync, withOptionalSignal } from '@kbn/securitysolution-hook-utils';
import { useHttp } from '../../../../common/lib/kibana';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import { pureFetchRuleById } from './api';

View file

@ -6,7 +6,7 @@
*/
import { useCallback, useEffect, useMemo } from 'react';
import { useAsync, withOptionalSignal } from '@kbn/securitysolution-list-hooks';
import { useAsync, withOptionalSignal } from '@kbn/securitysolution-hook-utils';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import { isNotFoundError } from '../../../../common/utils/api';
import { useQueryAlerts } from '../alerts/use_query';

View file

@ -2716,6 +2716,10 @@
version "0.0.0"
uid ""
"@kbn/securitysolution-hook-utils@link:bazel-bin/packages/kbn-securitysolution-hook-utils":
version "0.0.0"
uid ""
"@kbn/securitysolution-io-ts-alerting-types@link:bazel-bin/packages/kbn-securitysolution-io-ts-alerting-types":
version "0.0.0"
uid ""