diff --git a/package.json b/package.json index 5d381b2..886b18f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/utils", - "version": "0.0.30", + "version": "0.0.31", "description": "Chatwoot utils", "private": false, "license": "MIT", diff --git a/src/helpers.ts b/src/helpers.ts index b5be36b..d223878 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -190,3 +190,56 @@ export const splitName = ( return { firstName, lastName }; }; + +interface DownloadFileOptions { + url: string; + type: string; + extension?: string | null; +} +/** + * Downloads a file from a URL with proper file type handling + * @name downloadFile + * @description Downloads file from URL with proper type handling and cleanup + * @param {Object} options Download configuration options + * @param {string} options.url File URL to download + * @param {string} options.type File type identifier + * @param {string} [options.extension] Optional file extension + * @returns {Promise} Returns true if download successful, false otherwise + */ +export const downloadFile = async ({ + url, + type, + extension = null, +}: DownloadFileOptions): Promise => { + if (!url || !type) return; + + try { + const response = await fetch(url); + const blobData = await response.blob(); + + const contentType = response.headers.get('content-type'); + + const fileExtension = + extension || (contentType ? contentType.split('/')[1] : type); + + const dispositionHeader = response.headers.get('content-disposition'); + const filenameMatch = dispositionHeader?.match(/filename="(.*?)"/); + + const filename = + filenameMatch?.[1] ?? `attachment_${Date.now()}.${fileExtension}`; + + const blobUrl = URL.createObjectURL(blobData); + const link = Object.assign(document.createElement('a'), { + href: blobUrl, + download: filename, + style: 'display: none', + }); + + document.body.append(link); + link.click(); + link.remove(); + URL.revokeObjectURL(blobUrl); + } catch (error) { + console.warn('Download failed:', error); + } +}; diff --git a/src/index.ts b/src/index.ts index 97a1857..cfb9e04 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { convertSecondsToTimeUnit, fileNameWithEllipsis, splitName, + downloadFile, } from './helpers'; import { parseBoolean } from './string'; @@ -40,4 +41,5 @@ export { sortAsc, splitName, trimContent, + downloadFile, }; diff --git a/test/helpers.test.ts b/test/helpers.test.ts index 9958b13..1651af6 100644 --- a/test/helpers.test.ts +++ b/test/helpers.test.ts @@ -2,6 +2,7 @@ import { convertSecondsToTimeUnit, fileNameWithEllipsis, splitName, + downloadFile, } from '../src/helpers'; describe('#convertSecondsToTimeUnit', () => { @@ -128,3 +129,91 @@ describe('splitName', () => { }); }); }); + +describe('downloadFile', () => { + let mockFetch: jest.Mock; + let mockCreateObjectURL: jest.Mock; + let mockDOMElement: { [key: string]: jest.Mock | string }; + + beforeEach(() => { + // Mock fetch + mockFetch = jest.fn(); + global.fetch = mockFetch; + + // Mock URL methods + mockCreateObjectURL = jest.fn(() => 'blob:mock-url'); + URL.createObjectURL = mockCreateObjectURL; + URL.revokeObjectURL = jest.fn(); + + // Mock DOM element + mockDOMElement = { + click: jest.fn(), + remove: jest.fn(), + href: '', + download: '', + }; + document.createElement = jest.fn().mockReturnValue(mockDOMElement); + document.body.append = jest.fn(); + }); + + afterEach(() => jest.clearAllMocks()); + + describe('successful downloads', () => { + it('should download PDF file', async () => { + const blob = new Blob(['test'], { type: 'application/pdf' }); + mockFetch.mockResolvedValueOnce({ + ok: true, + blob: () => Promise.resolve(blob), + headers: new Headers({ 'content-type': 'application/pdf' }), + }); + + await downloadFile({ + url: 'test.com/doc.pdf', + type: 'pdf', + extension: 'pdf', + }); + + expect(mockFetch).toHaveBeenCalledWith('test.com/doc.pdf'); + expect(mockCreateObjectURL).toHaveBeenCalledWith(blob); + expect(mockDOMElement.click).toHaveBeenCalled(); + }); + + it('should download image file with content disposition', async () => { + const blob = new Blob(['test'], { type: 'image/png' }); + mockFetch.mockResolvedValueOnce({ + ok: true, + blob: () => Promise.resolve(blob), + headers: new Headers({ + 'content-type': 'image/png', + 'content-disposition': 'attachment; filename="test.png"', + }), + }); + + await downloadFile({ + url: 'test.com/image.png', + type: 'image', + extension: 'png', + }); + + expect(mockDOMElement.download).toBe('test.png'); + }); + }); + + describe('error handling', () => { + it('should skip if url or type missing', async () => { + await downloadFile({ url: '', type: 'pdf' }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should handle network errors', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + await downloadFile({ url: 'test.com/file', type: 'pdf' }); + expect(consoleSpy).toHaveBeenCalledWith( + 'Download failed:', + expect.any(Error) + ); + }); + }); +});