diff --git a/examples/views/js_commands/js_commands.css b/examples/views/js_commands/js_commands.css new file mode 100644 index 0000000..df22dad --- /dev/null +++ b/examples/views/js_commands/js_commands.css @@ -0,0 +1,45 @@ +.copied::after { + content: "✓"; + margin-left: 8px; +} + +#copy-text { + font-size: 0.75rem; + background-color: #f0f0f0; + padding: 1em; +} + +#counter { + padding: 1em; + margin-block: 1em; + background-color: #eee; + border-radius: 1em; + width: 16ch; +} + +#focus-form { + margin-block: 1em; + display: flex; + flex-direction: column; + gap: 0.25em; + width: 50ch; +} + + +@keyframes bounce { + + 0%, + 100% { + transform: translateY(-25%); + animation-timing-function: cubic-bezier(0.8, 0, 1, 1); + } + + 50% { + transform: translateY(0); + animation-timing-function: cubic-bezier(0, 0, 0.2, 1); + } +} + +.bounce { + animation: bounce 1s infinite; +} \ No newline at end of file diff --git a/examples/views/js_commands/js_commands.html b/examples/views/js_commands/js_commands.html index c5fa3ec..6b842da 100644 --- a/examples/views/js_commands/js_commands.html +++ b/examples/views/js_commands/js_commands.html @@ -1,3 +1,12 @@ + +

JS Commands

PyView JS commands support is a work in progress.

@@ -6,23 +15,80 @@

JS Commands

for an idea of what's coming.

Show/Hide

- - + +

JS Commands let you update the DOM without making a trip to the server.


Toggle

- +

JS Commands let you update the DOM without making a trip to the server.

Add/Remove Class

- - - + + +

JS Commands let you update the DOM without making a trip to the server.

+ +

Dispatch

+

+ Dispatch lets you send custom javascript events on the client, which you can listen to + using window.addEventListener. +

+

+ This example sends a "copy-to-clipboard" event when the button is clicked. +

+

+ It also demonstrates how to chain multiple JS commands together - this example adds a class to the button + when the copy-to-clipboard event is dispatched. +

+
{{ js.dispatch("copy-to-clipboard", "#copy-text") }}
+ + + +

Push

+

+ Push lets you push a new event to your view, similar to phx-click. +

+

+ This example increments a counter when the button is clicked. +

+

+ This can be useful if you want to chain the push event with other JS commands, like a transition. This example + uses the js.transition command to add a bounce animation to the counter when it is incremented. +

+

+ Counter | {{value}} +

+ + +

+ Focus +

+

+ Focus lets you focus an element on the page. +

+

+ The first button uses js.focus("#email") to focus the email input. +

+

+ The second button uses js.focus_first("#focus-form") to focus the first input in the form. +

