From e6e30cfb9931c7914097b29f5a119b5c2d22fb82 Mon Sep 17 00:00:00 2001 From: Caleb Pollman Date: Mon, 14 Oct 2024 19:13:25 -0700 Subject: [PATCH 1/2] feat(storage-browser): add useProcessTasks --- .../tasks/__tests__/useProcessTasks.spec.ts | 4 + .../components/StorageBrowser/tasks/index.ts | 1 + .../StorageBrowser/tasks/useProcessTasks.ts | 187 ++++++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 packages/react-storage/src/components/StorageBrowser/tasks/__tests__/useProcessTasks.spec.ts create mode 100644 packages/react-storage/src/components/StorageBrowser/tasks/index.ts create mode 100644 packages/react-storage/src/components/StorageBrowser/tasks/useProcessTasks.ts diff --git a/packages/react-storage/src/components/StorageBrowser/tasks/__tests__/useProcessTasks.spec.ts b/packages/react-storage/src/components/StorageBrowser/tasks/__tests__/useProcessTasks.spec.ts new file mode 100644 index 0000000000..3ba2ac42b3 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/tasks/__tests__/useProcessTasks.spec.ts @@ -0,0 +1,4 @@ +it.todo('does not remove an inflight task'); +it.todo('removes the expected task'); +it.todo('limits number of inflight tasks to provided `concurrency`'); +it.todo('excludes adding an item with an existing task'); diff --git a/packages/react-storage/src/components/StorageBrowser/tasks/index.ts b/packages/react-storage/src/components/StorageBrowser/tasks/index.ts new file mode 100644 index 0000000000..1463ecf95d --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/tasks/index.ts @@ -0,0 +1 @@ +export { Task, TaskStatus, useProcessTasks } from './useProcessTasks'; diff --git a/packages/react-storage/src/components/StorageBrowser/tasks/useProcessTasks.ts b/packages/react-storage/src/components/StorageBrowser/tasks/useProcessTasks.ts new file mode 100644 index 0000000000..22a6f7583a --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/tasks/useProcessTasks.ts @@ -0,0 +1,187 @@ +import React from 'react'; + +import { isFunction } from '@aws-amplify/ui'; + +import { + CancelableTaskHandlerOutput, + TaskHandlerInput, + TaskHandlerOutput, +} from '../actions'; + +const QUEUED_TASK_BASE = { + cancel: undefined, + message: undefined, + progress: undefined, + status: 'QUEUED' as const, +}; + +export function updateTasks( + tasks: T[], + task: Partial +): T[] { + const index = tasks.findIndex(({ key }) => key === task.key); + const updatedTask = { ...tasks[index], ...task }; + + if (index === 0) { + return [updatedTask, ...tasks.slice(1)]; + } + + if (index === tasks.length) { + return [...tasks.slice(-1), updatedTask]; + } + + return [...tasks.slice(0, index), updatedTask, ...tasks.slice(index + 1)]; +} + +export type TaskStatus = + | 'CANCELED' + | 'FAILED' + | 'COMPLETE' + | 'QUEUED' + | 'PENDING'; + +interface ProcessTasksOptions { + concurrency?: number; + isCancelable?: boolean; +} + +export type TaskHandler = ( + input: TaskHandlerInput +) => TaskHandlerOutput; + +export type ProcessTasks = ( + input: Omit, 'data'> +) => void; + +export interface Task { + cancel: undefined | (() => void); + item: T; + key: string; + message: string | undefined; + progress: number | undefined; + remove: () => void; + status: TaskStatus; +} + +const isCancelableOutput = ( + output: TaskHandlerOutput | CancelableTaskHandlerOutput +): output is CancelableTaskHandlerOutput => + isFunction((output as CancelableTaskHandlerOutput).cancel); + +type HandleProcess = ( + ...input: Omit, 'data'>[] +) => void; +export type ProcessTasksState = [ + tasks: Task[], + handleProcess: HandleProcess, +]; + +const hasExistingTask = (tasks: Task[], item: { key: string }) => + tasks.some(({ key }) => key === item.key); + +export const useProcessTasks = ( + handler: ( + input: TaskHandlerInput + ) => TaskHandlerOutput | CancelableTaskHandlerOutput, + items: { key: string; item: T }[] | undefined, + options?: ProcessTasksOptions +): ProcessTasksState => { + const { concurrency } = options ?? {}; + + const [tasks, setTasks] = React.useState[]>(() => []); + + const inflight = React.useRef(new Map()); + + React.useEffect(() => { + if (!items?.length) return; + + setTasks((prevTasks) => { + const nextTasks: Task[] = items.reduce((tasks, item) => { + const remove = () => { + if (inflight.current.has(item.key)) return; + + setTasks((prevTasks) => + prevTasks.filter(({ key }) => key !== item.key) + ); + }; + return hasExistingTask(prevTasks, item) + ? tasks + : [...tasks, { ...item, ...QUEUED_TASK_BASE, remove }]; + }, [] as Task[]); + + return [...prevTasks, ...nextTasks]; + }); + }, [items]); + + const _processTasks: ProcessTasks = React.useCallback( + (input) => { + setTasks((prevTasks) => { + const nextTask = prevTasks.find( + ({ key: _key, status }) => status === 'QUEUED' + ); + + if (!nextTask || inflight.current.has(nextTask.key)) { + return prevTasks; + } + + const { item, key } = nextTask; + + inflight.current.set(key, item); + + const output = handler({ ...input, data: { key, payload: item } }); + + const isCancelable = isCancelableOutput(output); + + const cancel = !isCancelable + ? undefined + : () => { + output.cancel?.(); + setTasks((prev) => + updateTasks(prev, { key, status: 'CANCELED' }) + ); + }; + + const { result } = output; + + result + .then((status) => { + setTasks((prev) => + updateTasks(prev, { key, cancel: undefined, status }) + ); + }) + .catch(({ message }: Error) => { + setTasks((prev) => + updateTasks(prev, { + key, + cancel: undefined, + message, + status: 'FAILED', + }) + ); + }) + .finally(() => { + inflight.current.delete(key); + _processTasks(input); + }); + + return updateTasks(prevTasks, { key, cancel, status: 'PENDING' }); + }); + }, + [handler] + ); + + const processTasks: HandleProcess = (input) => { + if (!concurrency) { + _processTasks(input); + return; + } + + let count = 0; + while (count < concurrency) { + _processTasks(input); + count++; + } + }; + + return [tasks, processTasks]; +}; From 5de62f030adb0487f21ff13dda0df50826541dd0 Mon Sep 17 00:00:00 2001 From: Caleb Pollman Date: Tue, 15 Oct 2024 13:45:58 -0700 Subject: [PATCH 2/2] add unit tests, split out utils, minor refactoring --- packages/react-storage/jest.config.ts | 8 +- .../tasks/__tests__/useProcessTasks.spec.ts | 328 +++++++++++++++++- .../tasks/__tests__/utils.spec.ts | 88 +++++ .../components/StorageBrowser/tasks/index.ts | 3 +- .../components/StorageBrowser/tasks/types.ts | 31 ++ .../StorageBrowser/tasks/useProcessTasks.ts | 79 +---- .../components/StorageBrowser/tasks/utils.ts | 30 ++ 7 files changed, 489 insertions(+), 78 deletions(-) create mode 100644 packages/react-storage/src/components/StorageBrowser/tasks/__tests__/utils.spec.ts create mode 100644 packages/react-storage/src/components/StorageBrowser/tasks/types.ts create mode 100644 packages/react-storage/src/components/StorageBrowser/tasks/utils.ts diff --git a/packages/react-storage/jest.config.ts b/packages/react-storage/jest.config.ts index 8e81de3569..cae67b73bc 100644 --- a/packages/react-storage/jest.config.ts +++ b/packages/react-storage/jest.config.ts @@ -16,10 +16,10 @@ const config: Config = { // functions: 90, // lines: 95, // statements: 95, - branches: 79, - functions: 82, - lines: 90, - statements: 89, + branches: 81, + functions: 83, + lines: 91, + statements: 90, }, }, moduleNameMapper: { '^uuid$': '/../../node_modules/uuid' }, diff --git a/packages/react-storage/src/components/StorageBrowser/tasks/__tests__/useProcessTasks.spec.ts b/packages/react-storage/src/components/StorageBrowser/tasks/__tests__/useProcessTasks.spec.ts index 3ba2ac42b3..90f10b58f2 100644 --- a/packages/react-storage/src/components/StorageBrowser/tasks/__tests__/useProcessTasks.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/tasks/__tests__/useProcessTasks.spec.ts @@ -1,4 +1,324 @@ -it.todo('does not remove an inflight task'); -it.todo('removes the expected task'); -it.todo('limits number of inflight tasks to provided `concurrency`'); -it.todo('excludes adding an item with an existing task'); +import { act, renderHook, waitFor } from '@testing-library/react'; + +import { + ActionInputConfig, + CancelableTaskHandlerOutput, + TaskHandlerInput, +} from '../../actions'; + +import { useProcessTasks } from '../../tasks/useProcessTasks'; + +const config: ActionInputConfig = { + accountId: 'accountId', + bucket: 'bucket', + credentials: jest.fn(), + region: 'region', +}; + +const prefix = 'prefix'; + +const items: { key: string; item: File }[] = [ + { key: '0', item: new File([], '0') }, + { key: '1', item: new File([], '1') }, + { key: '2', item: new File([], '2') }, +]; + +const action = jest.fn( + ({ data }: TaskHandlerInput) => { + const { key } = data; + if (key === '0') { + return { + key: '0', + cancel: undefined, + result: Promise.resolve('COMPLETE' as const), + }; + } + + if (key === '1') { + return { + key: '1', + cancel: undefined, + result: Promise.reject('FAILED' as const), + }; + } + + if (key === '2') { + return { + key: '2', + cancel: undefined, + result: Promise.resolve('COMPLETE' as const), + }; + } + throw new Error(); + } +); + +const sleep = ( + ms: number, + resolvedValue: T, + shouldReject = false +): Promise => + new Promise((resolve, reject) => + setTimeout(() => (shouldReject ? reject : resolve)(resolvedValue), ms) + ); + +const createTimedAction = + ({ + cancel, + key, + ms = 1000, + resolvedStatus = 'COMPLETE', + shouldReject, + }: { + cancel?: () => void; + key: string; + ms?: number; + resolvedStatus?: 'COMPLETE' | 'FAILED' | 'CANCELED'; + shouldReject?: boolean; + }): (() => CancelableTaskHandlerOutput) => + () => ({ + key, + cancel, + pause: undefined, + resume: undefined, + result: sleep(ms, resolvedStatus, shouldReject), + }); + +describe('useProcessTasks', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + beforeEach(() => { + action.mockClear(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('handles concurrent tasks as expected', async () => { + const { result } = renderHook(() => + useProcessTasks(action, items, { concurrency: 2 }) + ); + + const processTasks = result.current[1]; + + expect(result.current[0][0].status).toBe('QUEUED'); + expect(result.current[0][1].status).toBe('QUEUED'); + expect(result.current[0][2].status).toBe('QUEUED'); + + act(() => { + processTasks({ config, prefix }); + }); + + expect(action).toHaveBeenCalledTimes(2); + expect(action).toHaveBeenCalledWith({ + config, + data: { key: items[0].key, payload: items[0].item }, + prefix, + }); + expect(action).toHaveBeenCalledWith({ + config, + data: { key: items[1].key, payload: items[1].item }, + prefix, + }); + + expect(result.current[0][0].status).toBe('PENDING'); + expect(result.current[0][1].status).toBe('PENDING'); + expect(result.current[0][2].status).toBe('QUEUED'); + + await waitFor(() => { + expect(action).toHaveBeenCalledTimes(3); + }); + + expect(result.current[0][0].status).toBe('COMPLETE'); + expect(result.current[0][1].status).toBe('FAILED'); + expect(result.current[0][2].status).toBe('COMPLETE'); + }); + + it('cancels a task as expected', () => { + const cancel = jest.fn(); + const { key } = items[0]; + const cancelableAction = createTimedAction({ cancel, key }); + + const { result } = renderHook(() => + useProcessTasks(cancelableAction, items) + ); + + const processTasks = result.current[1]; + + expect(result.current[0][0].key).toBe(key); + expect(result.current[0][0].cancel).toBeUndefined(); + expect(result.current[0][0].status).toBe('QUEUED'); + + act(() => { + processTasks({ config, prefix }); + }); + + expect(result.current[0][0].key).toBe(key); + expect(result.current[0][0].cancel).toBeDefined(); + expect(result.current[0][0].status).toBe('PENDING'); + + act(() => { + result.current[0][0].cancel?.(); + }); + + expect(cancel).toHaveBeenCalledTimes(1); + expect(result.current[0][0].status).toBe('CANCELED'); + }); + + it.each(['COMPLETE' as const, 'FAILED' as const])( + 'does not cancel a %s task', + async (resolvedStatus) => { + const cancel = jest.fn(); + const { key } = items[0]; + const cancelableAction = createTimedAction({ + cancel, + key, + resolvedStatus, + shouldReject: resolvedStatus === 'FAILED', + }); + + const { result } = renderHook(() => + useProcessTasks(cancelableAction, items) + ); + + const processTasks = result.current[1]; + + expect(result.current[0][0].status).toBe('QUEUED'); + + act(() => { + processTasks({ config, prefix }); + }); + + expect(result.current[0][0].status).toBe('PENDING'); + + jest.advanceTimersToNextTimer(); + + await waitFor(() => { + expect(result.current[0][0].status).toBe(resolvedStatus); + }); + + act(() => { + result.current[0][0].cancel?.(); + }); + + expect(result.current[0][0].status).toBe(resolvedStatus); + } + ); + + it('behaves as expected in the happy path', async () => { + const { result } = renderHook(() => useProcessTasks(action, items)); + + const processTasks = result.current[1]; + + expect(result.current[0][0].status).toBe('QUEUED'); + expect(result.current[0][1].status).toBe('QUEUED'); + expect(result.current[0][2].status).toBe('QUEUED'); + + act(() => { + processTasks({ config, prefix, options: { extraOption: true } }); + }); + + expect(action).toHaveBeenCalledTimes(1); + expect(action).toHaveBeenCalledWith({ + config, + data: { key: items[0].key, payload: items[0].item }, + options: { extraOption: true }, + prefix, + }); + + expect(result.current[0][0].status).toBe('PENDING'); + expect(result.current[0][1].status).toBe('QUEUED'); + expect(result.current[0][2].status).toBe('QUEUED'); + + await waitFor(() => { + expect(action).toHaveBeenCalledTimes(3); + }); + + expect(result.current[0][0].status).toBe('COMPLETE'); + expect(result.current[0][1].status).toBe('FAILED'); + expect(result.current[0][2].status).toBe('COMPLETE'); + }); + + it('removes a task as expected', () => { + const { result } = renderHook(() => useProcessTasks(action, items)); + + const initTasks = result.current[0]; + const [task] = initTasks; + + expect(initTasks.length).toBe(3); + expect(task.key).toBe(items[0].key); + + act(() => { + task.remove(); + }); + + const nextTasks = result.current[0]; + expect(nextTasks.length).toBe(2); + }); + + it('does not remove an inflight task', async () => { + const { result } = renderHook(() => useProcessTasks(action, items)); + + const [initTasks, handleProcess] = result.current; + const [task] = initTasks; + + expect(initTasks.length).toBe(3); + expect(task.key).toBe(items[0].key); + + act(() => { + handleProcess(); + }); + + act(() => { + task.remove(); + }); + + await waitFor(() => { + const nextTasks = result.current[0]; + expect(nextTasks.length).toBe(3); + }); + }); + + it('excludes adding an item with an existing task', () => { + const { rerender, result } = renderHook( + (_items: { key: string; item: File }[] = items) => + useProcessTasks(action, _items) + ); + + const initTasks = result.current[0]; + expect(initTasks.length).toBe(3); + + const nextItems = [...items]; + + act(() => { + rerender(nextItems); + }); + + const nextTasks = result.current[0]; + expect(nextTasks.length).toBe(3); + }); + + it('returns the existing tasks when new items are empty', () => { + const { rerender, result } = renderHook( + (_items: { key: string; item: File }[] = items) => + useProcessTasks(action, _items) + ); + + const initTasks = result.current[0]; + expect(initTasks.length).toBe(3); + + const nextItems: { key: string; item: File }[] = []; + + act(() => { + rerender(nextItems); + }); + + const nextTasks = result.current[0]; + expect(nextTasks.length).toBe(3); + + expect(nextTasks).toBe(initTasks); + }); +}); diff --git a/packages/react-storage/src/components/StorageBrowser/tasks/__tests__/utils.spec.ts b/packages/react-storage/src/components/StorageBrowser/tasks/__tests__/utils.spec.ts new file mode 100644 index 0000000000..662ae3e8cd --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/tasks/__tests__/utils.spec.ts @@ -0,0 +1,88 @@ +import { CancelableTaskHandlerOutput, TaskHandlerOutput } from '../../actions'; +import { updateTasks, hasExistingTask, isCancelableOutput } from '../utils'; + +const one = { key: 'one', status: 'running' }; +const two = { key: 'two', status: 'walking' }; +const three = { key: 'three', status: 'crawling' }; +const four = { key: 'four', status: 'running' }; + +const tasks = [one, two, three, four]; + +describe('updateTasks', () => { + it('handles updating a `task` in the middle of `tasks` as expected', () => { + const result = updateTasks(tasks, { key: 'two', status: 'crawling' }); + + expect(result[1].key).toBe('two'); + expect(result[1].status).toBe('crawling'); + }); + + it('handles updating the first `task` of `tasks` as expected', () => { + const result = updateTasks(tasks, { key: 'one', status: 'crawling' }); + + expect(result[0].key).toBe('one'); + expect(result[0].status).toBe('crawling'); + }); + + it('handles updating the last `task` of `tasks` as expected', () => { + const result = updateTasks(tasks, { key: 'four', status: 'crawling' }); + + expect(result[3].key).toBe('four'); + expect(result[3].status).toBe('crawling'); + }); + + it('returns unmodified `tasks` when provided `task.key` is not found', () => { + const result = updateTasks(tasks, { + key: 'nooooooooo', + status: 'crawling', + }); + + expect(result).toBe(tasks); + }); +}); + +describe('isCancelableOutput', () => { + it('returns `true` when `output.cancel` is a function', () => { + const output: CancelableTaskHandlerOutput = { + cancel: jest.fn(), + key: 'testo', + result: Promise.resolve('COMPLETE'), + }; + const result = isCancelableOutput(output); + + expect(result).toBe(true); + }); + + it('returns `false` when `output.cancel` is not a function', () => { + const output: TaskHandlerOutput = { + // @ts-expect-error force allow `cancel` + cancel: 'cancel', + key: 'testo', + result: Promise.resolve('COMPLETE'), + }; + const result = isCancelableOutput(output); + + expect(result).toBe(false); + }); + + it('returns `false` when `output.cancel` is undefined', () => { + const output: TaskHandlerOutput = { + key: 'testo', + result: Promise.resolve('COMPLETE'), + }; + const result = isCancelableOutput(output); + + expect(result).toBe(false); + }); +}); + +describe('hasExistingTask', () => { + it('returns `true` for an existing `task`', () => { + const result = hasExistingTask(tasks, one); + expect(result).toBe(true); + }); + + it('returns `false` for a non-existint `task`', () => { + const result = hasExistingTask(tasks, { key: 'five' }); + expect(result).toBe(false); + }); +}); diff --git a/packages/react-storage/src/components/StorageBrowser/tasks/index.ts b/packages/react-storage/src/components/StorageBrowser/tasks/index.ts index 1463ecf95d..9b5dd5d6d4 100644 --- a/packages/react-storage/src/components/StorageBrowser/tasks/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/tasks/index.ts @@ -1 +1,2 @@ -export { Task, TaskStatus, useProcessTasks } from './useProcessTasks'; +export { useProcessTasks } from './useProcessTasks'; +export { Task, TaskStatus } from './types'; diff --git a/packages/react-storage/src/components/StorageBrowser/tasks/types.ts b/packages/react-storage/src/components/StorageBrowser/tasks/types.ts new file mode 100644 index 0000000000..1809caffca --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/tasks/types.ts @@ -0,0 +1,31 @@ +import { TaskHandlerInput } from '../actions'; + +export type TaskStatus = + | 'CANCELED' + | 'FAILED' + | 'COMPLETE' + | 'QUEUED' + | 'PENDING'; + +export interface ProcessTasksOptions { + concurrency?: number; + isCancelable?: boolean; +} + +export interface Task { + cancel: undefined | (() => void); + item: T; + key: string; + message: string | undefined; + remove: () => void; + status: TaskStatus; +} + +export type HandleProcessTasks = ( + ...input: Omit, 'data'>[] +) => void; + +export type ProcessTasksState = [ + tasks: Task[], + handleProcessTasks: HandleProcessTasks, +]; diff --git a/packages/react-storage/src/components/StorageBrowser/tasks/useProcessTasks.ts b/packages/react-storage/src/components/StorageBrowser/tasks/useProcessTasks.ts index 22a6f7583a..fc3f350f50 100644 --- a/packages/react-storage/src/components/StorageBrowser/tasks/useProcessTasks.ts +++ b/packages/react-storage/src/components/StorageBrowser/tasks/useProcessTasks.ts @@ -1,84 +1,25 @@ import React from 'react'; -import { isFunction } from '@aws-amplify/ui'; - import { CancelableTaskHandlerOutput, TaskHandlerInput, TaskHandlerOutput, } from '../actions'; +import { + HandleProcessTasks, + ProcessTasksOptions, + ProcessTasksState, + Task, +} from './types'; +import { hasExistingTask, isCancelableOutput, updateTasks } from './utils'; + const QUEUED_TASK_BASE = { cancel: undefined, message: undefined, - progress: undefined, status: 'QUEUED' as const, }; -export function updateTasks( - tasks: T[], - task: Partial -): T[] { - const index = tasks.findIndex(({ key }) => key === task.key); - const updatedTask = { ...tasks[index], ...task }; - - if (index === 0) { - return [updatedTask, ...tasks.slice(1)]; - } - - if (index === tasks.length) { - return [...tasks.slice(-1), updatedTask]; - } - - return [...tasks.slice(0, index), updatedTask, ...tasks.slice(index + 1)]; -} - -export type TaskStatus = - | 'CANCELED' - | 'FAILED' - | 'COMPLETE' - | 'QUEUED' - | 'PENDING'; - -interface ProcessTasksOptions { - concurrency?: number; - isCancelable?: boolean; -} - -export type TaskHandler = ( - input: TaskHandlerInput -) => TaskHandlerOutput; - -export type ProcessTasks = ( - input: Omit, 'data'> -) => void; - -export interface Task { - cancel: undefined | (() => void); - item: T; - key: string; - message: string | undefined; - progress: number | undefined; - remove: () => void; - status: TaskStatus; -} - -const isCancelableOutput = ( - output: TaskHandlerOutput | CancelableTaskHandlerOutput -): output is CancelableTaskHandlerOutput => - isFunction((output as CancelableTaskHandlerOutput).cancel); - -type HandleProcess = ( - ...input: Omit, 'data'>[] -) => void; -export type ProcessTasksState = [ - tasks: Task[], - handleProcess: HandleProcess, -]; - -const hasExistingTask = (tasks: Task[], item: { key: string }) => - tasks.some(({ key }) => key === item.key); - export const useProcessTasks = ( handler: ( input: TaskHandlerInput @@ -113,7 +54,7 @@ export const useProcessTasks = ( }); }, [items]); - const _processTasks: ProcessTasks = React.useCallback( + const _processTasks: HandleProcessTasks = React.useCallback( (input) => { setTasks((prevTasks) => { const nextTask = prevTasks.find( @@ -170,7 +111,7 @@ export const useProcessTasks = ( [handler] ); - const processTasks: HandleProcess = (input) => { + const processTasks: HandleProcessTasks = (input) => { if (!concurrency) { _processTasks(input); return; diff --git a/packages/react-storage/src/components/StorageBrowser/tasks/utils.ts b/packages/react-storage/src/components/StorageBrowser/tasks/utils.ts new file mode 100644 index 0000000000..5e0648594b --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/tasks/utils.ts @@ -0,0 +1,30 @@ +import { isFunction } from '@aws-amplify/ui'; + +import { CancelableTaskHandlerOutput, TaskHandlerOutput } from '../actions'; + +export const updateTasks = ( + tasks: T[], + task: Partial +): T[] => { + const index = tasks.findIndex(({ key }) => key === task.key); + + if (index === -1) return tasks; + + const nextTask = { ...tasks[index], ...task }; + + if (index === 0) return [nextTask, ...tasks.slice(1)]; + + if (index === tasks.length - 1) return [...tasks.slice(0, -1), nextTask]; + + return [...tasks.slice(0, index), nextTask, ...tasks.slice(index + 1)]; +}; + +export const isCancelableOutput = ( + output: TaskHandlerOutput | CancelableTaskHandlerOutput +): output is CancelableTaskHandlerOutput => + isFunction((output as CancelableTaskHandlerOutput).cancel); + +export const hasExistingTask = ( + tasks: { key: string }[], + item: { key: string } +): boolean => tasks.some(({ key }) => key === item.key);