From b39c4b040cc212df5342f3a4a702c7f14ec9ac9e Mon Sep 17 00:00:00 2001 From: Michael Yin Date: Wed, 18 Sep 2024 12:12:22 +0800 Subject: [PATCH] Feature/2 (#14) --- docs/source/slot.md | 53 ++++++-- src/django_viewcomponent/fields.py | 49 ++++--- .../templatetags/viewcomponent_tags.py | 4 +- tests/test_render_field.py | 122 ++++++++++++++++-- 4 files changed, 191 insertions(+), 37 deletions(-) diff --git a/docs/source/slot.md b/docs/source/slot.md index 61dcc69..f68fcad 100644 --- a/docs/source/slot.md +++ b/docs/source/slot.md @@ -122,6 +122,8 @@ Notes: This is the **killer feature**, so please read it carefully. +### Component argument in RendersOneField + Let's update the `BlogComponent` again ```python @@ -198,7 +200,7 @@ Notes: 1. We do not need to store the `classes` to the `BlogComponent` and then pass it to the `HeaderComponent`, just set `component='header'` in the `RendersOneField` field, the `HeaderComponent` would receive the `classes` argument automatically 2. If you check the template code in the `BlogComponent`, `{{ self.header.value }}` ia very simple to help you understand what it is. -## Component with RendersManyField +### Component argument in RendersManyField If you have @@ -245,14 +247,48 @@ With `component` argument, we can **connect** components together, in clean way. ![](./images/blog-components.png) -## Component argument in slot field - -`component` in `RendersOneField` or `RendersManyField` supports many variable types. +## Component argument in slot fields supports different variable types ### Component registered name ```python -header = RendersOneField(required=True, component="header") +@component.register("header") +class HeaderComponent(component.Component): + def __init__(self, classes, **kwargs): + self.classes = classes + + template = """ +

+ {{ self.content }} +

+ """ + + +@component.register("post") +class PostComponent(component.Component): + def __init__(self, post, **kwargs): + self.post = post + + template = """ + {% load viewcomponent_tags %} + +

{{ self.post.title }}

+
{{ self.post.description }}
+ """ + + +@component.register("blog") +class BlogComponent(component.Component): + header = RendersOneField(required=True, component="header") + posts = RendersManyField(required=True, component="post") + + template = """ + {% load viewcomponent_tags %} + {{ self.header.value }} + {% for post in self.posts.value %} + {{ post }} + {% endfor %} + """ ``` ### Component class @@ -301,7 +337,7 @@ class BlogComponent(component.Component): header = RendersOneField(required=True, component="header") posts = RendersManyField( required=True, - component=lambda post, **kwargs: mark_safe( + component=lambda self, post, **kwargs: mark_safe( f"""

{post.title}

{post.description}
@@ -321,17 +357,18 @@ class BlogComponent(component.Component): Notes: 1. Here we use lambda function to return string from the `post` variable, so we do not need to create a Component. +2. We can still use `self.xxx` to access value of the blog component. ### Function which return component instance -We can use function to return instance of a component. +We can use function to return instance of a component, this is useful when we need to pass some special default values to the other component. ```python class BlogComponent(component.Component): header = RendersOneField(required=True, component="header") posts = RendersManyField( required=True, - component=lambda post: PostComponent(post=post), + component=lambda post, **kwargs: PostComponent(post=post), ) template = """ diff --git a/src/django_viewcomponent/fields.py b/src/django_viewcomponent/fields.py index 026cf78..8f329d0 100644 --- a/src/django_viewcomponent/fields.py +++ b/src/django_viewcomponent/fields.py @@ -4,22 +4,18 @@ class FieldValue: def __init__( self, - content: str, + nodelist, dict_data: dict, component: None, parent_component=None, ): - self._content = content or "" + self._nodelist = nodelist self._dict_data = dict_data self._component = component self._parent_component = parent_component def __str__(self): - if self._component is None: - return self._content - else: - # If the slot field is defined with component, then we will use the component to render - return self.render() + return self.render() def render(self): from django_viewcomponent.component import Component @@ -31,7 +27,10 @@ def render(self): elif not isinstance(self._component, type) and callable(self._component): # self._component is function callable_component = self._component - result = callable_component(**self._dict_data) + result = callable_component( + self=self._parent_component, + **self._dict_data, + ) if isinstance(result, str): return result @@ -48,6 +47,8 @@ def render(self): ): # self._component is Component class return self._render_for_component_cls(self._component) + elif self._component is None: + return self._nodelist.render(self._parent_component.component_context) else: raise ValueError(f"Invalid component variable {self._component}") @@ -67,7 +68,7 @@ def _render_for_component_instance(self, component): # create slot fields component.create_slot_fields() - component.content = self._content + component.content = self._nodelist.render(updated_context) component.check_slot_fields() @@ -101,14 +102,14 @@ def filled(self): def required(self): return self._required - def handle_call(self, content, **kwargs): + def handle_call(self, nodelist, **kwargs): raise NotImplementedError("You must implement the `handle_call` method.") class RendersOneField(BaseSlotField): - def handle_call(self, content, **kwargs): + def handle_call(self, nodelist, **kwargs): value_instance = FieldValue( - content=content, + nodelist=nodelist, dict_data={**kwargs}, component=self._component, parent_component=self.parent_component, @@ -118,17 +119,35 @@ def handle_call(self, content, **kwargs): self._value = value_instance +class FieldValueListWrapper: + """ + This helps render FieldValue eagerly when component template has + {% for panel in self.panels.value %}, this can avoid issues if `panel` of the for loop statement + # override context variables in some cases. + """ + + def __init__(self): + self.data = [] + + def append(self, value): + self.data.append(value) + + def __iter__(self): + for field_value in self.data: + yield field_value.render() + + class RendersManyField(BaseSlotField): - def handle_call(self, content, **kwargs): + def handle_call(self, nodelist, **kwargs): value_instance = FieldValue( - content=content, + nodelist=nodelist, dict_data={**kwargs}, component=self._component, parent_component=self.parent_component, ) if self._value is None: - self._value = [] + self._value = FieldValueListWrapper() self._value.append(value_instance) self._filled = True diff --git a/src/django_viewcomponent/templatetags/viewcomponent_tags.py b/src/django_viewcomponent/templatetags/viewcomponent_tags.py index 518cbc6..a69088d 100644 --- a/src/django_viewcomponent/templatetags/viewcomponent_tags.py +++ b/src/django_viewcomponent/templatetags/viewcomponent_tags.py @@ -65,8 +65,6 @@ def __repr__(self): raise NotImplementedError def render(self, context): - content = self.nodelist.render(context) - resolved_kwargs = { key: safe_resolve(kwarg, context) for key, kwarg in self.kwargs.items() } @@ -76,7 +74,7 @@ def render(self, context): "The 'content' kwarg is reserved and cannot be passed in component call tag", ) - resolved_kwargs["content"] = content + resolved_kwargs["nodelist"] = self.nodelist component_token, field_token = self.args[0].token.split(".") component_instance = FilterExpression(component_token, self.parser).resolve( diff --git a/tests/test_render_field.py b/tests/test_render_field.py index e9c0117..f769171 100644 --- a/tests/test_render_field.py +++ b/tests/test_render_field.py @@ -8,6 +8,103 @@ from tests.utils import assert_dom_equal +@pytest.mark.django_db +class TestRenderFieldComponentContextLogic: + """ + HeaderComponent.get_context_data add extra context data + + We can still access the value via {{ site_name }} + """ + + class HeaderComponent(component.Component): + def __init__(self, classes, **kwargs): + self.classes = classes + + def get_context_data(self): + context = super().get_context_data() + context["site_name"] = "My Site" + return context + + template = """ +

+ {{ self.content }} +

+ """ + + class PostComponent(component.Component): + def __init__(self, post, **kwargs): + self.post = post + + template = """ + {% load viewcomponent_tags %} + +

{{ self.post.title }}

+
{{ self.post.description }}
+ """ + + class BlogComponent(component.Component): + header = RendersOneField(required=True, component="header") + posts = RendersManyField(required=True, component="post") + + template = """ + {% load viewcomponent_tags %} + {{ self.header.value }} + {% for post in self.posts.value %} + {{ post }} + {% endfor %} + """ + + @pytest.fixture(autouse=True) + def register_component(self): + component.registry.register("blog", self.BlogComponent) + component.registry.register("header", self.HeaderComponent) + component.registry.register("post", self.PostComponent) + + def test_field_context_logic(self): + for i in range(5): + title = f"test {i}" + description = f"test {i}" + Post.objects.create(title=title, description=description) + + qs = Post.objects.all() + + template = Template( + """ + {% load viewcomponent_tags %} + {% component 'blog' as component %} + {% call component.header classes='text-lg' %} + {{ site_name }} + {% endcall %} + {% for post in qs %} + {% call component.posts post=post %}{% endcall %} + {% endfor %} + {% endcomponent %} + """, + ) + rendered = template.render(Context({"qs": qs})) + expected = """ +

+ My Site +

+ +

test 0

+
test 0
+ +

test 1

+
test 1
+ +

test 2

+
test 2
+ +

test 3

+
test 3
+ +

test 4

+
test 4
+ """ + assert_dom_equal(expected, rendered) + + @pytest.mark.django_db class TestRenderFieldComponentParameterString: """ @@ -98,7 +195,7 @@ def test_field_component_parameter(self): assert_dom_equal(expected, rendered) -class BlogComponent(component.Component): +class BlogComponent1(component.Component): class HeaderComponent(component.Component): def __init__(self, classes, **kwargs): self.classes = classes @@ -140,7 +237,7 @@ class TestRenderFieldComponentParameterClass: @pytest.fixture(autouse=True) def register_component(self): - component.registry.register("blog", BlogComponent) + component.registry.register("blog", BlogComponent1) def test_field_component_parameter(self): for i in range(5): @@ -204,12 +301,15 @@ def __init__(self, classes, **kwargs): """ class BlogComponent(component.Component): + def __init__(self, **kwargs): + self.foo = "Hello" + header = RendersOneField(required=True, component="header") posts = RendersManyField( required=True, - component=lambda post, **kwargs: mark_safe( + component=lambda self, post, **kwargs: mark_safe( f""" -

{post.title}

+

{self.foo} {post.title}

{post.description}
""", ), @@ -255,25 +355,25 @@ def test_field_component_parameter(self): My Site -

test 0

+

Hello test 0

test 0
-

test 1

+

Hello test 1

test 1
-

test 2

+

Hello test 2

test 2
-

test 3

+

Hello test 3

test 3
-

test 4

+

Hello test 4

test 4
""" assert_dom_equal(expected, rendered) -class PostComponent(component.Component): +class PostComponent2(component.Component): def __init__(self, post, **kwargs): self.post = post @@ -305,7 +405,7 @@ class BlogComponent(component.Component): header = RendersOneField(required=True, component="header") posts = RendersManyField( required=True, - component=lambda post: PostComponent(post=post), + component=lambda post, **kwargs: PostComponent2(post=post), ) template = """