Sfoglia il codice sorgente

Upload: drag_active_style and multiple on_drop specs (#5207)

* Allow the user to specify `drag_active_style` that will be applied when a
  file is dragged over the dropzone.
* If the user provides a list of EventHandler/EventSpec, ensure that the
  `files` arg is updated to the on_drop param name for all of the given
  handlers.
Masen Furer 2 settimane fa
parent
commit
c90b73af83
4 ha cambiato i file con 56 aggiunte e 19 eliminazioni
  1. 1 1
      pyi_hashes.json
  2. 5 2
      reflex/app.py
  3. 48 16
      reflex/components/core/upload.py
  4. 2 0
      tests/units/test_app.py

+ 1 - 1
pyi_hashes.json

@@ -20,7 +20,7 @@
   "reflex/components/core/debounce.pyi": "affda049624c266c7d5620efa3b7041b",
   "reflex/components/core/debounce.pyi": "affda049624c266c7d5620efa3b7041b",
   "reflex/components/core/html.pyi": "b12117b42ef79ee90b6b4dec50baeb86",
   "reflex/components/core/html.pyi": "b12117b42ef79ee90b6b4dec50baeb86",
   "reflex/components/core/sticky.pyi": "c65131cf7c2312c68e1fddaa0cc27150",
   "reflex/components/core/sticky.pyi": "c65131cf7c2312c68e1fddaa0cc27150",
-  "reflex/components/core/upload.pyi": "53e06193fa23a603737bc49b1c6c2565",
+  "reflex/components/core/upload.pyi": "4680da6f7b3df704a682cc6441b1ac18",
   "reflex/components/datadisplay/__init__.pyi": "cf087efa8b3960decc6b231cc986cfa9",
   "reflex/components/datadisplay/__init__.pyi": "cf087efa8b3960decc6b231cc986cfa9",
   "reflex/components/datadisplay/code.pyi": "3d8f0ab4c2f123d7f80d15c7ebc553d9",
   "reflex/components/datadisplay/code.pyi": "3d8f0ab4c2f123d7f80d15c7ebc553d9",
   "reflex/components/datadisplay/dataeditor.pyi": "cb03d732e2fe771a8d46c7bcda671f92",
   "reflex/components/datadisplay/dataeditor.pyi": "cb03d732e2fe771a8d46c7bcda671f92",

+ 5 - 2
reflex/app.py

@@ -29,7 +29,7 @@ from starlette.datastructures import Headers
 from starlette.datastructures import UploadFile as StarletteUploadFile
 from starlette.datastructures import UploadFile as StarletteUploadFile
 from starlette.exceptions import HTTPException
 from starlette.exceptions import HTTPException
 from starlette.middleware import cors
 from starlette.middleware import cors
-from starlette.requests import Request
+from starlette.requests import ClientDisconnect, Request
 from starlette.responses import JSONResponse, Response, StreamingResponse
 from starlette.responses import JSONResponse, Response, StreamingResponse
 from starlette.staticfiles import StaticFiles
 from starlette.staticfiles import StaticFiles
 from typing_extensions import deprecated
 from typing_extensions import deprecated
@@ -1828,7 +1828,10 @@ def upload(app: App):
         from reflex.utils.exceptions import UploadTypeError, UploadValueError
         from reflex.utils.exceptions import UploadTypeError, UploadValueError
 
 
         # Get the files from the request.
         # Get the files from the request.
-        files = await request.form()
+        try:
+            files = await request.form()
+        except ClientDisconnect:
+            return Response()  # user cancelled
         files = files.getlist("files")
         files = files.getlist("files")
         if not files:
         if not files:
             raise UploadValueError("No files were uploaded.")
             raise UploadValueError("No files were uploaded.")

+ 48 - 16
reflex/components/core/upload.py

@@ -13,6 +13,7 @@ from reflex.components.component import (
     MemoizationLeaf,
     MemoizationLeaf,
     StatefulComponent,
     StatefulComponent,
 )
 )
+from reflex.components.core.cond import cond
 from reflex.components.el.elements.forms import Input
 from reflex.components.el.elements.forms import Input
 from reflex.components.radix.themes.layout.box import Box
 from reflex.components.radix.themes.layout.box import Box
 from reflex.config import environment
 from reflex.config import environment
@@ -28,6 +29,7 @@ from reflex.event import (
     parse_args_spec,
     parse_args_spec,
     run_script,
     run_script,
 )
 )
+from reflex.style import Style
 from reflex.utils import format
 from reflex.utils import format
 from reflex.utils.imports import ImportVar
 from reflex.utils.imports import ImportVar
 from reflex.vars import VarData
 from reflex.vars import VarData
@@ -231,6 +233,9 @@ class Upload(MemoizationLeaf):
     # Fired when files are dropped.
     # Fired when files are dropped.
     on_drop: EventHandler[_on_drop_spec]
     on_drop: EventHandler[_on_drop_spec]
 
 
