completely reworked upload area

This commit is contained in:
Jakob Pinterits
2024-09-13 21:38:52 +02:00
parent 084af750c3
commit 7910d4dae8
11 changed files with 475 additions and 38 deletions

View File

@@ -1,17 +1,63 @@
import { applySwitcheroo } from '../designApplication';
import { applyIcon, applySwitcheroo } from '../designApplication';
import { ComponentBase, ComponentState } from './componentBase';
import { RippleEffect } from '../rippleEffect';
import { markEventAsHandled } from '../eventHandling';
/// Maps MIME types to what sort of file they represent
const EXTENSION_TO_CATEGORY = {
bmp: 'picture',
csv: 'data',
doc: 'document',
docx: 'document',
gif: 'picture',
h5: 'data',
hdf5: 'data',
ico: 'picture',
ini: 'data',
jpg: 'picture',
json: 'data',
jxl: 'picture',
md: 'document',
mdb: 'data',
ods: 'data',
odt: 'document',
parquet: 'data',
pdf: 'document',
pickle: 'data',
png: 'picture',
pptx: 'document',
svg: 'picture',
toml: 'data',
tsv: 'data',
txt: 'document',
webp: 'picture',
xlsx: 'data',
};
/// Maps types of files to
///
/// - A human-readable name
/// - An icon
const CATEGORY_TO_METADATA = {
document: ['Documents', 'material/description'],
picture: ['Pictures', 'material/landscape:fill'],
data: ['Data files', 'material/pie_chart'],
};
type UploadAreaState = ComponentState & {
_type_: 'UploadArea-builtin';
content?: string;
file_types?: string[];
};
export class UploadAreaComponent extends ComponentBase {
state: Required<UploadAreaState>;
private fileInput: HTMLInputElement;
private iconElement: HTMLElement;
private childContainer: HTMLElement;
private titleElement: HTMLElement;
private fileTypesElement: HTMLElement;
private progressElement: HTMLElement;
private rippleInstance: RippleEffect;
@@ -20,13 +66,32 @@ export class UploadAreaComponent extends ComponentBase {
let element = document.createElement('div');
element.classList.add('rio-upload-area');
// Create the icon element but don't add it
this.iconElement = document.createElement('div');
this.iconElement.classList.add('rio-upload-area-icon');
// Create the child container
this.childContainer = document.createElement('div');
this.childContainer.classList.add('rio-upload-area-child-container');
element.appendChild(this.childContainer);
// Create the title element, but don't add it
this.titleElement = document.createElement('div');
this.titleElement.classList.add('rio-upload-area-title');
// Same for the file types element
this.fileTypesElement = document.createElement('div');
this.fileTypesElement.classList.add('rio-upload-area-file-types');
// Create the progress element
this.progressElement = document.createElement('div');
this.progressElement.classList.add('rio-upload-area-progress-bar');
element.appendChild(this.progressElement);
// A hidden file input
this.fileInput = document.createElement('input');
this.fileInput.type = 'file';
this.fileInput.multiple = true;
element.appendChild(this.fileInput);
// Add a material ripple effect
@@ -34,6 +99,9 @@ export class UploadAreaComponent extends ComponentBase {
triggerOnPress: false,
});
// Populate the child container with the default content
this.createDefaultContent();
// Connect event handlers
//
// Highlight drop area when dragging files over it
@@ -95,8 +163,8 @@ export class UploadAreaComponent extends ComponentBase {
// Handle files selected from the file input
this.fileInput.addEventListener('change', (e) => {
// const files = e.target.files;
// this.uploadFiles(files);
const files = e.target.files;
this.uploadFiles(files);
});
return element;
@@ -107,12 +175,159 @@ export class UploadAreaComponent extends ComponentBase {
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// Title
if (deltaState.content !== undefined) {
this.titleElement.textContent = deltaState.content;
}
// File types
if (deltaState.file_types !== undefined) {
// Update the UI
this.updateFileTypes(deltaState.file_types);
// Update the file input
this.fileInput.type = 'file';
this.fileInput.accept = deltaState.file_types
.map((x) => `.${x}`)
.join(',');
this.fileInput.style.display = 'none';
}
}
uploadFiles(files) {
[...files].forEach((file) => {
console.log(file.name);
// Add any further file handling logic here
});
/// Updates the text & icon for the file types
updateFileTypes(fileTypes: string[] | null): void {
// Start off generic
let fileTypesText: string = 'All files supported';
let icon: string = 'material/upload';
// Are there a nicer text & icon to display?
if (fileTypes !== null) {
// What sort of files types are we dealing with?
let fileCategories = new Set<string>();
for (const fileType of fileTypes) {
if (fileType in EXTENSION_TO_CATEGORY) {
fileCategories.add(EXTENSION_TO_CATEGORY[fileType]);
} else {
fileCategories.clear();
break;
}
}
// Is there a single type?
if (fileCategories.size === 1) {
const category = fileCategories.values().next().value;
const [newText, newIcon] = CATEGORY_TO_METADATA[category];
if (fileTypes.length === 1) {
fileTypesText = `${fileTypes[0].toUpperCase()} ${newText}`;
} else {
let extensionText = fileTypes
.map((x) => `*.${x}`)
.join(', ');
fileTypesText = `${newText} (${extensionText})`;
}
icon = newIcon;
}
// Nope, but we can list the extensions
else {
fileTypesText = fileTypes.map((x) => `*.${x}`).join(', ');
}
}
// Apply the values
applyIcon(this.iconElement, icon, 'currentColor');
this.fileTypesElement.textContent = fileTypesText;
}
/// Populates the child container with the default content
createDefaultContent(): void {
// Icon
this.childContainer.appendChild(this.iconElement);
// Column for the title and file types
let column = document.createElement('div');
column.classList.add('rio-upload-area-column');
this.childContainer.appendChild(column);
// Title
column.appendChild(this.titleElement);
// File types
column.appendChild(this.fileTypesElement);
// Button
//
// The structure below is more complicated than strictly necessary. This
// is done to emulate the HTML of a regular `rio.Button`, so the
// existing button styles can be used.
//
// Note that the button needs to event handler at all. The file input
// already handles click events as intended. The button merely serves
// as visual indicator that the area is clickable.
let buttonOuter = document.createElement('div');
buttonOuter.classList.add(
'rio-upload-area-button',
'rio-button',
'rio-shape-rounded'
);
this.childContainer.appendChild(buttonOuter);
let buttonInner = document.createElement('div');
buttonInner.classList.add(
'rio-switcheroo-bump',
'rio-buttonstyle-major'
);
buttonOuter.appendChild(buttonInner);
buttonInner.textContent = 'Browse';
}
uploadFiles(files: FileList): void {
// Build a `FormData` object containing the files
const data = new FormData();
let ii = 0;
for (const file of files || []) {
ii += 1;
data.append('file_names', file.name);
data.append('file_types', file.type);
data.append('file_sizes', file.size.toString());
data.append('file_streams', file, file.name);
}
// FastAPI has trouble parsing empty form data. Append a dummy value so
// it's never empty
data.append('dummy', 'dummy');
// Upload the files
const xhr = new XMLHttpRequest();
const url = `${globalThis.RIO_BASE_URL}rio/upload-to-component/${globalThis.SESSION_TOKEN}/${this.id}`;
xhr.open('PUT', url, true);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const progress = event.loaded / event.total;
this.progressElement.style.opacity = '1';
this.progressElement.style.setProperty(
'--progress',
`${progress * 100}%`
);
}
};
xhr.onload = () => {
this.progressElement.style.opacity = '0';
};
xhr.onerror = () => {
this.progressElement.style.opacity = '0';
};
xhr.send(data);
}
}

