diff --git a/x-pack/plugins/lists/public/common/hooks/use_async.test.ts b/x-pack/plugins/lists/public/common/hooks/use_async.test.ts new file mode 100644 index 00000000000000..6f115929c3f675 --- /dev/null +++ b/x-pack/plugins/lists/public/common/hooks/use_async.test.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; + +import { useAsync } from './use_async'; + +interface TestArgs { + n: number; + s: string; +} + +type TestReturn = Promise; + +describe('useAsync', () => { + let fn: jest.Mock; + let args: TestArgs; + + beforeEach(() => { + args = { n: 1, s: 's' }; + fn = jest.fn().mockResolvedValue(false); + }); + + it('does not invoke fn if start was not called', () => { + renderHook(() => useAsync(fn)); + expect(fn).not.toHaveBeenCalled(); + }); + + it('invokes the function when start is called', async () => { + const { result, waitForNextUpdate } = renderHook(() => useAsync(fn)); + + act(() => { + result.current.start(args); + }); + await waitForNextUpdate(); + + expect(fn).toHaveBeenCalled(); + }); + + it('invokes the function with start args', async () => { + const { result, waitForNextUpdate } = renderHook(() => useAsync(fn)); + const expectedArgs = { ...args }; + + act(() => { + result.current.start(args); + }); + await waitForNextUpdate(); + + expect(fn).toHaveBeenCalledWith(expectedArgs); + }); + + it('populates result with the resolved value of the fn', async () => { + const { result, waitForNextUpdate } = renderHook(() => useAsync(fn)); + fn.mockResolvedValue({ resolved: 'value' }); + + act(() => { + result.current.start(args); + }); + await waitForNextUpdate(); + + expect(result.current.result).toEqual({ resolved: 'value' }); + expect(result.current.error).toBeUndefined(); + }); + + it('populates error if function rejects', async () => { + fn.mockRejectedValue(new Error('whoops')); + const { result, waitForNextUpdate } = renderHook(() => useAsync(fn)); + + act(() => { + result.current.start(args); + }); + await waitForNextUpdate(); + + expect(result.current.result).toBeUndefined(); + expect(result.current.error).toEqual(new Error('whoops')); + }); + + it('populates the loading state while the function is pending', async () => { + let resolve: () => void; + fn.mockImplementation(() => new Promise((_resolve) => (resolve = _resolve))); + + const { result, waitForNextUpdate } = renderHook(() => useAsync(fn)); + + act(() => { + result.current.start(args); + }); + + expect(result.current.loading).toBe(true); + + act(() => resolve()); + await waitForNextUpdate(); + + expect(result.current.loading).toBe(false); + }); +}); diff --git a/x-pack/plugins/lists/public/common/hooks/use_async.ts b/x-pack/plugins/lists/public/common/hooks/use_async.ts new file mode 100644 index 00000000000000..362cad069b7ea1 --- /dev/null +++ b/x-pack/plugins/lists/public/common/hooks/use_async.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCallback, useState } from 'react'; + +import { useIsMounted } from './use_is_mounted'; + +export interface Async { + loading: boolean; + error: unknown | undefined; + result: Result | undefined; + start: (...args: Args) => void; +} + +/** + * + * @param fn Async function + * + * @returns An {@link AsyncTask} containing the underlying task's state along with a start callback + */ +export const useAsync = ( + fn: (...args: Args) => Promise +): Async => { + const isMounted = useIsMounted(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + const [result, setResult] = useState(); + + const start = useCallback( + (...args: Args) => { + setLoading(true); + fn(...args) + .then((r) => isMounted() && setResult(r)) + .catch((e) => isMounted() && setError(e)) + .finally(() => isMounted() && setLoading(false)); + }, + [fn, isMounted] + ); + + return { + error, + loading, + result, + start, + }; +}; diff --git a/x-pack/plugins/lists/public/common/hooks/use_async_task.test.ts b/x-pack/plugins/lists/public/common/hooks/use_async_task.test.ts deleted file mode 100644 index af3aa60cfa5065..00000000000000 --- a/x-pack/plugins/lists/public/common/hooks/use_async_task.test.ts +++ /dev/null @@ -1,93 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { act, renderHook } from '@testing-library/react-hooks'; - -import { useAsyncTask } from './use_async_task'; - -describe('useAsyncTask', () => { - let task: jest.Mock; - - beforeEach(() => { - task = jest.fn().mockResolvedValue('resolved value'); - }); - - it('does not invoke task if start was not called', () => { - renderHook(() => useAsyncTask(task)); - expect(task).not.toHaveBeenCalled(); - }); - - it('invokes the task when start is called', async () => { - const { result, waitForNextUpdate } = renderHook(() => useAsyncTask(task)); - - act(() => { - result.current.start({}); - }); - await waitForNextUpdate(); - - expect(task).toHaveBeenCalled(); - }); - - it('invokes the task with a signal and start args', async () => { - const { result, waitForNextUpdate } = renderHook(() => useAsyncTask(task)); - - act(() => { - result.current.start({ - arg1: 'value1', - arg2: 'value2', - }); - }); - await waitForNextUpdate(); - - expect(task).toHaveBeenCalledWith(expect.any(AbortController), { - arg1: 'value1', - arg2: 'value2', - }); - }); - - it('populates result with the resolved value of the task', async () => { - const { result, waitForNextUpdate } = renderHook(() => useAsyncTask(task)); - - act(() => { - result.current.start({}); - }); - await waitForNextUpdate(); - - expect(result.current.result).toEqual('resolved value'); - expect(result.current.error).toBeUndefined(); - }); - - it('populates error if task rejects', async () => { - task.mockRejectedValue(new Error('whoops')); - const { result, waitForNextUpdate } = renderHook(() => useAsyncTask(task)); - - act(() => { - result.current.start({}); - }); - await waitForNextUpdate(); - - expect(result.current.result).toBeUndefined(); - expect(result.current.error).toEqual(new Error('whoops')); - }); - - it('populates the loading state while the task is pending', async () => { - let resolve: () => void; - task.mockImplementation(() => new Promise((_resolve) => (resolve = _resolve))); - - const { result, waitForNextUpdate } = renderHook(() => useAsyncTask(task)); - - act(() => { - result.current.start({}); - }); - - expect(result.current.loading).toBe(true); - - act(() => resolve()); - await waitForNextUpdate(); - - expect(result.current.loading).toBe(false); - }); -}); diff --git a/x-pack/plugins/lists/public/common/hooks/use_async_task.ts b/x-pack/plugins/lists/public/common/hooks/use_async_task.ts deleted file mode 100644 index f767e9333c2348..00000000000000 --- a/x-pack/plugins/lists/public/common/hooks/use_async_task.ts +++ /dev/null @@ -1,48 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { useCallback, useRef } from 'react'; -import useAsyncFn from 'react-use/lib/useAsyncFn'; - -// Params can be generalized to a ...rest parameter extending unknown[] once https://github.com/microsoft/TypeScript/pull/39094 is available. -// for now, the task must still receive unknown as a second argument, and an argument must be passed to start() -export type UseAsyncTask = ( - task: (...args: [AbortController, Params]) => Promise -) => AsyncTask; - -export interface AsyncTask { - start: (params: Params) => void; - abort: () => void; - loading: boolean; - error: Error | undefined; - result: Result | undefined; -} - -/** - * - * @param task Async function receiving an AbortController and optional arguments - * - * @returns An {@link AsyncTask} containing the underlying task's state along with start/abort helpers - */ -export const useAsyncTask: UseAsyncTask = (task) => { - const ctrl = useRef(new AbortController()); - const abort = useCallback((): void => { - ctrl.current.abort(); - }, []); - - // @ts-ignore typings are incorrect, see: https://github.com/streamich/react-use/pull/589 - const [state, initiator] = useAsyncFn(task, [task]); - - const start = useCallback( - (args) => { - ctrl.current = new AbortController(); - initiator(ctrl.current, args); - }, - [initiator] - ); - - return { abort, error: state.error, loading: state.loading, result: state.value, start }; -}; diff --git a/x-pack/plugins/lists/public/common/hooks/use_is_mounted.test.ts b/x-pack/plugins/lists/public/common/hooks/use_is_mounted.test.ts new file mode 100644 index 00000000000000..e148ef155d458e --- /dev/null +++ b/x-pack/plugins/lists/public/common/hooks/use_is_mounted.test.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renderHook } from '@testing-library/react-hooks'; + +import { useIsMounted } from './use_is_mounted'; + +describe('useIsMounted', () => { + it('evaluates to true when mounted', () => { + const { result } = renderHook(() => useIsMounted()); + + expect(result.current()).toEqual(true); + }); + + it('evaluates to false when unmounted', () => { + const { result, unmount } = renderHook(() => useIsMounted()); + + unmount(); + expect(result.current()).toEqual(false); + }); +}); diff --git a/x-pack/plugins/lists/public/common/hooks/use_is_mounted.ts b/x-pack/plugins/lists/public/common/hooks/use_is_mounted.ts new file mode 100644 index 00000000000000..05b1b25917d0a5 --- /dev/null +++ b/x-pack/plugins/lists/public/common/hooks/use_is_mounted.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCallback, useEffect, useRef } from 'react'; + +type GetIsMounted = () => boolean; + +/** + * + * @returns A {@link GetIsMounted} getter function returning whether the component is currently mounted + */ +export const useIsMounted = (): GetIsMounted => { + const isMounted = useRef(false); + const getIsMounted: GetIsMounted = useCallback(() => isMounted.current, []); + const handleCleanup = useCallback(() => { + isMounted.current = false; + }, []); + + useEffect(() => { + isMounted.current = true; + return handleCleanup; + }, [handleCleanup]); + + return getIsMounted; +}; diff --git a/x-pack/plugins/lists/public/common/with_optional_signal.test.ts b/x-pack/plugins/lists/public/common/with_optional_signal.test.ts new file mode 100644 index 00000000000000..a2d4e4d95be6a0 --- /dev/null +++ b/x-pack/plugins/lists/public/common/with_optional_signal.test.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +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 }); + }); +}); diff --git a/x-pack/plugins/lists/public/common/with_optional_signal.ts b/x-pack/plugins/lists/public/common/with_optional_signal.ts new file mode 100644 index 00000000000000..4bd31950dfe2e2 --- /dev/null +++ b/x-pack/plugins/lists/public/common/with_optional_signal.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +interface SignalArgs { + signal: AbortSignal; +} + +export type OptionalSignalArgs = Omit & Partial; + +/** + * + * @param fn an async function receiving an AbortSignal argument + * + * @returns An async function where the AbortSignal argument is optional + */ +export const withOptionalSignal = (fn: (args: Args) => Result) => ( + args: OptionalSignalArgs +): Result => { + const signal = args.signal ?? new AbortController().signal; + return fn({ ...args, signal } as Args); +}; diff --git a/x-pack/plugins/lists/public/lists/hooks/use_delete_list.ts b/x-pack/plugins/lists/public/lists/hooks/use_delete_list.ts index 0f1f6facdd7c4b..455b8acadb9552 100644 --- a/x-pack/plugins/lists/public/lists/hooks/use_delete_list.ts +++ b/x-pack/plugins/lists/public/lists/hooks/use_delete_list.ts @@ -4,16 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useAsyncTask } from '../../common/hooks/use_async_task'; -import { DeleteListParams } from '../types'; +import { withOptionalSignal } from '../../common/with_optional_signal'; +import { useAsync } from '../../common/hooks/use_async'; import { deleteList } from '../api'; -export type DeleteListTaskArgs = Omit; - -const deleteListsTask = ( - { signal }: AbortController, - args: DeleteListTaskArgs -): ReturnType => deleteList({ signal, ...args }); +const deleteListWithOptionalSignal = withOptionalSignal(deleteList); // eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export const useDeleteList = () => useAsyncTask(deleteListsTask); +export const useDeleteList = () => useAsync(deleteListWithOptionalSignal); diff --git a/x-pack/plugins/lists/public/lists/hooks/use_export_list.ts b/x-pack/plugins/lists/public/lists/hooks/use_export_list.ts index 41efde939ead40..bbe555e5728c86 100644 --- a/x-pack/plugins/lists/public/lists/hooks/use_export_list.ts +++ b/x-pack/plugins/lists/public/lists/hooks/use_export_list.ts @@ -4,16 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useAsyncTask } from '../../common/hooks/use_async_task'; -import { ExportListParams } from '../types'; +import { withOptionalSignal } from '../../common/with_optional_signal'; +import { useAsync } from '../../common/hooks/use_async'; import { exportList } from '../api'; -export type ExportListTaskArgs = Omit; - -const exportListTask = ( - { signal }: AbortController, - args: ExportListTaskArgs -): ReturnType => exportList({ signal, ...args }); +const exportListWithOptionalSignal = withOptionalSignal(exportList); // eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export const useExportList = () => useAsyncTask(exportListTask); +export const useExportList = () => useAsync(exportListWithOptionalSignal); diff --git a/x-pack/plugins/lists/public/lists/hooks/use_find_lists.ts b/x-pack/plugins/lists/public/lists/hooks/use_find_lists.ts index d50a16855a547e..6beda67c02aeb5 100644 --- a/x-pack/plugins/lists/public/lists/hooks/use_find_lists.ts +++ b/x-pack/plugins/lists/public/lists/hooks/use_find_lists.ts @@ -4,16 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useAsyncTask } from '../../common/hooks/use_async_task'; -import { FindListsParams } from '../types'; +import { withOptionalSignal } from '../../common/with_optional_signal'; +import { useAsync } from '../../common/hooks/use_async'; import { findLists } from '../api'; -export type FindListsTaskArgs = Omit; - -const findListsTask = ( - { signal }: AbortController, - args: FindListsTaskArgs -): ReturnType => findLists({ signal, ...args }); +const findListsWithOptionalSignal = withOptionalSignal(findLists); // eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export const useFindLists = () => useAsyncTask(findListsTask); +export const useFindLists = () => useAsync(findListsWithOptionalSignal); diff --git a/x-pack/plugins/lists/public/lists/hooks/use_import_list.ts b/x-pack/plugins/lists/public/lists/hooks/use_import_list.ts index 2854acd6e522ee..322064f769df60 100644 --- a/x-pack/plugins/lists/public/lists/hooks/use_import_list.ts +++ b/x-pack/plugins/lists/public/lists/hooks/use_import_list.ts @@ -4,16 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useAsyncTask } from '../../common/hooks/use_async_task'; -import { ImportListParams } from '../types'; +import { withOptionalSignal } from '../../common/with_optional_signal'; +import { useAsync } from '../../common/hooks/use_async'; import { importList } from '../api'; -export type ImportListTaskArgs = Omit; - -const importListTask = ( - { signal }: AbortController, - args: ImportListTaskArgs -): ReturnType => importList({ signal, ...args }); +const importListWithOptionalSignal = withOptionalSignal(importList); // eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export const useImportList = () => useAsyncTask(importListTask); +export const useImportList = () => useAsync(importListWithOptionalSignal);