diff --git a/core/constants.ts b/core/constants.ts index b6b0aae9..319f49df 100644 --- a/core/constants.ts +++ b/core/constants.ts @@ -19,7 +19,7 @@ import cartesBancairesIcon from '../assets/carter-banceris.svg'; export const DEFAULT_WARNING_FOR_DANGEROUS_FILE_TYPE = 'This file type is not supported for preview.'; export const ZIP_EMPTY_ARCHIVE_MESSAGE = 'No files found in the ZIP archive.'; -export const ZIP_PREVIEW_LOADING_MESSAGE = 'Loading preview...'; +export const ZIP_PREVIEW_LOADING_MESSAGE = '...loading'; // MIME types recognised as a zip archive for `zipRender`. Deliberately exact: // 'application/gzip' also contains "zip" but is not a zip. @@ -544,6 +544,9 @@ export const ZIP_CONTAINER_STYLES = { 'flex-direction': 'row', height: '100%', width: '100%', + overflow: 'auto', + border: '1px solid #a5a2a2', + borderRadius: '4px', }, }; @@ -567,6 +570,10 @@ export const ZIP_NAV_LIST_ITEM_STYLES = { borderRadius: '4px', boxSizing: 'border-box' as const, backgroundColor: '#f9f9f9', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + wordBreak: 'break-all', }, [STYLE_TYPE.FOCUS]: { alignItems: 'center', @@ -576,7 +583,11 @@ export const ZIP_NAV_LIST_ITEM_STYLES = { borderRadius: '4px', boxSizing: 'border-box' as const, backgroundColor: '#f9f9f9', - border: '2px solid #b0b3b8ff', + border: '2px solid rgb(155, 158, 164)', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + wordBreak: 'break-all', }, }; diff --git a/core/types/index.ts b/core/types/index.ts index 3a2af0e7..f6443166 100644 --- a/core/types/index.ts +++ b/core/types/index.ts @@ -130,7 +130,7 @@ export interface IRevealResponseType { } export interface IUnzippedFileMetadata { name: string; - fileSize: number; + size: number; type: string; } @@ -565,6 +565,11 @@ export interface RenderFileResponse { success?: { skyflow_id: string, column: string, + // Metadata of the rendered file record itself (name, size, content type). + fileMetadata?: Record, + // Present only when a zip archive was rendered with `zipRender: true`: + // one entry per extracted file. + unZippedFilesMetadata?: IUnzippedFileMetadata[], }, errors?: { skyflowId: string, diff --git a/packages/skyflow-js/README.md b/packages/skyflow-js/README.md index 05639c80..e5b48db2 100644 --- a/packages/skyflow-js/README.md +++ b/packages/skyflow-js/README.md @@ -4376,13 +4376,15 @@ For zip files, the `renderFile()` success response also includes `unZippedFilesM column: 'string', fileMetadata: { ... }, unZippedFilesMetadata: [ - { name: 'photo.png', fileSize: 20480, type: 'image/png' }, - { name: 'doc.pdf', fileSize: 102400, type: 'application/pdf' }, + { name: 'photo.png', size: 20480, type: 'image/png' }, + { name: 'doc.pdf', size: 102400, type: 'application/pdf' }, ], }, } ``` +For TypeScript users, the npm package exports `RenderFileResponse` for this response, `UnzippedFileMetadata` for each entry of `unZippedFilesMetadata`, and `RenderOptions` for the `renderFile()` options object. + #### Download the currently previewed file For composable reveal elements rendering a zip file with `allowDownload: true`, call `downloadCurrentFile()` to download the file currently selected in the preview. Download is off by default; without `allowDownload: true` the call is ignored and an error is logged. diff --git a/packages/skyflow-js/src/index-node.ts b/packages/skyflow-js/src/index-node.ts index bd38c8ca..10a8b84c 100644 --- a/packages/skyflow-js/src/index-node.ts +++ b/packages/skyflow-js/src/index-node.ts @@ -53,6 +53,7 @@ export type { RevealResponse, RenderFileResponse, IRenderOptions as RenderOptions, + IUnzippedFileMetadata as UnzippedFileMetadata, IValidationRule as ValidationRule, ElementState, ErrorMessages, diff --git a/packages/skyflow-js/src/internal/reveal/reveal-frame.ts b/packages/skyflow-js/src/internal/reveal/reveal-frame.ts index e0d1ed02..b7360046 100644 --- a/packages/skyflow-js/src/internal/reveal/reveal-frame.ts +++ b/packages/skyflow-js/src/internal/reveal/reveal-frame.ts @@ -239,15 +239,15 @@ class RevealFrame extends CoreRevealFrame { if (resolvedResult.fields && data.column) { url = resolvedResult.fields[data.column]; } - const fileType = this.getExtension(url); + const fileType = this.getExtension(url) || resolvedResult?.fileMetadata?.file?.fileType; const isZip = RevealFrame.isZipMimeType(fileType); if (isZip && this.#renderOptions.zipRender) { - this.dataElememt.innerText = '...loading'; + this.dataElememt.innerText = ZIP_PREVIEW_LOADING_MESSAGE; this.unZipFiles(url).then((files) => { printLog(parameterizedString(logs.infoLogs.FILES_UNZIPPED_SUCCESSFULLY, CLASS_NAME, this.record?.skyflowID), MessageType.LOG, this.context?.logLevel); const metadata = files.map((f) => ({ - name: f.name, fileSize: f.size, type: f.type, + name: f.name, size: f.size, type: f.type, })); if (renderId !== this.#renderSeq) { // A newer renderFile() call owns the DOM now; just answer this one. @@ -457,7 +457,7 @@ class RevealFrame extends CoreRevealFrame { private async downloadCurrentFile(): Promise { if (!this.#renderOptions.allowDownload) { - printLog(logs.errorLogs.DOWNLOAD_NOT_ALLOWED, MessageType.ERROR, this.context?.logLevel); + printLog(logs.errorLogs.DOWNLOAD_NOT_ALLOWED, MessageType.WARN, this.context?.logLevel); return; } const currentFile = this.#currentFile; diff --git a/packages/skyflow-js/src/utils/validators/index.ts b/packages/skyflow-js/src/utils/validators/index.ts index c81363b5..888162d6 100644 --- a/packages/skyflow-js/src/utils/validators/index.ts +++ b/packages/skyflow-js/src/utils/validators/index.ts @@ -208,7 +208,9 @@ const DANGEROUS_FILE_EXTENSIONS = [ '.ova', '.ovf', '.vdi', '.vbox', ]; -export const isDangerousFileType = (file: { name?: string; type?: string }): boolean => { +export const isDangerousFileType = ( + file?: { name?: string; type?: string } | null, +): boolean => { const lowerName = (file?.name || '').toLowerCase(); const lowerType = (file?.type || '').toLowerCase(); if (DANGEROUS_FILE_EXTENSIONS.some((ext) => lowerName.endsWith(ext))) { diff --git a/packages/skyflow-js/tests/api-utils/reveal.test.js b/packages/skyflow-js/tests/api-utils/reveal.test.js index 799c2a73..e9f4e3e7 100644 --- a/packages/skyflow-js/tests/api-utils/reveal.test.js +++ b/packages/skyflow-js/tests/api-utils/reveal.test.js @@ -339,7 +339,7 @@ describe("formatForRenderClient fn test",()=>{ }}); }); test("with unZippedFilesMetadata should include it in success",()=>{ - const unZippedFilesMetadata = [{ name: 'a.png', fileSize: 10, type: 'image/png' }]; + const unZippedFilesMetadata = [{ name: 'a.png', size: 10, type: 'image/png' }]; const testInput = {"fields": { "col" : "http://dummy.com", "skyflow_id": "id" }, "fileMetadata": { contentType: 'application/zip' }, unZippedFilesMetadata }; const fnResponse = formatForRenderClient(testInput, 'col'); diff --git a/packages/skyflow-js/tests/core/internal/reveal/reveal-frame-zip.test.js b/packages/skyflow-js/tests/core/internal/reveal/reveal-frame-zip.test.js index 0cc5ab21..0dd5e1b0 100644 --- a/packages/skyflow-js/tests/core/internal/reveal/reveal-frame-zip.test.js +++ b/packages/skyflow-js/tests/core/internal/reveal/reveal-frame-zip.test.js @@ -206,7 +206,7 @@ describe("Reveal Frame - zip file render", () => { expect(success.unZippedFilesMetadata.map((f) => f.name).sort()).toEqual(['doc.pdf', 'photo.png']); // size comes from the archive's central directory, before any extraction expect(success.unZippedFilesMetadata.find((f) => f.name === 'photo.png')).toEqual({ - name: 'photo.png', type: 'image/png', fileSize: 'png-bytes'.length, + name: 'photo.png', type: 'image/png', size: 'png-bytes'.length, }); const items = navItems(); @@ -562,13 +562,26 @@ describe("Reveal Frame - zip file render", () => { }); test("download is blocked by default (allowDownload false)", async () => { - await renderZip({ 'a.png': 'a' }); + // A download attempt without allowDownload is a misuse, not a failure, so the SDK + // reports it at WARN level. Run this frame at LogLevel.WARN so the message is emitted. + defineUrl('http://localhost/?' + btoa(JSON.stringify({ + ...frameData, context: { logLevel: LogLevel.WARN, env: Env.PROD }, + }))); + mockFetch(await buildZip({ 'a.png': 'a' })); + RevealFrame.init(); + dispatchRenderRequest(); + await waitForResponse(); await waitFor(() => document.querySelector('#zip-panel img')); + + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); dispatchDownload(); await new Promise((r) => setTimeout(r, 10)); + expect(clickSpy).not.toHaveBeenCalled(); - expect(errorSpy.mock.calls.flat().join(' ')).toContain(logs.errorLogs.DOWNLOAD_NOT_ALLOWED); + expect(warnSpy.mock.calls.flat().join(' ')).toContain(logs.errorLogs.DOWNLOAD_NOT_ALLOWED); + // and it is a warning, not an error + expect(errorSpy.mock.calls.flat().join(' ')).not.toContain(logs.errorLogs.DOWNLOAD_NOT_ALLOWED); }); test("download is blocked for dangerous file types even when allowed", async () => { @@ -766,11 +779,75 @@ describe("Reveal Frame - zip file render", () => { for (let i = 0; i < 300; i += 1) entries[`dir${i % 7}/f${i}.txt`] = 'x'.repeat(50); const response = await renderZip(entries, { zipRender: true, autoSelectFirst: false }); expect(response.data.result.success.unZippedFilesMetadata).toHaveLength(300); - expect(response.data.result.success.unZippedFilesMetadata[0].fileSize).toBe(50); + expect(response.data.result.success.unZippedFilesMetadata[0].size).toBe(50); expect(navItems()).toHaveLength(300); expect(createObjectURL).not.toHaveBeenCalled(); }); + // ---------------------------------------------------------- nav chips + test("long unbroken file names: nav chips stay single-line, truncate with an ellipsis, and keep the full path on hover", async () => { + const longName = 'zipfilesfordemo/zipfile_flower_summer_flowers_yellow_flower_with_a_very_long_name_that_cannot_wrap.jpg'; + await renderZip({ [longName]: 'jpg', 'short.pdf': 'p' }, { zipRender: true, autoSelectFirst: false }); + + const items = Array.from(navItems()); + const chip = items.find((li) => li.title === longName); + expect(chip).toBeTruthy(); + // basename label (default labelMode) but the full archive path stays on hover + expect(chip.textContent).toBe('zipfile_flower_summer_flowers_yellow_flower_with_a_very_long_name_that_cannot_wrap.jpg'); + expect(chip.title).toBe(longName); + + // both the resting and the selected chip styles carry the truncation rules + [ZIP_NAV_LIST_ITEM_STYLES.base, ZIP_NAV_LIST_ITEM_STYLES.focus].forEach((style) => { + expect(style).toEqual(expect.objectContaining({ + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + })); + }); + const index = items.indexOf(chip); + const applied = jssCalls.filter((c) => c.name === `zip-nav-item${index}`).pop()?.styles; + expect(applied.base).toEqual(expect.objectContaining({ + whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', + })); + }); + + // ------------------------------------------------------- metadata shape + test("unZippedFilesMetadata: every entry has exactly name, size and type, with size from the archive", async () => { + const entries = { + 'img.png': 'png-bytes', + 'docs/2024/report.pdf': 'hello pdf', + 'docs/notes.txt': 'n', + }; + const response = await renderZip(entries, { zipRender: true, autoSelectFirst: false }); + const manifest = response.data.result.success.unZippedFilesMetadata; + + expect(manifest).toHaveLength(3); + manifest.forEach((entry) => { + expect(Object.keys(entry).sort()).toEqual(['name', 'size', 'type']); + expect(typeof entry.name).toBe('string'); + expect(typeof entry.size).toBe('number'); + expect(typeof entry.type).toBe('string'); + // size is the uncompressed byte length reported by the archive; nothing was inflated yet + expect(entry.size).toBe(entries[entry.name].length); + expect(entry).not.toHaveProperty('fileSize'); + }); + expect(manifest.map((f) => f.name).sort()).toEqual(['docs/2024/report.pdf', 'docs/notes.txt', 'img.png']); + expect(manifest.find((f) => f.name === 'img.png').type).toBe('image/png'); + expect(manifest.find((f) => f.name === 'docs/2024/report.pdf').type).toBe('application/pdf'); + expect(createObjectURL).not.toHaveBeenCalled(); + }); + + test("the archive's own fileMetadata is preserved alongside unZippedFilesMetadata", async () => { + const response = await renderZip({ 'a.png': 'a', 'b.pdf': 'bb' }, { zipRender: true, autoSelectFirst: false }); + const { success } = response.data.result; + // fileMetadata describes the zip record itself and must survive the manifest being added + expect(success.fileMetadata).toEqual({ contentType: 'application/zip' }); + expect(success.unZippedFilesMetadata).toHaveLength(2); + expect(success.unZippedFilesMetadata.map((f) => f.size)).toEqual(expect.arrayContaining([1, 2])); + expect(success.skyflow_id).toBe('abc123'); + expect(success.column).toBe('primary_card_file'); + }); + // ---------------------------------------------------------------- re-render / races test("re-rendering revokes previous blob URLs and rebuilds the layout", async () => { await renderZip({ 'a.png': 'a' }); diff --git a/packages/skyflow-js/tests/utils/validators.test.js b/packages/skyflow-js/tests/utils/validators.test.js index cfe21ad0..59336712 100644 --- a/packages/skyflow-js/tests/utils/validators.test.js +++ b/packages/skyflow-js/tests/utils/validators.test.js @@ -1482,6 +1482,21 @@ describe('isDangerousFileType', () => { expect(isDangerousFileType({ name: 'clip.mp4', type: 'video/mp4' })).toBe(false); expect(isDangerousFileType({})).toBe(false); expect(isDangerousFileType(undefined)).toBe(false); + expect(isDangerousFileType(null)).toBe(false); + expect(isDangerousFileType()).toBe(false); + }); + test('handles an undefined type: decides by extension alone', () => { + // Extracted zip entries often have no MIME type; the extension check must carry the decision. + expect(isDangerousFileType({ name: 'run.exe' })).toBe(true); + expect(isDangerousFileType({ name: 'run.exe', type: undefined })).toBe(true); + expect(isDangerousFileType({ name: 'photo.png' })).toBe(false); + expect(isDangerousFileType({ name: 'photo.png', type: undefined })).toBe(false); + }); + test('handles an undefined name: decides by mime type alone', () => { + expect(isDangerousFileType({ type: 'text/html' })).toBe(true); + expect(isDangerousFileType({ name: undefined, type: 'application/x-msdownload' })).toBe(true); + expect(isDangerousFileType({ type: 'image/png' })).toBe(false); + expect(isDangerousFileType({ name: undefined, type: 'application/pdf' })).toBe(false); }); });