diff --git a/CHANGELOG.md b/CHANGELOG.md index d1128b3..42678ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added helper method StateAddress.with_component_id() to create a StateAddress with the same session ID, but a different component ID. - Added reference to the state manager to InitStateContext and UpdateStateContext. - Added "find_ancestor(ancestor_type)" method to StateAddress and CallContext. +- 🚨 **Breaking Change**. Modified get_extra_context_data() to accept an instance of ExtraContextRequest(). This instance includes the component state, component_kwargs, current request, component state address, and the state manager. By including the state manager and address, it becomes possible to access stores for other components. +- 🚨 **Breaking Change**. Removed the passing of component kwargs to init_state() and update_state(). These kwargs are redundant since both InitStateContext and UpdateStateContext already have them. +- Added StatelessLiveComponent as a base class for components that do not need to store state. + ## 1.7.0 (2023-12-13) diff --git a/README.md b/README.md index bd8ca68..0e6a8e3 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,7 @@ Assuming that the component will be re-rendered on partial render, the state mus ```python from pydantic import BaseModel from livecomponents.component import LiveComponent +from livecomponents.manager.manager import InitStateContext class AlertState(BaseModel): message: str = "" @@ -179,19 +180,42 @@ class Alert(LiveComponent): template_name = "alert.html" - def init_state(self, **component_kwargs) -> AlertState: - return AlertState(**component_kwargs) + def init_state(self, context: InitStateContext) -> AlertState: + return AlertState(**context.component_kwargs) ``` Component states don't need to be stored if components are not expected to be re-rendered independently, and only as part of the parent component. For example, components for buttons are rarely re-rendered independently, so you get away without the state model. -## On calling component methods from others -There are two ways to call component methods from other components: +## Stateless 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: +If the component doesn't store any state, you can inherit it from the StatelessLiveComponent class. You may find it +helpful for rendering the hierarchy of components where the shared state is stored in the root components. + +```python +from livecomponents.component import StatelessLiveComponent + +class StatelessAlert(StatelessLiveComponent): + + template_name = "alert.html" + + def get_extra_context_data( + self, extra_context_request: "ExtraContextRequest[State]" + ) -> dict: + state_manager = extra_context_request.state_manager + root_addr = extra_context_request.state_addr.must_find_ancestor("root") + root_state = state_manager.get_component_state(root_addr) + return {"message": root_state.message} +``` + + +## Calling component methods from others + +There are several 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: ```python from livecomponents import LiveComponent, command, CallContext @@ -203,7 +227,7 @@ class MyComponent(LiveComponent): call_context.find_one("|message:0").set_message("Hello, world!") ``` -Using the "parent" reference. +**Using the "parent" reference.** ```python from livecomponents import LiveComponent, command, CallContext @@ -385,9 +409,9 @@ class SampleState(BaseModel): class Sample(LiveComponent): ... - def init_state(self, context: InitStateContext, **component_kwargs) -> SampleState: + def init_state(self, context: InitStateContext) -> SampleState: var = context.outer_context.get("var", "unset") - effective_kwargs = {**component_kwargs, "var": var} + effective_kwargs = {**context.component_kwargs, "var": var} return SampleState(**effective_kwargs) ``` diff --git a/example/coffee/components/coffee/row/row.py b/example/coffee/components/coffee/row/row.py index e313702..4d7a60b 100644 --- a/example/coffee/components/coffee/row/row.py +++ b/example/coffee/components/coffee/row/row.py @@ -30,8 +30,8 @@ class Meta: class RowComponent(LiveComponent[RowState]): template_name = "coffee/row/row.html" - def init_state(self, context: InitStateContext, **component_kwargs) -> RowState: - return RowState(**component_kwargs) + def init_state(self, context: InitStateContext) -> RowState: + return RowState(**context.component_kwargs) @command def edit_on(self, call_context: CallContext[RowState]): diff --git a/example/coffee/components/coffee/search/search.py b/example/coffee/components/coffee/search/search.py index a4be216..27b01ba 100644 --- a/example/coffee/components/coffee/search/search.py +++ b/example/coffee/components/coffee/search/search.py @@ -1,20 +1,12 @@ from django_components import component -from pydantic import BaseModel -from livecomponents import CallContext, InitStateContext, LiveComponent, command - - -class SearchState(BaseModel): - pass +from livecomponents import CallContext, StatelessLiveComponent, command @component.register("coffee/search") -class SearchComponent(LiveComponent[SearchState]): +class SearchComponent(StatelessLiveComponent): template_name = "coffee/search/search.html" - def init_state(self, context: InitStateContext, **component_kwargs) -> SearchState: - return SearchState(**component_kwargs) - @command - def update_search(self, call_context: CallContext[SearchState], search: str): + def update_search(self, call_context: CallContext, search: str): call_context.parent.update_search(search=search) diff --git a/example/coffee/components/coffee/table/table.py b/example/coffee/components/coffee/table/table.py index 614a4fa..fd652cc 100644 --- a/example/coffee/components/coffee/table/table.py +++ b/example/coffee/components/coffee/table/table.py @@ -2,8 +2,14 @@ from django_components import component from coffee.models import CoffeeBean -from livecomponents import CallContext, InitStateContext, LiveComponent, command -from livecomponents.utils import LiveComponentsModel +from livecomponents import ( + CallContext, + ExtraContextRequest, + InitStateContext, + LiveComponent, + LiveComponentsModel, + command, +) class TableState(LiveComponentsModel): @@ -14,7 +20,10 @@ class TableState(LiveComponentsModel): class TableComponent(LiveComponent[TableState]): template_name = "coffee/table/table.html" - def get_extra_context_data(self, state: TableState, **component_kwargs): + def get_extra_context_data( + self, extra_context_request: ExtraContextRequest[TableState] + ): + state = extra_context_request.state if state.search: beans = CoffeeBean.objects.filter( Q(name__icontains=state.search) @@ -26,8 +35,8 @@ def get_extra_context_data(self, state: TableState, **component_kwargs): beans = CoffeeBean.objects.all() return {"beans": beans} - def init_state(self, context: InitStateContext, **component_kwargs) -> TableState: - return TableState(**component_kwargs) + def init_state(self, context: InitStateContext) -> TableState: + return TableState(**context.component_kwargs) @command def update_search(self, call_context: CallContext[TableState], search: str): diff --git a/example/counters/components/clickcounter/clickcounter.py b/example/counters/components/clickcounter/clickcounter.py index d003be0..1664fa5 100644 --- a/example/counters/components/clickcounter/clickcounter.py +++ b/example/counters/components/clickcounter/clickcounter.py @@ -3,7 +3,13 @@ from django_components import component from pydantic import BaseModel -from livecomponents import CallContext, InitStateContext, LiveComponent, command +from livecomponents import ( + CallContext, + ExtraContextRequest, + InitStateContext, + LiveComponent, + command, +) from livecomponents.const import HIER_SEP, TYPE_SEP @@ -17,14 +23,12 @@ class ClickCounter(LiveComponent[ClickCounterState]): template_name = "clickcounter/clickcounter.html" def get_extra_context_data( - self, state: ClickCounterState, **component_kwargs + self, extra_context_request: ExtraContextRequest[ClickCounterState] ) -> dict[str, Any]: - return {"value_str": f"{state.value:,}"} + return {"value_str": f"{extra_context_request.state.value:,}"} - def init_state( - self, context: InitStateContext, **component_kwargs - ) -> ClickCounterState: - return ClickCounterState(**component_kwargs) + def init_state(self, context: InitStateContext) -> ClickCounterState: + return ClickCounterState(**context.component_kwargs) @command def increment(self, call_context: CallContext[ClickCounterState], value: int = 1): diff --git a/example/counters/components/incrementbutton/incrementbutton.py b/example/counters/components/incrementbutton/incrementbutton.py index f6f6def..0a2ac65 100644 --- a/example/counters/components/incrementbutton/incrementbutton.py +++ b/example/counters/components/incrementbutton/incrementbutton.py @@ -13,10 +13,8 @@ class IncrementButtonState(BaseModel): class IncrementButton(LiveComponent[IncrementButtonState]): template_name = "incrementbutton/incrementbutton.html" - def init_state( - self, context: InitStateContext, **component_kwargs - ) -> IncrementButtonState: - return IncrementButtonState(**component_kwargs) + def init_state(self, context: InitStateContext) -> IncrementButtonState: + return IncrementButtonState(**context.component_kwargs) @command def increment_parent_counter( diff --git a/example/counters/components/message/message.py b/example/counters/components/message/message.py index 477487f..dd813fb 100644 --- a/example/counters/components/message/message.py +++ b/example/counters/components/message/message.py @@ -14,13 +14,13 @@ class MessageState(BaseModel): class MessageComponent(LiveComponent[MessageState]): template_name = "message/message.html" - def init_state(self, context: InitStateContext, **component_kwargs) -> MessageState: + def init_state(self, context: InitStateContext) -> MessageState: initialized_from = context.request.META["REMOTE_ADDR"] if context.request.GET.get("forbidden"): raise PermissionDenied() - return MessageState(initialized_from=initialized_from, **component_kwargs) + return MessageState(initialized_from=initialized_from) @command def set_message(self, call_context: CallContext, message: str): diff --git a/example/counters/components/sample/sample.py b/example/counters/components/sample/sample.py index 1711f86..425b5c0 100644 --- a/example/counters/components/sample/sample.py +++ b/example/counters/components/sample/sample.py @@ -13,7 +13,7 @@ class SampleState(BaseModel): class SampleComponent(LiveComponent[SampleState]): template_name = "sample/sample.html" - def init_state(self, context: InitStateContext, **component_kwargs) -> SampleState: + def init_state(self, context: InitStateContext) -> SampleState: var = context.outer_context.get("var", "unset") - effective_kwargs = {**component_kwargs, "var": var} + effective_kwargs = {**context.component_kwargs, "var": var} return SampleState(**effective_kwargs) diff --git a/example/modals/components/emailsender/emailsender.py b/example/modals/components/emailsender/emailsender.py index 3c8b8fb..64c4ff2 100644 --- a/example/modals/components/emailsender/emailsender.py +++ b/example/modals/components/emailsender/emailsender.py @@ -1,29 +1,12 @@ -from typing import Any - from django_components import component -from pydantic import BaseModel - -from livecomponents import CallContext, InitStateContext, LiveComponent, command - -class EmailSenderState(BaseModel): - pass +from livecomponents import CallContext, StatelessLiveComponent, command @component.register("emailsender") -class EmailSender(LiveComponent[EmailSenderState]): +class EmailSender(StatelessLiveComponent): template_name = "emailsender/emailsender.html" - def get_extra_context_data( - self, state: EmailSenderState, **component_kwargs - ) -> dict[str, Any]: - return {} - - def init_state( - self, context: InitStateContext, **component_kwargs - ) -> EmailSenderState: - return EmailSenderState() - @command - def send_email(self, call_context: CallContext[EmailSenderState], email: str): + def send_email(self, call_context: CallContext, email: str): print("Sending email to", email) diff --git a/example/modals/components/modal/modal.py b/example/modals/components/modal/modal.py index c4c31a9..4b20909 100644 --- a/example/modals/components/modal/modal.py +++ b/example/modals/components/modal/modal.py @@ -1,5 +1,3 @@ -from typing import Any - from django_components import component from pydantic import BaseModel @@ -14,13 +12,8 @@ class ModalState(BaseModel): class Modal(LiveComponent[ModalState]): template_name = "modal/modal.html" - def get_extra_context_data( - self, state: ModalState, **component_kwargs - ) -> dict[str, Any]: - return {} - - def init_state(self, context: InitStateContext, **component_kwargs) -> ModalState: - return ModalState(**component_kwargs) + def init_state(self, context: InitStateContext) -> ModalState: + return ModalState(**context.component_kwargs) @command def close(self, call_context: CallContext[ModalState]): diff --git a/livecomponents/__init__.py b/livecomponents/__init__.py index 55ce58b..9aeddd6 100644 --- a/livecomponents/__init__.py +++ b/livecomponents/__init__.py @@ -1,14 +1,23 @@ -from livecomponents.component import LiveComponent, command +from livecomponents.component import ( + ExtraContextRequest, + LiveComponent, + StatelessLiveComponent, + command, +) from livecomponents.manager.manager import ( CallContext, InitStateContext, UpdateStateContext, ) +from livecomponents.utils import LiveComponentsModel __all__ = [ - "LiveComponent", "command", "CallContext", "InitStateContext", "UpdateStateContext", + "ExtraContextRequest", + "LiveComponent", + "LiveComponentsModel", + "StatelessLiveComponent", ] diff --git a/livecomponents/component.py b/livecomponents/component.py index 06ae866..a230bf9 100644 --- a/livecomponents/component.py +++ b/livecomponents/component.py @@ -1,14 +1,15 @@ import abc from collections.abc import Callable -from typing import Generic +from typing import Any, Generic +from django.http import HttpRequest from django_components import component from django_components.component import SimplifiedInterfaceMediaDefiningClass -from livecomponents.manager import get_state_manager +from livecomponents.manager import StateManager, get_state_manager from livecomponents.manager.manager import InitStateContext, UpdateStateContext from livecomponents.types import State, StateAddress -from livecomponents.utils import find_component_id +from livecomponents.utils import LiveComponentsModel, find_component_id DEFAULT_OWN_ID = "0" DEFAULT_PARENT_ID = "" @@ -75,7 +76,14 @@ def get_context_data( component_kwargs, ) - extra_context = self.get_extra_context_data(state, **component_kwargs) + extra_context_request: ExtraContextRequest[State] = ExtraContextRequest( + request=request, + state=state, + state_manager=state_manager, + state_addr=state_addr, + component_kwargs=component_kwargs, + ) + extra_context = self.get_extra_context_data(extra_context_request) context = { **component_kwargs, **state.model_dump(), @@ -86,32 +94,39 @@ def get_context_data( } return context - def get_extra_context_data(self, state: State, **component_kwargs) -> dict: + def get_extra_context_data( + self, extra_context_request: "ExtraContextRequest[State]" + ) -> dict: """Optionally add additional context data to the component. Override this method to add additional context data to the component. - Args: - state: The state of the component, that's been previously initialized - by `init_state`. The state is stored in Redis and is maintained - between re-renders of the component. - component_kwargs: The keyword arguments passed to the component in the - template tag. For example, if the component is rendered with - `{% component "mycomponent" foo="bar" %}`, then `component_kwargs` - will be `{"foo": "bar"}`. Remember that when the component is - re-rendered as a result of the command execution, no component - kwargs are passed. - - Returns: - A dictionary with extra context to render the component template. + Extra context request is a dataclass that contains enough information to + calculate the extra context data. It contains the following fields: + + - request: The current request. + - state: The state of the component that's been previously initialized by + `init_state`. The state is stored in Redis and is maintained between + re-renders of the component. + - state_manager: The state manager. You can use the manager to get the state + of other components. + - state_addr: The state address of the component. You can use + state_addr.with_component_id() to get the state address of other + components. + - component_kwargs: The keyword arguments passed to the component in the + template tag. For example, if the component is rendered with + `{% component "mycomponent" foo="bar" %}`, then `component_kwargs` + will be `{"foo": "bar"}`. Remember that when the component is + re-rendered as a result of the command execution, no component + kwargs are passed. """ return {} @abc.abstractmethod - def init_state(self, context: InitStateContext, **component_kwargs) -> State: + def init_state(self, context: InitStateContext) -> State: ... - def update_state(self, context: UpdateStateContext, **kwargs) -> None: + def update_state(self, context: UpdateStateContext) -> None: """Update in-place the state of this component if necessary during a re-render. This method is invoked in two scenarios: @@ -138,3 +153,30 @@ def update_state(self, context: UpdateStateContext, **kwargs) -> State: context.state.title = kwargs["title"] """ pass + + +class StatelessModel(LiveComponentsModel): + pass + + +class StatelessLiveComponent(LiveComponent[StatelessModel]): + """A stateless subclass of LiveComponent. + + Technically, it still stores the state in Redis, but the state is always + initialized to an empty model. + + Usually, the state is stored outside the component, e.g. in the parent + component, and can be addressed from get_extra_context_data() where + extra_context_request contains the state_manager and state_addr. + """ + + def init_state(self, context: InitStateContext) -> StatelessModel: + return StatelessModel() + + +class ExtraContextRequest(LiveComponentsModel, Generic[State]): + request: HttpRequest + state: State + state_manager: StateManager + state_addr: StateAddress + component_kwargs: dict[str, Any] diff --git a/livecomponents/management/commands/_templates.py b/livecomponents/management/commands/_templates.py index 6b0d6a2..3bddb47 100644 --- a/livecomponents/management/commands/_templates.py +++ b/livecomponents/management/commands/_templates.py @@ -9,8 +9,14 @@ COMPONENT_PYTHON_TEMPLATE = """from typing import Any from django_components import component -from livecomponents import CallContext, InitStateContext, LiveComponent, command -from livecomponents.utils import LiveComponentsModel +from livecomponents import ( + CallContext, + command, + ExtraContextRequest, + InitStateContext, + LiveComponent, + LiveComponentsModel, +) class {class_name}State(LiveComponentsModel): @@ -22,30 +28,14 @@ class {class_name}State(LiveComponentsModel): class {class_name}Component(LiveComponent[{class_name}State]): template_name = "{component_name}/{proper_name}.html" + def get_extra_context_data( - self, state: {class_name}State, **component_kwargs + self, extra_context_request: ExtraContextRequest[{class_name}State] ) -> dict[str, Any]: - \"\"\"Return extra context to render the component template. - - Args: - state: The state of the component, that's been previously initialized - by `init_state`. The state is stored in Redis and is maintained - between re-renders of the component. - component_kwargs: The keyword arguments passed to the component in the - template tag. For example, if the component is rendered with - `{{% component "mycomponent" foo="bar" %}}`, then `component_kwargs` - will be `{{"foo": "bar"}}`. Remember that when the component is - re-rendered as a result of the command execution, no component - kwargs are passed. - - Returns: - A dictionary with extra context to render the component template. - \"\"\" + \"\"\"Return extra context to render the component template.\"\"\" return {{}} - def init_state( - self, context: InitStateContext, **component_kwargs - ) -> {class_name}State: + def init_state(self, context: InitStateContext) -> {class_name}State: return {class_name}State(**component_kwargs) # @command diff --git a/livecomponents/manager/manager.py b/livecomponents/manager/manager.py index 108b8c2..d7393ee 100644 --- a/livecomponents/manager/manager.py +++ b/livecomponents/manager/manager.py @@ -123,7 +123,7 @@ def get_or_create_component_state( component_kwargs=component_kwargs, outer_context=outer_context, ) - update_state(update_state_context, **component_kwargs) + update_state(update_state_context) self.set_component_state(state_addr, state) return state @@ -134,7 +134,7 @@ def get_or_create_component_state( component_kwargs=component_kwargs, outer_context=outer_context, ) - state = init_state(init_state_context, **component_kwargs) + state = init_state(init_state_context) self.set_component_state(state_addr, state) return state