upload.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. """A file upload component."""
  2. from __future__ import annotations
  3. from pathlib import Path
  4. from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple
  5. from reflex.components.component import (
  6. Component,
  7. ComponentNamespace,
  8. MemoizationLeaf,
  9. StatefulComponent,
  10. )
  11. from reflex.components.el.elements.forms import Input
  12. from reflex.components.radix.themes.layout.box import Box
  13. from reflex.config import environment
  14. from reflex.constants import Dirs
  15. from reflex.constants.compiler import Imports
  16. from reflex.event import (
  17. CallableEventSpec,
  18. EventChain,
  19. EventHandler,
  20. EventSpec,
  21. call_event_fn,
  22. parse_args_spec,
  23. run_script,
  24. )
  25. from reflex.utils import format
  26. from reflex.utils.imports import ImportVar
  27. from reflex.vars import VarData
  28. from reflex.vars.base import CallableVar, LiteralVar, Var, get_unique_variable_name
  29. from reflex.vars.sequence import LiteralStringVar
  30. DEFAULT_UPLOAD_ID: str = "default"
  31. upload_files_context_var_data: VarData = VarData(
  32. imports={
  33. "react": "useContext",
  34. f"$/{Dirs.CONTEXTS_PATH}": "UploadFilesContext",
  35. },
  36. hooks={
  37. "const [filesById, setFilesById] = useContext(UploadFilesContext);": None,
  38. },
  39. )
  40. @CallableVar
  41. def upload_file(id_: str = DEFAULT_UPLOAD_ID) -> Var:
  42. """Get the file upload drop trigger.
  43. This var is passed to the dropzone component to update the file list when a
  44. drop occurs.
  45. Args:
  46. id_: The id of the upload to get the drop trigger for.
  47. Returns:
  48. A var referencing the file upload drop trigger.
  49. """
  50. id_var = LiteralStringVar.create(id_)
  51. var_name = f"""e => setFilesById(filesById => {{
  52. const updatedFilesById = Object.assign({{}}, filesById);
  53. updatedFilesById[{str(id_var)}] = e;
  54. return updatedFilesById;
  55. }})
  56. """
  57. return Var(
  58. _js_expr=var_name,
  59. _var_type=EventChain,
  60. _var_data=VarData.merge(
  61. upload_files_context_var_data, id_var._get_all_var_data()
  62. ),
  63. )
  64. @CallableVar
  65. def selected_files(id_: str = DEFAULT_UPLOAD_ID) -> Var:
  66. """Get the list of selected files.
  67. Args:
  68. id_: The id of the upload to get the selected files for.
  69. Returns:
  70. A var referencing the list of selected file paths.
  71. """
  72. id_var = LiteralStringVar.create(id_)
  73. return Var(
  74. _js_expr=f"(filesById[{str(id_var)}] ? filesById[{str(id_var)}].map((f) => (f.path || f.name)) : [])",
  75. _var_type=List[str],
  76. _var_data=VarData.merge(
  77. upload_files_context_var_data, id_var._get_all_var_data()
  78. ),
  79. ).guess_type()
  80. @CallableEventSpec
  81. def clear_selected_files(id_: str = DEFAULT_UPLOAD_ID) -> EventSpec:
  82. """Clear the list of selected files.
  83. Args:
  84. id_: The id of the upload to clear.
  85. Returns:
  86. An event spec that clears the list of selected files when triggered.
  87. """
  88. # UploadFilesProvider assigns a special function to clear selected files
  89. # into the shared global refs object to make it accessible outside a React
  90. # component via `run_script` (otherwise backend could never clear files).
  91. return run_script(f"refs['__clear_selected_files']({id_!r})")
  92. def cancel_upload(upload_id: str) -> EventSpec:
  93. """Cancel an upload.
  94. Args:
  95. upload_id: The id of the upload to cancel.
  96. Returns:
  97. An event spec that cancels the upload when triggered.
  98. """
  99. return run_script(
  100. f"upload_controllers[{str(LiteralVar.create(upload_id))}]?.abort()"
  101. )
  102. def get_upload_dir() -> Path:
  103. """Get the directory where uploaded files are stored.
  104. Returns:
  105. The directory where uploaded files are stored.
  106. """
  107. Upload.is_used = True
  108. uploaded_files_dir = environment.REFLEX_UPLOADED_FILES_DIR
  109. uploaded_files_dir.mkdir(parents=True, exist_ok=True)
  110. return uploaded_files_dir
  111. uploaded_files_url_prefix = Var(
  112. _js_expr="getBackendURL(env.UPLOAD)",
  113. _var_data=VarData(
  114. imports={
  115. f"$/{Dirs.STATE_PATH}": "getBackendURL",
  116. "$/env.json": ImportVar(tag="env", is_default=True),
  117. }
  118. ),
  119. ).to(str)
  120. def get_upload_url(file_path: str) -> Var[str]:
  121. """Get the URL of an uploaded file.
  122. Args:
  123. file_path: The path of the uploaded file.
  124. Returns:
  125. The URL of the uploaded file to be rendered from the frontend (as a str-encoded Var).
  126. """
  127. Upload.is_used = True
  128. return uploaded_files_url_prefix + "/" + file_path
  129. def _on_drop_spec(files: Var) -> Tuple[Var[Any]]:
  130. """Args spec for the on_drop event trigger.
  131. Args:
  132. files: The files to upload.
  133. Returns:
  134. Signature for on_drop handler including the files to upload.
  135. """
  136. return (files,)
  137. class UploadFilesProvider(Component):
  138. """AppWrap component that provides a dict of selected files by ID via useContext."""
  139. library = f"$/{Dirs.CONTEXTS_PATH}"
  140. tag = "UploadFilesProvider"
  141. class Upload(MemoizationLeaf):
  142. """A file upload component."""
  143. library = "react-dropzone@14.2.10"
  144. tag = ""
  145. # The list of accepted file types. This should be a dictionary of MIME types as keys and array of file formats as
  146. # values.
  147. # supported MIME types: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
  148. accept: Var[Optional[Dict[str, List]]]
  149. # Whether the dropzone is disabled.
  150. disabled: Var[bool]
  151. # The maximum number of files that can be uploaded.
  152. max_files: Var[int]
  153. # The maximum file size (bytes) that can be uploaded.
  154. max_size: Var[int]
  155. # The minimum file size (bytes) that can be uploaded.
  156. min_size: Var[int]
  157. # Whether to allow multiple files to be uploaded.
  158. multiple: Var[bool]
  159. # Whether to disable click to upload.
  160. no_click: Var[bool]
  161. # Whether to disable drag and drop.
  162. no_drag: Var[bool]
  163. # Whether to disable using the space/enter keys to upload.
  164. no_keyboard: Var[bool]
  165. # Marked True when any Upload component is created.
  166. is_used: ClassVar[bool] = False
  167. # Fired when files are dropped.
  168. on_drop: EventHandler[_on_drop_spec]
  169. @classmethod
  170. def create(cls, *children, **props) -> Component:
  171. """Create an upload component.
  172. Args:
  173. *children: The children of the component.
  174. **props: The properties of the component.
  175. Returns:
  176. The upload component.
  177. """
  178. # Mark the Upload component as used in the app.
  179. cls.is_used = True
  180. props.setdefault("multiple", True)
  181. # Apply the default classname
  182. given_class_name = props.pop("class_name", [])
  183. if isinstance(given_class_name, str):
  184. given_class_name = [given_class_name]
  185. props["class_name"] = ["rx-Upload", *given_class_name]
  186. # get only upload component props
  187. supported_props = cls.get_props().union({"on_drop"})
  188. upload_props = {
  189. key: value for key, value in props.items() if key in supported_props
  190. }
  191. # Create the component.
  192. upload_props["id"] = props.get("id", DEFAULT_UPLOAD_ID)
  193. if upload_props.get("on_drop") is None:
  194. # If on_drop is not provided, save files to be uploaded later.
  195. upload_props["on_drop"] = upload_file(upload_props["id"])
  196. else:
  197. on_drop = upload_props["on_drop"]
  198. if isinstance(on_drop, Callable):
  199. # Call the lambda to get the event chain.
  200. on_drop = call_event_fn(on_drop, _on_drop_spec) # type: ignore
  201. if isinstance(on_drop, EventSpec):
  202. # Update the provided args for direct use with on_drop.
  203. on_drop = on_drop.with_args(
  204. args=tuple(
  205. cls._update_arg_tuple_for_on_drop(arg_value)
  206. for arg_value in on_drop.args
  207. ),
  208. )
  209. upload_props["on_drop"] = on_drop
  210. input_props_unique_name = get_unique_variable_name()
  211. root_props_unique_name = get_unique_variable_name()
  212. event_var, callback_str = StatefulComponent._get_memoized_event_triggers(
  213. Box.create(on_click=upload_props["on_drop"]) # type: ignore
  214. )["on_click"]
  215. upload_props["on_drop"] = event_var
  216. upload_props = {
  217. format.to_camel_case(key): value for key, value in upload_props.items()
  218. }
  219. use_dropzone_arguements = {
  220. "onDrop": event_var,
  221. **upload_props,
  222. }
  223. left_side = f"const {{getRootProps: {root_props_unique_name}, getInputProps: {input_props_unique_name}}} "
  224. right_side = f"useDropzone({str(Var.create(use_dropzone_arguements))})"
  225. var_data = VarData.merge(
  226. VarData(
  227. imports=Imports.EVENTS,
  228. hooks={
  229. "const [addEvents, connectError] = useContext(EventLoopContext);": None
  230. },
  231. ),
  232. event_var._get_all_var_data(),
  233. VarData(
  234. hooks={
  235. callback_str: None,
  236. f"{left_side} = {right_side};": None,
  237. },
  238. imports={
  239. "react-dropzone": "useDropzone",
  240. **Imports.EVENTS,
  241. },
  242. ),
  243. )
  244. # The file input to use.
  245. upload = Input.create(type="file")
  246. upload.special_props = [
  247. Var(
  248. _js_expr=f"{{...{input_props_unique_name}()}}",
  249. _var_type=None,
  250. _var_data=var_data,
  251. )
  252. ]
  253. # The dropzone to use.
  254. zone = Box.create(
  255. upload,
  256. *children,
  257. **{k: v for k, v in props.items() if k not in supported_props},
  258. )
  259. zone.special_props = [
  260. Var(
  261. _js_expr=f"{{...{root_props_unique_name}()}}",
  262. _var_type=None,
  263. _var_data=var_data,
  264. )
  265. ]
  266. return super().create(
  267. zone,
  268. )
  269. @classmethod
  270. def _update_arg_tuple_for_on_drop(cls, arg_value: tuple[Var, Var]):
  271. """Helper to update caller-provided EventSpec args for direct use with on_drop.
  272. Args:
  273. arg_value: The arg tuple to update (if necessary).
  274. Returns:
  275. The updated arg_value tuple when arg is "files", otherwise the original arg_value.
  276. """
  277. if arg_value[0]._js_expr == "files":
  278. placeholder = parse_args_spec(_on_drop_spec)[0]
  279. return (arg_value[0], placeholder)
  280. return arg_value
  281. @staticmethod
  282. def _get_app_wrap_components() -> dict[tuple[int, str], Component]:
  283. return {
  284. (5, "UploadFilesProvider"): UploadFilesProvider.create(),
  285. }
  286. class StyledUpload(Upload):
  287. """The styled Upload Component."""
  288. @classmethod
  289. def create(cls, *children, **props) -> Component:
  290. """Create the styled upload component.
  291. Args:
  292. *children: The children of the component.
  293. **props: The properties of the component.
  294. Returns:
  295. The styled upload component.
  296. """
  297. # Set default props.
  298. props.setdefault("border", "1px dashed var(--accent-12)")
  299. props.setdefault("padding", "5em")
  300. props.setdefault("textAlign", "center")
  301. # Mark the Upload component as used in the app.
  302. Upload.is_used = True
  303. return super().create(
  304. *children,
  305. **props,
  306. )
  307. class UploadNamespace(ComponentNamespace):
  308. """Upload component namespace."""
  309. root = Upload.create
  310. __call__ = StyledUpload.create
  311. upload = UploadNamespace()