Skip to content

Commit

Permalink
Make it possible to use nested components
Browse files Browse the repository at this point in the history
Update hierarch separator from "/" to "|" and type separator from "." to ":".
With this change, components with "nested/types" (e.g., "myapp/mycomponent")
can be used with livecomponents and form the full path that can be parsed.

Update "coffee" example to use "coffee/<something>" component names.
  • Loading branch information
imankulov committed Dec 4, 2023
1 parent d5ce37b commit bf8f480
Show file tree
Hide file tree
Showing 21 changed files with 107 additions and 37 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ Make sure that your STATICFILES_DIRS setting includes the "components" directory
## On component IDs.

- Every component must have a root element that includes its ID. The id is "id={{ component_id }}".
- Component IDs represent the component hierarchy and formatted as absolute POSIX paths. For example, we can have a component /form.0/button.submit where "button" is the component type, "submit" is its name, and "form.0" is its parent.
- Component IDs represent the component hierarchy and formatted as "|parent:id|child:id". For example, we can have a component |form:0|button:submit where "button" is the component type, "submit" is its name, and "form:0" is its parent.


## On component states
Expand Down Expand Up @@ -191,7 +191,7 @@ you get away without the state model.

There are two ways to call component methods from other components:

Using the component ID. For example, if you have a component with ID "/message.0" and a method "set_message", you can call it like this:
Using the component ID. For example, if you have a component with ID "|message.0" and a method "set_message", you can call it like this:

```python
from livecomponents import LiveComponent, command, CallContext
Expand All @@ -200,7 +200,7 @@ class MyComponent(LiveComponent):

@command
def do_something(self, call_context: CallContext):
call_context.find_one("/message.0").set_message("Hello, world!")
call_context.find_one("|message:0").set_message("Hello, world!")
```

Using the "parent" reference.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ class Meta:
fields = ["name", "origin", "roast_level", "flavor_notes", "stock_quantity"]


@component.register("row")
@component.register("coffee/row")
class RowComponent(LiveComponent[RowState]):
template_name = "row/row.html"
template_name = "coffee/row/row.html"

def init_state(self, context: InitStateContext, **component_kwargs) -> RowState:
return RowState(**component_kwargs)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ class SearchState(BaseModel):
pass


@component.register("search")
@component.register("coffee/search")
class SearchComponent(LiveComponent[SearchState]):
template_name = "search/search.html"
template_name = "coffee/search/search.html"

def init_state(self, context: InitStateContext, **component_kwargs) -> SearchState:
return SearchState(**component_kwargs)
Expand Down
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
</thead>
<tbody data-testid="coffee-table-body">
{% for bean in beans %}
{% livecomponent_block "row" bean=bean parent_id=component_id own_id=bean.id %}
{% livecomponent_block "coffee/row" bean=bean parent_id=component_id own_id=bean.id %}
{% fill "stock_actions" %}
<a href="#"
hx-post="{% call_command component_id "change_stock" %}" hx-vals='{"amount": 1}'>+1</a>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ class TableState(LiveComponentsModel):
search: str = ""


@component.register("table")
@component.register("coffee/table")
class TableComponent(LiveComponent[TableState]):
template_name = "table/table.html"
template_name = "coffee/table/table.html"

def get_extra_context_data(self, state: TableState):
if state.search:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from pydantic import BaseModel

from livecomponents import CallContext, InitStateContext, LiveComponent, command
from livecomponents.const import HIER_SEP, TYPE_SEP


