diff --git a/package.json b/package.json index f08877e0..32989e0a 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "zod": "^3.23.6" }, "dependencies": { - "@douglasneuroinformatics/libjs": "^1.1.0", + "@douglasneuroinformatics/libjs": "^1.2.0", "@douglasneuroinformatics/libui-form-types": "^0.11.0", "@radix-ui/react-accordion": "^1.2.1", "@radix-ui/react-alert-dialog": "^1.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de36196b..4fa1e7ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,8 +8,8 @@ importers: .: dependencies: '@douglasneuroinformatics/libjs': - specifier: ^1.1.0 - version: 1.1.0(typescript@5.5.4) + specifier: ^1.2.0 + version: 1.2.0(typescript@5.5.4) '@douglasneuroinformatics/libui-form-types': specifier: ^0.11.0 version: 0.11.0 @@ -488,9 +488,9 @@ packages: typescript: optional: true - '@douglasneuroinformatics/libjs@1.1.0': + '@douglasneuroinformatics/libjs@1.2.0': resolution: - { integrity: sha512-wVVrhB+QKiPqZDLH/4skYQZmOroHCq2YgJe44YssN4ZtGhCwZR20REWgcqlkhtJFtJkjanZyF0RQmFJih5beTw== } + { integrity: sha512-oK5HgwsFiHFGwT0TeHH9QnTqYoXgg+PvTrxt5LWylo2500/a87XpWzGseLyqbwATjUW44/27EAYUElrE2adoIA== } peerDependencies: typescript: ^5.5.0 @@ -7297,7 +7297,7 @@ snapshots: - ts-node - vue-eslint-parser - '@douglasneuroinformatics/libjs@1.1.0(typescript@5.5.4)': + '@douglasneuroinformatics/libjs@1.2.0(typescript@5.5.4)': dependencies: type-fest: 4.31.0 typescript: 5.5.4 @@ -11522,7 +11522,7 @@ snapshots: dependencies: '@babel/code-frame': 7.25.7 index-to-position: 0.1.2 - type-fest: 4.26.1 + type-fest: 4.31.0 parse-ms@4.0.0: {} @@ -11857,7 +11857,7 @@ snapshots: '@types/normalize-package-data': 2.4.4 normalize-package-data: 6.0.2 parse-json: 8.1.0 - type-fest: 4.26.1 + type-fest: 4.31.0 unicorn-magic: 0.1.0 readable-stream@2.3.8: diff --git a/src/components/Form/DateField/DateField.spec.tsx b/src/components/Form/DateField/DateField.spec.tsx index 09324336..094e8170 100644 --- a/src/components/Form/DateField/DateField.spec.tsx +++ b/src/components/Form/DateField/DateField.spec.tsx @@ -1,7 +1,7 @@ -import { toBasicISOString } from '@douglasneuroinformatics/libjs'; +import { toBasicISOString, toLocalISOString } from '@douglasneuroinformatics/libjs'; import { getByText, render, screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DateField } from './DateField'; @@ -10,6 +10,12 @@ describe('DateField', () => { const setValue = vi.fn(); beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(2025, 0, 1, 22)); + }); + + afterEach(() => { + vi.useRealTimers(); vi.clearAllMocks(); }); @@ -78,14 +84,14 @@ describe('DateField', () => { datepicker = screen.getByTestId('datepicker'); await userEvent.click(getByText(datepicker, '1')); expectedDate = new Date(new Date().setDate(1)); - expectedDateString = toBasicISOString(expectedDate); + expectedDateString = toLocalISOString(expectedDate).split('T')[0]!; expect(toBasicISOString(setValue.mock.lastCall?.[0])).toBe(expectedDateString); await userEvent.click(input); datepicker = screen.getByTestId('datepicker'); await userEvent.click(getByText(datepicker, '2')); expectedDate = new Date(new Date().setDate(2)); - expectedDateString = toBasicISOString(expectedDate); + expectedDateString = toLocalISOString(expectedDate).split('T')[0]!; expect(toBasicISOString(setValue.mock.lastCall?.[0])).toBe(expectedDateString); }); it('should render the value provided as a prop', () => { diff --git a/src/hooks/useDownload/useDownload.test.ts b/src/hooks/useDownload/useDownload.test.ts index 7db93a37..38936449 100644 --- a/src/hooks/useDownload/useDownload.test.ts +++ b/src/hooks/useDownload/useDownload.test.ts @@ -1,5 +1,3 @@ -import React from 'react'; - import { act, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -17,8 +15,6 @@ describe('useDownload', () => { let download: ReturnType; beforeEach(() => { - vi.spyOn(document, 'createElement'); - vi.spyOn(React, 'useState'); const { result } = renderHook(() => useDownload()); download = result.current; }); @@ -27,15 +23,12 @@ describe('useDownload', () => { vi.clearAllMocks(); }); - it('should render', () => { - expect(download).toBeDefined(); - }); - it('should invoke the fetch data function', async () => { const fetchData = vi.fn(() => 'hello world'); await download('hello.txt', fetchData); expect(fetchData).toHaveBeenCalledOnce(); }); + it('should attempt at add a notification if the fetch data function throws an error', async () => { await act(() => download('hello.txt', () => { @@ -45,6 +38,7 @@ describe('useDownload', () => { expect(mockNotificationsStore.addNotification).toHaveBeenCalledOnce(); expect(mockNotificationsStore.addNotification.mock.lastCall?.[0]).toMatchObject({ message: 'An error occurred!' }); }); + it('should attempt at add a notification if the fetch data function throws a non-error', async () => { await act(() => download('hello.txt', () => { @@ -54,11 +48,19 @@ describe('useDownload', () => { ); expect(mockNotificationsStore.addNotification).toHaveBeenCalledOnce(); }); - it('should attempt to create one anchor element', async () => { - await act(() => download('hello.txt', () => 'hello world')); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(document.createElement).toHaveBeenLastCalledWith('a'); + + it('should invoke HTMLAnchorElement.prototype.click once', async () => { + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click'); + await act(() => download('hello.txt', 'hello world')); + expect(click).toHaveBeenCalledOnce(); + }); + + it('should allow multiple simultaneous downloads', async () => { + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click'); + await act(() => Promise.all([download('foo.txt', 'foo'), download('bar.txt', 'bar')])); + expect(click).toHaveBeenCalledTimes(2); }); + it('should invoke the fetch data a gather an image', async () => { const fetchData = vi.fn(() => new Blob()); await download('testdiv.png', fetchData, { blobType: 'image/png' }); diff --git a/src/hooks/useDownload/useDownload.ts b/src/hooks/useDownload/useDownload.ts index a8fe9785..d248fca3 100644 --- a/src/hooks/useDownload/useDownload.ts +++ b/src/hooks/useDownload/useDownload.ts @@ -20,35 +20,36 @@ interface DownloadFunction { (filename: string, data: () => Promisable, options?: DownloadTextOptions): Promise; } +type Downloadable = { + blobType: string; + data: Blob | string; + filename: string; + id: string; +}; + /** * Used to trigger downloads of arbitrary data to the client * @returns A function to invoke the download */ export function useDownload(): DownloadFunction { const notifications = useNotificationsStore(); - const [state, setState] = useState<{ - blobType: string; - data: Blob | string; - filename: string; - } | null>(null); + const [downloads, setDownloads] = useState([]); useEffect(() => { - if (state) { - const { blobType, data, filename } = state; + if (downloads.length) { + const { blobType, data, filename, id } = downloads.at(-1)!; const anchor = document.createElement('a'); document.body.appendChild(anchor); - const blob = new Blob([data], { type: blobType }); - const url = URL.createObjectURL(blob); anchor.href = url; anchor.download = filename; anchor.click(); URL.revokeObjectURL(url); anchor.remove(); - setState(null); + setDownloads((prevDownloads) => prevDownloads.filter((item) => item.id !== id)); } - }, [state]); + }, [downloads]); return async (filename, _data, options) => { try { @@ -56,7 +57,10 @@ export function useDownload(): DownloadFunction { if (typeof data !== 'string' && !options?.blobType) { throw new Error("argument 'blobType' must be defined when download is called with a Blob object"); } - setState({ blobType: options?.blobType ?? 'text/plain', data, filename }); + setDownloads((prevDownloads) => [ + ...prevDownloads, + { blobType: options?.blobType ?? 'text/plain', data, filename, id: crypto.randomUUID() } + ]); } catch (error) { const message = error instanceof Error ? error.message : 'An unknown error occurred'; notifications.addNotification({