+ + + +
+ + + + +
+
\ No newline at end of file diff --git a/examples/views/js_commands/js_commands.py b/examples/views/js_commands/js_commands.py index bd1384e..1f1eb00 100644 --- a/examples/views/js_commands/js_commands.py +++ b/examples/views/js_commands/js_commands.py @@ -1,12 +1,23 @@ from pyview import LiveView, LiveViewSocket +from pyview.live_socket import ConnectedLiveViewSocket +from typing import TypedDict -class JsCommandsLiveView(LiveView[dict]): +class JsCommandsLiveViewContext(TypedDict): + value: int + + +class JsCommandsLiveView(LiveView[JsCommandsLiveViewContext]): """ JS Commands JS Commands let you update the DOM without making a trip to the server. """ - async def mount(self, socket: LiveViewSocket[dict], session): - socket.context = {} + async def mount(self, socket: LiveViewSocket[JsCommandsLiveViewContext], session): + socket.context = JsCommandsLiveViewContext({"value": 0}) + + async def handle_event(self, event, payload, socket): + print(event, payload) + if event == "increment": + socket.context["value"] += 1 diff --git a/pyproject.toml b/pyproject.toml index be04461..d09e7fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ packages = [ { include = "pyview" }, ] -version = "0.1.0" +version = "0.2.0" description = "LiveView in Python" authors = ["Larry Ogrodnek "] license = "MIT" diff --git a/pyview/__init__.py b/pyview/__init__.py index 719e048..d950441 100644 --- a/pyview/__init__.py +++ b/pyview/__init__.py @@ -6,7 +6,7 @@ UnconnectedSocket, ) from pyview.pyview import PyView, defaultRootTemplate -from pyview.js import js +from pyview.js import JsCommand from pyview.pyview import RootTemplateContext, RootTemplate __all__ = [ @@ -14,7 +14,7 @@ "LiveViewSocket", "PyView", "defaultRootTemplate", - "js", + "JsCommand", "RootTemplateContext", "RootTemplate", "is_connected", diff --git a/pyview/js.py b/pyview/js.py index 5a15db3..649378d 100644 --- a/pyview/js.py +++ b/pyview/js.py @@ -1,34 +1,120 @@ import json -from typing import Union +from typing import Any, Optional from pyview.vendor.ibis import filters +from pyview.template.context_processor import context_processor +from dataclasses import dataclass -JsArgs = Union[tuple[str, str], tuple[str, str, list[str]]] +@context_processor +def add_js(meta): + return {"js": JsCommands([])} -@filters.register -def js(args: JsArgs): - if len(args) > 2: - cmd, id, names = args # type: ignore - return Js(cmd, id, names) - cmd, id = args # type: ignore - return Js(cmd, id) +@filters.register("js.add_class") +def js_add_class(js: "JsCommands", selector: str, *classes): + return js.add_class(selector, *classes) -class Js: - def __init__(self, cmd: str, id: str, names: list[str] = []): - self.cmd = cmd - self.id = id - self.names = names +@filters.register("js.remove_class") +def js_remove_class(js: "JsCommands", selector: str, *classes): + return js.remove_class(selector, *classes) - def __str__(self): - opts = { - "to": self.id, - "time": 200, - "transition": [[], [], []], - } - if len(self.names) > 0: - opts["names"] = self.names +@filters.register("js.show") +def js_show(js: "JsCommands", selector: str): + return js.show(selector) + + +@filters.register("js.hide") +def js_hide(js: "JsCommands", selector: str): + return js.hide(selector) + + +@filters.register("js.toggle") +def js_toggle(js: "JsCommands", selector: str): + return js.toggle(selector) + + +@filters.register("js.dispatch") +def js_dispatch(js: "JsCommands", event: str, selector: str): + return js.dispatch(event, selector) + + +@filters.register("js.push") +def js_push(js: "JsCommands", event: str, payload: Optional[dict[str, Any]] = None): + return js.push(event, payload) + + +@filters.register("js.focus") +def js_focus(js: "JsCommands", selector: str): + return js.focus(selector) + + +@filters.register("js.focus_first") +def js_focus_first(js: "JsCommands", selector: str): + return js.focus_first(selector) + + +@filters.register("js.transition") +def js_transition(js: "JsCommands", selector: str, transition: str, time: int = 200): + return js.transition(selector, transition, time) + - return json.dumps([[self.cmd, opts]]) +@dataclass +class JsCommand: + cmd: str + opts: dict[str, Any] + + +@dataclass +class JsCommands: + commands: list[JsCommand] + + def add(self, cmd: JsCommand) -> "JsCommands": + return JsCommands(self.commands + [cmd]) + + def show(self, selector: str) -> "JsCommands": + return self.add(JsCommand("show", {"to": selector})) + + def hide(self, selector: str) -> "JsCommands": + return self.add(JsCommand("hide", {"to": selector})) + + def toggle(self, selector: str) -> "JsCommands": + return self.add(JsCommand("toggle", {"to": selector})) + + def add_class(self, selector: str, *classes: str) -> "JsCommands": + return self.add(JsCommand("add_class", {"to": selector, "names": classes})) + + def remove_class(self, selector: str, *classes: str) -> "JsCommands": + return self.add(JsCommand("remove_class", {"to": selector, "names": classes})) + + def dispatch(self, event: str, selector: str) -> "JsCommands": + return self.add(JsCommand("dispatch", {"to": selector, "event": event})) + + def push( + self, event: str, payload: Optional[dict[str, Any]] = None + ) -> "JsCommands": + return self.add( + JsCommand( + "push", {"event": event} | ({"value": payload} if payload else {}) + ) + ) + + def focus(self, selector: str) -> "JsCommands": + return self.add(JsCommand("focus", {"to": selector})) + + def focus_first(self, selector: str) -> "JsCommands": + return self.add(JsCommand("focus_first", {"to": selector})) + + def transition( + self, selector: str, transition: str, time: int = 200 + ) -> "JsCommands": + return self.add( + JsCommand( + "transition", + {"to": selector, "time": time, "transition": [[transition], [], []]}, + ) + ) + + def __str__(self): + return json.dumps([(c.cmd, c.opts) for c in self.commands]) diff --git a/pyview/live_socket.py b/pyview/live_socket.py index 7f6fb09..a731648 100644 --- a/pyview/live_socket.py +++ b/pyview/live_socket.py @@ -16,6 +16,7 @@ from pyview.vendor.flet.pubsub import PubSubHub, PubSub from pyview.events import InfoEvent from pyview.uploads import UploadConstraints, UploadConfig, UploadManager +from pyview.meta import PyViewMeta import datetime @@ -62,6 +63,10 @@ def __init__(self, websocket: WebSocket, topic: str, liveview: LiveView): self.pending_events = [] self.upload_manager = UploadManager() + @property + def meta(self) -> PyViewMeta: + return PyViewMeta() + async def subscribe(self, topic: str): await self.pub_sub.subscribe_topic_async(topic, self._topic_callback_internal) @@ -94,7 +99,7 @@ def diff(self, render: dict[str, Any]) -> dict[str, Any]: async def send_info(self, event: InfoEvent): await self.liveview.handle_info(event, self) - r = await self.liveview.render(self.context) + r = await self.liveview.render(self.context, self.meta) resp = [None, None, self.topic, "diff", self.diff(r.tree())] try: diff --git a/pyview/live_view.py b/pyview/live_view.py index a6ff65c..8c07b96 100644 --- a/pyview/live_view.py +++ b/pyview/live_view.py @@ -8,6 +8,7 @@ find_associated_file, ) from pyview.events import InfoEvent +from pyview.meta import PyViewMeta from urllib.parse import ParseResult T = TypeVar("T") @@ -37,11 +38,11 @@ async def handle_params(self, url: URL, params, socket: LiveViewSocket[T]): async def disconnect(self, socket: ConnectedLiveViewSocket[T]): pass - async def render(self, assigns: T) -> RenderedContent: + async def render(self, assigns: T, meta: PyViewMeta) -> RenderedContent: html_render = _find_render(self) if html_render: - return LiveRender(html_render, assigns) + return LiveRender(html_render, assigns, meta) raise NotImplementedError() diff --git a/pyview/meta.py b/pyview/meta.py new file mode 100644 index 0000000..504cc20 --- /dev/null +++ b/pyview/meta.py @@ -0,0 +1,6 @@ +from dataclasses import dataclass + + +@dataclass +class PyViewMeta: + pass diff --git a/pyview/pyview.py b/pyview/pyview.py index c17ca64..f57c5bc 100644 --- a/pyview/pyview.py +++ b/pyview/pyview.py @@ -11,6 +11,7 @@ from pyview.csrf import generate_csrf_token from pyview.session import serialize_session from pyview.auth import AuthProviderFactory +from pyview.meta import PyViewMeta from .ws_handler import LiveSocketHandler from .live_view import LiveView from .live_routes import LiveViewLookup @@ -60,7 +61,7 @@ async def liveview_container( await lv.mount(s, session) await lv.handle_params(urlparse(url._url), parse_qs(url.query), s) - r = await lv.render(s.context) + r = await lv.render(s.context, PyViewMeta()) liveview_css = find_associated_css(lv) diff --git a/pyview/template/__init__.py b/pyview/template/__init__.py index cae3a98..e239d1d 100644 --- a/pyview/template/__init__.py +++ b/pyview/template/__init__.py @@ -2,6 +2,7 @@ from .live_template import LiveTemplate, template_file, RenderedContent, LiveRender from .root_template import RootTemplate, RootTemplateContext, defaultRootTemplate from .utils import find_associated_css, find_associated_file +from .context_processor import context_processor __all__ = [ "Template", @@ -14,4 +15,5 @@ "defaultRootTemplate", "find_associated_css", "find_associated_file", + "context_processor", ] diff --git a/pyview/template/context_processor.py b/pyview/template/context_processor.py new file mode 100644 index 0000000..6a05ec5 --- /dev/null +++ b/pyview/template/context_processor.py @@ -0,0 +1,17 @@ +from pyview.meta import PyViewMeta + +context_processors = [] + + +def context_processor(func): + context_processors.append(func) + return func + + +def apply_context_processors(meta: PyViewMeta): + context = {} + + for processor in context_processors: + context.update(processor(meta)) + + return context diff --git a/pyview/template/live_template.py b/pyview/template/live_template.py index c2c7523..be56803 100644 --- a/pyview/template/live_template.py +++ b/pyview/template/live_template.py @@ -3,6 +3,8 @@ from dataclasses import asdict, Field from .serializer import serialize import os.path +from pyview.template.context_processor import apply_context_processors +from pyview.meta import PyViewMeta class DataclassInstance(Protocol): @@ -23,18 +25,20 @@ class LiveTemplate: def __init__(self, template: Template): self.t = template - def tree(self, assigns: Assigns) -> dict[str, Any]: + def tree(self, assigns: Assigns, meta: PyViewMeta) -> dict[str, Any]: if not isinstance(assigns, dict): assigns = serialize(assigns) - return self.t.tree(assigns) + additional_context = apply_context_processors(meta) + return self.t.tree(additional_context | assigns) - def render(self, assigns: Assigns) -> str: + def render(self, assigns: Assigns, meta: PyViewMeta) -> str: if not isinstance(assigns, dict): assigns = asdict(assigns) - return self.t.render(assigns) + additional_context = apply_context_processors(meta) + return self.t.render(additional_context | assigns) - def text(self, assigns: Assigns) -> str: - return self.render(assigns) + def text(self, assigns: Assigns, meta: PyViewMeta) -> str: + return self.render(assigns, meta) def debug(self) -> str: return self.t.root_node.to_str() @@ -47,15 +51,16 @@ def text(self) -> str: ... class LiveRender: - def __init__(self, template: LiveTemplate, assigns: Any): + def __init__(self, template: LiveTemplate, assigns: Any, meta: PyViewMeta): self.template = template self.assigns = assigns + self.meta = meta def tree(self) -> dict[str, Any]: - return self.template.tree(self.assigns) + return self.template.tree(self.assigns, self.meta) def text(self) -> str: - return self.template.text(self.assigns) + return self.template.text(self.assigns, self.meta) _cache = {} @@ -73,6 +78,6 @@ def template_file(filename: str) -> Optional[LiveTemplate]: return cached_template with open(filename, "r") as f: - t = LiveTemplate(Template(f.read())) + t = LiveTemplate(Template(f.read(), template_id=filename)) _cache[filename] = (mtime, t) return t diff --git a/pyview/vendor/ibis/template.py b/pyview/vendor/ibis/template.py index 0b2a27d..efe3744 100644 --- a/pyview/vendor/ibis/template.py +++ b/pyview/vendor/ibis/template.py @@ -10,6 +10,7 @@ class Template: def __init__(self, template_string, template_id="UNIDENTIFIED"): self.root_node = ibis.compiler.compile(template_string, template_id) self.blocks = self._register_blocks(self.root_node, {}) + self.template_id = template_id def __str__(self): return str(self.root_node) diff --git a/pyview/ws_handler.py b/pyview/ws_handler.py index b905023..cda90f3 100644 --- a/pyview/ws_handler.py +++ b/pyview/ws_handler.py @@ -236,7 +236,7 @@ async def handle_connected( async def _render(socket: ConnectedLiveViewSocket): - rendered = (await socket.liveview.render(socket.context)).tree() + rendered = (await socket.liveview.render(socket.context, socket.meta)).tree() if socket.live_title: rendered["t"] = socket.live_title