From c21ac5902e541296f52f287116df3c357e90b698 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Tue, 21 Nov 2023 09:16:46 -0800 Subject: [PATCH 01/14] Fix app names --- laces/apps.py | 2 +- laces/test/apps.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/laces/apps.py b/laces/apps.py index e7a190b..bc37fbd 100644 --- a/laces/apps.py +++ b/laces/apps.py @@ -4,4 +4,4 @@ class LacesAppConfig(AppConfig): label = "laces" name = "laces" - verbose_name = "Wagtail laces" + verbose_name = "Laces" diff --git a/laces/test/apps.py b/laces/test/apps.py index a346248..c06b941 100644 --- a/laces/test/apps.py +++ b/laces/test/apps.py @@ -4,4 +4,4 @@ class LacesTestAppConfig(AppConfig): label = "laces_test" name = "laces.test" - verbose_name = "Wagtail laces tests" + verbose_name = "Laces test app" From 59c16aa8ff11be592e836fd27e24af73e80184b9 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Tue, 21 Nov 2023 09:17:09 -0800 Subject: [PATCH 02/14] Copy over templatetag tests from Wagtail Source: https://github.com/wagtail/wagtail/blob/094834909d5c4b48517fd2703eb1f6d386572ffa/wagtail/admin/tests/test_templatetags.py#L225-L305 The only changes so far are to fix import issues. --- laces/components.py | 2 + laces/templatetags/__init__.py | 0 laces/templatetags/laces.py | 9 +++ laces/tests/__init__.py | 0 laces/tests/test_templatetags/__init__.py | 0 laces/tests/test_templatetags/test_laces.py | 84 +++++++++++++++++++++ 6 files changed, 95 insertions(+) create mode 100644 laces/components.py create mode 100644 laces/templatetags/__init__.py create mode 100644 laces/templatetags/laces.py create mode 100644 laces/tests/__init__.py create mode 100644 laces/tests/test_templatetags/__init__.py create mode 100644 laces/tests/test_templatetags/test_laces.py diff --git a/laces/components.py b/laces/components.py new file mode 100644 index 0000000..964f527 --- /dev/null +++ b/laces/components.py @@ -0,0 +1,2 @@ +class Component: + pass diff --git a/laces/templatetags/__init__.py b/laces/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/laces/templatetags/laces.py b/laces/templatetags/laces.py new file mode 100644 index 0000000..ff9e801 --- /dev/null +++ b/laces/templatetags/laces.py @@ -0,0 +1,9 @@ +from django.template import library + + +register = library.Library() + + +@register.simple_tag() +def component(): + pass diff --git a/laces/tests/__init__.py b/laces/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/laces/tests/test_templatetags/__init__.py b/laces/tests/test_templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/laces/tests/test_templatetags/test_laces.py b/laces/tests/test_templatetags/test_laces.py new file mode 100644 index 0000000..8caa642 --- /dev/null +++ b/laces/tests/test_templatetags/test_laces.py @@ -0,0 +1,84 @@ +from django.template import Context, Template +from django.test import SimpleTestCase +from django.utils.html import format_html + +from laces.components import Component + + +class TestComponentTag(SimpleTestCase): + def test_passing_context_to_component(self): + class MyComponent(Component): + def render_html(self, parent_context): + return format_html( + "

{} was here

", parent_context.get("first_name", "nobody") + ) + + template = Template( + "{% load laces %}{% with first_name='Kilroy' %}{% component my_component %}{% endwith %}" + ) + html = template.render(Context({"my_component": MyComponent()})) + self.assertEqual(html, "

Kilroy was here

") + + template = Template( + "{% load laces %}{% component my_component with first_name='Kilroy' %}" + ) + html = template.render(Context({"my_component": MyComponent()})) + self.assertEqual(html, "

Kilroy was here

") + + template = Template( + "{% load laces %}{% with first_name='Kilroy' %}{% component my_component with surname='Silk' only %}{% endwith %}" + ) + html = template.render(Context({"my_component": MyComponent()})) + self.assertEqual(html, "

nobody was here

") + + def test_fallback_render_method(self): + class MyComponent(Component): + def render_html(self, parent_context): + return format_html("

I am a component

") + + class MyNonComponent: + def render(self): + return format_html("

