Skip to content

Commit

Permalink
[Security Solution] kbn package for generic hook utils (#101976)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
rylnd and kibanamachine authored Jun 17, 2021
1 parent a0effa1 commit ac07ebb
Show file tree
Hide file tree
Showing 37 changed files with 424 additions and 97 deletions.
1 change: 1 addition & 0 deletions docs/developer/getting-started/monorepo-packages.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
87 changes: 87 additions & 0 deletions packages/kbn-securitysolution-hook-utils/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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"],
)
3 changes: 3 additions & 0 deletions packages/kbn-securitysolution-hook-utils/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# kbn-securitysolution-hook-utils

This package contains shared utilities for React hooks.
13 changes: 13 additions & 0 deletions packages/kbn-securitysolution-hook-utils/jest.config.js
Original file line number Diff line number Diff line change
@@ -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'],
};
9 changes: 9 additions & 0 deletions packages/kbn-securitysolution-hook-utils/package.json
Original file line number Diff line number Diff line change
@@ -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
}
12 changes: 12 additions & 0 deletions packages/kbn-securitysolution-hook-utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
18 changes: 18 additions & 0 deletions packages/kbn-securitysolution-hook-utils/src/types.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* 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 AsyncTask} containing the underlying task's state along with a start callback
* @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>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading

0 comments on commit ac07ebb

Please sign in to comment.