diff --git a/requirements/pkg-extras.txt b/requirements/pkg-extras.txt index 7d7a59bdb..54aab2c12 100644 --- a/requirements/pkg-extras.txt +++ b/requirements/pkg-extras.txt @@ -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 diff --git a/requirements/test-env.txt b/requirements/test-env.txt index 7e5ab1955..6522ebd17 100644 --- a/requirements/test-env.txt +++ b/requirements/test-env.txt @@ -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 diff --git a/src/client/packages/idom-client-react/src/components.js b/src/client/packages/idom-client-react/src/components.js index 7ca471c6e..9412ede1f 100644 --- a/src/client/packages/idom-client-react/src/components.js +++ b/src/client/packages/idom-client-react/src/components.js @@ -29,6 +29,14 @@ 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) { @@ -36,15 +44,16 @@ export function Element({ model }) { } 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 }) { @@ -155,6 +164,41 @@ function ScriptElement({ model }) { return html`
`; } +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); diff --git a/src/idom/backend/_common.py b/src/idom/backend/_common.py index 90e2dea5b..85f3d29af 100644 --- a/src/idom/backend/_common.py +++ b/src/idom/backend/_common.py @@ -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, @@ -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: diff --git a/src/idom/backend/sanic.py b/src/idom/backend/sanic.py index fda9d214f..d6f79e2bc 100644 --- a/src/idom/backend/sanic.py +++ b/src/idom/backend/sanic.py @@ -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, @@ -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}//") + 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}//", + name="model_stream_path", + ) def _make_send_recv_callbacks( diff --git a/src/idom/backend/utils.py b/src/idom/backend/utils.py index 6398d6b0d..ccfc6830e 100644 --- a/src/idom/backend/utils.py +++ b/src/idom/backend/utils.py @@ -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__) diff --git a/tests/test_client.py b/tests/test_client.py index 0e48e3390..60966683c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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 @@ -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 diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 497b89787..56b2e7850 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -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, @@ -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), @@ -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)