laces
Advanced tools
Sorry, the diff of this file is not supported yet
| import os | ||
| import random | ||
| from pathlib import Path | ||
| from typing import TYPE_CHECKING | ||
| from django.conf import settings | ||
| from django.forms import widgets | ||
| from django.template import Context | ||
| from django.test import SimpleTestCase | ||
| from django.utils.safestring import SafeString | ||
| from laces.components import Component, MediaContainer | ||
| from laces.tests.utils import MediaAssertionMixin | ||
| if TYPE_CHECKING: | ||
| from typing import Any, Optional, Union | ||
| from laces.typing import RenderContext | ||
| class TestComponent(MediaAssertionMixin, SimpleTestCase): | ||
| """Directly test the Component class.""" | ||
| def setUp(self) -> None: | ||
| self.component = Component() | ||
| def test_render_html(self) -> None: | ||
| """Test the `render_html` method.""" | ||
| # The default Component does not specify a `template_name` attribute which is | ||
| # required for `render_html`. So calling the method on the Component class | ||
| # will raise an error. | ||
| with self.assertRaises(AttributeError): | ||
| self.component.render_html() | ||
| def test_get_context_data_parent_context_empty_context(self) -> None: | ||
| """ | ||
| Test the default get_context_data. | ||
| The parent context should not matter, but we use it as it is used in | ||
| `render_html` (which passes a `Context` object). | ||
| """ | ||
| result = self.component.get_context_data(parent_context=Context()) | ||
| self.assertIsInstance(result, dict) | ||
| self.assertEqual(result, {}) | ||
| def test_media(self) -> None: | ||
| """ | ||
| Test the `media` property. | ||
| The `media` property is added through the `metaclass=MediaDefiningClass` | ||
| definition. | ||
| """ | ||
| empty_media = widgets.Media() | ||
| self.assertIsInstance(self.component.media, widgets.Media) | ||
| self.assertMediaEqual(self.component.media, empty_media) | ||
| class TestComponentSubclasses(MediaAssertionMixin, SimpleTestCase): | ||
| """ | ||
| Test the Component class through subclasses. | ||
| Most functionality of the Component class is only unlocked through subclassing and | ||
| definition of certain attributes (like `template_name`) or overriding of the | ||
| existing methods. This test class tests the functionality that is unlocked through | ||
| subclassing. | ||
| """ | ||
| @classmethod | ||
| def make_example_template_name(cls) -> str: | ||
| return f"example-{random.randint(1000, 10000)}.html" | ||
| @classmethod | ||
| def get_example_template_name(cls) -> str: | ||
| example_template_name = cls.make_example_template_name() | ||
| while os.path.exists(example_template_name): | ||
| example_template_name = cls.make_example_template_name() | ||
| return example_template_name | ||
| def setUp(self) -> None: | ||
| self.example_template_name = self.get_example_template_name() | ||
| self.example_template = ( | ||
| Path(settings.PROJECT_DIR) / "templates" / self.example_template_name | ||
| ) | ||
| # Write content to the template file to ensure it exists. | ||
| self.set_example_template_content("") | ||
| def set_example_template_content(self, content: str) -> None: | ||
| with open(self.example_template, "w") as f: | ||
| f.write(content) | ||
| def test_render_html_with_template_name_set(self) -> None: | ||
| """ | ||
| Test `render_html` method with a set `template_name` attribute. | ||
| """ | ||
| # ----------------------------------------------------------------------------- | ||
| class ExampleComponent(Component): | ||
| template_name = self.example_template_name | ||
| # ----------------------------------------------------------------------------- | ||
| self.set_example_template_content("Test") | ||
| result = ExampleComponent().render_html() | ||
| self.assertIsInstance(result, str) | ||
| self.assertIsInstance(result, SafeString) | ||
| self.assertEqual(result, "Test") | ||
| def test_render_html_with_template_name_set_and_data_from_get_context_data( | ||
| self, | ||
| ) -> None: | ||
| """ | ||
| Test `render_html` method with `get_context_data` providing data for the | ||
| context. | ||
| """ | ||
| # ----------------------------------------------------------------------------- | ||
| class ExampleComponent(Component): | ||
| template_name = self.example_template_name | ||
| def get_context_data( | ||
| self, | ||
| parent_context: "Optional[RenderContext]", | ||
| ) -> "RenderContext": | ||
| return {"name": "World"} | ||
| # ----------------------------------------------------------------------------- | ||
| self.set_example_template_content("Hello {{ name }}") | ||
| result = ExampleComponent().render_html() | ||
| self.assertEqual(result, "Hello World") | ||
| def test_render_html_when_get_context_data_returns_None(self) -> None: | ||
| """ | ||
| Test `render_html` method when `get_context_data` returns `None`. | ||
| The `render_html` method raises a `TypeError` when `None` is returned from | ||
| `get_context_method`. This behavior was present when the class was extracted | ||
| from Wagtail. It is not totally clear why this specific check is needed. By | ||
| default, the `get_context_data` method provides and empty dict. If an override | ||
| wanted to `get_context_data` return `None`, it should be expected that no | ||
| context data is available during rendering. The underlying `template.render` | ||
| method does not seem to be ok with `None` as the provided context. | ||
| """ | ||
| # ----------------------------------------------------------------------------- | ||
| class ExampleComponent(Component): | ||
| def get_context_data( | ||
| self, | ||
| parent_context: "Optional[Union[Context, dict[str, Any]]]", | ||
| ) -> None: | ||
| return None | ||
| # ----------------------------------------------------------------------------- | ||
| with self.assertRaises(TypeError): | ||
| ExampleComponent().render_html() | ||
| def test_media_defined_through_nested_class(self) -> None: | ||
| """ | ||
| Test the `media` property when defined through a nested class. | ||
| The `media` property is added through the `metaclass=MediaDefiningClass` | ||
| definition. This test ensures that the `media` property is available when | ||
| configured through a nested class. | ||
| """ | ||
| # ----------------------------------------------------------------------------- | ||
| class ExampleComponent(Component): | ||
| class Media: | ||
| css = {"all": ["example.css"]} | ||
| js = ["example.js"] | ||
| # ----------------------------------------------------------------------------- | ||
| result = ExampleComponent().media | ||
| self.assertIsInstance(result, widgets.Media) | ||
| self.assertMediaEqual( | ||
| result, | ||
| widgets.Media(css={"all": ["example.css"]}, js=["example.js"]), | ||
| ) | ||
| def tearDown(self) -> None: | ||
| os.remove(path=self.example_template) | ||
| class TestMediaContainer(MediaAssertionMixin, SimpleTestCase): | ||
| """ | ||
| Test the MediaContainer class. | ||
| The `MediaContainer` functionality depends on the `django.forms.widgets.Media` | ||
| class. The `Media` class provides the logic to combine the media definitions of | ||
| multiple objects through its `__add__` method. The `MediaContainer` relies on this | ||
| functionality to provide a `media` property that combines the media definitions of | ||
| its members. | ||
| See also: | ||
| https://docs.djangoproject.com/en/4.2/topics/forms/media | ||
| """ | ||
| def setUp(self) -> None: | ||
| self.media_container = MediaContainer() | ||
| def test_empty(self) -> None: | ||
| result = self.media_container.media | ||
| self.assertIsInstance(result, widgets.Media) | ||
| self.assertMediaEqual(result, widgets.Media()) | ||
| def test_single_member(self) -> None: | ||
| # ----------------------------------------------------------------------------- | ||
| class ExampleClass: | ||
| media = widgets.Media(css={"all": ["example.css"]}) | ||
| # ----------------------------------------------------------------------------- | ||
| example = ExampleClass() | ||
| self.media_container.append(example) | ||
| result = self.media_container.media | ||
| self.assertIsInstance(result, widgets.Media) | ||
| self.assertMediaEqual(result, example.media) | ||
| self.assertMediaEqual(result, widgets.Media(css={"all": ["example.css"]})) | ||
| def test_two_members_of_same_class(self) -> None: | ||
| # ----------------------------------------------------------------------------- | ||
| class ExampleClass: | ||
| media = widgets.Media(css={"all": ["example.css"]}, js=["example.js"]) | ||
| # ----------------------------------------------------------------------------- | ||
| example_1 = ExampleClass() | ||
| example_2 = ExampleClass() | ||
| self.media_container.append(example_1) | ||
| self.media_container.append(example_2) | ||
| result = self.media_container.media | ||
| self.assertIsInstance(result, widgets.Media) | ||
| self.assertMediaEqual( | ||
| result, | ||
| widgets.Media(css={"all": ["example.css"]}, js=["example.js"]), | ||
| ) | ||
| def test_two_members_of_different_classes(self) -> None: | ||
| # ----------------------------------------------------------------------------- | ||
| class ExampleClass: | ||
| media = widgets.Media(css={"all": ["shared.css"]}, js=["example.js"]) | ||
| class OtherExampleClass: | ||
| media = widgets.Media( | ||
| css={ | ||
| "all": ["other.css", "shared.css"], | ||
| "print": ["print.css"], | ||
| }, | ||
| js=["other.js"], | ||
| ) | ||
| # ----------------------------------------------------------------------------- | ||
| example = ExampleClass() | ||
| self.media_container.append(example) | ||
| other = OtherExampleClass() | ||
| self.media_container.append(other) | ||
| result = self.media_container.media | ||
| self.assertIsInstance(result, widgets.Media) | ||
| self.assertMediaEqual( | ||
| result, | ||
| widgets.Media( | ||
| css={ | ||
| "all": ["other.css", "shared.css"], | ||
| "print": ["print.css"], | ||
| }, | ||
| js=["example.js", "other.js"], | ||
| ), | ||
| ) |
| """Utilities for tests in the `laces` package.""" | ||
| from django.forms import widgets | ||
| class MediaAssertionMixin: | ||
| @staticmethod | ||
| def assertMediaEqual(first: widgets.Media, second: widgets.Media) -> bool: | ||
| """ | ||
| Compare two `Media` instances. | ||
| The `Media` class does not implement `__eq__`, but its `__repr__` shows how to | ||
| recreate the instance. | ||
| We can use this to compare two `Media` instances. | ||
| Parameters | ||
| ---------- | ||
| first : widgets.Media | ||
| First `Media` instance. | ||
| second : widgets.Media | ||
| Second `Media` instance. | ||
| Returns | ||
| ------- | ||
| bool | ||
| Whether the two `Media` instances are equal. | ||
| """ | ||
| return repr(first) == repr(second) |
| from typing import TYPE_CHECKING, Protocol, Union | ||
| if TYPE_CHECKING: | ||
| from typing import Any, Optional, TypeAlias | ||
| from django.forms.widgets import Media | ||
| from django.template import Context | ||
| from django.utils.safestring import SafeString | ||
| RenderContext: TypeAlias = Union[Context, dict[str, Any]] | ||
| class HasRenderHtmlMethod(Protocol): | ||
| def render_html( # noqa: E704 | ||
| self, | ||
| parent_context: "Optional[RenderContext]", | ||
| ) -> "SafeString": ... | ||
| class HasRenderMethod(Protocol): | ||
| def render( # noqa: E704 | ||
| self, | ||
| ) -> "SafeString": ... | ||
| Renderable: "TypeAlias" = Union[HasRenderHtmlMethod, HasRenderMethod] | ||
| class HasMediaProperty(Protocol): | ||
| @property | ||
| def media(self) -> "Media": ... # noqa: E704 |
+29
-4
@@ -1,2 +0,2 @@ | ||
| # laces Changelog | ||
| # Laces Changelog | ||
@@ -8,4 +8,30 @@ All notable changes to this project will be documented in this file. | ||
| ## [Unreleased] | ||
| ## [Unreleased] - YYYY-MM-DD | ||
| ### Added | ||
| - ... | ||
| ### Changed | ||
| - ... | ||
| ### Removed | ||
| - ... | ||
| ## [0.1.1] - 2024-02-10 | ||
| ### Added | ||
| - Added more tests and example usage. ([#6](https://github.com/tbrlpld/laces/pull/6)) | ||
| - Added support for Python 3.12 and Django 5.0. ([#15](https://github.com/tbrlpld/laces/pull/15)) | ||
| - Added type hints and type checking with `mypy` in CI. ([#18](https://github.com/tbrlpld/laces/pull/18)) | ||
| ### Changed | ||
| - Fixed tox configuration to actually run Django 3.2 in CI. Tox also uses the "testing" dependencies without the need to duplicate them in the `tox.ini`. ([#10](https://github.com/tbrlpld/laces/pull/10)) | ||
| - Bumped GitHub Actions to the latest versions. This removes a reliance on the now deprecated Node 16. ([#10](https://github.com/tbrlpld/laces/pull/10)) | ||
| - Extend documentation in README to simplify first examples and improve structure. ([#7](https://github.com/tbrlpld/laces/pull/7)) | ||
| ## [0.1.0] - 2023-11-29 | ||
@@ -20,5 +46,4 @@ | ||
| ## [Unreleased] - YYYY-MM-DD | ||
| ## [x.y.z] - YYYY-MM-DD | ||
| ### Added | ||
@@ -25,0 +50,0 @@ |
@@ -1,2 +0,2 @@ | ||
| VERSION = (0, 1, 0) | ||
| VERSION = (0, 1, 1) | ||
| __version__ = ".".join(map(str, VERSION)) |
+60
-11
@@ -1,8 +0,18 @@ | ||
| from typing import Any, MutableMapping | ||
| from typing import TYPE_CHECKING, List | ||
| from django.forms import Media, MediaDefiningClass | ||
| from django.forms.widgets import Media, MediaDefiningClass | ||
| from django.template import Context | ||
| from django.template.loader import get_template | ||
| from laces.typing import HasMediaProperty | ||
| if TYPE_CHECKING: | ||
| from typing import Optional | ||
| from django.utils.safestring import SafeString | ||
| from laces.typing import RenderContext | ||
| class Component(metaclass=MediaDefiningClass): | ||
@@ -14,10 +24,15 @@ """ | ||
| https://github.com/wagtail/wagtail/blob/094834909d5c4b48517fd2703eb1f6d386572ffa/wagtail/admin/ui/components.py#L8-L22 # noqa: E501 | ||
| A component uses the `MetaDefiningClass` metaclass to add a `media` property, which | ||
| allows the definitions of CSS and JavaScript assets that are associated with the | ||
| component. This works the same as `Media` class used by Django forms. | ||
| See also: https://docs.djangoproject.com/en/4.2/topics/forms/media/ | ||
| """ | ||
| def get_context_data( | ||
| self, parent_context: MutableMapping[str, Any] | ||
| ) -> MutableMapping[str, Any]: | ||
| return {} | ||
| template_name: str | ||
| def render_html(self, parent_context: MutableMapping[str, Any] = None) -> str: | ||
| def render_html( | ||
| self, | ||
| parent_context: "Optional[RenderContext]" = None, | ||
| ) -> "SafeString": | ||
| """ | ||
@@ -27,3 +42,3 @@ Return string representation of the object. | ||
| Given a context dictionary from the calling template (which may be a | ||
| `django.template.Context` object or a plain ``dict`` of context variables), | ||
| `django.template.Context` object or a plain `dict` of context variables), | ||
| returns the string representation to be rendered. | ||
@@ -44,6 +59,24 @@ | ||
| def get_context_data( | ||
| self, | ||
| parent_context: "RenderContext", | ||
| ) -> "Optional[RenderContext]": | ||
| return {} | ||
| class MediaContainer(list): | ||
| # fmt: off | ||
| if TYPE_CHECKING: | ||
| # It's ugly, I know. But it seems to be the best way to make `mypy` happy. | ||
| # The `media` property is dynamically added by the `MediaDefiningClass` | ||
| # metaclass. Because of how dynamic it is, `mypy` is not able to pick it up. | ||
| # This is why we need to add a type hint for it here. The other way would be a | ||
| # stub, but that would require the whole module to be stubbed and that is even | ||
| # more annoying to keep up to date. | ||
| @property | ||
| def media(self) -> Media: ... # noqa: E704 | ||
| # fmt: on | ||
| class MediaContainer(List[HasMediaProperty]): | ||
| """ | ||
| A list that provides a ``media`` property that combines the media definitions | ||
| A list that provides a `media` property that combines the media definitions | ||
| of its members. | ||
@@ -53,6 +86,22 @@ | ||
| https://github.com/wagtail/wagtail/blob/ca8a87077b82e20397e5a5b80154d923995e6ca9/wagtail/admin/ui/components.py#L25-L36 # noqa: E501 | ||
| The `MediaContainer` functionality depends on the `django.forms.widgets.Media` | ||
| class. The `Media` class provides the logic to combine the media definitions of | ||
| multiple objects through its `__add__` method. The `MediaContainer` relies on this | ||
| functionality to provide a `media` property that combines the media definitions of | ||
| its members. | ||
| See also: | ||
| https://docs.djangoproject.com/en/4.2/topics/forms/media | ||
| """ | ||
| @property | ||
| def media(self): | ||
| def media(self) -> Media: | ||
| """ | ||
| Return a `Media` object containing the media definitions of all members. | ||
| This makes use of the `Media.__add__` method, which combines the media | ||
| definitions of two `Media` objects. | ||
| """ | ||
| media = Media() | ||
@@ -59,0 +108,0 @@ for item in self: |
@@ -0,6 +1,17 @@ | ||
| from typing import TYPE_CHECKING | ||
| from django import template | ||
| from django.template.base import token_kwargs | ||
| from django.template.defaultfilters import conditional_escape | ||
| from django.utils.html import conditional_escape | ||
| from django.utils.safestring import SafeString | ||
| if TYPE_CHECKING: | ||
| from typing import Optional | ||
| from django.template.base import FilterExpression, Parser, Token | ||
| from laces.typing import Renderable | ||
| register = template.library.Library() | ||
@@ -19,8 +30,8 @@ | ||
| self, | ||
| component, | ||
| extra_context=None, | ||
| isolated_context=False, | ||
| fallback_render_method=None, | ||
| target_var=None, | ||
| ): | ||
| component: "FilterExpression", | ||
| extra_context: "Optional[dict[str, FilterExpression]]" = None, | ||
| isolated_context: bool = False, | ||
| fallback_render_method: "Optional[FilterExpression]" = None, | ||
| target_var: "Optional[str]" = None, | ||
| ) -> None: | ||
| self.component = component | ||
@@ -32,11 +43,27 @@ self.extra_context = extra_context or {} | ||
| def render(self, context: template.Context) -> str: | ||
| # Render a component by calling its render_html method, passing request and context from the | ||
| # calling template. | ||
| # If fallback_render_method is true, objects without a render_html method will have render() | ||
| # called instead (with no arguments) - this is to provide deprecation path for things that have | ||
| # been newly upgraded to use the component pattern. | ||
| def render(self, context: template.Context) -> SafeString: | ||
| """ | ||
| Render the ComponentNode template node. | ||
| component = self.component.resolve(context) | ||
| The rendering is done by rendering the passed component by calling its | ||
| `render_html` method and passing context from the calling template. | ||
| If the passed object does not have a `render_html` method but a `render` method | ||
| and the `fallback_render_method` arguments of the template tag is true, then | ||
| the `render` method is used. The `render` method does not receive any arguments. | ||
| Additional context variables can be passed to the component by using the `with` | ||
| keyword. The `with` keyword accepts a list of key-value pairs. The key is the | ||
| name of the context variable and the value is the value of the context variable. | ||
| The `only` keyword can be used to isolate the context. This means that the | ||
| context variables from the parent context are not passed to the component the | ||
| only context variables passed to the component are the ones passed with the | ||
| `with` keyword. | ||
| The `as` keyword can be used to store the rendered component in a variable | ||
| in the parent context. The variable name is passed after the `as` keyword. | ||
| """ | ||
| component: "Renderable" = self.component.resolve(context) | ||
| if self.fallback_render_method: | ||
@@ -64,3 +91,3 @@ fallback_render_method = self.fallback_render_method.resolve(context) | ||
| context[self.target_var] = html | ||
| return "" | ||
| return SafeString("") | ||
| else: | ||
@@ -73,3 +100,3 @@ if context.autoescape: | ||
| @register.tag(name="component") | ||
| def component(parser, token): | ||
| def component(parser: "Parser", token: "Token") -> ComponentNode: | ||
| """ | ||
@@ -91,5 +118,5 @@ Template tag to render a component via ComponentNode. | ||
| # is fallback_render_method | ||
| flags = token_kwargs(bits, parser) | ||
| fallback_render_method = flags.pop("fallback_render_method", None) | ||
| if flags: | ||
| kwargs = token_kwargs(bits, parser) | ||
| fallback_render_method = kwargs.pop("fallback_render_method", None) | ||
| if kwargs: | ||
| raise template.TemplateSyntaxError( | ||
@@ -96,0 +123,0 @@ "'component' tag only accepts 'fallback_render_method' as a keyword argument" |
@@ -1,2 +0,11 @@ | ||
| from django.template import Context, Template | ||
| import os | ||
| import random | ||
| from copy import deepcopy | ||
| from pathlib import Path | ||
| from typing import TYPE_CHECKING | ||
| from unittest.mock import MagicMock | ||
| from django.conf import settings | ||
| from django.template import Context, Template, TemplateSyntaxError | ||
| from django.test import SimpleTestCase | ||
@@ -8,2 +17,34 @@ from django.utils.html import format_html | ||
| if TYPE_CHECKING: | ||
| from typing import Any, Dict, List | ||
| from django.utils.safestring import SafeString | ||
| from laces.typing import RenderContext | ||
| class CopyingMock(MagicMock): | ||
| """ | ||
| A mock that stores copies of the call arguments. | ||
| The default behaviour of a mock is to store references to the call arguments. This | ||
| means that if the call arguments are mutable, then the stored call arguments will | ||
| change when the call arguments are changed. This is not always desirable. E.g. the | ||
| `django.template.Context` class is mutable and the different layers are popped off | ||
| the context during rendering. This makes it hard to inspect the context that was | ||
| passed to a mock. | ||
| This variant of the mock stores copies of the call arguments. This means that the | ||
| stored call arguments will not change when the actual call arguments are changed. | ||
| This override is based on the Python docs: | ||
| https://docs.python.org/3/library/unittest.mock-examples.html#coping-with-mutable-arguments # noqa: E501 | ||
| """ | ||
| def __call__(self, /, *args: "List[Any]", **kwargs: "Dict[str, Any]") -> "Any": | ||
| args = deepcopy(args) | ||
| kwargs = deepcopy(kwargs) | ||
| return super().__call__(*args, **kwargs) | ||
| class TestComponentTag(SimpleTestCase): | ||
@@ -17,77 +58,386 @@ """ | ||
| def test_passing_context_to_component(self): | ||
| class MyComponent(Component): | ||
| def render_html(self, parent_context): | ||
| return format_html( | ||
| "<h1>{} was here</h1>", parent_context.get("first_name", "nobody") | ||
| ) | ||
| def setUp(self) -> None: | ||
| self.parent_template = Template("") | ||
| template = Template( | ||
| "{% load laces %}{% with first_name='Kilroy' %}{% component my_component %}{% endwith %}" | ||
| class ExampleComponent(Component): | ||
| # Using a mock to be able to check if the `render_html` method is called. | ||
| render_html = CopyingMock(return_value="Rendered HTML") | ||
| self.component = ExampleComponent() | ||
| def set_parent_template(self, template_string: str) -> None: | ||
| template_string = "{% load laces %}" + template_string | ||
| self.parent_template = Template(template_string) | ||
| def render_parent_template_with_context( | ||
| self, | ||
| context: "RenderContext", | ||
| ) -> "SafeString": | ||
| """ | ||
| Render the parent template with the given context. | ||
| Parameters | ||
| ---------- | ||
| context: RenderContext | ||
| Context to render the parent template with. | ||
| Returns | ||
| ------- | ||
| SafeString | ||
| The parent template rendered with the given context. | ||
| """ | ||
| return self.parent_template.render(Context(context)) | ||
| def assertVariablesAvailableInRenderHTMLParentContext( | ||
| self, | ||
| expected_context_variables: "Dict[str, Any]", | ||
| ) -> None: | ||
| """ | ||
| Assert that the variables defined in the given dictionary are available in the | ||
| parent context of the `render_html` method. | ||
| Keys and values are checked. | ||
| """ | ||
| actual_context = self.component.render_html.call_args.args[0] | ||
| for key, value in expected_context_variables.items(): | ||
| self.assertIn(key, actual_context) | ||
| actual_value = actual_context[key] | ||
| if not isinstance(actual_value, Component): | ||
| # Because we are inspecting copies of the context variables, we cannot | ||
| # easily compare the components by identity. For now, we just | ||
| # skip components. | ||
| self.assertEqual(actual_value, value) | ||
| def test_render_html_return_in_parent_template(self) -> None: | ||
| self.assertEqual(self.component.render_html(), "Rendered HTML") | ||
| self.set_parent_template("Before {% component my_component %} After") | ||
| result = self.render_parent_template_with_context( | ||
| {"my_component": self.component}, | ||
| ) | ||
| html = template.render(Context({"my_component": MyComponent()})) | ||
| self.assertEqual(html, "<h1>Kilroy was here</h1>") | ||
| template = Template( | ||
| "{% load laces %}{% component my_component with first_name='Kilroy' %}" | ||
| # This matches the return value of the `render_html` method inserted into the | ||
| # parent template. | ||
| self.assertEqual(result, "Before Rendered HTML After") | ||
| def test_render_html_return_is_escaped(self) -> None: | ||
| self.component.render_html.return_value = ( | ||
| "Look, I'm running with scissors! 8< 8< 8<" | ||
| ) | ||
| html = template.render(Context({"my_component": MyComponent()})) | ||
| self.assertEqual(html, "<h1>Kilroy was here</h1>") | ||
| self.set_parent_template("{% component my_component %}") | ||
| template = Template( | ||
| "{% load laces %}{% with first_name='Kilroy' %}{% component my_component with surname='Silk' only %}{% endwith %}" | ||
| result = self.render_parent_template_with_context( | ||
| {"my_component": self.component}, | ||
| ) | ||
| html = template.render(Context({"my_component": MyComponent()})) | ||
| self.assertEqual(html, "<h1>nobody was here</h1>") | ||
| def test_fallback_render_method(self): | ||
| class MyComponent(Component): | ||
| def render_html(self, parent_context): | ||
| return format_html("<h1>I am a component</h1>") | ||
| self.assertEqual( | ||
| result, | ||
| "Look, I'm running with scissors! 8< 8< 8<", | ||
| ) | ||
| class MyNonComponent: | ||
| def render(self): | ||
| return format_html("<h1>I am not a component</h1>") | ||
| def test_render_html_return_not_escaped_when_formatted_html(self) -> None: | ||
| self.component.render_html.return_value = format_html("<h1>My component</h1>") | ||
| self.set_parent_template("{% component my_component %}") | ||
| template = Template("{% load laces %}{% component my_component %}") | ||
| html = template.render(Context({"my_component": MyComponent()})) | ||
| self.assertEqual(html, "<h1>I am a component</h1>") | ||
| result = self.render_parent_template_with_context( | ||
| {"my_component": self.component}, | ||
| ) | ||
| self.assertEqual(result, "<h1>My component</h1>") | ||
| def test_render_html_return_not_escaped_when_actually_rendered_template( | ||
| self, | ||
| ) -> None: | ||
| example_template_name = f"example-{random.randint(1000, 10000)}.html" | ||
| example_template = ( | ||
| Path(settings.PROJECT_DIR) / "templates" / example_template_name | ||
| ) | ||
| with open(example_template, "w") as f: | ||
| f.write("<h1>My component</h1>") | ||
| # ----------------------------------------------------------------------------- | ||
| class RealExampleComponent(Component): | ||
| template_name = example_template_name | ||
| # ----------------------------------------------------------------------------- | ||
| component = RealExampleComponent() | ||
| self.set_parent_template("{% component my_component %}") | ||
| result = self.render_parent_template_with_context( | ||
| {"my_component": component}, | ||
| ) | ||
| self.assertEqual(result, "<h1>My component</h1>") | ||
| os.remove(example_template) | ||
| def test_render_html_parent_context_when_only_component_in_context(self) -> None: | ||
| self.set_parent_template("{% component my_component %}") | ||
| self.render_parent_template_with_context({"my_component": self.component}) | ||
| self.assertVariablesAvailableInRenderHTMLParentContext( | ||
| {"my_component": self.component} | ||
| ) | ||
| def test_render_html_parent_context_when_other_variable_in_context(self) -> None: | ||
| self.set_parent_template("{% component my_component %}") | ||
| self.render_parent_template_with_context( | ||
| { | ||
| "my_component": self.component, | ||
| "test": "something", | ||
| } | ||
| ) | ||
| self.assertVariablesAvailableInRenderHTMLParentContext( | ||
| { | ||
| "my_component": self.component, | ||
| "test": "something", | ||
| } | ||
| ) | ||
| def test_render_html_parent_context_when_with_block_sets_extra_context( | ||
| self, | ||
| ) -> None: | ||
| self.set_parent_template( | ||
| "{% with test='something' %}{% component my_component %}{% endwith %}" | ||
| ) | ||
| self.render_parent_template_with_context({"my_component": self.component}) | ||
| self.assertVariablesAvailableInRenderHTMLParentContext( | ||
| { | ||
| "my_component": self.component, | ||
| "test": "something", | ||
| } | ||
| ) | ||
| def test_render_html_parent_context_when_with_keyword_sets_extra_context( | ||
| self, | ||
| ) -> None: | ||
| self.set_parent_template("{% component my_component with test='something' %}") | ||
| self.render_parent_template_with_context({"my_component": self.component}) | ||
| self.assertVariablesAvailableInRenderHTMLParentContext( | ||
| { | ||
| "my_component": self.component, | ||
| "test": "something", | ||
| } | ||
| ) | ||
| def test_render_html_parent_context_when_with_only_keyword_limits_extra_context( | ||
| self, | ||
| ) -> None: | ||
| self.set_parent_template( | ||
| "{% component my_component with test='nothing else' only %}" | ||
| ) | ||
| self.render_parent_template_with_context( | ||
| { | ||
| "my_component": self.component, | ||
| "other": "something else", | ||
| } | ||
| ) | ||
| # The `my_component` and `other` variables from the parent's rendering context | ||
| # are not included in the context that is passed to the `render_html` method. | ||
| # The `test` variable, that was defined with the with-keyword, is present | ||
| # though. Both of these effects come form the `only` keyword. | ||
| self.assertVariablesAvailableInRenderHTMLParentContext({"test": "nothing else"}) | ||
| def test_render_html_parent_context_when_with_block_overrides_context(self) -> None: | ||
| self.set_parent_template( | ||
| "{% with test='something else' %}{% component my_component %}{% endwith %}" | ||
| ) | ||
| self.render_parent_template_with_context( | ||
| { | ||
| "my_component": self.component, | ||
| "test": "something", | ||
| } | ||
| ) | ||
| self.assertVariablesAvailableInRenderHTMLParentContext( | ||
| { | ||
| "my_component": self.component, | ||
| # The `test` variable is overriden by the `with` block. | ||
| "test": "something else", | ||
| } | ||
| ) | ||
| def test_render_html_parent_context_when_with_keyword_overrides_context( | ||
| self, | ||
| ) -> None: | ||
| self.set_parent_template( | ||
| "{% component my_component with test='something else' %}" | ||
| ) | ||
| self.render_parent_template_with_context( | ||
| { | ||
| "my_component": self.component, | ||
| "test": "something", | ||
| } | ||
| ) | ||
| self.assertVariablesAvailableInRenderHTMLParentContext( | ||
| { | ||
| "my_component": self.component, | ||
| # The `test` variable is overriden by the `with` keyword. | ||
| "test": "something else", | ||
| }, | ||
| ) | ||
| def test_render_html_parent_context_when_with_keyword_overrides_with_block( | ||
| self, | ||
| ) -> None: | ||
| self.set_parent_template( | ||
| """ | ||
| {% with test='something' %} | ||
| {% component my_component with test='something else' %} | ||
| {% endwith %} | ||
| """ | ||
| ) | ||
| self.render_parent_template_with_context({"my_component": self.component}) | ||
| self.assertVariablesAvailableInRenderHTMLParentContext( | ||
| { | ||
| "my_component": self.component, | ||
| "test": "something else", | ||
| } | ||
| ) | ||
| def test_fallback_render_method_arg_true_and_object_with_render_method( | ||
| self, | ||
| ) -> None: | ||
| # ----------------------------------------------------------------------------- | ||
| class ExampleNonComponentWithRenderMethod: | ||
| def render(self) -> str: | ||
| return "Rendered non-component" | ||
| # ----------------------------------------------------------------------------- | ||
| non_component = ExampleNonComponentWithRenderMethod() | ||
| self.set_parent_template( | ||
| "{% component my_non_component fallback_render_method=True %}" | ||
| ) | ||
| result = self.render_parent_template_with_context( | ||
| {"my_non_component": non_component}, | ||
| ) | ||
| self.assertEqual(result, "Rendered non-component") | ||
| def test_fallback_render_method_arg_true_but_object_without_render_method( | ||
| self, | ||
| ) -> None: | ||
| # ----------------------------------------------------------------------------- | ||
| class ExampleNonComponentWithoutRenderMethod: | ||
| pass | ||
| # ----------------------------------------------------------------------------- | ||
| non_component = ExampleNonComponentWithoutRenderMethod() | ||
| self.set_parent_template( | ||
| "{% component my_non_component fallback_render_method=True %}" | ||
| ) | ||
| with self.assertRaises(ValueError): | ||
| template.render(Context({"my_component": MyNonComponent()})) | ||
| self.render_parent_template_with_context( | ||
| {"my_non_component": non_component}, | ||
| ) | ||
| template = Template( | ||
| "{% load laces %}{% component my_component fallback_render_method=True %}" | ||
| def test_no_fallback_render_method_arg_and_object_without_render_method( | ||
| self, | ||
| ) -> None: | ||
| # ----------------------------------------------------------------------------- | ||
| class ExampleNonComponentWithoutRenderMethod: | ||
| def __repr__(self) -> str: | ||
| return "<Example repr>" | ||
| # ----------------------------------------------------------------------------- | ||
| non_component = ExampleNonComponentWithoutRenderMethod() | ||
| self.set_parent_template("{% component my_non_component %}") | ||
| with self.assertRaises(ValueError) as cm: | ||
| self.render_parent_template_with_context( | ||
| {"my_non_component": non_component}, | ||
| ) | ||
| self.assertEqual( | ||
| str(cm.exception), | ||
| "Cannot render <Example repr> as a component", | ||
| ) | ||
| html = template.render(Context({"my_component": MyComponent()})) | ||
| self.assertEqual(html, "<h1>I am a component</h1>") | ||
| html = template.render(Context({"my_component": MyNonComponent()})) | ||
| self.assertEqual(html, "<h1>I am not a component</h1>") | ||
| def test_component_escapes_unsafe_strings(self): | ||
| class MyComponent(Component): | ||
| def render_html(self, parent_context): | ||
| return "Look, I'm running with scissors! 8< 8< 8<" | ||
| def test_as_keyword_stores_render_html_return_as_variable(self) -> None: | ||
| self.set_parent_template( | ||
| "{% component my_component as my_var %}The result was: {{ my_var }}" | ||
| ) | ||
| template = Template("{% load laces %}<h1>{% component my_component %}</h1>") | ||
| html = template.render(Context({"my_component": MyComponent()})) | ||
| result = self.render_parent_template_with_context( | ||
| {"my_component": self.component}, | ||
| ) | ||
| self.assertEqual(result, "The result was: Rendered HTML") | ||
| def test_as_keyword_without_variable_name(self) -> None: | ||
| # The template is already parsed when the parent template is set. This is the | ||
| # moment where the parsing error is raised. | ||
| with self.assertRaises(TemplateSyntaxError) as cm: | ||
| self.set_parent_template("{% component my_component as %}") | ||
| self.assertEqual( | ||
| html, "<h1>Look, I'm running with scissors! 8< 8< 8<</h1>" | ||
| str(cm.exception), | ||
| "'component' tag with 'as' must be followed by a variable name", | ||
| ) | ||
| def test_error_on_rendering_non_component(self): | ||
| template = Template("{% load laces %}<h1>{% component my_component %}</h1>") | ||
| def test_autoescape_off_block_can_disable_escaping_of_render_html_return( | ||
| self, | ||
| ) -> None: | ||
| self.component.render_html.return_value = ( | ||
| "Look, I'm running with scissors! 8< 8< 8<" | ||
| ) | ||
| self.set_parent_template( | ||
| "{% autoescape off %}{% component my_component %}{% endautoescape %}" | ||
| ) | ||
| with self.assertRaises(ValueError) as cm: | ||
| template.render(Context({"my_component": "hello"})) | ||
| self.assertEqual(str(cm.exception), "Cannot render 'hello' as a component") | ||
| result = self.render_parent_template_with_context( | ||
| {"my_component": self.component}, | ||
| ) | ||
| def test_render_as_var(self): | ||
| class MyComponent(Component): | ||
| def render_html(self, parent_context): | ||
| return format_html("<h1>I am a component</h1>") | ||
| self.assertEqual( | ||
| result, | ||
| "Look, I'm running with scissors! 8< 8< 8<", | ||
| ) | ||
| template = Template( | ||
| "{% load laces %}{% component my_component as my_html %}The result was: {{ my_html }}" | ||
| def test_parsing_no_arguments(self) -> None: | ||
| # The template is already parsed when the parent template is set. This is the | ||
| # moment where the parsing error is raised. | ||
| with self.assertRaises(TemplateSyntaxError) as cm: | ||
| self.set_parent_template("{% component %}") | ||
| self.assertEqual( | ||
| str(cm.exception), | ||
| "'component' tag requires at least one argument, the component object", | ||
| ) | ||
| html = template.render(Context({"my_component": MyComponent()})) | ||
| self.assertEqual(html, "The result was: <h1>I am a component</h1>") | ||
| def test_parsing_unknown_kwarg(self) -> None: | ||
| # The template is already parsed when the parent template is set. This is the | ||
| # moment where the parsing error is raised. | ||
| with self.assertRaises(TemplateSyntaxError) as cm: | ||
| self.set_parent_template("{% component my_component unknown_kwarg=True %}") | ||
| self.assertEqual( | ||
| str(cm.exception), | ||
| "'component' tag only accepts 'fallback_render_method' as a keyword argument", | ||
| ) | ||
| def test_parsing_unknown_bit(self) -> None: | ||
| # The template is already parsed when the parent template is set. This is the | ||
| # moment where the parsing error is raised. | ||
| with self.assertRaises(TemplateSyntaxError) as cm: | ||
| self.set_parent_template("{% component my_component unknown_bit %}") | ||
| self.assertEqual( | ||
| str(cm.exception), | ||
| "'component' tag received an unknown argument: 'unknown_bit'", | ||
| ) |
+664
-85
| Metadata-Version: 2.1 | ||
| Name: laces | ||
| Version: 0.1.0 | ||
| Version: 0.1.1 | ||
| Summary: Django components that know how to render themselves. | ||
@@ -18,2 +18,3 @@ Author-email: Tibor Leupold <tibor@lpld.io> | ||
| Classifier: Programming Language :: Python :: 3.11 | ||
| Classifier: Programming Language :: Python :: 3.12 | ||
| Classifier: Framework :: Django | ||
@@ -24,10 +25,22 @@ Classifier: Framework :: Django :: 3.2 | ||
| Classifier: Framework :: Django :: 4.2 | ||
| Classifier: Framework :: Django :: 5.0 | ||
| Requires-Dist: Django>=3.2 | ||
| Requires-Dist: tox==4.11.3 ; extra == "ci" | ||
| Requires-Dist: tox-gh-actions==3.1.3 ; extra == "ci" | ||
| Requires-Dist: virtualenv-pyenv==0.4.0 ; extra == "ci" | ||
| Requires-Dist: tox==4.12.1 ; extra == "dev" | ||
| Requires-Dist: tox-gh-actions==3.2.0 ; extra == "dev" | ||
| Requires-Dist: virtualenv-pyenv==0.4.0 ; extra == "dev" | ||
| Requires-Dist: coverage==7.3.4 ; extra == "dev" | ||
| Requires-Dist: pre-commit==3.4.0 ; extra == "dev" | ||
| Requires-Dist: black==24.1.1 ; extra == "dev" | ||
| Requires-Dist: blacken-docs==1.16.0 ; extra == "dev" | ||
| Requires-Dist: isort==5.13.2 ; extra == "dev" | ||
| Requires-Dist: flake8==7.0.0 ; extra == "dev" | ||
| Requires-Dist: flake8-bugbear ; extra == "dev" | ||
| Requires-Dist: flake8-comprehensions ; extra == "dev" | ||
| Requires-Dist: mypy==1.7.1 ; extra == "dev" | ||
| Requires-Dist: django-stubs[compatible-mypy]==4.2.7 ; extra == "dev" | ||
| Requires-Dist: types-requests==2.31.0.20240125 ; extra == "dev" | ||
| Requires-Dist: dj-database-url==2.1.0 ; extra == "testing" | ||
| Requires-Dist: pre-commit==3.4.0 ; extra == "testing" | ||
| Requires-Dist: coverage==7.3.4 ; extra == "testing" | ||
| Project-URL: Home, https://github.com/tbrlpld/laces | ||
| Provides-Extra: ci | ||
| Provides-Extra: dev | ||
| Provides-Extra: testing | ||
@@ -40,2 +53,3 @@ | ||
| [](https://github.com/tbrlpld/laces/actions/workflows/test.yml) | ||
| [](https://codecov.io/gh/tbrlpld/laces) | ||
@@ -46,25 +60,38 @@ --- | ||
| Laces components provide a simple way to combine data (in the form of Python objects) with the Django templates that are meant to render that data. | ||
| The components can then be simply rendered in any other template using the `{% component %}` template tag. | ||
| That parent template does not need to know anything about the component's template or data. | ||
| No need to receive, filter, restructure or pass any data to the component's template. | ||
| Just let the component render itself. | ||
| Working with objects that know how to render themselves as HTML elements is a common pattern found in complex Django applications (e.g. the [Wagtail](https://github.com/wagtail/wagtail) admin interface). | ||
| This package provides tools enable and support working with such objects, also known as "components". | ||
| Template and data are tied together (sorry, not sorry 😅) in the component, and they can be passed around together. | ||
| This becomes especially useful when components are nested — it allows us to avoid building the same nested structure twice (once in the data and again in the templates). | ||
| The APIs provided in the package have previously been discovered, developed and solidified in the Wagtail project. | ||
| Working with objects that know how to render themselves as HTML elements is a common pattern found in complex Django applications, such as the [Wagtail](https://github.com/wagtail/wagtail) admin interface. | ||
| The Wagtail admin is also where the APIs provided in this package have previously been discovered, developed and solidified. | ||
| The purpose of this package is to make these tools available to other Django projects outside the Wagtail ecosystem. | ||
| ## Links | ||
| - [Documentation](https://github.com/tbrlpld/laces/blob/main/README.md) | ||
| - [Getting started](#getting-started) | ||
| - [Installation](#installation) | ||
| - [Creating components](#creating-components) | ||
| - [Passing context to the component template](#passing-context-to-the-component-template) | ||
| - [Using components in other templates](#using-components-in-other-templates) | ||
| - [Adding JavaScript and CSS assets to a component](#adding-javascript-and-css-assets-to-a-component) | ||
| - [Patterns for using components](#patterns-for-using-components) | ||
| - [Nesting components](#nesting-components) | ||
| - [Nested groups of components](#nested-groups-of-components) | ||
| - [Container components](#container-components) | ||
| - [Using dataclasses](#using-dataclasses) | ||
| - [About Laces and components](#about-laces-and-components) | ||
| - [Contributing](#contributing) | ||
| - [Changelog](https://github.com/tbrlpld/laces/blob/main/CHANGELOG.md) | ||
| - [Contributing](https://github.com/tbrlpld/laces/blob/main/CONTRIBUTING.md) | ||
| - [Discussions](https://github.com/tbrlpld/laces/discussions) | ||
| - [Security](https://github.com/tbrlpld/laces/security) | ||
| ## Supported versions | ||
| ## Getting started | ||
| - Python >= 3.8 | ||
| - Django >= 3.2 | ||
| ### Installation | ||
| ## Installation | ||
| First, install with pip: | ||
@@ -85,8 +112,5 @@ ```sh | ||
| ## Usage | ||
| ### Creating components | ||
| The preferred way to create a component is to define a subclass of `laces.components.Component` and specify a `template_name` attribute on it. | ||
| The rendered template will then be used as the component's HTML representation: | ||
| The simplest way to create a component is to define a subclass of `laces.components.Component` and specify a `template_name` attribute on it. | ||
@@ -100,16 +124,51 @@ ```python | ||
| class WelcomePanel(Component): | ||
| template_name = "my_app/panels/welcome.html" | ||
| template_name = "my_app/components/welcome.html" | ||
| ``` | ||
| ```html+django | ||
| {# my_app/templates/my_app/components/welcome.html #} | ||
| my_welcome_panel = WelcomePanel() | ||
| <h1>Hello World!</h1> | ||
| ``` | ||
| With the above in place, you then instantiate the component (e.g., in a view) and pass it to another template for rendering. | ||
| ```python | ||
| # my_app/views.py | ||
| from django.shortcuts import render | ||
| from my_app.components import WelcomePanel | ||
| def home(request): | ||
| welcome = WelcomePanel() # <-- Instantiates the component | ||
| return render( | ||
| request, | ||
| "my_app/home.html", | ||
| {"welcome": welcome}, # <-- Passes the component to the view template | ||
| ) | ||
| ``` | ||
| In the view template, we `load` the `laces` tag library and use the `{% component %}` tag to render the component. | ||
| ```html+django | ||
| {# my_app/templates/my_app/panels/welcome.html #} | ||
| {# my_app/templates/my_app/home.html #} | ||
| <h1>Welcome to my app!</h1> | ||
| {% load laces %} | ||
| {% component welcome %} {# <-- Renders the component #} | ||
| ``` | ||
| For simple cases that don't require a template, the `render_html` method can be overridden instead: | ||
| That's it! | ||
| The component's template will be rendered right there in the view template. | ||
| Of course, this is a very simple example and not much more useful than using a simple `include`. | ||
| We will go into some more useful use cases below. | ||
| ### Without a template | ||
| Before we dig deeper into the component use cases, just a quick note that components don't have to have a template. | ||
| For simple cases that don't require a template, the `render_html` method can be overridden instead. | ||
| If the return value contains HTML, it should be marked as safe using `django.utils.html.format_html` or `django.utils.safestring.mark_safe`. | ||
| ```python | ||
@@ -124,10 +183,20 @@ # my_app/components.py | ||
| def render_html(self, parent_context): | ||
| return format_html("<h1>{}</h1>", "Welcome to my app!") | ||
| return format_html("<h1>Hello World!</h1>") | ||
| ``` | ||
| ### Passing context to the template | ||
| ### Passing context to the component template | ||
| The `get_context_data` method can be overridden to pass context variables to the template. | ||
| As with `render_html`, this receives the context dictionary from the calling template. | ||
| Now back to components with templates. | ||
| The example shown above with the static welcome message in the template is, of course, not very useful. | ||
| It seems more like an overcomplicated way to replace a simple `include`. | ||
| But, we rarely ever want to render templates with static content. | ||
| Usually, we want to pass some context variables to the template to be rendered. | ||
| This is where components start to become interesting. | ||
| The default implementation of `render_html` calls the component's `get_context_data` method to get the context variables to pass to the template. | ||
| The default implementation of `get_context_data` returns an empty dictionary. | ||
| To customize the context variables passed to the template, we can override `get_context_data`. | ||
| ```python | ||
@@ -140,20 +209,25 @@ # my_app/components.py | ||
| class WelcomePanel(Component): | ||
| template_name = "my_app/panels/welcome.html" | ||
| template_name = "my_app/components/welcome.html" | ||
| def get_context_data(self, parent_context): | ||
| context = super().get_context_data(parent_context) | ||
| context["username"] = parent_context["request"].user.username | ||
| return context | ||
| return {"name": "Alice"} | ||
| ``` | ||
| ```html+django | ||
| {# my_app/templates/my_app/panels/welcome.html #} | ||
| {# my_app/templates/my_app/components/welcome.html #} | ||
| <h1>Welcome to my app, {{ username }}!</h1> | ||
| <h1>Hello {{ name }}</h1> | ||
| ``` | ||
| ### Adding media definitions | ||
| With the above we are now rendering a welcome message with the name coming from the component's `get_context_data` method. | ||
| Nice. | ||
| But, still not very useful, as the name is still hardcoded — in the component method instead of the template, but hardcoded nonetheless. | ||
| Like Django form widgets, components can specify associated JavaScript and CSS resources using either an inner `Media` class or a dynamic `media` property. | ||
| #### Using class properties | ||
| When considering how to make the context of our components more useful, it's helpful to remember that components are just normal Python classes and objects. | ||
| So, you are basically free to get the context data into the component in any way you like. | ||
| For example, we can pass arguments to the constructor and use them in the component's methods, like `get_context_data`. | ||
| ```python | ||
@@ -166,14 +240,70 @@ # my_app/components.py | ||
| class WelcomePanel(Component): | ||
| template_name = "my_app/panels/welcome.html" | ||
| template_name = "my_app/components/welcome.html" | ||
| class Media: | ||
| css = {"all": ("my_app/css/welcome-panel.css",)} | ||
| def __init__(self, name): | ||
| self.name = name | ||
| def get_context_data(self, parent_context): | ||
| return {"name": self.name} | ||
| ``` | ||
| Nice, this is getting better. | ||
| Now we can pass the name to the component when we instantiate it and pass the component ready to be rendered to the view template. | ||
| ```python | ||
| # my_app/views.py | ||
| from django.shortcuts import render | ||
| from my_app.components import WelcomePanel | ||
| def home(request): | ||
| welcome = WelcomePanel(name="Alice") | ||
| return render( | ||
| request, | ||
| "my_app/home.html", | ||
| {"welcome": welcome}, | ||
| ) | ||
| ``` | ||
| So, as mentioned before, we can use the full power of Python classes and objects to provide context data to our components. | ||
| A couple more examples of how components can be used can be found [below](#patterns-for-using-components). | ||
| #### Using the parent context | ||
| You may have noticed in the above examples that the `render_html` and `get_context_data` methods take a `parent_context` argument. | ||
| This is the context of the template that is calling the component. | ||
| The `parent_context` is passed into the `render_html` method by the `{% component %}` template tag. | ||
| In the default implementation of the `render_html` method, the `parent_context` is then passed to the `get_context_data` method. | ||
| The default implementation of the `get_context_data` method, however, ignores the `parent_context` argument and returns an empty dictionary. | ||
| To make use of it, you will have to override the `get_context_data` method. | ||
| Relying on data from the parent context somewhat forgoes some of the benefits of components, which is tying the data and template together. | ||
| Especially for nested uses of components, you now require that the data in the right format is passed through all layers of templates again. | ||
| It is usually cleaner to provide all the data needed by the component directly to the component itself. | ||
| However, there may be cases where this is not possible or desirable. | ||
| For those cases, you have access to the parent context in the component's `get_context_data` method. | ||
| ```python | ||
| # my_app/components.py | ||
| from laces.components import Component | ||
| class WelcomePanel(Component): | ||
| template_name = "my_app/components/welcome.html" | ||
| def get_context_data(self, parent_context): | ||
| return {"name": parent_context["request"].user.first_name} | ||
| ``` | ||
| (Of course, this could have also been achieved by passing the request or user object to the component in the view, but this is just an example.) | ||
| ### Using components in other templates | ||
| The `laces` tag library provides a `{% component %}` tag for including components on a template. | ||
| This takes care of passing context variables from the calling template to the component (which would not be the case for a basic `{{ ... }}` variable tag). | ||
| As mentioned in the [first example](#creating-components), components are rendered in other templates using the `{% component %}` tag from the `laces` tag library. | ||
| For example, given the view passes an instance of `WelcomePanel` to the context of `my_app/welcome.html`. | ||
| Here is that example from above again, in which the view passes an instance of `WelcomePanel` to the context of `my_app/home.html`. | ||
@@ -188,10 +318,10 @@ ```python | ||
| def welcome_page(request): | ||
| panel = (WelcomePanel(),) | ||
| def home(request): | ||
| welcome = WelcomePanel() | ||
| return render( | ||
| request, | ||
| "my_app/welcome.html", | ||
| "my_app/home.html", | ||
| { | ||
| "panel": panel, | ||
| "welcome": welcome, | ||
| }, | ||
@@ -201,58 +331,152 @@ ) | ||
| The template `my_app/templates/my_app/welcome.html` could render the panel as follows: | ||
| Then, in the `my_app/templates/my_app/home.html` template we render the welcome panel component as follows: | ||
| ```html+django | ||
| {# my_app/templates/my_app/welcome.html #} | ||
| {# my_app/templates/my_app/home.html #} | ||
| {% load laces %} | ||
| {% component panel %} | ||
| {% component welcome %} | ||
| ``` | ||
| You can pass additional context variables to the component using the keyword `with`: | ||
| This is the basic usage of components and should cover most cases. | ||
| However, the `{% component %}` tag also supports some additional features. | ||
| Specifically, the keywords `with`, `only` and `as` are supported, similar to how they work with the [`{% include %}`](https://docs.djangoproject.com/en/5.0/ref/templates/builtins/#std-templatetag-include) tag. | ||
| #### Provide additional parent context variables with `with` | ||
| You can pass additional parent context variables to the component using the keyword `with`: | ||
| ```html+django | ||
| {% component panel with username=request.user.username %} | ||
| {% component welcome with name=request.user.first_name %} | ||
| ``` | ||
| To render the component with only the variables provided (and no others from the calling template's context), use `only`: | ||
| **Note**: These extra variables will be added to the `parent_context` which is passed to the component's `render_html` and `get_context_data` methods. | ||
| The default implementation of `get_context_data` ignores the `parent_context` argument, so you will have to override it to make use of the extra variables. | ||
| For more information see the above section on the [parent context](#using-the-parent-context). | ||
| #### Limit the parent context variables with `only` | ||
| To limit the parent context variables passed to the component to only those variables provided by the `with` keyword (and no others from the calling template's context), use `only`: | ||
| ```html+django | ||
| {% component panel with username=request.user.username only %} | ||
| {% component welcome with name=request.user.first_name only %} | ||
| ``` | ||
| **Note**: Both, `with` and `only`, only affect the `parent_context` which is passed to the component's `render_html` and `get_context_data` methods. They do not have any direct effect on actual context that is passed to the component's template. E.g. if the component's `get_context_data` method returns a dictionary which always contains a key `foo`, then that key will be available in the component's template, regardless of whether `only` was used or not. | ||
| #### Store the rendered output in a variable with `as` | ||
| To store the component's rendered output in a variable rather than outputting it immediately, use `as` followed by the variable name: | ||
| ```html+django | ||
| {% component panel as panel_html %} | ||
| {% component welcome as welcome_html %} | ||
| {{ panel_html }} | ||
| {{ welcome_html }} | ||
| ``` | ||
| Note that it is your template's responsibility to output any media declarations defined on the components. | ||
| This can be done by constructing a media object for the whole page within the view, passing this to the template, and outputting it via `media.js` and `media.css`. | ||
| ### Adding JavaScript and CSS assets to a component | ||
| Like Django form widgets, components can specify associated JavaScript and CSS assets. | ||
| The assets for a component can be specified in the same way that [Django form assets are defined](https://docs.djangoproject.com/en/5.0/topics/forms/media). | ||
| This can be achieved using either an inner `Media` class or a dynamic `media` property. | ||
| An inner `Media` class definition looks like this: | ||
| ```python | ||
| # my_app/components.py | ||
| from laces.components import Component | ||
| class WelcomePanel(Component): | ||
| template_name = "my_app/components/welcome.html" | ||
| class Media: | ||
| css = {"all": ("my_app/css/welcome-panel.css",)} | ||
| ``` | ||
| The more dynamic definition via a `media` property looks like this: | ||
| ```python | ||
| # my_app/components.py | ||
| from django.forms import Media | ||
| from laces.components import Component | ||
| class WelcomePanel(Component): | ||
| template_name = "my_app/components/welcome.html" | ||
| @property | ||
| def media(self): | ||
| return Media(css={"all": ("my_app/css/welcome-panel.css",)}) | ||
| ``` | ||
| **Note**: | ||
| It is your template's responsibility to output any media declarations defined on the components. | ||
| #### Outputting component media in templates | ||
| Once you have defined the assets on the component in one of the two ways above, you can output them in your templates. | ||
| This, again, works in the same way as it does for Django form widgets. | ||
| The component instance will have a `media` property which returns an instance of the `django.forms.Media` class. | ||
| This is the case, even if you used the nested `Media` class to define the assets. | ||
| The [string representation of a `Media` objects](https://docs.djangoproject.com/en/5.0/topics/forms/media#s-media-objects) are the HTML declarations to include the assets. | ||
| In the example home template from above, we can output the component's media declarations like so: | ||
| ```html+django | ||
| {# my_app/templates/my_app/home.html #} | ||
| {% load laces %} | ||
| <head> | ||
| {{ welcome.media }} | ||
| <head> | ||
| <body> | ||
| {% component welcome %} | ||
| </body> | ||
| ``` | ||
| #### Combining media with `MediaContainer` | ||
| When you have many components in a page, it can be cumbersome to output the media declarations for each component individually. | ||
| To make that process a bit easier, Laces provides a `MediaContainer` class. | ||
| The `MediaContainer` class is a subclass of Python's built-in `list` class which combines the `media` of all it's members. | ||
| In a view we can create a `MediaContainer` instance containing several media-defining components and pass it to the view template. | ||
| ```python | ||
| # my_app/views.py | ||
| from django.forms import Media | ||
| from django.shortcuts import render | ||
| from laces.components import MediaContainer | ||
| from my_app.components import WelcomePanel | ||
| from my_app.components import ( | ||
| Dashboard, | ||
| Footer, | ||
| Header, | ||
| Sidebar, | ||
| WelcomePanel, | ||
| ) | ||
| def welcome_page(request): | ||
| panels = [ | ||
| WelcomePanel(), | ||
| ] | ||
| def home(request): | ||
| components = MediaContainer( | ||
| [ | ||
| Header(), | ||
| Sidebar(), | ||
| WelcomePanel(), | ||
| Dashboard(), | ||
| Footer(), | ||
| ] | ||
| ) | ||
| media = Media() | ||
| for panel in panels: | ||
| media += panel.media | ||
| render( | ||
| return render( | ||
| request, | ||
| "my_app/welcome.html", | ||
| "my_app/home.html", | ||
| { | ||
| "panels": panels, | ||
| "media": media, | ||
| "components": components, | ||
| }, | ||
@@ -262,5 +486,6 @@ ) | ||
| Then, in the view template, we can output the media declarations for all components in the container at once. | ||
| ```html+django | ||
| {# my_app/templates/my_app/welcome.html #} | ||
| {# my_app/templates/my_app/home.html #} | ||
@@ -270,12 +495,321 @@ {% load laces %} | ||
| <head> | ||
| {{ media.js }} | ||
| {{ media.css }} | ||
| {{ components.media }} | ||
| <head> | ||
| <body> | ||
| {% for component in components %} | ||
| {% component component %} | ||
| {% endfor %} | ||
| </body> | ||
| ``` | ||
| This will output a combined media declaration for all components in the container. | ||
| The combination of the media declarations follows the behaviour outlined in the [Django documentation](https://docs.djangoproject.com/en/5.0/topics/forms/media/#combining-media-objects). | ||
| **Note**: | ||
| The use of `MediaContainer` is not limited to contain components. | ||
| It can be used to combine the `media` properties of any kind of objects that have a `media` property. | ||
| ## Patterns for using components | ||
| Below, we want to show a few more examples of how components can be used that were not covered in the ["Getting started" section](#getting-started) above. | ||
| ### Nesting components | ||
| The combination of data and template that components provide becomes especially useful when components are nested. | ||
| ```python | ||
| # my_app/components.py | ||
| from laces.components import Component | ||
| class WelcomePanel(Component): ... | ||
| class Dashboard(Component): | ||
| template_name = "my_app/components/dashboard.html" | ||
| def __init__(self, user): | ||
| self.welcome = WelcomePanel(name=user.first_name) | ||
| ... | ||
| def get_context_data(self, parent_context): | ||
| return {"welcome": self.welcome} | ||
| ``` | ||
| The template of the "parent" component does not need to know anything about the "child" component, except for which template variable is a component. | ||
| The child component already contains the data it needs and knows which template to use to render that data. | ||
| ```html+django | ||
| {# my_app/templates/my_app/components/dashboard.html #} | ||
| {% load laces %} | ||
| <div class="dashboard"> | ||
| {% component welcome %} | ||
| ... | ||
| </div> | ||
| ``` | ||
| The nesting also provides us with a nice data structure we can test. | ||
| ```python | ||
| dashboard = Dashboard(user=request.user) | ||
| assert dashboard.welcome.name == request.user.first_name | ||
| ``` | ||
| ### Nested groups of components | ||
| The nesting of components is not limited to single instances. | ||
| We can also nest groups of components. | ||
| ```python | ||
| # my_app/components.py | ||
| from laces.components import Component | ||
| class WelcomePanel(Component): ... | ||
| class UsagePanel(Component): ... | ||
| class TeamPanel(Component): ... | ||
| class Dashboard(Component): | ||
| template_name = "my_app/components/dashboard.html" | ||
| def __init__(self, user): | ||
| self.panels = [ | ||
| WelcomePanel(name=user.first_name), | ||
| UsagePanel(user=user), | ||
| TeamPanel(groups=user.groups.all()), | ||
| ] | ||
| ... | ||
| def get_context_data(self, parent_context): | ||
| return {"panels": self.panels} | ||
| ``` | ||
| ```html+django | ||
| {# my_app/templates/my_app/components/dashboard.html #} | ||
| {% load laces %} | ||
| <div class="dashboard"> | ||
| {% for panel in panels %} | ||
| {% component panel %} | ||
| {% endfor %} | ||
| ... | ||
| </div> | ||
| ``` | ||
| ### Container components | ||
| The [above example](#nested-groups-of-components) is relatively static. | ||
| The `Dashboard` component always contains the same panels. | ||
| You could also imagine passing the child components in through the constructor. | ||
| This would make your component into a dynamic container component. | ||
| ```python | ||
| # my_app/components.py | ||
| from laces.components import Component | ||
| class Section(Component): | ||
| template_name = "my_app/components/section.html" | ||
| def __init__(self, children: list[Component]): | ||
| self.children = children | ||
| ... | ||
| def get_context_data(self, parent_context): | ||
| return {"children": self.children} | ||
| class Heading(Component): ... | ||
| class Paragraph(Component): ... | ||
| class Image(Component): ... | ||
| ``` | ||
| ```html+django | ||
| {# my_app/templates/my_app/components/section.html #} | ||
| {% load laces %} | ||
| <section> | ||
| {% for child in children %} | ||
| {% component child %} | ||
| {% endfor %} | ||
| </section> | ||
| ``` | ||
| The above `Section` component can take any kind of component as children. | ||
| The only thing that `Section` requires is that the children can be rendered with the `{% component %}` tag (which all components do). | ||
| In the view, we can now instantiate the `Section` component with any children we want. | ||
| ```python | ||
| # my_app/views.py | ||
| from django.shortcuts import render | ||
| from my_app.components import ( | ||
| Heading, | ||
| Image, | ||
| Paragraph, | ||
| Section, | ||
| ) | ||
| def home(request): | ||
| content = Section( | ||
| children=[ | ||
| Heading(...), | ||
| Paragraph(...), | ||
| Image(...), | ||
| ] | ||
| ) | ||
| return render( | ||
| request, | ||
| "my_app/home.html", | ||
| {"content": content}, | ||
| ) | ||
| ``` | ||
| ```html+django | ||
| {# my_app/templates/my_app/home.html #} | ||
| {% load laces %} | ||
| <body> | ||
| {% component content %} | ||
| ... | ||
| </body> | ||
| ``` | ||
| ### Using dataclasses | ||
| Above, we showed how to [use class properties](#using-class-properties) to add data to the component's context. | ||
| This is a very useful and common pattern. | ||
| However, it is a bit verbose, especially when you have many properties and directly pass the properties to the template context. | ||
| To make this a little more convenient, we can use [`dataclasses`](https://docs.python.org/3.12/library/dataclasses.html#module-dataclasses). | ||
| ```python | ||
| # my_app/components.py | ||
| from dataclasses import dataclass, asdict | ||
| from laces.components import Component | ||
| @dataclass | ||
| class WelcomePanel(Component): | ||
| template_name = "my_app/components/welcome.html" | ||
| name: str | ||
| def get_context_data(self, parent_context): | ||
| return asdict(self) | ||
| ``` | ||
| With dataclasses we define the name and type of the properties we want to pass to the component in the class definition. | ||
| Then, we can use the `asdict` function to convert the dataclass instance to a dictionary that can be directly as the template context. | ||
| The `asdict` function only adds keys to the dictionary that were defined as the properties defined in the dataclass. | ||
| In the above example, the dictionary returned by `asdict` would only contain the `name` key. | ||
| It would not contain the `template_name` key, because that is set on the class with a value but without a type annotation. | ||
| If you were to add the type annotation, then the `template_name` key would also be included in the dictionary returned by `asdict`. | ||
| ### Custom constructor methods | ||
| When a component has many properties, it can be a pain to pass each property to the constructor individually. | ||
| This is especially true when the component is used in many places and the data preparation would need to be repeated in each use case. | ||
| Custom constructor methods can help with that. | ||
| In case of our `WelcomePanel` example, we might want to show some more user information, including a profile image and link to the user's profile page. | ||
| We can add a `classmethod` that takes the user object and returns an instance of the component with all the data needed to render the component. | ||
| We can also use this method to encapsulate the logic for generating additional data, such as the profile URL. | ||
| ```python | ||
| # my_app/components.py | ||
| from django import urls | ||
| from dataclasses import dataclass, asdict | ||
| from laces.components import Component | ||
| @dataclass | ||
| class WelcomePanel(Component): | ||
| template_name = "my_app/components/welcome.html" | ||
| first_name: str | ||
| last_name: str | ||
| profile_url: str | ||
| profile_image_url: str | ||
| @classmethod | ||
| def from_user(cls, user): | ||
| profile_url = urls.reverse("profile", kwargs={"pk": user.pk}) | ||
| return cls( | ||
| first_name=user.first_name, | ||
| last_name=user.last_name, | ||
| profile_url=profile_url, | ||
| profile_image_url=user.profile.image.url, | ||
| ) | ||
| def get_context_data(self, parent_context): | ||
| return asdict(self) | ||
| ``` | ||
| Now, we can instantiate the component in the view like so: | ||
| ```python | ||
| # my_app/views.py | ||
| from django.shortcuts import render | ||
| from my_app.components import WelcomePanel | ||
| def home(request): | ||
| welcome = WelcomePanel.from_user(request.user) | ||
| return render( | ||
| request, | ||
| "my_app/home.html", | ||
| {"welcome": welcome}, | ||
| ) | ||
| ``` | ||
| The constructor method allows us to keep our view very simple and clean as all the data preparation is encapsulated in the component. | ||
| As in the example above, custom constructor methods pair very well with the use of dataclasses, but they can of course also be used without them. | ||
| ## About Laces and components | ||
| ### Why "Laces"? | ||
| "Laces" is somewhat of a reference to the feature of tying data and templates together. | ||
| The components are also "self-rendering," which could be seen as "self-reliance," which relates to "bootstrapping." | ||
| And aren't "bootstraps" just a long kind of "(shoe)laces?" | ||
| Finally, it is a nod to [@mixxorz](https://github.com/mixxorz)'s fantastic [Slippers package](https://github.com/mixxorz/slippers), which also takes a component focused approach to improve the experience when working with Django templates, but in a quite different way. | ||
| ### Supported versions | ||
| - Python >= 3.8 | ||
| - Django >= 3.2 | ||
| ## Contributing | ||
@@ -292,3 +826,3 @@ | ||
| With your preferred virtualenv activated, install testing dependencies: | ||
| With your preferred virtualenv activated, install the development dependencies: | ||
@@ -299,3 +833,3 @@ #### Using pip | ||
| $ python -m pip install --upgrade pip>=21.3 | ||
| $ python -m pip install -e '.[testing]' -U | ||
| $ python -m pip install -e '.[dev]' -U | ||
| ``` | ||
@@ -316,4 +850,2 @@ | ||
| ```shell | ||
| # go to the project directory | ||
| $ cd laces | ||
| # initialize pre-commit | ||
@@ -337,3 +869,3 @@ $ pre-commit install | ||
| ```sh | ||
| $ tox -e python3.11-django4.2-wagtail5.1 | ||
| $ tox -e python3.11-django4.2 | ||
| ``` | ||
@@ -344,3 +876,3 @@ | ||
| ```sh | ||
| $ tox -e python3.11-django4.2-wagtail5.1-sqlite laces.tests.test_file.TestClass.test_method | ||
| $ tox -e python3.11-django4.2 laces.tests.test_file.TestClass.test_method | ||
| ``` | ||
@@ -356,8 +888,48 @@ | ||
| #### Testing with coverage | ||
| `tox` is configured to run tests with coverage. | ||
| The coverage report is combined for all environments. | ||
| This is done by using the `--append` flag when running coverage in `tox`. | ||
| This means it will also include previous results. | ||
| You can see the coverage report by running: | ||
| ```sh | ||
| $ coverage report | ||
| ``` | ||
| To get a clean report, you can run `coverage erase` before running `tox`. | ||
| #### Running tests without `tox` | ||
| If you want to run tests without `tox`, you can use the `testmanage.py` script. | ||
| This script is a wrapper around Django's `manage.py` and will run tests with the correct settings. | ||
| To make this work, you need to have the `testing` dependencies installed. | ||
| ```sh | ||
| $ python -m pip install -e '.[testing]' -U | ||
| ``` | ||
| Then you can run tests with: | ||
| ```sh | ||
| $ ./testmanage.py test | ||
| ```` | ||
| To run tests with coverage, use: | ||
| ```sh | ||
| $ coverage run ./testmanage.py test | ||
| ``` | ||
| ### Python version management | ||
| Tox will attempt to find installed Python versions on your machine. | ||
| If you use `pyenv` to manage multiple versions, you can tell `tox` to use those versions. | ||
| This working, is depended on [`virtualenv-pyenv`](https://pypi.org/project/virtualenv-pyenv/) (note: this is not `pyenv-virtualenv`) which is part of the CI dependencies (just like `tox` itself is). | ||
| To enable the use, you want to set the environment variable `VIRTUALENV_DISCOVERY=pyenv`. | ||
| To ensure that `tox` will find Python versions installed with `pyenv` you need [`virtualenv-pyenv`](https://pypi.org/project/virtualenv-pyenv/) (note: this is not `pyenv-virtualenv`). | ||
| `virtualenv-pyenv` is part of the development dependencies (just like `tox` itself). | ||
| Additionally, you have to set the environment variable `VIRTUALENV_DISCOVERY=pyenv`. | ||
@@ -369,4 +941,11 @@ ### Publishing | ||
| Before publishing a new release, make sure to update the changelog in `CHANGELOG.md` and the version number in `laces/__init__.py`. | ||
| To create a release, you need a Git tag. | ||
| The tag can either be created on the command line and pushed or in the "create release" interface on GitHub. | ||
| The tag name should be the version number prefixed with a `v` (e.g. `v0.1.0`). | ||
| Before publishing a new release, make sure to update | ||
| - the changelog in `CHANGELOG.md`, and | ||
| - the version number in `laces/__init__.py`. | ||
| To manually test publishing the package, you can use `flit`. | ||
@@ -373,0 +952,0 @@ Be sure to configure the `testpypi` repository in your `~/.pypirc` file according to the Flit [documentation](https://flit.pypa.io/en/stable/upload.html#controlling-package-uploads). |
+31
-5
@@ -22,2 +22,3 @@ [build-system] | ||
| "Programming Language :: Python :: 3.11", | ||
| "Programming Language :: Python :: 3.12", | ||
| "Framework :: Django", | ||
@@ -28,2 +29,3 @@ "Framework :: Django :: 3.2", | ||
| "Framework :: Django :: 4.2", | ||
| "Framework :: Django :: 5.0", | ||
| ] | ||
@@ -38,9 +40,25 @@ requires-python = ">=3.8" | ||
| "dj-database-url==2.1.0", | ||
| "pre-commit==3.4.0" | ||
| # Running tests with coverage inside the tox test environment. | ||
| "coverage==7.3.4", | ||
| ] | ||
| ci = [ | ||
| "tox==4.11.3", | ||
| "tox-gh-actions==3.1.3", | ||
| dev = [ | ||
| "tox==4.12.1", | ||
| "tox-gh-actions==3.2.0", | ||
| # Allow use of pyenv for virtual environments. To enable you need to set `VIRTUALENV_DISCOVERY=pyenv` in the shell. | ||
| "virtualenv-pyenv==0.4.0" | ||
| # This is useful to help tox find the correct python version when using pyenv. | ||
| "virtualenv-pyenv==0.4.0", | ||
| # This is to have coverage available in the development environment. | ||
| # It's not great that it's duplicated, but I can't find a way to make it work with tox otherwise. | ||
| "coverage==7.3.4", | ||
| # Linting etc. | ||
| "pre-commit==3.4.0", | ||
| "black==24.1.1", | ||
| "blacken-docs==1.16.0", | ||
| "isort==5.13.2", | ||
| "flake8==7.0.0", | ||
| "flake8-bugbear", | ||
| "flake8-comprehensions", | ||
| "mypy==1.7.1", | ||
| "django-stubs[compatible-mypy]==4.2.7", | ||
| "types-requests==2.31.0.20240125", | ||
| ] | ||
@@ -66,1 +84,9 @@ | ||
| ] | ||
| [tool.mypy] | ||
| files = ["."] | ||
| plugins = ["mypy_django_plugin.main"] | ||
| strict = true | ||
| [tool.django-stubs] | ||
| django_settings_module = "laces.test.settings" |
+645
-79
@@ -6,2 +6,3 @@ # Laces | ||
| [](https://github.com/tbrlpld/laces/actions/workflows/test.yml) | ||
| [](https://codecov.io/gh/tbrlpld/laces) | ||
@@ -12,25 +13,38 @@ --- | ||
| Laces components provide a simple way to combine data (in the form of Python objects) with the Django templates that are meant to render that data. | ||
| The components can then be simply rendered in any other template using the `{% component %}` template tag. | ||
| That parent template does not need to know anything about the component's template or data. | ||
| No need to receive, filter, restructure or pass any data to the component's template. | ||
| Just let the component render itself. | ||
| Working with objects that know how to render themselves as HTML elements is a common pattern found in complex Django applications (e.g. the [Wagtail](https://github.com/wagtail/wagtail) admin interface). | ||
| This package provides tools enable and support working with such objects, also known as "components". | ||
| Template and data are tied together (sorry, not sorry 😅) in the component, and they can be passed around together. | ||
| This becomes especially useful when components are nested — it allows us to avoid building the same nested structure twice (once in the data and again in the templates). | ||
| The APIs provided in the package have previously been discovered, developed and solidified in the Wagtail project. | ||
| Working with objects that know how to render themselves as HTML elements is a common pattern found in complex Django applications, such as the [Wagtail](https://github.com/wagtail/wagtail) admin interface. | ||
| The Wagtail admin is also where the APIs provided in this package have previously been discovered, developed and solidified. | ||
| The purpose of this package is to make these tools available to other Django projects outside the Wagtail ecosystem. | ||
| ## Links | ||
| - [Documentation](https://github.com/tbrlpld/laces/blob/main/README.md) | ||
| - [Getting started](#getting-started) | ||
| - [Installation](#installation) | ||
| - [Creating components](#creating-components) | ||
| - [Passing context to the component template](#passing-context-to-the-component-template) | ||
| - [Using components in other templates](#using-components-in-other-templates) | ||
| - [Adding JavaScript and CSS assets to a component](#adding-javascript-and-css-assets-to-a-component) | ||
| - [Patterns for using components](#patterns-for-using-components) | ||
| - [Nesting components](#nesting-components) | ||
| - [Nested groups of components](#nested-groups-of-components) | ||
| - [Container components](#container-components) | ||
| - [Using dataclasses](#using-dataclasses) | ||
| - [About Laces and components](#about-laces-and-components) | ||
| - [Contributing](#contributing) | ||
| - [Changelog](https://github.com/tbrlpld/laces/blob/main/CHANGELOG.md) | ||
| - [Contributing](https://github.com/tbrlpld/laces/blob/main/CONTRIBUTING.md) | ||
| - [Discussions](https://github.com/tbrlpld/laces/discussions) | ||
| - [Security](https://github.com/tbrlpld/laces/security) | ||
| ## Supported versions | ||
| ## Getting started | ||
| - Python >= 3.8 | ||
| - Django >= 3.2 | ||
| ### Installation | ||
| ## Installation | ||
| First, install with pip: | ||
@@ -51,8 +65,5 @@ ```sh | ||
| ## Usage | ||
| ### Creating components | ||
| The preferred way to create a component is to define a subclass of `laces.components.Component` and specify a `template_name` attribute on it. | ||
| The rendered template will then be used as the component's HTML representation: | ||
| The simplest way to create a component is to define a subclass of `laces.components.Component` and specify a `template_name` attribute on it. | ||
@@ -66,16 +77,51 @@ ```python | ||
| class WelcomePanel(Component): | ||
| template_name = "my_app/panels/welcome.html" | ||
| template_name = "my_app/components/welcome.html" | ||
| ``` | ||
| ```html+django | ||
| {# my_app/templates/my_app/components/welcome.html #} | ||
| my_welcome_panel = WelcomePanel() | ||
| <h1>Hello World!</h1> | ||
| ``` | ||
| With the above in place, you then instantiate the component (e.g., in a view) and pass it to another template for rendering. | ||
| ```python | ||
| # my_app/views.py | ||
| from django.shortcuts import render | ||
| from my_app.components import WelcomePanel | ||
| def home(request): | ||
| welcome = WelcomePanel() # <-- Instantiates the component | ||
| return render( | ||
| request, | ||
| "my_app/home.html", | ||
| {"welcome": welcome}, # <-- Passes the component to the view template | ||
| ) | ||
| ``` | ||
| In the view template, we `load` the `laces` tag library and use the `{% component %}` tag to render the component. | ||
| ```html+django | ||
| {# my_app/templates/my_app/panels/welcome.html #} | ||
| {# my_app/templates/my_app/home.html #} | ||
| <h1>Welcome to my app!</h1> | ||
| {% load laces %} | ||
| {% component welcome %} {# <-- Renders the component #} | ||
| ``` | ||
| For simple cases that don't require a template, the `render_html` method can be overridden instead: | ||
| That's it! | ||
| The component's template will be rendered right there in the view template. | ||
| Of course, this is a very simple example and not much more useful than using a simple `include`. | ||
| We will go into some more useful use cases below. | ||
| ### Without a template | ||
| Before we dig deeper into the component use cases, just a quick note that components don't have to have a template. | ||
| For simple cases that don't require a template, the `render_html` method can be overridden instead. | ||
| If the return value contains HTML, it should be marked as safe using `django.utils.html.format_html` or `django.utils.safestring.mark_safe`. | ||
| ```python | ||
@@ -90,10 +136,20 @@ # my_app/components.py | ||
| def render_html(self, parent_context): | ||
| return format_html("<h1>{}</h1>", "Welcome to my app!") | ||
| return format_html("<h1>Hello World!</h1>") | ||
| ``` | ||
| ### Passing context to the template | ||
| ### Passing context to the component template | ||
| The `get_context_data` method can be overridden to pass context variables to the template. | ||
| As with `render_html`, this receives the context dictionary from the calling template. | ||
| Now back to components with templates. | ||
| The example shown above with the static welcome message in the template is, of course, not very useful. | ||
| It seems more like an overcomplicated way to replace a simple `include`. | ||
| But, we rarely ever want to render templates with static content. | ||
| Usually, we want to pass some context variables to the template to be rendered. | ||
| This is where components start to become interesting. | ||
| The default implementation of `render_html` calls the component's `get_context_data` method to get the context variables to pass to the template. | ||
| The default implementation of `get_context_data` returns an empty dictionary. | ||
| To customize the context variables passed to the template, we can override `get_context_data`. | ||
| ```python | ||
@@ -106,20 +162,25 @@ # my_app/components.py | ||
| class WelcomePanel(Component): | ||
| template_name = "my_app/panels/welcome.html" | ||
| template_name = "my_app/components/welcome.html" | ||
| def get_context_data(self, parent_context): | ||
| context = super().get_context_data(parent_context) | ||
| context["username"] = parent_context["request"].user.username | ||
| return context | ||
| return {"name": "Alice"} | ||
| ``` | ||
| ```html+django | ||
| {# my_app/templates/my_app/panels/welcome.html #} | ||
| {# my_app/templates/my_app/components/welcome.html #} | ||
| <h1>Welcome to my app, {{ username }}!</h1> | ||
| <h1>Hello {{ name }}</h1> | ||
| ``` | ||
| ### Adding media definitions | ||
| With the above we are now rendering a welcome message with the name coming from the component's `get_context_data` method. | ||
| Nice. | ||
| But, still not very useful, as the name is still hardcoded — in the component method instead of the template, but hardcoded nonetheless. | ||
| Like Django form widgets, components can specify associated JavaScript and CSS resources using either an inner `Media` class or a dynamic `media` property. | ||
| #### Using class properties | ||
| When considering how to make the context of our components more useful, it's helpful to remember that components are just normal Python classes and objects. | ||
| So, you are basically free to get the context data into the component in any way you like. | ||
| For example, we can pass arguments to the constructor and use them in the component's methods, like `get_context_data`. | ||
| ```python | ||
@@ -132,14 +193,70 @@ # my_app/components.py | ||
| class WelcomePanel(Component): | ||
| template_name = "my_app/panels/welcome.html" | ||
| template_name = "my_app/components/welcome.html" | ||
| class Media: | ||
| css = {"all": ("my_app/css/welcome-panel.css",)} | ||
| def __init__(self, name): | ||
| self.name = name | ||
| def get_context_data(self, parent_context): | ||
| return {"name": self.name} | ||
| ``` | ||
| Nice, this is getting better. | ||
| Now we can pass the name to the component when we instantiate it and pass the component ready to be rendered to the view template. | ||
| ```python | ||
| # my_app/views.py | ||
| from django.shortcuts import render | ||
| from my_app.components import WelcomePanel | ||
| def home(request): | ||
| welcome = WelcomePanel(name="Alice") | ||
| return render( | ||
| request, | ||
| "my_app/home.html", | ||
| {"welcome": welcome}, | ||
| ) | ||
| ``` | ||
| So, as mentioned before, we can use the full power of Python classes and objects to provide context data to our components. | ||
| A couple more examples of how components can be used can be found [below](#patterns-for-using-components). | ||
| #### Using the parent context | ||
| You may have noticed in the above examples that the `render_html` and `get_context_data` methods take a `parent_context` argument. | ||
| This is the context of the template that is calling the component. | ||
| The `parent_context` is passed into the `render_html` method by the `{% component %}` template tag. | ||
| In the default implementation of the `render_html` method, the `parent_context` is then passed to the `get_context_data` method. | ||
| The default implementation of the `get_context_data` method, however, ignores the `parent_context` argument and returns an empty dictionary. | ||
| To make use of it, you will have to override the `get_context_data` method. | ||
| Relying on data from the parent context somewhat forgoes some of the benefits of components, which is tying the data and template together. | ||
| Especially for nested uses of components, you now require that the data in the right format is passed through all layers of templates again. | ||
| It is usually cleaner to provide all the data needed by the component directly to the component itself. | ||
| However, there may be cases where this is not possible or desirable. | ||
| For those cases, you have access to the parent context in the component's `get_context_data` method. | ||
| ```python | ||
| # my_app/components.py | ||
| from laces.components import Component | ||
| class WelcomePanel(Component): | ||
| template_name = "my_app/components/welcome.html" | ||
| def get_context_data(self, parent_context): | ||
| return {"name": parent_context["request"].user.first_name} | ||
| ``` | ||
| (Of course, this could have also been achieved by passing the request or user object to the component in the view, but this is just an example.) | ||
| ### Using components in other templates | ||
| The `laces` tag library provides a `{% component %}` tag for including components on a template. | ||
| This takes care of passing context variables from the calling template to the component (which would not be the case for a basic `{{ ... }}` variable tag). | ||
| As mentioned in the [first example](#creating-components), components are rendered in other templates using the `{% component %}` tag from the `laces` tag library. | ||
| For example, given the view passes an instance of `WelcomePanel` to the context of `my_app/welcome.html`. | ||
| Here is that example from above again, in which the view passes an instance of `WelcomePanel` to the context of `my_app/home.html`. | ||
@@ -154,10 +271,10 @@ ```python | ||
| def welcome_page(request): | ||
| panel = (WelcomePanel(),) | ||
| def home(request): | ||
| welcome = WelcomePanel() | ||
| return render( | ||
| request, | ||
| "my_app/welcome.html", | ||
| "my_app/home.html", | ||
| { | ||
| "panel": panel, | ||
| "welcome": welcome, | ||
| }, | ||
@@ -167,58 +284,152 @@ ) | ||
| The template `my_app/templates/my_app/welcome.html` could render the panel as follows: | ||
| Then, in the `my_app/templates/my_app/home.html` template we render the welcome panel component as follows: | ||
| ```html+django | ||
| {# my_app/templates/my_app/welcome.html #} | ||
| {# my_app/templates/my_app/home.html #} | ||
| {% load laces %} | ||
| {% component panel %} | ||
| {% component welcome %} | ||
| ``` | ||
| You can pass additional context variables to the component using the keyword `with`: | ||
| This is the basic usage of components and should cover most cases. | ||
| However, the `{% component %}` tag also supports some additional features. | ||
| Specifically, the keywords `with`, `only` and `as` are supported, similar to how they work with the [`{% include %}`](https://docs.djangoproject.com/en/5.0/ref/templates/builtins/#std-templatetag-include) tag. | ||
| #### Provide additional parent context variables with `with` | ||
| You can pass additional parent context variables to the component using the keyword `with`: | ||
| ```html+django | ||
| {% component panel with username=request.user.username %} | ||
| {% component welcome with name=request.user.first_name %} | ||
| ``` | ||
| To render the component with only the variables provided (and no others from the calling template's context), use `only`: | ||
| **Note**: These extra variables will be added to the `parent_context` which is passed to the component's `render_html` and `get_context_data` methods. | ||
| The default implementation of `get_context_data` ignores the `parent_context` argument, so you will have to override it to make use of the extra variables. | ||
| For more information see the above section on the [parent context](#using-the-parent-context). | ||
| #### Limit the parent context variables with `only` | ||
| To limit the parent context variables passed to the component to only those variables provided by the `with` keyword (and no others from the calling template's context), use `only`: | ||
| ```html+django | ||
| {% component panel with username=request.user.username only %} | ||
| {% component welcome with name=request.user.first_name only %} | ||
| ``` | ||
| **Note**: Both, `with` and `only`, only affect the `parent_context` which is passed to the component's `render_html` and `get_context_data` methods. They do not have any direct effect on actual context that is passed to the component's template. E.g. if the component's `get_context_data` method returns a dictionary which always contains a key `foo`, then that key will be available in the component's template, regardless of whether `only` was used or not. | ||
| #### Store the rendered output in a variable with `as` | ||
| To store the component's rendered output in a variable rather than outputting it immediately, use `as` followed by the variable name: | ||
| ```html+django | ||
| {% component panel as panel_html %} | ||
| {% component welcome as welcome_html %} | ||
| {{ panel_html }} | ||
| {{ welcome_html }} | ||
| ``` | ||
| Note that it is your template's responsibility to output any media declarations defined on the components. | ||
| This can be done by constructing a media object for the whole page within the view, passing this to the template, and outputting it via `media.js` and `media.css`. | ||
| ### Adding JavaScript and CSS assets to a component | ||
| Like Django form widgets, components can specify associated JavaScript and CSS assets. | ||
| The assets for a component can be specified in the same way that [Django form assets are defined](https://docs.djangoproject.com/en/5.0/topics/forms/media). | ||
| This can be achieved using either an inner `Media` class or a dynamic `media` property. | ||
| An inner `Media` class definition looks like this: | ||
| ```python | ||
| # my_app/components.py | ||
| from laces.components import Component | ||
| class WelcomePanel(Component): | ||
| template_name = "my_app/components/welcome.html" | ||
| class Media: | ||
| css = {"all": ("my_app/css/welcome-panel.css",)} | ||
| ``` | ||
| The more dynamic definition via a `media` property looks like this: | ||
| ```python | ||
| # my_app/components.py | ||
| from django.forms import Media | ||
| from laces.components import Component | ||
| class WelcomePanel(Component): | ||
| template_name = "my_app/components/welcome.html" | ||
| @property | ||
| def media(self): | ||
| return Media(css={"all": ("my_app/css/welcome-panel.css",)}) | ||
| ``` | ||
| **Note**: | ||
| It is your template's responsibility to output any media declarations defined on the components. | ||
| #### Outputting component media in templates | ||
| Once you have defined the assets on the component in one of the two ways above, you can output them in your templates. | ||
| This, again, works in the same way as it does for Django form widgets. | ||
| The component instance will have a `media` property which returns an instance of the `django.forms.Media` class. | ||
| This is the case, even if you used the nested `Media` class to define the assets. | ||
| The [string representation of a `Media` objects](https://docs.djangoproject.com/en/5.0/topics/forms/media#s-media-objects) are the HTML declarations to include the assets. | ||
| In the example home template from above, we can output the component's media declarations like so: | ||
| ```html+django | ||
| {# my_app/templates/my_app/home.html #} | ||
| {% load laces %} | ||
| <head> | ||
| {{ welcome.media }} | ||
| <head> | ||
| <body> | ||
| {% component welcome %} | ||
| </body> | ||
| ``` | ||
| #### Combining media with `MediaContainer` | ||
| When you have many components in a page, it can be cumbersome to output the media declarations for each component individually. | ||
| To make that process a bit easier, Laces provides a `MediaContainer` class. | ||
| The `MediaContainer` class is a subclass of Python's built-in `list` class which combines the `media` of all it's members. | ||
| In a view we can create a `MediaContainer` instance containing several media-defining components and pass it to the view template. | ||
| ```python | ||
| # my_app/views.py | ||
| from django.forms import Media | ||
| from django.shortcuts import render | ||
| from laces.components import MediaContainer | ||
| from my_app.components import WelcomePanel | ||
| from my_app.components import ( | ||
| Dashboard, | ||
| Footer, | ||
| Header, | ||
| Sidebar, | ||
| WelcomePanel, | ||
| ) | ||
| def welcome_page(request): | ||
| panels = [ | ||
| WelcomePanel(), | ||
| ] | ||
| def home(request): | ||
| components = MediaContainer( | ||
| [ | ||
| Header(), | ||
| Sidebar(), | ||
| WelcomePanel(), | ||
| Dashboard(), | ||
| Footer(), | ||
| ] | ||
| ) | ||
| media = Media() | ||
| for panel in panels: | ||
| media += panel.media | ||
| render( | ||
| return render( | ||
| request, | ||
| "my_app/welcome.html", | ||
| "my_app/home.html", | ||
| { | ||
| "panels": panels, | ||
| "media": media, | ||
| "components": components, | ||
| }, | ||
@@ -228,5 +439,6 @@ ) | ||
| Then, in the view template, we can output the media declarations for all components in the container at once. | ||
| ```html+django | ||
| {# my_app/templates/my_app/welcome.html #} | ||
| {# my_app/templates/my_app/home.html #} | ||
@@ -236,12 +448,321 @@ {% load laces %} | ||
| <head> | ||
| {{ media.js }} | ||
| {{ media.css }} | ||
| {{ components.media }} | ||
| <head> | ||
| <body> | ||
| {% for component in components %} | ||
| {% component component %} | ||
| {% endfor %} | ||
| </body> | ||
| ``` | ||
| This will output a combined media declaration for all components in the container. | ||
| The combination of the media declarations follows the behaviour outlined in the [Django documentation](https://docs.djangoproject.com/en/5.0/topics/forms/media/#combining-media-objects). | ||
| **Note**: | ||
| The use of `MediaContainer` is not limited to contain components. | ||
| It can be used to combine the `media` properties of any kind of objects that have a `media` property. | ||
| ## Patterns for using components | ||
| Below, we want to show a few more examples of how components can be used that were not covered in the ["Getting started" section](#getting-started) above. | ||
| ### Nesting components | ||
| The combination of data and template that components provide becomes especially useful when components are nested. | ||
| ```python | ||
| # my_app/components.py | ||
| from laces.components import Component | ||
| class WelcomePanel(Component): ... | ||
| class Dashboard(Component): | ||
| template_name = "my_app/components/dashboard.html" | ||
| def __init__(self, user): | ||
| self.welcome = WelcomePanel(name=user.first_name) | ||
| ... | ||
| def get_context_data(self, parent_context): | ||
| return {"welcome": self.welcome} | ||
| ``` | ||
| The template of the "parent" component does not need to know anything about the "child" component, except for which template variable is a component. | ||
| The child component already contains the data it needs and knows which template to use to render that data. | ||
| ```html+django | ||
| {# my_app/templates/my_app/components/dashboard.html #} | ||
| {% load laces %} | ||
| <div class="dashboard"> | ||
| {% component welcome %} | ||
| ... | ||
| </div> | ||
| ``` | ||
| The nesting also provides us with a nice data structure we can test. | ||
| ```python | ||
| dashboard = Dashboard(user=request.user) | ||
| assert dashboard.welcome.name == request.user.first_name | ||
| ``` | ||
| ### Nested groups of components | ||
| The nesting of components is not limited to single instances. | ||
| We can also nest groups of components. | ||
| ```python | ||
| # my_app/components.py | ||
| from laces.components import Component | ||
| class WelcomePanel(Component): ... | ||
| class UsagePanel(Component): ... | ||
| class TeamPanel(Component): ... | ||
| class Dashboard(Component): | ||
| template_name = "my_app/components/dashboard.html" | ||
| def __init__(self, user): | ||
| self.panels = [ | ||
| WelcomePanel(name=user.first_name), | ||
| UsagePanel(user=user), | ||
| TeamPanel(groups=user.groups.all()), | ||
| ] | ||
| ... | ||
| def get_context_data(self, parent_context): | ||
| return {"panels": self.panels} | ||
| ``` | ||
| ```html+django | ||
| {# my_app/templates/my_app/components/dashboard.html #} | ||
| {% load laces %} | ||
| <div class="dashboard"> | ||
| {% for panel in panels %} | ||
| {% component panel %} | ||
| {% endfor %} | ||
| ... | ||
| </div> | ||
| ``` | ||
| ### Container components | ||
| The [above example](#nested-groups-of-components) is relatively static. | ||
| The `Dashboard` component always contains the same panels. | ||
| You could also imagine passing the child components in through the constructor. | ||
| This would make your component into a dynamic container component. | ||
| ```python | ||
| # my_app/components.py | ||
| from laces.components import Component | ||
| class Section(Component): | ||
| template_name = "my_app/components/section.html" | ||
| def __init__(self, children: list[Component]): | ||
| self.children = children | ||
| ... | ||
| def get_context_data(self, parent_context): | ||
| return {"children": self.children} | ||
| class Heading(Component): ... | ||
| class Paragraph(Component): ... | ||
| class Image(Component): ... | ||
| ``` | ||
| ```html+django | ||
| {# my_app/templates/my_app/components/section.html #} | ||
| {% load laces %} | ||
| <section> | ||
| {% for child in children %} | ||
| {% component child %} | ||
| {% endfor %} | ||
| </section> | ||
| ``` | ||
| The above `Section` component can take any kind of component as children. | ||
| The only thing that `Section` requires is that the children can be rendered with the `{% component %}` tag (which all components do). | ||
| In the view, we can now instantiate the `Section` component with any children we want. | ||
| ```python | ||
| # my_app/views.py | ||
| from django.shortcuts import render | ||
| from my_app.components import ( | ||
| Heading, | ||
| Image, | ||
| Paragraph, | ||
| Section, | ||
| ) | ||
| def home(request): | ||
| content = Section( | ||
| children=[ | ||
| Heading(...), | ||
| Paragraph(...), | ||
| Image(...), | ||
| ] | ||
| ) | ||
| return render( | ||
| request, | ||
| "my_app/home.html", | ||
| {"content": content}, | ||
| ) | ||
| ``` | ||
| ```html+django | ||
| {# my_app/templates/my_app/home.html #} | ||
| {% load laces %} | ||
| <body> | ||
| {% component content %} | ||
| ... | ||
| </body> | ||
| ``` | ||
| ### Using dataclasses | ||
| Above, we showed how to [use class properties](#using-class-properties) to add data to the component's context. | ||
| This is a very useful and common pattern. | ||
| However, it is a bit verbose, especially when you have many properties and directly pass the properties to the template context. | ||
| To make this a little more convenient, we can use [`dataclasses`](https://docs.python.org/3.12/library/dataclasses.html#module-dataclasses). | ||
| ```python | ||
| # my_app/components.py | ||
| from dataclasses import dataclass, asdict | ||
| from laces.components import Component | ||
| @dataclass | ||
| class WelcomePanel(Component): | ||
| template_name = "my_app/components/welcome.html" | ||
| name: str | ||
| def get_context_data(self, parent_context): | ||
| return asdict(self) | ||
| ``` | ||
| With dataclasses we define the name and type of the properties we want to pass to the component in the class definition. | ||
| Then, we can use the `asdict` function to convert the dataclass instance to a dictionary that can be directly as the template context. | ||
| The `asdict` function only adds keys to the dictionary that were defined as the properties defined in the dataclass. | ||
| In the above example, the dictionary returned by `asdict` would only contain the `name` key. | ||
| It would not contain the `template_name` key, because that is set on the class with a value but without a type annotation. | ||
| If you were to add the type annotation, then the `template_name` key would also be included in the dictionary returned by `asdict`. | ||
| ### Custom constructor methods | ||
| When a component has many properties, it can be a pain to pass each property to the constructor individually. | ||
| This is especially true when the component is used in many places and the data preparation would need to be repeated in each use case. | ||
| Custom constructor methods can help with that. | ||
| In case of our `WelcomePanel` example, we might want to show some more user information, including a profile image and link to the user's profile page. | ||
| We can add a `classmethod` that takes the user object and returns an instance of the component with all the data needed to render the component. | ||
| We can also use this method to encapsulate the logic for generating additional data, such as the profile URL. | ||
| ```python | ||
| # my_app/components.py | ||
| from django import urls | ||
| from dataclasses import dataclass, asdict | ||
| from laces.components import Component | ||
| @dataclass | ||
| class WelcomePanel(Component): | ||
| template_name = "my_app/components/welcome.html" | ||
| first_name: str | ||
| last_name: str | ||
| profile_url: str | ||
| profile_image_url: str | ||
| @classmethod | ||
| def from_user(cls, user): | ||
| profile_url = urls.reverse("profile", kwargs={"pk": user.pk}) | ||
| return cls( | ||
| first_name=user.first_name, | ||
| last_name=user.last_name, | ||
| profile_url=profile_url, | ||
| profile_image_url=user.profile.image.url, | ||
| ) | ||
| def get_context_data(self, parent_context): | ||
| return asdict(self) | ||
| ``` | ||
| Now, we can instantiate the component in the view like so: | ||
| ```python | ||
| # my_app/views.py | ||
| from django.shortcuts import render | ||
| from my_app.components import WelcomePanel | ||
| def home(request): | ||
| welcome = WelcomePanel.from_user(request.user) | ||
| return render( | ||
| request, | ||
| "my_app/home.html", | ||
| {"welcome": welcome}, | ||
| ) | ||
| ``` | ||
| The constructor method allows us to keep our view very simple and clean as all the data preparation is encapsulated in the component. | ||
| As in the example above, custom constructor methods pair very well with the use of dataclasses, but they can of course also be used without them. | ||
| ## About Laces and components | ||
| ### Why "Laces"? | ||
| "Laces" is somewhat of a reference to the feature of tying data and templates together. | ||
| The components are also "self-rendering," which could be seen as "self-reliance," which relates to "bootstrapping." | ||
| And aren't "bootstraps" just a long kind of "(shoe)laces?" | ||
| Finally, it is a nod to [@mixxorz](https://github.com/mixxorz)'s fantastic [Slippers package](https://github.com/mixxorz/slippers), which also takes a component focused approach to improve the experience when working with Django templates, but in a quite different way. | ||
| ### Supported versions | ||
| - Python >= 3.8 | ||
| - Django >= 3.2 | ||
| ## Contributing | ||
@@ -258,3 +779,3 @@ | ||
| With your preferred virtualenv activated, install testing dependencies: | ||
| With your preferred virtualenv activated, install the development dependencies: | ||
@@ -265,3 +786,3 @@ #### Using pip | ||
| $ python -m pip install --upgrade pip>=21.3 | ||
| $ python -m pip install -e '.[testing]' -U | ||
| $ python -m pip install -e '.[dev]' -U | ||
| ``` | ||
@@ -282,4 +803,2 @@ | ||
| ```shell | ||
| # go to the project directory | ||
| $ cd laces | ||
| # initialize pre-commit | ||
@@ -303,3 +822,3 @@ $ pre-commit install | ||
| ```sh | ||
| $ tox -e python3.11-django4.2-wagtail5.1 | ||
| $ tox -e python3.11-django4.2 | ||
| ``` | ||
@@ -310,3 +829,3 @@ | ||
| ```sh | ||
| $ tox -e python3.11-django4.2-wagtail5.1-sqlite laces.tests.test_file.TestClass.test_method | ||
| $ tox -e python3.11-django4.2 laces.tests.test_file.TestClass.test_method | ||
| ``` | ||
@@ -322,8 +841,48 @@ | ||
| #### Testing with coverage | ||
| `tox` is configured to run tests with coverage. | ||
| The coverage report is combined for all environments. | ||
| This is done by using the `--append` flag when running coverage in `tox`. | ||
| This means it will also include previous results. | ||
| You can see the coverage report by running: | ||
| ```sh | ||
| $ coverage report | ||
| ``` | ||
| To get a clean report, you can run `coverage erase` before running `tox`. | ||
| #### Running tests without `tox` | ||
| If you want to run tests without `tox`, you can use the `testmanage.py` script. | ||
| This script is a wrapper around Django's `manage.py` and will run tests with the correct settings. | ||
| To make this work, you need to have the `testing` dependencies installed. | ||
| ```sh | ||
| $ python -m pip install -e '.[testing]' -U | ||
| ``` | ||
| Then you can run tests with: | ||
| ```sh | ||
| $ ./testmanage.py test | ||
| ```` | ||
| To run tests with coverage, use: | ||
| ```sh | ||
| $ coverage run ./testmanage.py test | ||
| ``` | ||
| ### Python version management | ||
| Tox will attempt to find installed Python versions on your machine. | ||
| If you use `pyenv` to manage multiple versions, you can tell `tox` to use those versions. | ||
| This working, is depended on [`virtualenv-pyenv`](https://pypi.org/project/virtualenv-pyenv/) (note: this is not `pyenv-virtualenv`) which is part of the CI dependencies (just like `tox` itself is). | ||
| To enable the use, you want to set the environment variable `VIRTUALENV_DISCOVERY=pyenv`. | ||
| To ensure that `tox` will find Python versions installed with `pyenv` you need [`virtualenv-pyenv`](https://pypi.org/project/virtualenv-pyenv/) (note: this is not `pyenv-virtualenv`). | ||
| `virtualenv-pyenv` is part of the development dependencies (just like `tox` itself). | ||
| Additionally, you have to set the environment variable `VIRTUALENV_DISCOVERY=pyenv`. | ||
@@ -335,4 +894,11 @@ ### Publishing | ||
| Before publishing a new release, make sure to update the changelog in `CHANGELOG.md` and the version number in `laces/__init__.py`. | ||
| To create a release, you need a Git tag. | ||
| The tag can either be created on the command line and pushed or in the "create release" interface on GitHub. | ||
| The tag name should be the version number prefixed with a `v` (e.g. `v0.1.0`). | ||
| Before publishing a new release, make sure to update | ||
| - the changelog in `CHANGELOG.md`, and | ||
| - the version number in `laces/__init__.py`. | ||
| To manually test publishing the package, you can use `flit`. | ||
@@ -339,0 +905,0 @@ Be sure to configure the `testpypi` repository in your `~/.pypirc` file according to the Flit [documentation](https://flit.pypa.io/en/stable/upload.html#controlling-package-uploads). |
Alert delta unavailable
Currently unable to show alert delta for PyPI packages.
102142
202.07%21
16.67%819
262.39%