class ClickCounterState(BaseModel):
Expand All @@ -26,7 +27,7 @@ def init_state(
@command
def increment(self, call_context: CallContext[ClickCounterState], value: int = 1):
call_context.state.value += value
call_context.find_one("/message.0").set_message(
call_context.find_one(f"{HIER_SEP}message{TYPE_SEP}0").set_message(
message=(
f"Counter {call_context.state.title!r} incremented "
f"to {value}. "
Expand Down
2 changes: 1 addition & 1 deletion example/templates/coffee.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@

{% block body %}
<h1>Coffee beans</h1>
{% livecomponent "table" beans=beans %}
{% livecomponent "coffee/table" beans=beans %}
{% endblock %}
10 changes: 6 additions & 4 deletions example/templates/modals.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,27 @@
</thead>

{% for user in users %}
{% component_id "modal" user.username as modal_username %}
{% component_id "emailsender" user.username as emailsender_username %}
<tr>
<td>{{ user.username }}</td>
<td>{{ user.email }}</td>
<td>
<a href="#" hx-post="{% call_command "/modal."|add:user.username "open" %}">
<a href="#" hx-post="{% call_command modal_username "open" %}">
Send email
</a>
{% livecomponent "emailsender" own_id=user.username %}
{% livecomponent_block "modal" own_id=user.username save_context="user" %}
{% livecomponent_block "modal" own_id=user.username save_context="user, modal_username, emailsender_username" %}
{% fill "modal_title" %}
{{ user.username|capfirst }}
{% endfill %}
{% fill "modal_body" %}
Send email to {{ user.email }}?
{% endfill %}
{% fill "modal_footer" %}
<a data-testid="modal-close" href="#" role="button" class="secondary" hx-post="{% call_command "/modal."|add:user.username "close" %}">Cancel</a>
<a data-testid="modal-close" href="#" role="button" class="secondary" hx-post="{% call_command modal_username "close" %}">Cancel</a>
<a href="#" role="button"
hx-post="{% call_command "/emailsender."|add:user.username "send_email" %}"
hx-post="{% call_command emailsender_username "send_email" %}"
hx-vals='{"email": "{{ user.email }}"}'
>Send email</a>
{% endfill %}
Expand Down
22 changes: 9 additions & 13 deletions livecomponents/const.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
# The full ID look like this:
# "parent-type.parent-id/child-type.child-id"

# HTMX doesn't escape query selectors, so elsewhere in the code we
# have to explicitly oass the escape the "hx-swap-oob" attributes
# as "hx-swap-oob="morph:#parent-type\.parent-id\/child-type\.child-id"
# https://github.com/bigskysoftware/htmx/issues/1537

# We chose dot and slash as separators because this way we can leverage
# pathlib.PosixPath to parse it.
# We can use "stem" and "suffix" to get the component type and ID

HIER_SEP = "/"
TYPE_SEP = "."
# "|parent-type:parent-id|child-type:child-id"
#
#
# A few things to note:
# - We use LiveComponentsPath() to parse it.
# - We intentionally don't use "/" as the hierarchy separator because we want to use
# components that have "/" in their names. See "coffee/row" for example.
HIER_SEP = "|"
TYPE_SEP = ":"
19 changes: 19 additions & 0 deletions livecomponents/templatetags/livecomponents.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
try_parse_as_named_fill_tag_set,
)

from livecomponents.const import HIER_SEP, TYPE_SEP
from livecomponents.sessions import get_session_id
from livecomponents.templatetags.utils import (
capture_used_tokens,
Expand All @@ -31,6 +32,24 @@
register = template.Library()


@register.simple_tag
def component_id(*args) -> str:
"""Component ID, built from type and ID pairs, following one after another.
For example:
{% component_id "table" "primary" "row" 1 "cell" "x" as cell_x %}
will return
|table:primary|row:1|cell:x
"""
chunks = [""]
for type_, id_ in zip(args[::2], args[1::2]):
chunks.append(f"{type_}{TYPE_SEP}{id_}")
return HIER_SEP.join(chunks)


@register.simple_tag(takes_context=True)
def call_command(context, component_id: str, command_name: str) -> str:
session_id = context["LIVECOMPONENTS_SESSION_ID"]
Expand Down
9 changes: 5 additions & 4 deletions livecomponents/types.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from pathlib import PurePosixPath
from typing import TypeVar

from pydantic import BaseModel, ConfigDict

from livecomponents.utils import LiveComponentsPath

State = TypeVar("State")


Expand All @@ -14,8 +15,8 @@ def get_parent(self) -> "StateAddress | None":
"""Returns the parent of this component or None.
Reutrn None if this component is a root component."""
parent = PurePosixPath(self.component_id).parent
if parent == PurePosixPath("/"):
parent = LiveComponentsPath(self.component_id).parent
if parent == LiveComponentsPath("|"):
return None
return StateAddress(session_id=self.session_id, component_id=str(parent))

Expand All @@ -30,7 +31,7 @@ def must_get_parent(self) -> "StateAddress":
return parent

def get_component_name(self):
return PurePosixPath(self.component_id).stem
return LiveComponentsPath(self.component_id).stem

model_config = ConfigDict(frozen=True)

Expand Down
31 changes: 30 additions & 1 deletion livecomponents/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def find_component_id(
to generate the "basename for the component ID" (e.g., "button.1").
Finally, we use parent_id (a component ID) and the basename to generate
the full component ID (e.g., "/form.0/button.1").
the full component ID (e.g., "|form.0|button.1").
"""
# Used to re-render the component
if full_component_id is not None:
Expand All @@ -36,3 +36,32 @@ class LiveComponentsModel(BaseModel):
"""A subclass of Pydantic's model that allows arbitrary types."""

model_config = ConfigDict(arbitrary_types_allowed=True)


class LiveComponentsPath:
"""Like a POSIX path, but with a different separator."""

def __init__(self, path: str):
if not path:
raise ValueError("path must not be empty")
if not path.startswith(HIER_SEP):
raise ValueError(f"path must start with '{HIER_SEP}'. Path: {path}")
self._path = path

def __str__(self):
return self._path

@property
def parent(self) -> "LiveComponentsPath":
if self._path == HIER_SEP:
return self
parent_path = self._path.rsplit(HIER_SEP, maxsplit=1)[0]
return LiveComponentsPath(parent_path or HIER_SEP)

@property
def name(self) -> str:
return self._path.split(HIER_SEP)[-1]

@property
def stem(self) -> str:
return self.name.split(TYPE_SEP)[0]
11 changes: 8 additions & 3 deletions tests/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from django import template
from django.template.loader import render_to_string

from livecomponents.const import HIER_SEP, TYPE_SEP
from livecomponents.manager.manager import CallContext
from livecomponents.types import StateAddress
from livecomponents.views import re_render_component
Expand All @@ -12,16 +13,18 @@

def test_parser(rf, state_manager):
session_id = "foo"
component_id = f"{HIER_SEP}sample{TYPE_SEP}0" # |sample:0

request = rf.get("/")
rendered = render_to_string(
"sample.html", {"var": "body", "LIVECOMPONENTS_SESSION_ID": session_id}, request
).strip()
assert re.match(
r'<div data-livecomponent-id="/sample\.0".*>BODYOVERRIDE</div>', rendered
rf'<div data-livecomponent-id="{re.escape(component_id)}".*>BODYOVERRIDE</div>',
rendered,
)

state_address = StateAddress(session_id=session_id, component_id="/sample.0")
state_address = StateAddress(session_id=session_id, component_id=component_id)
state = state_manager.get_component_state(state_address)
call_context = CallContext(
request=request,
Expand All @@ -32,6 +35,8 @@ def test_parser(rf, state_manager):
re_rendered = re_render_component(
call_context=call_context, state_address=state_address
).strip()

assert re.match(
r'<div data-livecomponent-id="/sample\.0".*>BODYOVERRIDE</div>', re_rendered
rf'<div data-livecomponent-id="{re.escape(component_id)}".*>BODYOVERRIDE</div>',
re_rendered,
)
8 changes: 8 additions & 0 deletions tests/test_templatetags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from livecomponents.templatetags.livecomponents import component_id


def test_component_id():
assert (
component_id("table", "primary", "row", 1, "cell", "x")
== "|table:primary|row:1|cell:x"
)
9 changes: 9 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# LiveComponentsPath
from livecomponents.utils import LiveComponentsPath


def test_live_components_path():
path = LiveComponentsPath("|foo:bar|baz:spam")
assert path.stem == "baz"
assert str(path.parent) == "|foo:bar"
assert str(path.parent.parent) == "|"

0 comments on commit bf8f480

Please sign in to comment.