Skip to content

use fetch request to upload form data #859

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion requirements/pkg-extras.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ starlette >=0.13.6
uvicorn[standard] >=0.19.0

# extra=sanic
sanic >=21
sanic >=21,<22.12
sanic-cors
uvicorn[standard] >=0.19.0

# extra=fastapi
fastapi >=0.63.0
Expand Down
3 changes: 3 additions & 0 deletions requirements/test-env.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@ playwright

# I'm not quite sure why this needs to be installed for tests with Sanic to pass
sanic-testing

# required to test uploading form data with starlette
python-multipart
58 changes: 51 additions & 7 deletions src/client/packages/idom-client-react/src/components.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,22 +29,31 @@ export function Layout({ saveUpdateHook, sendEvent, loadImportSource }) {
`;
}

const ELEMENT_WRAPPER_COMPONENTS = {
form: FormElement,
input: UserInputElement,
script: ScriptElement,
select: UserInputElement,
textarea: UserInputElement,
};

export function Element({ model }) {
if (model.error !== undefined) {
if (model.error) {
return html`<pre>${model.error}</pre>`;
} else {
return null;
}
} else if (model.tagName == "script") {
return html`<${ScriptElement} model=${model} />`;
} else if (["input", "select", "textarea"].includes(model.tagName)) {
return html`<${UserInputElement} model=${model} />`;
} else if (model.importSource) {
return html`<${ImportedElement} model=${model} />`;
}

let Component;
if (model.importSource) {
Component = ImportedElement;
} else {
return html`<${StandardElement} model=${model} />`;
Component = ELEMENT_WRAPPER_COMPONENTS[model.tagName] || StandardElement;
}

return html`<${Component} model=${model} />`;
}

function StandardElement({ model }) {
Expand Down Expand Up @@ -155,6 +164,41 @@ function ScriptElement({ model }) {
return html`<div ref=${ref} />`;
}

function FormElement({ model }) {
const layoutContext = React.useContext(LayoutContext);
const props = createElementAttributes(model, layoutContext.sendEvent);

if (props.target && props.target !== "_self") {
return html`<${StandardElement} model=${model} />`;
}

const oldOnSubmit = props.onSubmit || (() => {});
async function onSubmit(event) {
event.preventDefault();
if (!model?.eventHandlers?.onSubmit?.preventDefault) {
await fetch(props.action || window.location.href, {
method: props.method || "POST",
body: new FormData(event.target),
});
}
if (oldOnSubmit) {
oldOnSubmit(event);
}
}

// Use createElement here to avoid warning about variable numbers of children not
// having keys. Warning about this must now be the responsibility of the server
// providing the models instead of the client rendering them.
return React.createElement(
model.tagName,
{ ...props, onSubmit },
...createElementChildren(
model,
(model) => html`<${Element} key=${model.key} model=${model} />`
)
);
}

function ImportedElement({ model }) {
const layoutContext = React.useContext(LayoutContext);

Expand Down
12 changes: 5 additions & 7 deletions src/idom/backend/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ async def serve_development_asgi(
started: asyncio.Event | None,
) -> None:
"""Run a development server for starlette"""
started = started or asyncio.Event()

server = UvicornServer(
UvicornConfig(
app,
Expand All @@ -42,15 +44,11 @@ async def serve_development_asgi(
)
)

coros: list[Awaitable[Any]] = [server.serve()]

if started:
coros.append(_check_if_started(server, started))

try:
await asyncio.gather(*coros)
await asyncio.gather(server.serve(), _check_if_started(server, started))
finally:
await asyncio.wait_for(server.shutdown(), timeout=3)
if started.is_set():
await asyncio.wait_for(server.shutdown(), timeout=3)


async def _check_if_started(server: UvicornServer, started: asyncio.Event) -> None:
Expand Down
24 changes: 20 additions & 4 deletions src/idom/backend/sanic.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,16 @@ async def single_page_app_files(
) -> response.HTTPResponse:
return response.html(index_html)

spa_blueprint.add_route(single_page_app_files, "/")
spa_blueprint.add_route(single_page_app_files, "/<_:path>")
spa_blueprint.add_route(
single_page_app_files,
"/",
name="single_page_app_files_root",
)
spa_blueprint.add_route(
single_page_app_files,
"/<_:path>",
name="single_page_app_files_path",
)

async def asset_files(
request: request.Request,
Expand Down Expand Up @@ -188,8 +196,16 @@ async def model_stream(
recv,
)

api_blueprint.add_websocket_route(model_stream, f"/{STREAM_PATH.name}")
api_blueprint.add_websocket_route(model_stream, f"/{STREAM_PATH.name}/<path:path>/")
api_blueprint.add_websocket_route(
model_stream,
f"/{STREAM_PATH.name}",
name="model_stream_root",
)
api_blueprint.add_websocket_route(
model_stream,
f"/{STREAM_PATH.name}/<path:path>/",
name="model_stream_path",
)


def _make_send_recv_callbacks(
Expand Down
3 changes: 1 addition & 2 deletions src/idom/backend/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@
from importlib import import_module
from typing import Any, Iterator

from idom.backend.types import BackendImplementation
from idom.types import RootComponentConstructor

from .types import BackendImplementation


logger = logging.getLogger(__name__)

Expand Down
52 changes: 52 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import asyncio
from contextlib import AsyncExitStack
from pathlib import Path
from tempfile import NamedTemporaryFile

from playwright.async_api import Browser
from starlette.applications import Starlette
from starlette.datastructures import UploadFile
from starlette.requests import Request
from starlette.responses import Response

import idom
from idom import html
from idom.backend import starlette as starlette_backend
from idom.backend.utils import find_available_port
from idom.testing import BackendFixture, DisplayFixture
from tests.tooling.common import DEFAULT_TYPE_DELAY
Expand Down Expand Up @@ -125,3 +132,48 @@ async def handle_change(event):
await inp.type("hello", delay=DEFAULT_TYPE_DELAY)

assert (await inp.evaluate("node => node.value")) == "hello"


async def test_form_upload_file(page):
file_content = asyncio.Future()

async def handle_file_upload(request: Request) -> Response:
form = await request.form()
file: UploadFile = form.get("file")
file_content.set_result((await file.read()).decode("utf-8"))
return Response()

app = Starlette()
app.add_route("/file-upload", handle_file_upload, methods=["POST"])

@idom.component
def CheckUploadFile():
return html.form(
{
"enctype": "multipart/form-data",
"action": "/file-upload",
"method": "POST",
},
html.input({"type": "file", "name": "file", "id": "file-input"}),
html.input({"type": "submit", "id": "form-submit"}),
)

async with AsyncExitStack() as es:
file = Path(es.enter_context(NamedTemporaryFile()).name)

expected_file_content = "Hello, World!"
file.write_text(expected_file_content)

server = await es.enter_async_context(
BackendFixture(app=app, implementation=starlette_backend)
)
display = await es.enter_async_context(DisplayFixture(server, driver=page))

await display.show(CheckUploadFile)

file_input = await display.page.wait_for_selector("#file-input")
await file_input.set_input_files(file)

await (await display.page.wait_for_selector("#form-submit")).click()

assert (await asyncio.wait_for(file_content, 5)) == expected_file_content
19 changes: 10 additions & 9 deletions tests/test_web/test_module.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from pathlib import Path

import pytest
from sanic import Sanic
from starlette.applications import Starlette
from starlette.responses import FileResponse

import idom
from idom.backend import sanic as sanic_implementation
from idom.backend import starlette as starlette_implementation
from idom.testing import (
BackendFixture,
DisplayFixture,
Expand Down Expand Up @@ -52,14 +53,12 @@ def ShowCurrentComponent():


async def test_module_from_url(browser):
app = Sanic("test_module_from_url")
app = Starlette()

# instead of directing the URL to a CDN, we just point it to this static file
app.static(
"/simple-button.js",
str(JS_FIXTURES_DIR / "simple-button.js"),
content_type="text/javascript",
)
@app.route("/simple-button.js")
async def simple_button_js(request):
return FileResponse(str(JS_FIXTURES_DIR / "simple-button.js"))

SimpleButton = idom.web.export(
idom.web.module_from_url("/simple-button.js", resolve_exports=False),
Expand All @@ -70,7 +69,9 @@ async def test_module_from_url(browser):
def ShowSimpleButton():
return SimpleButton({"id": "my-button"})

async with BackendFixture(app=app, implementation=sanic_implementation) as server:
async with BackendFixture(
app=app, implementation=starlette_implementation
) as server:
async with DisplayFixture(server, browser) as display:
await display.show(ShowSimpleButton)

Expand Down