+    # Style rules to apply when actively dragging.
+    drag_active_style: Style | None = None
+
     @classmethod
     @classmethod
     def create(cls, *children, **props) -> Component:
     def create(cls, *children, **props) -> Component:
         """Create an upload component.
         """Create an upload component.
@@ -266,25 +271,46 @@ class Upload(MemoizationLeaf):
             # If on_drop is not provided, save files to be uploaded later.
             # If on_drop is not provided, save files to be uploaded later.
             upload_props["on_drop"] = upload_file(upload_props["id"])
             upload_props["on_drop"] = upload_file(upload_props["id"])
         else:
         else:
-            on_drop = upload_props["on_drop"]
-            if isinstance(on_drop, (EventHandler, EventSpec)):
-                # Call the lambda to get the event chain.
-                on_drop = call_event_handler(on_drop, _on_drop_spec)
-            elif isinstance(on_drop, Callable):
-                # Call the lambda to get the event chain.
-                on_drop = call_event_fn(on_drop, _on_drop_spec)
-            if isinstance(on_drop, EventSpec):
-                # Update the provided args for direct use with on_drop.
-                on_drop = on_drop.with_args(
-                    args=tuple(
-                        cls._update_arg_tuple_for_on_drop(arg_value)
-                        for arg_value in on_drop.args
-                    ),
-                )
+            on_drop = (
+                [on_drop_prop]
+                if not isinstance(on_drop_prop := upload_props["on_drop"], Sequence)
+                else list(on_drop_prop)
+            )
+            for ix, event in enumerate(on_drop):
+                if isinstance(event, (EventHandler, EventSpec)):
+                    # Call the lambda to get the event chain.
+                    event = call_event_handler(event, _on_drop_spec)
+                elif isinstance(event, Callable):
+                    # Call the lambda to get the event chain.
+                    event = call_event_fn(event, _on_drop_spec)
+                if isinstance(event, EventSpec):
+                    # Update the provided args for direct use with on_drop.
+                    event = event.with_args(
+                        args=tuple(
+                            cls._update_arg_tuple_for_on_drop(arg_value)
+                            for arg_value in event.args
+                        ),
+                    )
+                on_drop[ix] = event
             upload_props["on_drop"] = on_drop
             upload_props["on_drop"] = on_drop
 
 
         input_props_unique_name = get_unique_variable_name()
         input_props_unique_name = get_unique_variable_name()
         root_props_unique_name = get_unique_variable_name()
         root_props_unique_name = get_unique_variable_name()
+        is_drag_active_unique_name = get_unique_variable_name()
+        drag_active_css_class_unique_name = get_unique_variable_name() + "-drag-active"
+
+        # Handle special style when dragging over the drop zone.
+        if "drag_active_style" in props:
+            props.setdefault("style", Style())[
+                f"&:where(.{drag_active_css_class_unique_name})"
+            ] = props.pop("drag_active_style")
+            props["class_name"].append(
+                cond(
+                    Var(is_drag_active_unique_name),
+                    drag_active_css_class_unique_name,
+                    "",
+                ),
+            )
 
 
         event_var, callback_str = StatefulComponent._get_memoized_event_triggers(
         event_var, callback_str = StatefulComponent._get_memoized_event_triggers(
             GhostUpload.create(on_drop=upload_props["on_drop"])
             GhostUpload.create(on_drop=upload_props["on_drop"])
@@ -303,7 +329,13 @@ class Upload(MemoizationLeaf):
             }
             }
         )
         )
 
 
-        left_side = f"const {{getRootProps: {root_props_unique_name}, getInputProps: {input_props_unique_name}}} "
+        left_side = (
+            "const { "
+            f"getRootProps: {root_props_unique_name}, "
+            f"getInputProps: {input_props_unique_name}, "
+            f"isDragActive: {is_drag_active_unique_name}"
+            "}"
+        )
         right_side = f"useDropzone({use_dropzone_arguments!s})"
         right_side = f"useDropzone({use_dropzone_arguments!s})"
 
 
         var_data = VarData.merge(
         var_data = VarData.merge(

+ 2 - 0
tests/units/test_app.py

@@ -14,6 +14,7 @@ from unittest.mock import AsyncMock
 
 
 import pytest
 import pytest
 import sqlmodel
 import sqlmodel
+from fastapi.responses import StreamingResponse
 from pytest_mock import MockerFixture
 from pytest_mock import MockerFixture
 from starlette.applications import Starlette
 from starlette.applications import Starlette
 from starlette.datastructures import UploadFile
 from starlette.datastructures import UploadFile
@@ -830,6 +831,7 @@ async def test_upload_file(tmp_path, state, delta, token: str, mocker):
 
 
     upload_fn = upload(app)
     upload_fn = upload(app)
     streaming_response = await upload_fn(request_mock)
     streaming_response = await upload_fn(request_mock)
+    assert isinstance(streaming_response, StreamingResponse)
     async for state_update in streaming_response.body_iterator:
     async for state_update in streaming_response.body_iterator:
         assert (
         assert (
             state_update
             state_update