I am not a component

") + + template = Template("{% load laces %}{% component my_component %}") + html = template.render(Context({"my_component": MyComponent()})) + self.assertEqual(html, "

I am a component

") + with self.assertRaises(ValueError): + template.render(Context({"my_component": MyNonComponent()})) + + template = Template( + "{% load laces %}{% component my_component fallback_render_method=True %}" + ) + html = template.render(Context({"my_component": MyComponent()})) + self.assertEqual(html, "

I am a component

") + html = template.render(Context({"my_component": MyNonComponent()})) + self.assertEqual(html, "

I am not a component

") + + 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<" + + template = Template("{% load laces %}

{% component my_component %}

") + html = template.render(Context({"my_component": MyComponent()})) + self.assertEqual( + html, "

Look, I'm running with scissors! 8< 8< 8<

" + ) + + def test_error_on_rendering_non_component(self): + template = Template("{% load laces %}

{% component my_component %}

") + + with self.assertRaises(ValueError) as cm: + template.render(Context({"my_component": "hello"})) + self.assertEqual(str(cm.exception), "Cannot render 'hello' as a component") + + def test_render_as_var(self): + class MyComponent(Component): + def render_html(self, parent_context): + return format_html("

I am a component

") + + template = Template( + "{% load laces %}{% component my_component as my_html %}The result was: {{ my_html }}" + ) + html = template.render(Context({"my_component": MyComponent()})) + self.assertEqual(html, "The result was:

I am a component

") From fec6a2573e63e2f2bed200f432cb757bc9525426 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Tue, 21 Nov 2023 09:42:59 -0800 Subject: [PATCH 03/14] Copy over templatetag implementation from Wagtail Source: https://github.com/wagtail/wagtail/blob/094834909d5c4b48517fd2703eb1f6d386572ffa/wagtail/admin/templatetags/wagtailadmin_tags.py#L937-L1037 --- laces/templatetags/laces.py | 110 ++++++++++++++++++++++++++++++++++-- 1 file changed, 105 insertions(+), 5 deletions(-) diff --git a/laces/templatetags/laces.py b/laces/templatetags/laces.py index ff9e801..d58296a 100644 --- a/laces/templatetags/laces.py +++ b/laces/templatetags/laces.py @@ -1,9 +1,109 @@ -from django.template import library +from django import template +from django.template.base import token_kwargs +from django.template.defaultfilters import conditional_escape -register = library.Library() +register = template.library.Library() -@register.simple_tag() -def component(): - pass +class ComponentNode(template.Node): + def __init__( + self, + component, + extra_context=None, + isolated_context=False, + fallback_render_method=None, + target_var=None, + ): + self.component = component + self.extra_context = extra_context or {} + self.isolated_context = isolated_context + self.fallback_render_method = fallback_render_method + self.target_var = target_var + + 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. + + component = self.component.resolve(context) + + if self.fallback_render_method: + fallback_render_method = self.fallback_render_method.resolve(context) + else: + fallback_render_method = False + + values = { + name: var.resolve(context) for name, var in self.extra_context.items() + } + + if hasattr(component, "render_html"): + if self.isolated_context: + html = component.render_html(context.new(values)) + else: + with context.push(**values): + html = component.render_html(context) + elif fallback_render_method and hasattr(component, "render"): + html = component.render() + else: + raise ValueError(f"Cannot render {component!r} as a component") + + if self.target_var: + context[self.target_var] = html + return "" + else: + if context.autoescape: + html = conditional_escape(html) + return html + + +@register.tag(name="component") +def component(parser, token): + bits = token.split_contents()[1:] + if not bits: + raise template.TemplateSyntaxError( + "'component' tag requires at least one argument, the component object" + ) + + component = parser.compile_filter(bits.pop(0)) + + # the only valid keyword argument immediately following the component + # is fallback_render_method + flags = token_kwargs(bits, parser) + fallback_render_method = flags.pop("fallback_render_method", None) + if flags: + raise template.TemplateSyntaxError( + "'component' tag only accepts 'fallback_render_method' as a keyword argument" + ) + + extra_context = {} + isolated_context = False + target_var = None + + while bits: + bit = bits.pop(0) + if bit == "with": + extra_context = token_kwargs(bits, parser) + elif bit == "only": + isolated_context = True + elif bit == "as": + try: + target_var = bits.pop(0) + except IndexError: + raise template.TemplateSyntaxError( + "'component' tag with 'as' must be followed by a variable name" + ) + else: + raise template.TemplateSyntaxError( + "'component' tag received an unknown argument: %r" % bit + ) + + return ComponentNode( + component, + extra_context=extra_context, + isolated_context=isolated_context, + fallback_render_method=fallback_render_method, + target_var=target_var, + ) From 87e4f3dff443f7e8f20e01720be8473e1307b6d9 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Tue, 21 Nov 2023 09:59:36 -0800 Subject: [PATCH 04/14] Copy component class definition from Wagtail --- laces/components.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/laces/components.py b/laces/components.py index 964f527..c08d27a 100644 --- a/laces/components.py +++ b/laces/components.py @@ -1,2 +1,22 @@ -class Component: - pass +from typing import Any, MutableMapping + +from django.forms import MediaDefiningClass +from django.template import Context +from django.template.loader import get_template + + +class Component(metaclass=MediaDefiningClass): + def get_context_data( + self, parent_context: MutableMapping[str, Any] + ) -> MutableMapping[str, Any]: + return {} + + def render_html(self, parent_context: MutableMapping[str, Any] = None) -> str: + if parent_context is None: + parent_context = Context() + context_data = self.get_context_data(parent_context) + if context_data is None: + raise TypeError("Expected a dict from get_context_data, got None") + + template = get_template(self.template_name) + return template.render(context_data) From fb58d42598b27a98c9bbbfc9e92258bf7c3c3bbf Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Tue, 21 Nov 2023 14:07:13 -0800 Subject: [PATCH 05/14] Add docstrings and references to sources --- laces/components.py | 7 +++++++ laces/templatetags/laces.py | 13 +++++++++++++ laces/tests/test_templatetags/test_laces.py | 7 +++++++ 3 files changed, 27 insertions(+) diff --git a/laces/components.py b/laces/components.py index c08d27a..e457025 100644 --- a/laces/components.py +++ b/laces/components.py @@ -6,6 +6,13 @@ class Component(metaclass=MediaDefiningClass): + """ + A class that knows how to render itself. + + Extracted from Wagtail. See: + https://github.com/wagtail/wagtail/blob/094834909d5c4b48517fd2703eb1f6d386572ffa/wagtail/admin/ui/components.py#L8-L22 # noqa: E501 + """ + def get_context_data( self, parent_context: MutableMapping[str, Any] ) -> MutableMapping[str, Any]: diff --git a/laces/templatetags/laces.py b/laces/templatetags/laces.py index d58296a..761960f 100644 --- a/laces/templatetags/laces.py +++ b/laces/templatetags/laces.py @@ -7,6 +7,13 @@ class ComponentNode(template.Node): + """ + Template node to render a component. + + Extracted from Wagtail. See: + https://github.com/wagtail/wagtail/blob/094834909d5c4b48517fd2703eb1f6d386572ffa/wagtail/admin/templatetags/wagtailadmin_tags.py#L937-L987 # noqa: E501 + """ + def __init__( self, component, @@ -61,6 +68,12 @@ def render(self, context: template.Context) -> str: @register.tag(name="component") def component(parser, token): + """ + Template tag to render a component via ComponentNode. + + Extracted from Wagtail. See: + https://github.com/wagtail/wagtail/blob/094834909d5c4b48517fd2703eb1f6d386572ffa/wagtail/admin/templatetags/wagtailadmin_tags.py#L990-L1037 # noqa: E501 + """ bits = token.split_contents()[1:] if not bits: raise template.TemplateSyntaxError( diff --git a/laces/tests/test_templatetags/test_laces.py b/laces/tests/test_templatetags/test_laces.py index 8caa642..cc29047 100644 --- a/laces/tests/test_templatetags/test_laces.py +++ b/laces/tests/test_templatetags/test_laces.py @@ -6,6 +6,13 @@ class TestComponentTag(SimpleTestCase): + """ + Test for the `component` template tag. + + Extracted from Wagtail. See: + https://github.com/wagtail/wagtail/blob/main/wagtail/admin/tests/test_templatetags.py#L225-L305 # noqa: E501 + """ + def test_passing_context_to_component(self): class MyComponent(Component): def render_html(self, parent_context): From 778f40f6cb2859501c911f9e49d195d57e2c1432 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Tue, 21 Nov 2023 14:19:33 -0800 Subject: [PATCH 06/14] Add example view for manual test --- laces/test/home/__init__.py | 0 laces/test/home/templates/home/home.html | 1 + laces/test/home/views.py | 5 +++++ laces/test/settings.py | 1 + laces/test/urls.py | 3 +++ 5 files changed, 10 insertions(+) create mode 100644 laces/test/home/__init__.py create mode 100644 laces/test/home/templates/home/home.html create mode 100644 laces/test/home/views.py diff --git a/laces/test/home/__init__.py b/laces/test/home/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/laces/test/home/templates/home/home.html b/laces/test/home/templates/home/home.html new file mode 100644 index 0000000..f95bef3 --- /dev/null +++ b/laces/test/home/templates/home/home.html @@ -0,0 +1 @@ +

Home

diff --git a/laces/test/home/views.py b/laces/test/home/views.py new file mode 100644 index 0000000..6eb7054 --- /dev/null +++ b/laces/test/home/views.py @@ -0,0 +1,5 @@ +from django.shortcuts import render + + +def home(request): + return render(request, "home/home.html") diff --git a/laces/test/settings.py b/laces/test/settings.py index 0e2af9d..db49348 100644 --- a/laces/test/settings.py +++ b/laces/test/settings.py @@ -35,6 +35,7 @@ INSTALLED_APPS = [ "laces", "laces.test", + "laces.test.home", "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", diff --git a/laces/test/urls.py b/laces/test/urls.py index f690a95..f586e93 100644 --- a/laces/test/urls.py +++ b/laces/test/urls.py @@ -1,7 +1,10 @@ from django.contrib import admin from django.urls import path +from laces.test.home.views import home + urlpatterns = [ + path("", home), path("django-admin/", admin.site.urls), ] From 164667dc4a7740006f93a7339b0f9fcf738ce37f Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Tue, 21 Nov 2023 14:42:54 -0800 Subject: [PATCH 07/14] Add example component and usage to test app --- laces/test/components/__init__.py | 1 + laces/test/components/heading.py | 5 +++++ laces/test/components/templates/components/heading.html | 1 + laces/test/home/templates/home/home.html | 3 ++- laces/test/home/views.py | 9 ++++++++- laces/test/settings.py | 1 + 6 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 laces/test/components/__init__.py create mode 100644 laces/test/components/heading.py create mode 100644 laces/test/components/templates/components/heading.html diff --git a/laces/test/components/__init__.py b/laces/test/components/__init__.py new file mode 100644 index 0000000..45f4f4c --- /dev/null +++ b/laces/test/components/__init__.py @@ -0,0 +1 @@ +from .heading import * # noqa: F401, F403 diff --git a/laces/test/components/heading.py b/laces/test/components/heading.py new file mode 100644 index 0000000..23f2557 --- /dev/null +++ b/laces/test/components/heading.py @@ -0,0 +1,5 @@ +from laces.components import Component + + +class Heading(Component): + template_name = "components/heading.html" diff --git a/laces/test/components/templates/components/heading.html b/laces/test/components/templates/components/heading.html new file mode 100644 index 0000000..e583759 --- /dev/null +++ b/laces/test/components/templates/components/heading.html @@ -0,0 +1 @@ +

Test

diff --git a/laces/test/home/templates/home/home.html b/laces/test/home/templates/home/home.html index f95bef3..1a00229 100644 --- a/laces/test/home/templates/home/home.html +++ b/laces/test/home/templates/home/home.html @@ -1 +1,2 @@ -

Home

+{% load laces %} +{% component heading %} diff --git a/laces/test/home/views.py b/laces/test/home/views.py index 6eb7054..ea1f6fe 100644 --- a/laces/test/home/views.py +++ b/laces/test/home/views.py @@ -1,5 +1,12 @@ from django.shortcuts import render +from laces.test.components import Heading + def home(request): - return render(request, "home/home.html") + heading = Heading() + return render( + request, + template_name="home/home.html", + context={"heading": heading}, + ) diff --git a/laces/test/settings.py b/laces/test/settings.py index db49348..b2148d7 100644 --- a/laces/test/settings.py +++ b/laces/test/settings.py @@ -36,6 +36,7 @@ "laces", "laces.test", "laces.test.home", + "laces.test.components", "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", From 4662b7e14db201305ea5e56893e1ceb65f7e66ff Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Tue, 21 Nov 2023 15:19:35 -0800 Subject: [PATCH 08/14] Extend installation instructions --- README.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e7cc9b1..5333cbe 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,25 @@ Django components that know how to render themselves. ## Supported versions -- Python ... -- Django ... +- Python >= 3.8 +- Django >= 3.2 ## Installation -- `python -m pip install laces` -- ... +First, install with pip: +```sh +$ python -m pip install laces +``` + +Then, add to your installed apps: + +```python +# settings.py + +INSTALLED_APPS = ["laces", ...] +``` + +That's it. ## Contributing From b68d682b926f67dd9be52880ff44729465269511 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Tue, 21 Nov 2023 15:27:28 -0800 Subject: [PATCH 09/14] Use $ in the beginning of shell examples consitently --- README.md | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 5333cbe..703d2b4 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,8 @@ That's it. To make changes to this project, first clone this repository: ```sh -git clone https://github.com/tbrlpld/laces.git -cd laces +$ git clone https://github.com/tbrlpld/laces.git +$ cd laces ``` With your preferred virtualenv activated, install testing dependencies: @@ -52,15 +52,15 @@ With your preferred virtualenv activated, install testing dependencies: #### Using pip ```sh -python -m pip install --upgrade pip>=21.3 -python -m pip install -e '.[testing]' -U +$ python -m pip install --upgrade pip>=21.3 +$ python -m pip install -e '.[testing]' -U ``` #### Using flit ```sh -python -m pip install flit -flit install +$ python -m pip install flit +$ flit install ``` ### pre-commit @@ -80,16 +80,31 @@ $ git ls-files --others --cached --exclude-standard | xargs pre-commit run --fil ### How to run tests -Now you can run tests as shown below: +Now you can run all tests like so: + +```sh +$ tox +``` + +Or, you can run them for a specific environment: + +```sh +$ tox -e python3.11-django4.2-wagtail5.1 +``` + +Or, run only a specific test: ```sh -tox +$ tox -e python3.11-django4.2-wagtail5.1-sqlite laces.tests.test_file.TestClass.test_method ``` -or, you can run them for a specific environment `tox -e python3.11-django4.2-wagtail5.1` or specific test -`tox -e python3.11-django4.2-wagtail5.1-sqlite laces.tests.test_file.TestClass.test_method` +To run the test app interactively, use: + +```sh +$ tox -e interactive +``` -To run the test app interactively, use `tox -e interactive`, visit `http://127.0.0.1:8020/admin/` and log in with `admin`/`changeme`. +You can now visit `http://localhost:8020/`. ### Python version management From f332e4ab863e4d4748665fdac741d84eeb49dc7f Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Tue, 21 Nov 2023 15:38:49 -0800 Subject: [PATCH 10/14] Add "Template components" docs as usage section to README Source: https://github.com/wagtail/wagtail/blob/ca8a87077b82e20397e5a5b80154d923995e6ca9/docs/extending/template_components.md --- README.md | 191 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) diff --git a/README.md b/README.md index 703d2b4..6f894a6 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,197 @@ INSTALLED_APPS = ["laces", ...] That's it. +## Usage + + +Working with objects that know how to render themselves as elements on an HTML template is a common pattern seen throughout the Wagtail admin. For example, the admin homepage is a view provided by the central `wagtail.admin` app, but brings together information panels sourced from various other modules of Wagtail, such as images and documents (potentially along with others provided by third-party packages). These panels are passed to the homepage via the [`construct_homepage_panels`](construct_homepage_panels) hook, and each one is responsible for providing its own HTML rendering. In this way, the module providing the panel has full control over how it appears on the homepage. + +Wagtail implements this pattern using a standard object type known as a **component**. A component is a Python object that provides the following methods and properties: + +```{eval-rst} +.. method:: render_html(self, parent_context=None) + +Given a context dictionary from the calling template (which may be a :py:class:`Context ` object or a plain ``dict`` of context variables), returns the string representation to be inserted into the template. This will be subject to Django's HTML escaping rules, so a return value consisting of HTML should typically be returned as a :py:mod:`SafeString ` instance. + +.. attribute:: media + +A (possibly empty) :doc:`form media ` object defining JavaScript and CSS resources used by the component. +``` + +```{note} + Any object implementing this API can be considered a valid component; it does not necessarily have to inherit from the `Component` class described below, and user code that works with components should not assume this (for example, it must not use `isinstance` to check whether a given value is a component). +``` + +(creating_template_components)= + +### Creating components + +The preferred way to create a component is to define a subclass of `wagtail.admin.ui.components.Component` and specify a `template_name` attribute on it. The rendered template will then be used as the component's HTML representation: + +```python +from wagtail.admin.ui.components import Component + + +class WelcomePanel(Component): + template_name = "my_app/panels/welcome.html" + + +my_welcome_panel = WelcomePanel() +``` + +`my_app/templates/my_app/panels/welcome.html`: + +```html+django +

Welcome to my app!

+``` + +For simple cases that don't require a template, the `render_html` method can be overridden instead: + +```python +from django.utils.html import format_html +from wagtail.admin.components import Component + + +class WelcomePanel(Component): + def render_html(self, parent_context): + return format_html("

{}

", "Welcome to my app!") +``` + +### Passing context to the 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: + +```python +from wagtail.admin.ui.components import Component + + +class WelcomePanel(Component): + template_name = "my_app/panels/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 +``` + +`my_app/templates/my_app/panels/welcome.html`: + +```html+django +

Welcome to my app, {{ username }}!

+``` + +### Adding media definitions + +Like Django form widgets, components can specify associated JavaScript and CSS resources using either an inner `Media` class or a dynamic `media` property: + +```python +class WelcomePanel(Component): + template_name = "my_app/panels/welcome.html" + + class Media: + css = {"all": ("my_app/css/welcome-panel.css",)} +``` + +### Using components on your own templates + +The `wagtailadmin_tags` 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). For example, given the view: + +```python +from django.shortcuts import render + + +def welcome_page(request): + panels = [ + WelcomePanel(), + ] + + render( + request, + "my_app/welcome.html", + { + "panels": panels, + }, + ) +``` + +the `my_app/welcome.html` template could render the panels as follows: + +```html+django +{% load wagtailadmin_tags %} +{% for panel in panels %} + {% component panel %} +{% endfor %} +``` + +You can pass additional context variables to the component using the keyword `with`: + +```html+django +{% component panel with username=request.user.username %} +``` + +To render the component with only the variables provided (and no others from the calling template's context), use `only`: + +```html+django +{% component panel with username=request.user.username only %} +``` + +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 %} + +{{ panel_html }} +``` + +Note that it is your template's responsibility to output any media declarations defined on the components. For a Wagtail admin view, this is best done by constructing a media object for the whole page within the view, passing this to the template, and outputting it via the base template's `extra_js` and `extra_css` blocks: + +```python +from django.forms import Media +from django.shortcuts import render + + +def welcome_page(request): + panels = [ + WelcomePanel(), + ] + + media = Media() + for panel in panels: + media += panel.media + + render( + request, + "my_app/welcome.html", + { + "panels": panels, + "media": media, + }, + ) +``` + +`my_app/welcome.html`: + +```html+django +{% extends "wagtailadmin/base.html" %} +{% load wagtailadmin_tags %} + +{% block extra_js %} + {{ block.super }} + {{ media.js }} +{% endblock %} + +{% block extra_css %} + {{ block.super }} + {{ media.css }} +{% endblock %} + +{% block content %} + {% for panel in panels %} + {% component panel %} + {% endfor %} +{% endblock %} +``` + ## Contributing ### Install From 2944704bfffaa1c4470a1877f7cdfad3e9cfbe4c Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Tue, 21 Nov 2023 16:02:03 -0800 Subject: [PATCH 11/14] Add intro section to README --- README.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6f894a6..bd876f3 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,21 @@ # Laces -Django components that know how to render themselves. - [![License: BSD-3-Clause](https://img.shields.io/badge/License-BSD--3--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) [![PyPI version](https://badge.fury.io/py/laces.svg)](https://badge.fury.io/py/laces) [![laces CI](https://github.com/tbrlpld/laces/actions/workflows/test.yml/badge.svg)](https://github.com/tbrlpld/laces/actions/workflows/test.yml) +--- + +Django components that know how to render themselves. + + +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". + +The APIs provided in the package have previously been discovered, developed and solidified in the Wagtail project. +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) From 416d4fce0bafd04df7d0e744d70400ecf15356ce Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Tue, 21 Nov 2023 16:12:29 -0800 Subject: [PATCH 12/14] Move the reference description and use a method docstring instead --- README.md | 21 --------------------- laces/components.py | 11 +++++++++++ 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index bd876f3..c1418fa 100644 --- a/README.md +++ b/README.md @@ -48,27 +48,6 @@ That's it. ## Usage - -Working with objects that know how to render themselves as elements on an HTML template is a common pattern seen throughout the Wagtail admin. For example, the admin homepage is a view provided by the central `wagtail.admin` app, but brings together information panels sourced from various other modules of Wagtail, such as images and documents (potentially along with others provided by third-party packages). These panels are passed to the homepage via the [`construct_homepage_panels`](construct_homepage_panels) hook, and each one is responsible for providing its own HTML rendering. In this way, the module providing the panel has full control over how it appears on the homepage. - -Wagtail implements this pattern using a standard object type known as a **component**. A component is a Python object that provides the following methods and properties: - -```{eval-rst} -.. method:: render_html(self, parent_context=None) - -Given a context dictionary from the calling template (which may be a :py:class:`Context ` object or a plain ``dict`` of context variables), returns the string representation to be inserted into the template. This will be subject to Django's HTML escaping rules, so a return value consisting of HTML should typically be returned as a :py:mod:`SafeString ` instance. - -.. attribute:: media - -A (possibly empty) :doc:`form media ` object defining JavaScript and CSS resources used by the component. -``` - -```{note} - Any object implementing this API can be considered a valid component; it does not necessarily have to inherit from the `Component` class described below, and user code that works with components should not assume this (for example, it must not use `isinstance` to check whether a given value is a component). -``` - -(creating_template_components)= - ### Creating components The preferred way to create a component is to define a subclass of `wagtail.admin.ui.components.Component` and specify a `template_name` attribute on it. The rendered template will then be used as the component's HTML representation: diff --git a/laces/components.py b/laces/components.py index e457025..aa89592 100644 --- a/laces/components.py +++ b/laces/components.py @@ -19,6 +19,17 @@ def get_context_data( return {} def render_html(self, parent_context: MutableMapping[str, Any] = None) -> str: + """ + 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), + returns the string representation to be rendered. + + This will be subject to Django's HTML escaping rules, so a return value + consisting of HTML should typically be returned as a + `django.utils.safestring.SafeString` instance. + """ if parent_context is None: parent_context = Context() context_data = self.get_context_data(parent_context) From 24c0718e789a19b0de2fbb64aee2677ba46039a7 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Tue, 21 Nov 2023 16:39:49 -0800 Subject: [PATCH 13/14] Update usage instructions to reference laces instead of Wagtail admin --- README.md | 89 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 53 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index c1418fa..890d16e 100644 --- a/README.md +++ b/README.md @@ -50,10 +50,13 @@ That's it. ### Creating components -The preferred way to create a component is to define a subclass of `wagtail.admin.ui.components.Component` and specify a `template_name` attribute on it. The rendered template will then be used as the component's HTML representation: +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: ```python -from wagtail.admin.ui.components import Component +# my_app/components.py + +from laces.components import Component class WelcomePanel(Component): @@ -63,17 +66,19 @@ class WelcomePanel(Component): my_welcome_panel = WelcomePanel() ``` -`my_app/templates/my_app/panels/welcome.html`: - ```html+django +{# my_app/templates/my_app/panels/welcome.html #} +

Welcome to my app!

``` For simple cases that don't require a template, the `render_html` method can be overridden instead: ```python +# my_app/components.py + from django.utils.html import format_html -from wagtail.admin.components import Component +from laces.components import Component class WelcomePanel(Component): @@ -83,10 +88,13 @@ class WelcomePanel(Component): ### Passing context to the 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: +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. ```python -from wagtail.admin.ui.components import Component +# my_app/components.py + +from laces.components import Component class WelcomePanel(Component): @@ -98,17 +106,22 @@ class WelcomePanel(Component): return context ``` -`my_app/templates/my_app/panels/welcome.html`: - ```html+django +{# my_app/templates/my_app/panels/welcome.html #} +

Welcome to my app, {{ username }}!

``` ### Adding media definitions -Like Django form widgets, components can specify associated JavaScript and CSS resources using either an inner `Media` class or a dynamic `media` property: +Like Django form widgets, components can specify associated JavaScript and CSS resources using either an inner `Media` class or a dynamic `media` property. ```python +# my_app/components.py + +from laces.components import Component + + class WelcomePanel(Component): template_name = "my_app/panels/welcome.html" @@ -116,35 +129,40 @@ class WelcomePanel(Component): css = {"all": ("my_app/css/welcome-panel.css",)} ``` -### Using components on your own templates +### 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). -The `wagtailadmin_tags` 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). For example, given the view: +For example, given the view passes an instance of `WelcomePanel` to the context of `my_app/welcome.html`. ```python +# my_app/views.py + from django.shortcuts import render +from my_app.components import WelcomePanel + def welcome_page(request): - panels = [ - WelcomePanel(), - ] + panel = (WelcomePanel(),) - render( + return render( request, "my_app/welcome.html", { - "panels": panels, + "panel": panel, }, ) ``` -the `my_app/welcome.html` template could render the panels as follows: +The template `my_app/templates/my_app/welcome.html` could render the panel as follows: ```html+django -{% load wagtailadmin_tags %} -{% for panel in panels %} - {% component panel %} -{% endfor %} +{# my_app/templates/my_app/welcome.html #} + +{% load laces %} +{% component panel %} ``` You can pass additional context variables to the component using the keyword `with`: @@ -167,12 +185,17 @@ To store the component's rendered output in a variable rather than outputting it {{ panel_html }} ``` -Note that it is your template's responsibility to output any media declarations defined on the components. For a Wagtail admin view, this is best done by constructing a media object for the whole page within the view, passing this to the template, and outputting it via the base template's `extra_js` and `extra_css` blocks: +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`. ```python +# my_app/views.py + from django.forms import Media from django.shortcuts import render +from my_app.components import WelcomePanel + def welcome_page(request): panels = [ @@ -193,27 +216,21 @@ def welcome_page(request): ) ``` -`my_app/welcome.html`: ```html+django -{% extends "wagtailadmin/base.html" %} -{% load wagtailadmin_tags %} +{# my_app/templates/my_app/welcome.html #} -{% block extra_js %} - {{ block.super }} - {{ media.js }} -{% endblock %} +{% load laces %} -{% block extra_css %} - {{ block.super }} + + {{ media.js }} {{ media.css }} -{% endblock %} - -{% block content %} + + {% for panel in panels %} {% component panel %} {% endfor %} -{% endblock %} + ``` ## Contributing From be341df1d63fe7d6c35bbcab017fb21a3b577e33 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Tue, 21 Nov 2023 16:50:23 -0800 Subject: [PATCH 14/14] Add MediaContainer class --- laces/components.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/laces/components.py b/laces/components.py index aa89592..46fbadf 100644 --- a/laces/components.py +++ b/laces/components.py @@ -1,6 +1,6 @@ from typing import Any, MutableMapping -from django.forms import MediaDefiningClass +from django.forms import Media, MediaDefiningClass from django.template import Context from django.template.loader import get_template @@ -38,3 +38,20 @@ def render_html(self, parent_context: MutableMapping[str, Any] = None) -> str: template = get_template(self.template_name) return template.render(context_data) + + +class MediaContainer(list): + """ + A list that provides a ``media`` property that combines the media definitions + of its members. + + Extracted from Wagtail. See: + https://github.com/wagtail/wagtail/blob/ca8a87077b82e20397e5a5b80154d923995e6ca9/wagtail/admin/ui/components.py#L25-L36 # noqa: E501 + """ + + @property + def media(self): + media = Media() + for item in self: + media += item.media + return media