Explorar o código

Handle file uploads with component-local state (#1616)

Masen Furer hai 1 ano
pai
achega
042710ca91

+ 174 - 0
integration/test_upload.py

@@ -0,0 +1,174 @@
+"""Integration tests for file upload."""
+from __future__ import annotations
+
+import time
+from typing import Generator
+
+import pytest
+from selenium.webdriver.common.by import By
+
+from reflex.testing import AppHarness
+
+
+def UploadFile():
+    """App for testing dynamic routes."""
+    import reflex as rx
+
+    class UploadState(rx.State):
+        _file_data: dict[str, str] = {}
+
+        async def handle_upload(self, files: list[rx.UploadFile]):
+            for file in files:
+                upload_data = await file.read()
+                self._file_data[file.filename or ""] = upload_data.decode("utf-8")
+
+        @rx.var
+        def token(self) -> str:
+            return self.get_token()
+
+    def index():
+        return rx.vstack(
+            rx.input(value=UploadState.token, is_read_only=True, id="token"),
+            rx.upload(
+                rx.vstack(
+                    rx.button("Select File"),
+                    rx.text("Drag and drop files here or click to select files"),
+                ),
+            ),
+            rx.button(
+                "Upload",
+                on_click=lambda: UploadState.handle_upload(rx.upload_files()),  # type: ignore
+                id="upload_button",
+            ),
+            rx.box(
+                rx.foreach(
+                    rx.selected_files,
+                    lambda f: rx.text(f),
+                ),
+                id="selected_files",
+            ),
+        )
+
+    app = rx.App(state=UploadState)
+    app.add_page(index)
+    app.compile()
+
+
+@pytest.fixture(scope="session")
+def upload_file(tmp_path_factory) -> Generator[AppHarness, None, None]:
+    """Start UploadFile app at tmp_path via AppHarness.
+
+    Args:
+        tmp_path_factory: pytest tmp_path_factory fixture
+
+    Yields:
+        running AppHarness instance
+    """
+    with AppHarness.create(
+        root=tmp_path_factory.mktemp("upload_file"),
+        app_source=UploadFile,  # type: ignore
+    ) as harness:
+        yield harness
+
+
+@pytest.fixture
+def driver(upload_file: AppHarness):
+    """Get an instance of the browser open to the upload_file app.
+
+    Args:
+        upload_file: harness for DynamicRoute app
+
+    Yields:
+        WebDriver instance.
+    """
+    assert upload_file.app_instance is not None, "app is not running"
+    driver = upload_file.frontend()
+    try:
+        assert upload_file.poll_for_clients()
+        yield driver
+    finally:
+        driver.quit()
+
+
+def test_upload_file(tmp_path, upload_file: AppHarness, driver):
+    """Submit a file upload and check that it arrived on the backend.
+
+    Args:
+        tmp_path: pytest tmp_path fixture
+        upload_file: harness for UploadFile app.
+        driver: WebDriver instance.
+    """
+    assert upload_file.app_instance is not None
+    token_input = driver.find_element(By.ID, "token")
+    assert token_input
+    # wait for the backend connection to send the token
+    token = upload_file.poll_for_value(token_input)
+    assert token is not None
+
+    upload_box = driver.find_element(By.XPATH, "//input[@type='file']")
+    assert upload_box
+    upload_button = driver.find_element(By.ID, "upload_button")
+    assert upload_button
+
+    exp_name = "test.txt"
+    exp_contents = "test file contents!"
+    target_file = tmp_path / exp_name
+    target_file.write_text(exp_contents)
+
+    upload_box.send_keys(str(target_file))
+    upload_button.click()
+
+    # look up the backend state and assert on uploaded contents
+    backend_state = upload_file.app_instance.state_manager.states[token]
+    time.sleep(0.5)
+    assert backend_state._file_data[exp_name] == exp_contents
+
+    # check that the selected files are displayed
+    selected_files = driver.find_element(By.ID, "selected_files")
+    assert selected_files.text == exp_name
+
+
+def test_upload_file_multiple(tmp_path, upload_file: AppHarness, driver):
+    """Submit several file uploads and check that they arrived on the backend.
+
+    Args:
+        tmp_path: pytest tmp_path fixture
+        upload_file: harness for UploadFile app.
+        driver: WebDriver instance.
+    """
+    assert upload_file.app_instance is not None
+    token_input = driver.find_element(By.ID, "token")
+    assert token_input
+    # wait for the backend connection to send the token
+    token = upload_file.poll_for_value(token_input)
+    assert token is not None
+
+    upload_box = driver.find_element(By.XPATH, "//input[@type='file']")
+    assert upload_box
+    upload_button = driver.find_element(By.ID, "upload_button")
+    assert upload_button
+
+    exp_files = {
+        "test1.txt": "test file contents!",
+        "test2.txt": "this is test file number 2!",
+        "reflex.txt": "reflex is awesome!",
+    }
+    for exp_name, exp_contents in exp_files.items():
+        target_file = tmp_path / exp_name
+        target_file.write_text(exp_contents)
+        upload_box.send_keys(str(target_file))
+
+    time.sleep(0.2)
+
+    # check that the selected files are displayed
+    selected_files = driver.find_element(By.ID, "selected_files")
+    assert selected_files.text == "\n".join(exp_files)
+
+    # do the upload
+    upload_button.click()
+
+    # look up the backend state and assert on uploaded contents
+    backend_state = upload_file.app_instance.state_manager.states[token]
+    time.sleep(0.5)
+    for exp_name, exp_contents in exp_files.items():
+        assert backend_state._file_data[exp_name] == exp_contents

+ 4 - 6
reflex/.templates/web/utils/state.js

@@ -193,10 +193,10 @@ export const applyEvent = async (event, socket) => {
  *
  *
  * @returns Whether the event was sent.
  * @returns Whether the event was sent.
  */
  */
-export const applyRestEvent = async (event, state) => {
+export const applyRestEvent = async (event) => {
   let eventSent = false;
   let eventSent = false;
   if (event.handler == "uploadFiles") {
   if (event.handler == "uploadFiles") {
-    eventSent = await uploadFiles(state, event.name);
+    eventSent = await uploadFiles(event.name, event.payload.files);
   }
   }
   return eventSent;
   return eventSent;
 };
 };
@@ -232,7 +232,7 @@ export const processEvent = async (
   let eventSent = false
   let eventSent = false
   // Process events with handlers via REST and all others via websockets.
   // Process events with handlers via REST and all others via websockets.
   if (event.handler) {
   if (event.handler) {
-    eventSent = await applyRestEvent(event, currentState);
+    eventSent = await applyRestEvent(event);
   } else {
   } else {
     eventSent = await applyEvent(event, socket);
     eventSent = await applyEvent(event, socket);
   }
   }
@@ -298,9 +298,7 @@ export const connect = async (
  *
  *
  * @returns Whether the files were uploaded.
  * @returns Whether the files were uploaded.
  */
  */
-export const uploadFiles = async (state, handler) => {
-  const files = state.files;
-
+export const uploadFiles = async (handler, files) => {
   // return if there's no file to upload
   // return if there's no file to upload
   if (files.length == 0) {
   if (files.length == 0) {
     return false;
     return false;

+ 2 - 1
reflex/components/forms/__init__.py

@@ -46,10 +46,11 @@ from .select import Option, Select
 from .slider import Slider, SliderFilledTrack, SliderMark, SliderThumb, SliderTrack
 from .slider import Slider, SliderFilledTrack, SliderMark, SliderThumb, SliderTrack
 from .switch import Switch
 from .switch import Switch
 from .textarea import TextArea
 from .textarea import TextArea
-from .upload import Upload
+from .upload import Upload, selected_files
 
 
 helpers = [
 helpers = [
     "color_mode_cond",
     "color_mode_cond",
+    "selected_files",
 ]
 ]
 
 
 __all__ = [f for f in dir() if f[0].isupper()] + helpers  # type: ignore
 __all__ = [f for f in dir() if f[0].isupper()] + helpers  # type: ignore

+ 10 - 2
reflex/components/forms/upload.py

@@ -1,4 +1,5 @@
 """A file upload component."""
 """A file upload component."""
+from __future__ import annotations
 
 
 from typing import Dict, List, Optional
 from typing import Dict, List, Optional
 
 
@@ -8,7 +9,11 @@ from reflex.components.layout.box import Box
 from reflex.event import EventChain
 from reflex.event import EventChain
 from reflex.vars import BaseVar, Var
 from reflex.vars import BaseVar, Var
 
 
-upload_file = BaseVar(name="e => File(e)", type_=EventChain)
+files_state = "const [files, setFiles] = useState([]);"
+upload_file = BaseVar(name="e => setFiles((files) => e)", type_=EventChain)
+
+# Use this var along with the Upload component to render the list of selected files.
+selected_files = BaseVar(name="files.map((f) => f.name)", type_=List[str])
 
 
 
 
 class Upload(Component):
 class Upload(Component):
@@ -73,7 +78,7 @@ class Upload(Component):
         zone = Box.create(
         zone = Box.create(
             upload,
             upload,
             *children,
             *children,
-            **{k: v for k, v in props.items() if k not in supported_props}
+            **{k: v for k, v in props.items() if k not in supported_props},
         )
         )
         zone.special_props = {BaseVar(name="{...getRootProps()}", type_=None)}
         zone.special_props = {BaseVar(name="{...getRootProps()}", type_=None)}
 
 
@@ -94,3 +99,6 @@ class Upload(Component):
         out = super()._render()
         out = super()._render()
         out.args = ("getRootProps", "getInputProps")
         out.args = ("getRootProps", "getInputProps")
         return out
         return out
+
+    def _get_hooks(self) -> str | None:
+        return (super()._get_hooks() or "") + files_state

+ 2 - 0
reflex/event.py

@@ -64,6 +64,8 @@ class EventHandler(Base):
                 return EventSpec(
                 return EventSpec(
                     handler=self,
                     handler=self,
                     client_handler_name="uploadFiles",
                     client_handler_name="uploadFiles",
+                    # `files` is defined in the Upload component's _use_hooks
+                    args=((Var.create_safe("files"), Var.create_safe("files")),),
                 )
                 )
 
 
             # Otherwise, convert to JSON.
             # Otherwise, convert to JSON.

+ 2 - 2
tests/components/forms/test_uploads.py

@@ -53,7 +53,7 @@ def test_upload_component_render(upload_component):
     assert upload["name"] == "ReactDropzone"
     assert upload["name"] == "ReactDropzone"
     assert upload["props"] == [
     assert upload["props"] == [
         "multiple={true}",
         "multiple={true}",
-        "onDrop={e => File(e)}",
+        "onDrop={e => setFiles((files) => e)}",
     ]
     ]
     assert upload["args"] == ("getRootProps", "getInputProps")
     assert upload["args"] == ("getRootProps", "getInputProps")
 
 
@@ -92,5 +92,5 @@ def test_upload_component_with_props_render(upload_component_with_props):
         "maxFiles={2}",
         "maxFiles={2}",
         "multiple={true}",
         "multiple={true}",
         "noDrag={true}",
         "noDrag={true}",
-        "onDrop={e => File(e)}",
+        "onDrop={e => setFiles((files) => e)}",
     ]
     ]