diff --git a/alfort_dom/dom.py b/alfort_dom/dom.py index abf10e4..2b0b395 100644 --- a/alfort_dom/dom.py +++ b/alfort_dom/dom.py @@ -26,7 +26,7 @@ def dom_effect( ) -> Callable[[Callable[[HTMLElement, Dispatch[M]], None]], Effect[M]]: def _wrapper(fun: Callable[[HTMLElement, Dispatch[M]], None]) -> Effect[M]: @functools.wraps(fun) - def _wrapped(dispatch: Dispatch[M]) -> None: + async def _wrapped(dispatch: Dispatch[M]) -> None: def _f(_: Any) -> None: dom = document.getElementById(dom_id) fun(dom, dispatch) diff --git a/docs/examples/index.html b/docs/examples/index.html index 1469ce2..51b1d32 100755 --- a/docs/examples/index.html +++ b/docs/examples/index.html @@ -8,6 +8,7 @@
diff --git a/docs/examples/simple_api/index.html b/docs/examples/simple_api/index.html new file mode 100755 index 0000000..a0fb0f9 --- /dev/null +++ b/docs/examples/simple_api/index.html @@ -0,0 +1,29 @@ + + + + + Alfort-Dom • Simple Counter + + + + +
+ + diff --git a/docs/examples/simple_api/main.py b/docs/examples/simple_api/main.py new file mode 100755 index 0000000..fd87452 --- /dev/null +++ b/docs/examples/simple_api/main.py @@ -0,0 +1,157 @@ +from dataclasses import dataclass +from typing import Any, Callable, Coroutine, TypeAlias +from urllib.parse import ParseResult as URL +from urllib.parse import urlparse + +import pyodide.http +from alfort import Dispatch, Effect +from alfort.vdom import VDom, el + +from alfort_dom import AlfortDom + + +@dataclass(frozen=True) +class Photo: + album_id: int + id: int + title: str + url: URL + thumbnail_url: URL + + +@dataclass(frozen=True) +class State: + album_id: int + is_fetching: bool + photos: list[Photo] + + +@dataclass(frozen=True) +class SelectAlbum: + alumb_id: int + + +@dataclass(frozen=True) +class ReceivePhotos: + photos: list[Photo] + + +Msg: TypeAlias = SelectAlbum | ReceivePhotos + + +async def fetch_photos(album_id: int) -> list[Photo]: + url = f"https://jsonplaceholder.typicode.com/albums/{album_id}/photos" + res = await pyodide.http.pyfetch(url) + return [ + Photo( + album_id=obj["albumId"], + id=obj["id"], + title=obj["title"], + url=urlparse(obj["url"]), + thumbnail_url=urlparse(obj["thumbnailUrl"]), + ) + for obj in await res.json() + ] + + +def title(text: str) -> VDom: + return el("h1", {}, [text]) + + +def album_selector(album_id: int) -> VDom: + def _on_change(e: Any) -> Msg: + return SelectAlbum(e.target.value) + + return el( + "div", + {"style": {"margin": "15px"}}, + [ + el("label", {"style": {"margin-right": "8px"}}, ["Album ID"]), + el( + "select", + { + "onchange": _on_change, + }, + [ + el( + "option", + { + "value": i, + "selected": album_id == i, + }, + [str(i)], + ) + for i in range(15) + ], + ), + ], + ) + + +def album_photos(photos: list[Photo]) -> VDom: + photo_elms: list[VDom] = [] + for photo in photos: + photo_elms.append( + el( + "img", + {"src": photo.thumbnail_url.geturl(), "style": {"margin": "3px"}}, + [], + ) + ) + + return el("div", {"style": {"width": "75%", "line-height": "0px"}}, photo_elms) + + +def fetching_dialog() -> VDom: + return el("div", {}, ["Loading..."]) + + +def view(state: State) -> VDom: + return el( + "div", + { + "style": { + "display": "flex", + "justify-content": "center", + "align-items": "center", + "flex-flow": "column", + } + }, + [ + title("Simple Photo Album"), + album_selector(state.album_id), + fetching_dialog() if state.is_fetching else album_photos(state.photos), + ], + ) + + +def create_fetch_effect( + album_id: int, +) -> Callable[[Dispatch[Msg]], Coroutine[None, None, Any]]: + async def _fetch(dispatch: Dispatch[Msg]) -> None: + recv_photos = await fetch_photos(album_id=album_id) + dispatch(ReceivePhotos(recv_photos)) + + return _fetch + + +def init() -> tuple[State, list[Effect[Msg]]]: + return (State(album_id=1, is_fetching=True, photos=[]), [create_fetch_effect(1)]) + + +def update(msg: Msg, state: State) -> tuple[State, list[Effect[Msg]]]: + match msg: + case SelectAlbum(album_id): + state = State(album_id=album_id, is_fetching=True, photos=[]) + return (state, [create_fetch_effect(album_id=album_id)]) + case ReceivePhotos(photos): + state = State(album_id=state.album_id, is_fetching=False, photos=photos) + return (state, []) + + +app = AlfortDom[State, Msg]( + init=init, + view=view, + update=update, +) +app.main(root="root") diff --git a/docs/examples/simple_api/pkg_install.py b/docs/examples/simple_api/pkg_install.py new file mode 100755 index 0000000..04a30ae --- /dev/null +++ b/docs/examples/simple_api/pkg_install.py @@ -0,0 +1,7 @@ +import micropip # type: ignore # noqa: F401 + +try: + # try to use development version + await micropip.install("../dist/alfort_dom-0.0.0.dev0-py3-none-any.whl") # type: ignore # noqa: F704 +except ValueError: + await micropip.install("alfort-dom") # type: ignore # noqa: F704 diff --git a/docs/examples/todomvc/main.py b/docs/examples/todomvc/main.py index 18156a9..ed76367 100755 --- a/docs/examples/todomvc/main.py +++ b/docs/examples/todomvc/main.py @@ -30,7 +30,11 @@ def with_local_storage(update: Update["Msg", "Model"]) -> Update["Msg", "Model"] @functools.wraps(update) def _update(msg: Msg, model: Model) -> tuple[Model, list[Effect[Msg]]]: model, effects = update(msg, model) - return model, [lambda _: save_model(model), *effects] + + async def _save_model(_: Dispatch[Msg]) -> None: + save_model(model) + + return model, [_save_model, *effects] return _update diff --git a/pyproject.toml b/pyproject.toml index d3b8b55..deeb900 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ include = ["alfort_dom/py.typed"] [tool.poetry.dependencies] python = "^3.10" -alfort = "^0.1.8" +alfort = "^0.1.9" [tool.poetry.group.dev.dependencies] poethepoet = "^0.13.1" diff --git a/stubs/pyodide/http/__init__.pyi b/stubs/pyodide/http/__init__.pyi new file mode 100644 index 0000000..ecb0a50 --- /dev/null +++ b/stubs/pyodide/http/__init__.pyi @@ -0,0 +1,37 @@ +# derived from https://github.com/pyodide/pyodide/blob/main/src/py/pyodide/http.py + +from io import StringIO +from typing import Any, BinaryIO, TextIO + +from .. import JsProxy + +def open_url(url: str) -> StringIO: ... + +class FetchResponse: + def __init__(self, url: str, js_response: JsProxy) -> None: ... + @property + def body_used(self) -> bool: ... + @property + def ok(self) -> bool: ... + @property + def redirected(self) -> bool: ... + @property + def status(self) -> str: ... + @property + def status_text(self) -> str: ... + @property + def type(self) -> str: ... + @property + def url(self) -> str: ... + def clone(self) -> "FetchResponse": ... + async def buffer(self) -> JsProxy: ... + async def string(self) -> str: ... + async def json(self, **kwargs: Any) -> Any: ... + async def memoryview(self) -> memoryview: ... + async def bytes(self) -> bytes: ... + async def _into_file(self, f: TextIO | BinaryIO) -> None: ... + async def unpack_archive( + self, *, extract_dir: str | None, format: str | None + ) -> None: ... + +async def pyfetch(url: str, **kwargs: Any) -> FetchResponse: ...