mui / toolpad

Toolpad: Full stack components and low-code builder for dashboards and internal apps.
https://mui.com/toolpad/
MIT License
1.26k stars 283 forks source link

[studio] Download file utils #3904

Open oliviertassinari opened 3 months ago

oliviertassinari commented 3 months ago

Summary

I think we need an helper to download files.

Similar to https://docs.retool.com/queries/reference/libraries/utils#method-downloadFile (https://community.retool.com/t/utils-downloadfile-downloads-empty-pdf/4642 for more context)

Examples

I tried to create a "download" button with this onClick handler:

            (() => {
              const blob = new Blob([textarea.value], {
                type: 'text/csv',
              });
              // Save the blob in a json file
              const url = URL.createObjectURL(blob);
              const a = document.createElement('a');
              a.href = url;
              a.download = 'file.csv';
              a.click();
              setTimeout(() => {
                URL.revokeObjectURL(url);
              });
            })();

which works when I use it in my console; but doesn't in Toolpad because of the way the sandboxing works. If fails with

TypeError: Blob is not a constructor at eval (eval at evalCode (jsBrowserRuntime.tsx:31:50),

You can see https://github.com/mui/mui-private/pull/538 for a reproduction and use case. Now, it's not that important for the use case I'm trying to solve here, so I'm adding the waiting for upvotes label, better be lazy.

Janpot commented 3 months ago

The Blob should be an easy fix

diff --git a/packages/toolpad-studio-runtime/src/jsBrowserRuntime.tsx b/packages/toolpad-studio-runtime/src/jsBrowserRuntime.tsx
index 7e2a92fb3..4f90baacf 100644
--- a/packages/toolpad-studio-runtime/src/jsBrowserRuntime.tsx
+++ b/packages/toolpad-studio-runtime/src/jsBrowserRuntime.tsx
@@ -32,7 +32,7 @@ function createBrowserRuntime(): JsRuntime {
       (() => {
         // See https://tc39.es/ecma262/multipage/global-object.html#sec-global-object
         const ecmaGlobals = new Set([ 'globalThis', 'Infinity', 'NaN', 'undefined', 'eval', 'isFinite', 'isNaN', 'parseFloat', 'parseInt', 'decodeURI', 'decodeURIComponent', 'encodeURI', 'encodeURIComponent', 'AggregateError', 'Array', 'ArrayBuffer', 'BigInt', 'BigInt64Array', 'BigUint64Array', 'Boolean', 'DataView', 'Date', 'Error', 'EvalError', 'FinalizationRegistry', 'Float32Array', 'Float64Array', 'Function', 'Int8Array', 'Int16Array', 'Int32Array', 'Map', 'Number', 'Object', 'Promise', 'Proxy', 'RangeError', 'ReferenceError', 'RegExp', 'Set', 'SharedArrayBuffer', 'String', 'Symbol', 'SyntaxError', 'TypeError', 'Uint8Array', 'Uint8ClampedArray', 'Uint16Array', 'Uint32Array', 'URIError', 'WeakMap', 'WeakRef', 'WeakSet', 'Atomics', 'JSON', 'Math', 'Reflect' ]);
-        const allowedDomGlobals = new Set([ 'setTimeout', 'console', 'URL', 'URLSearchParams', 'Intl' ])
+        const allowedDomGlobals = new Set([ 'setTimeout', 'console', 'URL', 'URLSearchParams', 'Intl', 'Blob' ])

         // NOTE: This is by no means intended to be a secure way to hide DOM globals 
         const globalThis = new Proxy(window.__SCOPE, {

Your code will fail likely on the document access. I don't think we should allow users to update the dom directly, it will for sure mess with our rendering and state management.


edit:

Perhaps we need to build a "initiate download" action? It would take a bindable input for content and inputs for type and name. Using this binding on a button will initiate a download for said content

Screenshot 2024-08-07 at 17 46 53

oliviertassinari commented 3 months ago

Your code will fail likely on the document access

Correct, it fails. From https://stackoverflow.com/questions/19327749/javascript-blob-filename-without-link, all the permutations seem to require access to the DOM.

Perhaps we need to build a "initiate download" action?

We could build this, though, I would expect that there are cases where developers need to run custom JavaScript logic, so we might as do the most generic and cheaper approach first: a new API, Retool-like.

Another benchmark: https://docs.appsmith.com/reference/appsmith-framework/widget-actions/download

Janpot commented 2 months ago

Ok for me. Perhaps to avoid interfering with builtins we could add a global toolpad namespace that holds custom utilities?

interface ToolpadGlobal {
  download(data: URL | Blob | string | ..., fileName?: string)
}

declare global {
  var toolpad: ToolpadGlobal
}

To be used as

toolpad.download(new Blob([textarea.value], { type: 'text/csv' }), 'file.csv')
oliviertassinari commented 2 months ago

👍

In the end, I built the tool without the download button https://github.com/mui/mui-private/pull/538. We will see if Tina uses this or prefers the CLI. If a community member faces the same problem, and fixes this, we can then update Toolpad, it can save her a bit of time, but it's still cheaper for engineers to not spend time on this 😄