View File

@@ -77,7 +77,7 @@ export function requestFileUpload(message: any): void {
input.multiple = message.multiple;
if (message.fileExtensions !== null) {
input.accept = message.fileExtensions.join(',');
input.accept = message.fileExtensions.map((x) => `.${x}`).join(',');
}
input.style.display = 'none';

View File

@@ -3798,23 +3798,83 @@ html.picking-component * {
position: relative;
text-align: center;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: stretch;
// To round the progress bar
overflow: hidden;
color: var(--rio-local-fg);
background-color: var(--rio-local-bg-variant);
border-radius: var(--rio-global-corner-radius-medium);
@include single-container();
}
.rio-upload-area:not(.rio-upload-area-file-hover):hover {
background-color: var(--rio-local-bg-active);
}
.rio-upload-area-child-container {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
}
.rio-upload-area-icon {
width: 3.5rem;
height: 3.5rem;
}
.rio-upload-area-column {
flex-grow: 1;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.rio-upload-area-title {
}
.rio-upload-area-file-types {
opacity: 0.5;
}
.rio-upload-area-button > div {
color: var(--rio-local-text-color);
padding: 0.5rem 1rem;
font-weight: bold;
}
.rio-upload-area-progress-bar {
flex-grow: 0;
height: 0.2rem;
opacity: 0;
transition: opacity 0.3s;
}
.rio-upload-area-progress-bar::after {
content: '';
display: block;
position: relative;
width: var(--progress);
height: 100%;
background: var(--rio-local-level-2-bg);
// This would be nice, but also causes the progress bar to animate when
// receding back to 0.
//
// transition: width 0.3s ease-in-out;
}
.rio-upload-area-file-hover {
cursor: copy;
color: var(--rio-local-level-2-bg);
}
.rio-upload-area::before {
@@ -3831,7 +3891,7 @@ html.picking-component * {
background: radial-gradient(
circle at var(--x) var(--y),
var(--rio-local-level-2-bg),
transparent 30rem
var(--rio-local-bg-active) 30rem
);
opacity: 0;

View File

@@ -150,9 +150,9 @@ class AbstractAppServer(abc.ABC):
self,
session: rio.Session,
*,
file_extensions: Iterable[str] | None = None,
file_types: Iterable[str] | None = None,
multiple: bool = False,
) -> utils.FileInfo | tuple[utils.FileInfo, ...]:
) -> utils.FileInfo | list[utils.FileInfo]:
raise NotImplementedError
@abc.abstractmethod

View File

@@ -829,6 +829,9 @@ Sitemap: {base_url / "/rio/sitemap"}
file_streams,
)
for file in files:
print(f"DEBUG: Got file: {file.name} ({file.size_in_bytes} bytes)")
# Let the component handle the files
await handler(files)
@@ -841,9 +844,9 @@ Sitemap: {base_url / "/rio/sitemap"}
self,
session: rio.Session,
*,
file_extensions: Iterable[str] | None = None,
file_types: Iterable[str] | None = None,
multiple: bool = False,
) -> utils.FileInfo | tuple[utils.FileInfo, ...]:
) -> utils.FileInfo | list[utils.FileInfo]:
# Create a secret id and register the file upload with the app server
upload_id = secrets.token_urlsafe()
future = asyncio.Future[list[utils.FileInfo]]()
@@ -851,10 +854,9 @@ Sitemap: {base_url / "/rio/sitemap"}
self._pending_file_uploads[upload_id] = future
# Allow the user to specify both `jpg` and `.jpg`
if file_extensions is not None:
file_extensions = [
ext if ext.startswith(".") else f".{ext}"
for ext in file_extensions
if file_types is not None:
file_types = [
ext if ext.startswith(".") else f".{ext}" for ext in file_types
]
# Tell the frontend to upload a file
@@ -862,7 +864,7 @@ Sitemap: {base_url / "/rio/sitemap"}
await session._request_file_upload(
upload_url=str(base_url / f"rio/upload/{upload_id}"),
file_extensions=file_extensions,
file_types=file_types,
multiple=multiple,
)
@@ -882,7 +884,7 @@ Sitemap: {base_url / "/rio/sitemap"}
# Return the file info
if multiple:
return tuple(files) # type: ignore
return files
else:
return files[0]

View File

@@ -25,7 +25,7 @@ class TestingServer(AbstractAppServer):
self,
session: rio.Session,
*,
file_extensions: Iterable[str] | None = None,
file_types: Iterable[str] | None = None,
multiple: bool = False,
) -> utils.FileInfo | tuple[utils.FileInfo, ...]:
raise NotImplementedError

