From 4b4c109bc4a4787894c6c6a0a5a27d7e4d2ae2ac Mon Sep 17 00:00:00 2001 From: joshunrau Date: Wed, 1 Jan 2025 13:19:36 -0500 Subject: [PATCH 1/9] test: mock system time in DateField test --- src/components/Form/DateField/DateField.spec.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/Form/DateField/DateField.spec.tsx b/src/components/Form/DateField/DateField.spec.tsx index 09324336..12160420 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 { 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(); }); From debc35038cb0fc9d88610bc099f4b29e3a41b8d8 Mon Sep 17 00:00:00 2001 From: joshunrau Date: Wed, 1 Jan 2025 15:55:05 -0500 Subject: [PATCH 2/9] chore: bump libjs --- package.json | 2 +- pnpm-lock.yaml | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) 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: From 07310805ff1c08e165f2597c226194166797d468 Mon Sep 17 00:00:00 2001 From: joshunrau Date: Wed, 1 Jan 2025 16:17:41 -0500 Subject: [PATCH 3/9] fix: closes #42 --- src/components/Form/DateField/DateField.spec.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Form/DateField/DateField.spec.tsx b/src/components/Form/DateField/DateField.spec.tsx index 12160420..094e8170 100644 --- a/src/components/Form/DateField/DateField.spec.tsx +++ b/src/components/Form/DateField/DateField.spec.tsx @@ -1,4 +1,4 @@ -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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -84,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', () => { From cf7bae2cc7eee732ca3ece340c25edd4d7491242 Mon Sep 17 00:00:00 2001 From: joshunrau Date: Wed, 1 Jan 2025 16:34:14 -0500 Subject: [PATCH 4/9] test: cleanup --- src/hooks/useDownload/useDownload.test.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/hooks/useDownload/useDownload.test.ts b/src/hooks/useDownload/useDownload.test.ts index 7db93a37..e3a4ce2d 100644 --- a/src/hooks/useDownload/useDownload.test.ts +++ b/src/hooks/useDownload/useDownload.test.ts @@ -1,7 +1,5 @@ -import React from 'react'; - import { act, renderHook } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; import { useDownload } from './useDownload'; @@ -16,9 +14,10 @@ vi.mock('../useNotificationsStore', () => ({ describe('useDownload', () => { let download: ReturnType; + let createElement: MockInstance; + beforeEach(() => { - vi.spyOn(document, 'createElement'); - vi.spyOn(React, 'useState'); + createElement = vi.spyOn(document, 'createElement'); const { result } = renderHook(() => useDownload()); download = result.current; }); @@ -54,10 +53,9 @@ describe('useDownload', () => { ); expect(mockNotificationsStore.addNotification).toHaveBeenCalledOnce(); }); - it('should attempt to create one anchor element', async () => { + it('should attempt to create one anchor element, if called once', async () => { await act(() => download('hello.txt', () => 'hello world')); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(document.createElement).toHaveBeenLastCalledWith('a'); + expect(createElement).toHaveBeenLastCalledWith('a'); }); it('should invoke the fetch data a gather an image', async () => { const fetchData = vi.fn(() => new Blob()); From ef2fe791875f0b9ad8048e1406117ec670e4127c Mon Sep 17 00:00:00 2001 From: joshunrau Date: Wed, 1 Jan 2025 16:39:54 -0500 Subject: [PATCH 5/9] test: remove useless --- src/hooks/useDownload/useDownload.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/hooks/useDownload/useDownload.test.ts b/src/hooks/useDownload/useDownload.test.ts index e3a4ce2d..01826d26 100644 --- a/src/hooks/useDownload/useDownload.test.ts +++ b/src/hooks/useDownload/useDownload.test.ts @@ -26,15 +26,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', () => { From faac66e69e946a65dd32fc17599863037d850d70 Mon Sep 17 00:00:00 2001 From: joshunrau Date: Wed, 1 Jan 2025 16:48:01 -0500 Subject: [PATCH 6/9] test: update useDownload.test.ts --- src/hooks/useDownload/useDownload.test.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/hooks/useDownload/useDownload.test.ts b/src/hooks/useDownload/useDownload.test.ts index 01826d26..cd5f78ba 100644 --- a/src/hooks/useDownload/useDownload.test.ts +++ b/src/hooks/useDownload/useDownload.test.ts @@ -1,5 +1,5 @@ import { act, renderHook } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useDownload } from './useDownload'; @@ -14,10 +14,7 @@ vi.mock('../useNotificationsStore', () => ({ describe('useDownload', () => { let download: ReturnType; - let createElement: MockInstance; - beforeEach(() => { - createElement = vi.spyOn(document, 'createElement'); const { result } = renderHook(() => useDownload()); download = result.current; }); @@ -41,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', () => { @@ -50,10 +48,13 @@ describe('useDownload', () => { ); expect(mockNotificationsStore.addNotification).toHaveBeenCalledOnce(); }); - it('should attempt to create one anchor element, if called once', async () => { - await act(() => download('hello.txt', () => 'hello world')); - expect(createElement).toHaveBeenLastCalledWith('a'); + + it('should click an anchor element', async () => { + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click'); + await act(() => download('hello.txt', 'hello world')); + expect(click).toHaveBeenCalledOnce(); }); + 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' }); From b7547e029cd44f7ac0b1628d94f775776ceed00b Mon Sep 17 00:00:00 2001 From: joshunrau Date: Wed, 1 Jan 2025 16:51:04 -0500 Subject: [PATCH 7/9] test: update useDownload.test.ts for multiple simultaneous downloads --- src/hooks/useDownload/useDownload.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/hooks/useDownload/useDownload.test.ts b/src/hooks/useDownload/useDownload.test.ts index cd5f78ba..38936449 100644 --- a/src/hooks/useDownload/useDownload.test.ts +++ b/src/hooks/useDownload/useDownload.test.ts @@ -49,12 +49,18 @@ describe('useDownload', () => { expect(mockNotificationsStore.addNotification).toHaveBeenCalledOnce(); }); - it('should click an anchor element', async () => { + 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' }); From 7ccc7e8d76ce221a675148b1b41fe61e7c58ac3b Mon Sep 17 00:00:00 2001 From: joshunrau Date: Wed, 1 Jan 2025 17:20:38 -0500 Subject: [PATCH 8/9] refactor: create Downloadable --- src/hooks/useDownload/useDownload.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/hooks/useDownload/useDownload.ts b/src/hooks/useDownload/useDownload.ts index a8fe9785..e28b57cb 100644 --- a/src/hooks/useDownload/useDownload.ts +++ b/src/hooks/useDownload/useDownload.ts @@ -20,17 +20,19 @@ interface DownloadFunction { (filename: string, data: () => Promisable, options?: DownloadTextOptions): Promise; } +type Downloadable = { + blobType: string; + data: Blob | string; + filename: 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 [state, setState] = useState(null); useEffect(() => { if (state) { From 234d854f6f6201aba85ecc008a9653a529553891 Mon Sep 17 00:00:00 2001 From: joshunrau Date: Wed, 1 Jan 2025 17:35:05 -0500 Subject: [PATCH 9/9] fix: closes #43 --- src/hooks/useDownload/useDownload.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/hooks/useDownload/useDownload.ts b/src/hooks/useDownload/useDownload.ts index e28b57cb..d248fca3 100644 --- a/src/hooks/useDownload/useDownload.ts +++ b/src/hooks/useDownload/useDownload.ts @@ -24,6 +24,7 @@ type Downloadable = { blobType: string; data: Blob | string; filename: string; + id: string; }; /** @@ -32,25 +33,23 @@ type Downloadable = { */ export function useDownload(): DownloadFunction { const notifications = useNotificationsStore(); - const [state, setState] = useState(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 { @@ -58,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({