Skip to content
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

context_processor, js command cleanup #48

Merged
merged 2 commits into from
Sep 25, 2024
Merged
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
45 changes: 45 additions & 0 deletions examples/views/js_commands/js_commands.css
Original file line number Diff line number Diff line change
@@ -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;
}
78 changes: 72 additions & 6 deletions examples/views/js_commands/js_commands.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
<script>
window.addEventListener("copy-to-clipboard", function (event) {
if ("clipboard" in navigator) {
const text = event.target.textContent;
navigator.clipboard.writeText(text);
}
});
</script>

<div>
<h1>JS Commands</h1>
<p>PyView JS commands support is a work in progress.</p>
Expand All @@ -6,23 +15,80 @@ <h1>JS Commands</h1>
for an idea of what's coming.
</p>
<h3>Show/Hide</h3>
<button phx-click='{{("show", "#bq") | js}}'>Show</button>
<button phx-click='{{("hide", "#bq") | js}}'>Hide</button>
<button phx-click='{{ js.show("#bq") }}'>Show</button>
<button phx-click='{{ js.hide("#bq") }}'>Hide</button>
<blockquote id="bq" class="hint">
<p>JS Commands let you update the DOM without making a trip to the server.</p>
</blockquote>
<hr />
<h3>Toggle</h3>
<button phx-click='{{("toggle", "#bq2") | js}}'>Toggle</button>
<button phx-click='{{ js.toggle("#bq2") }}'>Toggle</button>
<blockquote id="bq2" class="hint">
<p>JS Commands let you update the DOM without making a trip to the server.</p>
</blockquote>
<h3>Add/Remove Class</h3>
<button phx-click='{{("add_class", "#bq3", ["hint"]) | js}}'>Add "hint"</button>
<button phx-click='{{("add_class", "#bq3", ["warn"]) | js}}'>Add "warn"</button>
<button phx-click='{{("remove_class", "#bq3", ["warn", "hint"]) | js}}'>Remove all</button>
<button phx-click='{{ js.add_class("#bq3", "hint") }}'>Add "hint"</button>
<button phx-click='{{ js.add_class("#bq3", "warn") }}'>Add "warn"</button>
<button phx-click='{{ js.remove_class("#bq3", "warn", "hint") }}'>Remove all</button>

<blockquote id="bq3">
<p>JS Commands let you update the DOM without making a trip to the server.</p>
</blockquote>

<h3>Dispatch</h3>
<p>
Dispatch lets you send custom javascript events on the client, which you can listen to
using <code>window.addEventListener</code>.
</p>
<p>
This example sends a "copy-to-clipboard" event when the button is clicked.
</p>
<p>
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.
</p>
<pre id="copy-text">{{ js.dispatch("copy-to-clipboard", "#copy-text") }}</pre>

<button id="copy-button"
phx-click='{{ js.dispatch("copy-to-clipboard", "#copy-text") | js.add_class("#copy-button", "copied") }}'>Copy
to clipboard</button>

<h3>Push</h3>
<p>
Push lets you push a new event to your view, similar to <code>phx-click</code>.
</p>
<p>
This example increments a counter when the button is clicked.
</p>
<p>
This can be useful if you want to chain the push event with other JS commands, like a transition. This example
uses the <code>js.transition</code> command to add a bounce animation to the counter when it is incremented.
</p>
<p id="counter">
<b>Counter</b> | <span>{{value}}</span>
</p>
<button phx-click='{{ js.push("increment") | js.transition("#counter", "bounce")}}'>Increment</button>

<h3>
Focus
</h3>
<p>
Focus lets you focus an element on the page.
</p>
<p>
The first button uses <code>js.focus("#email")</code> to focus the email input.
</p>
<p>
The second button uses <code>js.focus_first("#focus-form")</code> to focus the first input in the form.
</p>

<button phx-click='{{ js.focus("#email") }}'>Focus</button>
<button phx-click='{{ js.focus_first("#focus-form") }}'>Focus first</button>
<form id="focus-form" autocomplete="off">
<label for="name">Name</label>
<input type="text" id="name" />
<label for="email">Email</label>
<input type="text" id="email" />
</form>

</div>
17 changes: 14 additions & 3 deletions examples/views/js_commands/js_commands.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ packages = [
{ include = "pyview" },
]

version = "0.1.0"
version = "0.2.0"
description = "LiveView in Python"
authors = ["Larry Ogrodnek <[email protected]>"]
license = "MIT"
Expand Down
4 changes: 2 additions & 2 deletions pyview/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@
UnconnectedSocket,
)
from pyview.pyview import PyView, defaultRootTemplate
from pyview.js import js
from pyview.js import JsCommand
from pyview.pyview import RootTemplateContext, RootTemplate

__all__ = [
"LiveView",
"LiveViewSocket",
"PyView",
"defaultRootTemplate",
"js",
"JsCommand",
"RootTemplateContext",
"RootTemplate",
"is_connected",
Expand Down
132 changes: 109 additions & 23 deletions pyview/js.py
Original file line number Diff line number Diff line change
@@ -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])
7 changes: 6 additions & 1 deletion pyview/live_socket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions pyview/live_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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()

Expand Down
Loading
Loading