View File

@@ -3,8 +3,11 @@ from __future__ import annotations
from dataclasses import KW_ONLY
from typing import * # type: ignore
from uniserde import JsonDoc
import rio
from .. import utils
from .fundamental_component import FundamentalComponent
__all__ = [
@@ -14,10 +17,29 @@ __all__ = [
@final
class UploadArea(FundamentalComponent):
content: str = "Drag & drop files here"
_: KW_ONLY
file_types: list[str] | None = None
on_file_upload: rio.EventHandler[list[rio.FileInfo]] = None
async def _on_file_upload(self, files: list[rio.FileInfo]) -> None:
print(f"Files uploaded: {files}")
def _custom_serialize_(self) -> JsonDoc:
if self.file_types is None:
return {}
return {
"file_types": list(
{
utils.normalize_file_type(file_type)
for file_type in self.file_types
}
)
}
async def _on_file_upload_(self, files: list[rio.FileInfo]) -> None:
print(f"Files uploaded:")
for file in files:
print(f" {file.name} ({file.size_in_bytes} bytes)")
UploadArea._unique_id_ = "UploadArea-builtin"

View File

@@ -13,7 +13,7 @@ if TYPE_CHECKING:
__all__ = [
"deprecated",
"_remap_kwargs",
"component_kwarg_renamed",
"function_kwarg_renamed",
"warn",
]
@@ -182,3 +182,37 @@ def _remap_kwargs(
since=since,
message=f"The {old_name!r} parameter of rio.{func_name} is deprecated. Please use `{new_name!r}` instead.",
)
def function_kwarg_renamed(
since: str,
old_name: str,
new_name: str,
) -> Callable[[F], F]:
"""
This decorator helps with renaming a keyword argument of a function, NOT a
component.
"""
def decorator(old_function: F) -> F:
@functools.wraps(old_function)
def new_function(*args: tuple, **kwargs: dict):
# Remap the old parameter to the new one
try:
kwargs[new_name] = kwargs.pop(old_name)
except KeyError:
pass
else:
warn(
since=since,
message=f"The `{old_name}` parameter of `{old_function.__name__}` is deprecated. Please use `{new_name}` instead.",
stacklevel=3,
)
# Delegate to the original function
return old_function(*args, **kwargs)
# Return the modified function
return new_function
return decorator

View File

@@ -29,6 +29,7 @@ from . import (
app_server,
assets,
data_models,
deprecations,
errors,
fills,
global_state,
@@ -2026,7 +2027,7 @@ window.history.{method}(null, "", {json.dumps(active_page_url.path)})
async def file_chooser(
self,
*,
file_extensions: Iterable[str] | None = None,
file_types: Iterable[str] | None = None,
multiple: Literal[False] = False,
) -> utils.FileInfo: ...
@@ -2034,16 +2035,21 @@ window.history.{method}(null, "", {json.dumps(active_page_url.path)})
async def file_chooser(
self,
*,
file_extensions: Iterable[str] | None = None,
file_types: Iterable[str] | None = None,
multiple: Literal[True],
) -> tuple[utils.FileInfo, ...]: ...
) -> list[utils.FileInfo]: ...
@deprecations.function_kwarg_renamed(
since="0.9.3",
old_name="file_extension",
new_name="file_types",
)
async def file_chooser(
self,
*,
file_extensions: Iterable[str] | None = None,
file_types: Iterable[str] | None = None,
multiple: bool = False,
) -> utils.FileInfo | tuple[utils.FileInfo, ...]:
) -> utils.FileInfo | list[utils.FileInfo]:
"""
Open a file chooser dialog.
@@ -2055,9 +2061,10 @@ window.history.{method}(null, "", {json.dumps(active_page_url.path)})
## Parameters
`file_extensions`: A list of file extensions which the user is allowed
to select. Defaults to `None`, which means that the user may
select any file.
`file_types`: A list of file extensions which the user is allowed
to select. Defaults to `None`, which means that the user may select
any file. Values can be passed as file extensions, ('pdf',
'.pdf', '*.pdf' are all accepted) or MIME types (e.g. 'application/pdf').
`multiple`: Whether the user should pick a single file, or multiple.
@@ -2066,9 +2073,15 @@ window.history.{method}(null, "", {json.dumps(active_page_url.path)})
`NoFileSelectedError`: If the user did not select a file.
"""
# Normalize the file types
if file_types is not None:
file_types = {
utils.normalize_file_type(file_type) for file_type in file_types
}
return await self._app_server.file_chooser(
self,
file_extensions=file_extensions,
file_types=file_types,
multiple=multiple,
)
@@ -2970,11 +2983,14 @@ a.remove();
async def _request_file_upload(
self,
upload_url: str,
file_extensions: list[str] | None,
file_types: list[str] | None,
multiple: bool,
) -> None:
"""
Tell the client to upload a file to the server.
If `file_types` is provided, the strings should be file extensions,
without any leading dots. E.g. `["pdf", "png"]`.
"""
raise NotImplementedError # pragma: no cover

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import hashlib
import importlib.util
import mimetypes
import os
import re
import secrets
@@ -423,3 +424,60 @@ def load_module_from_path(file_path: Path, *, module_name: str | None = None):
def is_python_script(path: Path) -> bool:
return path.suffix in (".py", ".pyc", ".pyd", ".pyo", ".pyw")
def normalize_file_type(file_type: str) -> str:
"""
Converts different file type formats into a common one.
This function takes various formats of file types, such as file extensions
(e.g., ".pdf", "PDF", "*.pdf") or MIME types (e.g., "application/pdf"), and
converts them into a standardized file extension. The result is always
lowercase and without any leading dots or wildcard characters.
This is best-effort. If the input type is invalid or unknown, the cleaned
input may not be accurate.
Examples:
>>> standardize_file_type("pdf")
'pdf'
>>> standardize_file_type(".PDF")
'pdf'
>>> standardize_file_type("*.pdf")
'pdf'
>>> standardize_file_type("application/pdf")
'pdf'
"""
# Normalize the input string
file_type = file_type.lower().strip()
# If this is a MIME type, guess the extension
if "/" in file_type:
guessed_type = mimetypes.guess_extension(file_type, strict=False)
if guessed_type is None:
file_type = file_type.rsplit("/", 1)[-1]
else:
file_type = guessed_type.lstrip(".")
# If it isn't a MIME type, convert it to one anyway. Some file types have
# multiple commonly used extensions. This will always map them to the same
# one. For example "jpeg" and "jpg" are both mapped to "jpg".
else:
guessed_type, _ = mimetypes.guess_type(
f"file.{file_type}", strict=False
)
if guessed_type is None:
file_type = file_type.lstrip(".*")
else:
guessed_type = mimetypes.guess_extension(
guessed_type,
strict=False,
)
assert guessed_type is not None
file_type = guessed_type.lstrip(".")
# Done
return file_type

30
tests/test_utils.py Normal file
View File

@@ -0,0 +1,30 @@
import pytest
import rio
@pytest.mark.parametrize(
"input_type,output_type",
[
# Simple cases
("pdf", "pdf"),
(".Pdf", "pdf"),
(".PDF", "pdf"),
("*pdf", "pdf"),
("*.Pdf", "pdf"),
("*.PDF", "pdf"),
("application/pdf", "pdf"),
# Make the results are standardized
(".jpg", "jpg"),
(".jpeg", "jpg"),
# Invalid MIME types
("not/a/real/type", "type"),
("////Type", "type"),
],
)
def test_standardize_file_types(
input_type: str,
output_type: str,
) -> None:
cleaned_type = rio.utils.normalize_file_type(input_type)
assert cleaned_type == output_type