From 19012f7b7fa99da6599da2252af139eb206c4fa6 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Thu, 4 Jan 2024 20:38:54 -0800 Subject: [PATCH 01/37] Simplify first example use of get_context_data --- README.md | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 405953d..8429a7f 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ class WelcomePanel(Component): ```html+django {# my_app/templates/my_app/components/welcome.html #} -

Welcome to my app!

+

Hello World!

``` With the above in place, you then instantiate the component (e.g. in a view) and pass it to another template for rendering. @@ -118,13 +118,22 @@ from laces.components import Component class WelcomePanel(Component): def render_html(self, parent_context=None): - return format_html("

Welcome to my app!

") + return format_html("

Hello World!

") ``` ### 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. +So, to customize the context variables passed to the template, we can override `get_context_data`. ```python # my_app/components.py @@ -136,17 +145,24 @@ class WelcomePanel(Component): 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/components/welcome.html #} -

Welcome to my app, {{ username }}!

+

Hello {{ name }}

``` +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. + +### Context examples + +TODO: Expand this section with examples of how to add context to the component hen instantiating it. + ### 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. From 400dc3a9be09545104b65c2e05f86a5d97297f3f Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sat, 6 Jan 2024 16:52:58 -0800 Subject: [PATCH 02/37] Add sections about constructor arguments and dataclasses --- README.md | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 73 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8429a7f..0b073dc 100644 --- a/README.md +++ b/README.md @@ -156,12 +156,81 @@ class WelcomePanel(Component): 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. +But, still not very useful, as the name is still hardcoded — in the component method instead of the template, but hardcoded nonetheless. -### Context examples +### Some more useful context examples -TODO: Expand this section with examples of how to add context to the component hen instantiating it. +Let's have look at some more useful examples of how to pass context to the component template. + +#### Using the component's constructor + +Component are just normal Python classes and objects. +That means, we can pass arguments to the constructor and use them in the component's methods. + +```python +# my_app/components.py + +from laces.components import Component + + +class WelcomePanel(Component): + template_name = "my_app/components/welcome.html" + + def __init__(self, name): + self.name = name + + def get_context_data(self, parent_context): + return {"name": self.name} +``` + +```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}, + ) +``` + +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. + +#### Using dataclasses + +The above example is neat already, but is may become a little verbose when we have more than one or two arguments to pass to the component. +You would have to list them all manually in the constructor and then assign them to the context. + +To make this a little easier, we can use 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 passed to the template context. +The `asdict` function only contains the properties defined in the dataclass, so we don't have to worry about accidentally passing other properties to the template. ### Adding media definitions From e5373e534e048e052f0e8b9000bf0e8db1fd83b8 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sat, 6 Jan 2024 17:28:54 -0800 Subject: [PATCH 03/37] Rework the intro section --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0b073dc..e1f0339 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,15 @@ Django components that know how to render themselves. +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 benefit of this combination is that the components can be used in other templates without having to worry about passing the right context variables to the template. +Template and data are tied together 😅 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). -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. +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) From 5d64fba5ae83cfdca63816559eac84ae5c052fc3 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sat, 6 Jan 2024 20:13:47 -0800 Subject: [PATCH 04/37] Restructure the README To make writing a bit easier and more focused, I am adjusting the structure to already fit what I want to go toward. Then I only need to fill those gaps. --- README.md | 114 +++++++++++++++++++++++++++++------------------------- 1 file changed, 61 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index e1f0339..0865e17 100644 --- a/README.md +++ b/README.md @@ -159,14 +159,11 @@ With the above we are now rendering a welcome message with the name coming from Nice. But, still not very useful, as the name is still hardcoded — in the component method instead of the template, but hardcoded nonetheless. -### Some more useful context examples +#### Using class properties -Let's have look at some more useful examples of how to pass context to the component template. - -#### Using the component's constructor - -Component are just normal Python classes and objects. -That means, we can pass arguments to the constructor and use them in the component's methods. +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 # my_app/components.py @@ -204,51 +201,7 @@ def home(request): 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. -#### Using dataclasses - -The above example is neat already, but is may become a little verbose when we have more than one or two arguments to pass to the component. -You would have to list them all manually in the constructor and then assign them to the context. - -To make this a little easier, we can use 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 passed to the template context. -The `asdict` function only contains the properties defined in the dataclass, so we don't have to worry about accidentally passing other properties to the template. - -### 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 -# 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",)} -``` +#### Parent context ### Using components in other templates @@ -306,6 +259,23 @@ To store the component's rendered output in a variable rather than outputting it {{ welcome_html }} ``` +### Adding static files to a component + +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/components/welcome.html" + + class Media: + css = {"all": ("my_app/css/welcome-panel.css",)} +``` + 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`. @@ -337,7 +307,6 @@ def home(request): ) ``` - ```html+django {# my_app/templates/my_app/home.html #} @@ -354,6 +323,45 @@ def home(request): ``` +## Patterns for using components + +### Using dataclasses + +The above example is neat already, but is may become a little verbose when we have more than one or two arguments to pass to the component. +You would have to list them all manually in the constructor and then assign them to the context. + +To make this a little easier, we can use 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 passed to the template context. +The `asdict` function only contains the properties defined in the dataclass, so we don't have to worry about accidentally passing other properties to the template. + +### Special constructor methods + +### Nesting components + +### Sets of components + +## About Laces and Components + ## Contributing ### Install From a9f62a25efe21e898ee6ed313c1aa0d733234f3f Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sat, 6 Jan 2024 20:44:53 -0800 Subject: [PATCH 05/37] Add example for parent context use --- README.md | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0865e17..3d8a4f6 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ from laces.components import Component class WelcomePanel(Component): - def render_html(self, parent_context=None): + def render_html(self, parent_context): return format_html("

Hello World!

") ``` @@ -163,6 +163,7 @@ But, still not very useful, as the name is still hardcoded — in the component 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 @@ -181,6 +182,9 @@ class WelcomePanel(Component): 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 @@ -198,11 +202,36 @@ def home(request): ) ``` -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. +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). #### Parent context +You may have noticed in the above examples that the `get_context_data` method takes a `parent_context` argument. +This is the context of the template that is calling the component. + +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 know 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 of 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. From 1f75d34784700419e3b4aa590d98c8d3b24d427d Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sat, 6 Jan 2024 21:28:53 -0800 Subject: [PATCH 06/37] Update parent context explanations --- README.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3d8a4f6..062c124 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ In the view template, we `load` the `laces` tag library and use the `component` {# my_app/templates/my_app/home.html #} {% load laces %} -{% component welcome %} +{% component welcome %} {# <-- Renders the component #} ``` That's it! @@ -134,7 +134,8 @@ 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. -So, to customize the context variables passed to the template, we can override `get_context_data`. +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 # my_app/components.py @@ -207,11 +208,15 @@ A couple more examples of how components can be used can be found [below](#patte #### Parent context -You may have noticed in the above examples that the `get_context_data` method takes a `parent_context` argument. +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 know require that the data in the right format is passed through all layers of templates again. +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 of desirable. From 9b2c3516eb18f0ee29207f4cf30893725a2b1144 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sat, 6 Jan 2024 22:00:34 -0800 Subject: [PATCH 07/37] Refine section on extra features of the component template tag --- README.md | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 062c124..e845025 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ def home(request): 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). -#### Parent context +#### 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. @@ -239,10 +239,9 @@ class WelcomePanel(Component): ### 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). +It's already been mentioned in the [first example](#creating-components), that 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/home.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`. ```python # my_app/views.py @@ -264,7 +263,7 @@ def home(request): ) ``` -The template `my_app/templates/my_app/home.html` could render the welcome panel component 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/home.html #} @@ -273,18 +272,35 @@ The template `my_app/templates/my_app/home.html` could render the welcome 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 `component` tag supports the `with`, `only` and `as` keywords, akin to 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 welcome 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` + +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 welcome 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 From 5685708cc38f9a9f6c7cf5213be5cb54e15730b2 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sat, 6 Jan 2024 22:15:00 -0800 Subject: [PATCH 08/37] Fix sentence beginning --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e845025..78314e9 100644 --- a/README.md +++ b/README.md @@ -291,7 +291,7 @@ For more information see the above section on the [parent context](#using-the-pa #### Limit the parent context variables with `only` -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`: +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 welcome with name=request.user.first_name only %} From 9b2cf2a86c5813410baef17d90fd368cbab44a53 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sat, 13 Jan 2024 10:30:30 -0800 Subject: [PATCH 09/37] Move installation into a "getting started" section --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 78314e9..8ddd432 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,9 @@ The purpose of this package is to make these tools available to other Django pro - Python >= 3.8 - Django >= 3.2 -## Installation +## Getting started + +### Installation First, install with pip: ```sh @@ -48,8 +50,6 @@ INSTALLED_APPS = ["laces", ...] That's it. -## Usage - ### Creating components The simplest way to create a component is to define a subclass of `laces.components.Component` and specify a `template_name` attribute on it. From 13e3d2306cee693de0b659c0173421ebe40523f3 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sat, 13 Jan 2024 10:33:53 -0800 Subject: [PATCH 10/37] Fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8ddd432..7375d78 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ Relying on data from the parent context somewhat forgoes some of the benefits of 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 of desirable. +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 From 0becf6cf2c933b2b48be80b05d8080cbffa8acbb Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sat, 13 Jan 2024 10:37:29 -0800 Subject: [PATCH 11/37] Reduce repetition in sentence --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7375d78..cf994f1 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ def home(request): ) ``` -In the view template, we `load` the `laces` tag library and use the `component` tag to render the component. +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/home.html #} @@ -275,7 +275,7 @@ Then, in the `my_app/templates/my_app/home.html` template we render the welcome This is the basic usage of components and should cover most cases. However, the `{% component %}` tag also supports some additional features. -Specifically, the `component` tag supports the `with`, `only` and `as` keywords, akin to the [`include`](https://docs.djangoproject.com/en/5.0/ref/templates/builtins/#std-templatetag-include) tag. +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` From 74c7b5f277c9996426d45e8a22447c6e4c1ee72e Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sat, 13 Jan 2024 11:08:30 -0800 Subject: [PATCH 12/37] Simplify the first media example --- README.md | 53 +++++++++++++++++++++++++---------------------------- 1 file changed, 25 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index cf994f1..558ec97 100644 --- a/README.md +++ b/README.md @@ -309,9 +309,13 @@ To store the component's rendered output in a variable rather than outputting it {{ welcome_html }} ``` -### Adding static files to a component +### Adding JavaScript and CSS assets to a component -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 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 @@ -326,53 +330,46 @@ class WelcomePanel(Component): css = {"all": ("my_app/css/welcome-panel.css",)} ``` -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`. +The more dynamic definition via a `media` property looks like this: ```python -# my_app/views.py +# my_app/components.py from django.forms import Media -from django.shortcuts import render - -from my_app.components import WelcomePanel +from laces.components import Component -def home(request): - components = [ - WelcomePanel(), - ] - media = Media() - for component in components: - media += component.media +class WelcomePanel(Component): + template_name = "my_app/components/welcome.html" - render( - request, - "my_app/home.html", - { - "components": components, - "media": media, - }, - ) + @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. + +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 %} - {{ media.js }} - {{ media.css }} + {{ welcome.media }} - {% for comp in components %} - {% component comp %} - {% endfor %} + {% component welcome %} ``` +TODO: Fix this section. +If you have many components, you can combine their media definitions into a single object with the `MediaContainer` class. +~~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`.~~ + ## Patterns for using components ### Using dataclasses From fff12b7ef15f639016fb6016d6f1a0c543885944 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sat, 13 Jan 2024 11:10:06 -0800 Subject: [PATCH 13/37] Reorder example components --- laces/test/example/components.py | 33 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/laces/test/example/components.py b/laces/test/example/components.py index ded2b9b..2815d5d 100644 --- a/laces/test/example/components.py +++ b/laces/test/example/components.py @@ -109,6 +109,21 @@ def get_context_data( } +class ListSectionComponent(Component): + template_name = "components/list-section.html" + + def __init__(self, heading: "HeadingComponent", items: "list[Component]"): + super().__init__() + self.heading = heading + self.items = items + + def get_context_data(self, parent_context=None): + return { + "heading": self.heading, + "items": self.items, + } + + class HeadingComponent(Component): def __init__(self, text: str): super().__init__() @@ -133,24 +148,6 @@ def render_html( return format_html("

{}

\n", self.text) -class ListSectionComponent(Component): - template_name = "components/list-section.html" - - def __init__(self, heading: "HeadingComponent", items: "list[Component]"): - super().__init__() - self.heading = heading - self.items = items - - def get_context_data( - self, - parent_context: "Optional[RenderContext]" = None, - ) -> "RenderContext": - return { - "heading": self.heading, - "items": self.items, - } - - class BlockquoteComponent(Component): def __init__(self, text: str): super().__init__() From 9ec72724e2f29bb534b1324c4683a48dd06c5c48 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sat, 13 Jan 2024 11:22:59 -0800 Subject: [PATCH 14/37] Test usage of media defining component --- laces/test/example/components.py | 11 ++++++++++ .../example/templates/pages/kitchen-sink.html | 2 ++ laces/test/example/views.py | 3 +++ laces/test/tests/test_components.py | 22 +++++++++++++++++++ laces/test/tests/test_views.py | 6 +++++ 5 files changed, 44 insertions(+) diff --git a/laces/test/example/components.py b/laces/test/example/components.py index 2815d5d..feca61f 100644 --- a/laces/test/example/components.py +++ b/laces/test/example/components.py @@ -158,3 +158,14 @@ def render_html( parent_context: "Optional[RenderContext]" = None, ) -> "SafeString": return format_html("
{}
\n", self.text) + + +class MediaDefiningComponent(Component): + template_name = "components/hello-name.html" + + def get_context_data(self, parent_context=None): + return {"name": "Media"} + + class Media: + css = {"all": ("component.css",)} + js = ("component.js",) diff --git a/laces/test/example/templates/pages/kitchen-sink.html b/laces/test/example/templates/pages/kitchen-sink.html index 5fc4910..b325e36 100644 --- a/laces/test/example/templates/pages/kitchen-sink.html +++ b/laces/test/example/templates/pages/kitchen-sink.html @@ -3,6 +3,7 @@ Kitchen Sink + {{ media_defining_component.media }} {% component fixed_content_template %} @@ -29,5 +30,6 @@ {% component section_with_heading_and_paragraph %} {% component list_section %} + {% component media_defining_component %} diff --git a/laces/test/example/views.py b/laces/test/example/views.py index 84dad52..165d489 100644 --- a/laces/test/example/views.py +++ b/laces/test/example/views.py @@ -7,6 +7,7 @@ DataclassAsDictContextComponent, HeadingComponent, ListSectionComponent, + MediaDefiningComponent, ParagraphComponent, PassesFixedNameToContextComponent, PassesInstanceAttributeToContextComponent, @@ -43,6 +44,7 @@ def kitchen_sink(request: "HttpRequest") -> "HttpResponse": ParagraphComponent(text="Item 3"), ], ) + media_defining_component = MediaDefiningComponent() return render( request, @@ -58,5 +60,6 @@ def kitchen_sink(request: "HttpRequest") -> "HttpResponse": "name": "Dan", # Provide as an example of parent context. "section_with_heading_and_paragraph": section_with_heading_and_paragraph, "list_section": list_section, + "media_defining_component": media_defining_component, }, ) diff --git a/laces/test/tests/test_components.py b/laces/test/tests/test_components.py index 97863da..6a859ee 100644 --- a/laces/test/tests/test_components.py +++ b/laces/test/tests/test_components.py @@ -11,6 +11,7 @@ DataclassAsDictContextComponent, HeadingComponent, ListSectionComponent, + MediaDefiningComponent, ParagraphComponent, PassesFixedNameToContextComponent, PassesInstanceAttributeToContextComponent, @@ -250,3 +251,24 @@ def test_render_html(self) -> None: """, ) + + +class TestMediaDefiningComponent(SimpleTestCase): + def setUp(self): + self.component = MediaDefiningComponent() + + def test_media(self): + self.assertEqual( + self.component.media._css, + { + "all": [ + "component.css", + ] + }, + ) + self.assertEqual( + self.component.media._js, + [ + "component.js", + ], + ) diff --git a/laces/test/tests/test_views.py b/laces/test/tests/test_views.py index 1be9b33..7b2751b 100644 --- a/laces/test/tests/test_views.py +++ b/laces/test/tests/test_views.py @@ -54,3 +54,9 @@ def test_get(self) -> None: """, response_html, ) + self.assertInHTML("

Hello Media

", response_html) + self.assertInHTML( + '', + response_html, + ) + self.assertInHTML('', response_html) From c61a8d3bca8078a72352bdc994affb1d8921ac40 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sat, 13 Jan 2024 15:25:24 -0800 Subject: [PATCH 15/37] Add more background on how to output media in templates --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 558ec97..ad8151c 100644 --- a/README.md +++ b/README.md @@ -351,6 +351,14 @@ class WelcomePanel(Component): **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 @@ -366,10 +374,16 @@ In the example home template from above, we can output the component's media dec ``` +#### Combining media with `MediaContainer` + TODO: Fix this section. If you have many components, you can combine their media definitions into a single object with the `MediaContainer` class. ~~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`.~~ +**Note**: +The use of `MediaContainer` is not limited to contain components. +It can be used to combine the `media` properties of any number of objects that have a `media` property. + ## Patterns for using components ### Using dataclasses From 25e49f498218aeecb2dbbd1840abe7b9d7814729 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sat, 13 Jan 2024 15:46:59 -0800 Subject: [PATCH 16/37] Add section about `MediaContainer` --- README.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ad8151c..d0c33c3 100644 --- a/README.md +++ b/README.md @@ -376,13 +376,68 @@ In the example home template from above, we can output the component's media dec #### Combining media with `MediaContainer` -TODO: Fix this section. -If you have many components, you can combine their media definitions into a single object with the `MediaContainer` class. -~~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`.~~ +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.shortcuts import render +from laces.components import MediaContainer + +from my_app.components import ( + Dashboard, + Footer, + Header, + Sidebar, + WelcomePanel, +) + + +def home(request): + components = MediaContainer( + Header(), + Sidebar(), + WelcomePanel(), + Dashboard(), + Footer(), + ) + + return render( + request, + "my_app/home.html", + { + "components": components, + }, + ) +``` + +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/home.html #} + +{% load laces %} + + + {{ components.media }} + + + {% for component in components %} + {% component component %} + {% endfor %} + +``` + +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 number of objects that have a `media` property. +It can be used to combine the `media` properties of any kind of objects that have a `media` property. ## Patterns for using components From 953231d435ccddf2efd9b234f44272a5c673defc Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sat, 13 Jan 2024 15:55:15 -0800 Subject: [PATCH 17/37] Simplify sentence --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d0c33c3..ab4a80e 100644 --- a/README.md +++ b/README.md @@ -239,7 +239,7 @@ class WelcomePanel(Component): ### Using components in other templates -It's already been mentioned in the [first example](#creating-components), that components are rendered in other templates using the `{% component %}` tag from the `laces` tag library. +As mentioned in the [first example](#creating-components), components are rendered in other templates using the `{% component %}` tag from the `laces` tag library. Here is that example from above again, in which the view passes an instance of `WelcomePanel` to the context of `my_app/home.html`. From aa8042129ed2565a12870d0caed4da0aae6974a1 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sat, 13 Jan 2024 16:10:23 -0800 Subject: [PATCH 18/37] Add example for media container usage --- README.md | 12 +++++++----- laces/test/example/components.py | 18 ++++++++++++++++++ .../example/templates/pages/kitchen-sink.html | 4 ++++ laces/test/example/views.py | 10 ++++++++++ laces/test/tests/test_views.py | 17 +++++++++++++++++ 5 files changed, 56 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ab4a80e..09b01ae 100644 --- a/README.md +++ b/README.md @@ -399,11 +399,13 @@ from my_app.components import ( def home(request): components = MediaContainer( - Header(), - Sidebar(), - WelcomePanel(), - Dashboard(), - Footer(), + [ + Header(), + Sidebar(), + WelcomePanel(), + Dashboard(), + Footer(), + ] ) return render( diff --git a/laces/test/example/components.py b/laces/test/example/components.py index feca61f..52f7256 100644 --- a/laces/test/example/components.py +++ b/laces/test/example/components.py @@ -169,3 +169,21 @@ def get_context_data(self, parent_context=None): class Media: css = {"all": ("component.css",)} js = ("component.js",) + + +class HeaderWithMediaComponent(Component): + def render_html(self, parent_context=None): + return format_html("
Header with Media
") + + class Media: + css = {"all": ("header.css",)} + js = ("header.js", "common.js") + + +class FooterWithMediaComponent(Component): + def render_html(self, parent_context=None): + return format_html("
Footer with Media
") + + class Media: + css = {"all": ("footer.css",)} + js = ("footer.js", "common.js") diff --git a/laces/test/example/templates/pages/kitchen-sink.html b/laces/test/example/templates/pages/kitchen-sink.html index b325e36..3292d4a 100644 --- a/laces/test/example/templates/pages/kitchen-sink.html +++ b/laces/test/example/templates/pages/kitchen-sink.html @@ -4,6 +4,7 @@ Kitchen Sink {{ media_defining_component.media }} + {{ components_with_media.media }} {% component fixed_content_template %} @@ -31,5 +32,8 @@ {% component section_with_heading_and_paragraph %} {% component list_section %} {% component media_defining_component %} + {% for comp in components_with_media %} + {% component comp %} + {% endfor %} diff --git a/laces/test/example/views.py b/laces/test/example/views.py index 165d489..1924648 100644 --- a/laces/test/example/views.py +++ b/laces/test/example/views.py @@ -2,9 +2,12 @@ from django.shortcuts import render +from laces.components import MediaContainer from laces.test.example.components import ( BlockquoteComponent, DataclassAsDictContextComponent, + FooterWithMediaComponent, + HeaderWithMediaComponent, HeadingComponent, ListSectionComponent, MediaDefiningComponent, @@ -45,6 +48,12 @@ def kitchen_sink(request: "HttpRequest") -> "HttpResponse": ], ) media_defining_component = MediaDefiningComponent() + components_with_media = MediaContainer( + [ + HeaderWithMediaComponent(), + FooterWithMediaComponent(), + ] + ) return render( request, @@ -61,5 +70,6 @@ def kitchen_sink(request: "HttpRequest") -> "HttpResponse": "section_with_heading_and_paragraph": section_with_heading_and_paragraph, "list_section": list_section, "media_defining_component": media_defining_component, + "components_with_media": components_with_media, }, ) diff --git a/laces/test/tests/test_views.py b/laces/test/tests/test_views.py index 7b2751b..50dc2f2 100644 --- a/laces/test/tests/test_views.py +++ b/laces/test/tests/test_views.py @@ -60,3 +60,20 @@ def test_get(self) -> None: response_html, ) self.assertInHTML('', response_html) + self.assertInHTML("
Header with Media
", response_html) + self.assertInHTML("
Footer with Media
", response_html) + self.assertInHTML( + '', + response_html, + ) + self.assertInHTML( + '', + response_html, + ) + self.assertInHTML('', response_html) + self.assertInHTML('', response_html) + self.assertInHTML( + '', + response_html, + count=1, + ) From 7718660cd51fafdfe71335605b99d30484362ef9 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sat, 13 Jan 2024 16:17:58 -0800 Subject: [PATCH 19/37] Don't use title case --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 09b01ae..e3c5dbb 100644 --- a/README.md +++ b/README.md @@ -478,7 +478,7 @@ The `asdict` function only contains the properties defined in the dataclass, so ### Sets of components -## About Laces and Components +## About Laces and components ## Contributing From ca730b845266f9f1ee39a6c6a89ffc318431c503 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sat, 13 Jan 2024 17:24:55 -0800 Subject: [PATCH 20/37] Expand dataclasses section a little --- README.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e3c5dbb..2a06090 100644 --- a/README.md +++ b/README.md @@ -443,12 +443,15 @@ It can be used to combine the `media` properties of any kind of objects that hav ## Patterns for using components +Below, we want so show a few more examples of how components can be used that were not covered in the ["Getting started" section](#getting-started) above. + ### Using dataclasses -The above example is neat already, but is may become a little verbose when we have more than one or two arguments to pass to the component. -You would have to list them all manually in the constructor and then assign them to the context. +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 directly pass the properties to the template context. -To make this a little easier, we can use dataclasses. +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 @@ -469,8 +472,12 @@ class WelcomePanel(Component): ``` 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 passed to the template context. -The `asdict` function only contains the properties defined in the dataclass, so we don't have to worry about accidentally passing other properties to the template. +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`. ### Special constructor methods From f7b1c62e91dd553a15f59c95ead88ec83edfb071 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sun, 14 Jan 2024 16:25:00 -0800 Subject: [PATCH 21/37] Add section on custom constructor methods --- README.md | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2a06090..f36381b 100644 --- a/README.md +++ b/README.md @@ -445,6 +445,10 @@ It can be used to combine the `media` properties of any kind of objects that hav Below, we want so 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 + +### Sets of components + ### Using dataclasses Above, we showed how to [use class properties](#using-class-properties) to add data to the component's context. @@ -479,11 +483,70 @@ In the above example, the dictionary returned by `asdict` would only contain the 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`. -### Special constructor methods +### Custom constructor methods -### Nesting components +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. -### Sets of components +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 From 6be042578ee0e82d9ad6a3bd2959f031d213d2f8 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sun, 14 Jan 2024 17:28:04 -0800 Subject: [PATCH 22/37] Add section on nested components --- README.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/README.md b/README.md index f36381b..461bde3 100644 --- a/README.md +++ b/README.md @@ -447,6 +447,44 @@ Below, we want so show a few more examples of how components can be used that we ### 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 %} + +
+ {% component welcome %} + + ... +
+``` + ### Sets of components ### Using dataclasses From de07eae0604b7a7cbaa93858d0208d7588525898 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sun, 14 Jan 2024 17:34:42 -0800 Subject: [PATCH 23/37] Mention testing --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 461bde3..4eb4a39 100644 --- a/README.md +++ b/README.md @@ -485,6 +485,14 @@ The child component already contains the data it needs and knows which template ``` +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 +``` + ### Sets of components ### Using dataclasses From 486d68ed31ee99e302b4a4fbc4917f900c83a9dd Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Wed, 17 Jan 2024 16:02:18 -0800 Subject: [PATCH 24/37] Add sections on nested groups and container components --- README.md | 146 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 144 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4eb4a39..eaab379 100644 --- a/README.md +++ b/README.md @@ -493,13 +493,155 @@ dashboard = Dashboard(user=request.user) assert dashboard.welcome.name == request.user.first_name ``` -### Sets of components +### Nested groups of components + +The nesting of components is of course 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 %} + +
+ {% for panel in panels %} + {% component panel %} + {% endfor %} + ... +
+``` + +### 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 %} +
+ {% for child in children %} + {% component child %} + {% endfor %} +
+``` + +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 %} + + + {% component content %} + ... + +``` ### 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 directly pass the properties to the template context. +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). From 891ed84b4b950d8d1174f79907230a5a04321915 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Wed, 17 Jan 2024 16:14:08 -0800 Subject: [PATCH 25/37] Rework the intro paragraph --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index eaab379..b09b6d3 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,10 @@ Django components that know how to render themselves. 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 benefit of this combination is that the components can be used in other templates without having to worry about passing the right context variables to the template. -Template and data are tied together 😅 and they can be passed around together. +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. +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). 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. From 4ce9f7b7c20224d5480d318134ce9cb184847958 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Wed, 17 Jan 2024 16:33:29 -0800 Subject: [PATCH 26/37] Add name explainer --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b09b6d3..b93e82d 100644 --- a/README.md +++ b/README.md @@ -738,7 +738,13 @@ The constructor method allows us to keep our view very simple and clean as all t 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 a 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 trying to improve the work with Django templates in a component focused fashion, but with a quite different approach. ## Contributing From 781f94cf8d3c243cd39d0ec71afe212347e36d47 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Wed, 17 Jan 2024 16:40:49 -0800 Subject: [PATCH 27/37] Extra paragraph split in intro --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index b93e82d..0db095d 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ Laces components provide a simple way to combine data (in the form of Python obj 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. + 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). From 26f28f6e9e299f09c10d316cdbe7c7ea70e124d1 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Wed, 17 Jan 2024 16:45:36 -0800 Subject: [PATCH 28/37] Improve Slippers reference --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0db095d..6c0b131 100644 --- a/README.md +++ b/README.md @@ -746,7 +746,7 @@ As in the example above, custom constructor methods pair very well with the use The components are also "self-rendering", which could be a 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 trying to improve the work with Django templates in a component focused fashion, but with a quite different approach. +Finally, it is a nod to [@mixxorz](https://github.com/mixxorz)'s fantastic [Slippers package](https://github.com/mixxorz/slippers), which also taking a component focused approach to improve the experience when working with Django templates, but with a quite different way. ## Contributing From 25325f29f9263ea52a22b985759eede2a9fd5dc9 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Wed, 17 Jan 2024 16:54:55 -0800 Subject: [PATCH 29/37] Move "supported version down --- README.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6c0b131..41cb13f 100644 --- a/README.md +++ b/README.md @@ -30,11 +30,6 @@ The purpose of this package is to make these tools available to other Django pro - [Discussions](https://github.com/tbrlpld/laces/discussions) - [Security](https://github.com/tbrlpld/laces/security) -## Supported versions - -- Python >= 3.8 -- Django >= 3.2 - ## Getting started ### Installation @@ -740,7 +735,9 @@ The constructor method allows us to keep our view very simple and clean as all t 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. -## Why "Laces"? +## 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 a seen as "self-reliance", which relates to "bootstrapping". @@ -748,6 +745,11 @@ 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 taking a component focused approach to improve the experience when working with Django templates, but with a quite different way. +### Supported versions + +- Python >= 3.8 +- Django >= 3.2 + ## Contributing ### Install From 93a329e2e87d9204d1df6e140d94bf2c698b6bab Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Wed, 17 Jan 2024 17:00:11 -0800 Subject: [PATCH 30/37] Turn links more into TOC --- README.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 41cb13f..476c148 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,20 @@ The purpose of this package is to make these tools available to other Django pro ## 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) From ecbc7986083e0f7d22ad8f3720b41fc3655630de Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sun, 21 Jan 2024 17:39:26 -0800 Subject: [PATCH 31/37] Fix markup assertion for Django 3.2 --- laces/test/tests/test_views.py | 44 ++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/laces/test/tests/test_views.py b/laces/test/tests/test_views.py index 50dc2f2..caa1fed 100644 --- a/laces/test/tests/test_views.py +++ b/laces/test/tests/test_views.py @@ -2,6 +2,8 @@ from http import HTTPStatus +import django + from django.test import RequestFactory, TestCase from laces.test.example.views import kitchen_sink @@ -55,21 +57,39 @@ def test_get(self) -> None: response_html, ) self.assertInHTML("

Hello Media

", response_html) - self.assertInHTML( - '', - response_html, - ) + if django.VERSION < (4, 0): + # Before Django 4.0 the markup was including the (useless) + # `type="text/css"` attribute. + self.assertInHTML( + '', # noqa: E501 + response_html, + ) + else: + self.assertInHTML( + '', + response_html, + ) self.assertInHTML('', response_html) self.assertInHTML("
Header with Media
", response_html) self.assertInHTML("
Footer with Media
", response_html) - self.assertInHTML( - '', - response_html, - ) - self.assertInHTML( - '', - response_html, - ) + if django.VERSION < (4, 0): + self.assertInHTML( + '', # noqa: E501 + response_html, + ) + self.assertInHTML( + '', # noqa: E501 + response_html, + ) + else: + self.assertInHTML( + '', + response_html, + ) + self.assertInHTML( + '', + response_html, + ) self.assertInHTML('', response_html) self.assertInHTML('', response_html) self.assertInHTML( From a205093f0e3151f4b9d4b6923169910bd2459d14 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Wed, 31 Jan 2024 20:31:48 -0800 Subject: [PATCH 32/37] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1116200..f2d2b3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 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)) ### Removed From 57a33395318ac2e2c153f901bf0755a4a0df3b1e Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Wed, 31 Jan 2024 20:32:46 -0800 Subject: [PATCH 33/37] Unify changelog verbiage --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2d2b3d..ff22f7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add more tests and example usage. ([#6](https://github.com/tbrlpld/laces/pull/6)) +- 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)) From 8cecfc818fce55f1bbc7fec3a1f093818266358c Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Thu, 1 Feb 2024 20:53:35 -0800 Subject: [PATCH 34/37] Blacken the docs --- README.md | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 476c148..2809702 100644 --- a/README.md +++ b/README.md @@ -465,8 +465,7 @@ The combination of data and template that components provide becomes especially from laces.components import Component -class WelcomePanel(Component): - ... +class WelcomePanel(Component): ... class Dashboard(Component): @@ -514,16 +513,13 @@ We can also nest groups of components. from laces.components import Component -class WelcomePanel(Component): - ... +class WelcomePanel(Component): ... -class UsagePanel(Component): - ... +class UsagePanel(Component): ... -class TeamPanel(Component): - ... +class TeamPanel(Component): ... class Dashboard(Component): @@ -579,16 +575,13 @@ class Section(Component): return {"children": self.children} -class Heading(Component): - ... +class Heading(Component): ... -class Paragraph(Component): - ... +class Paragraph(Component): ... -class Image(Component): - ... +class Image(Component): ... ``` ```html+django From 453ae4f089b185c96aa43450ecf1a443372e2f6f Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sat, 10 Feb 2024 13:22:17 -0800 Subject: [PATCH 35/37] Add type hints to functions --- laces/test/example/components.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/laces/test/example/components.py b/laces/test/example/components.py index 52f7256..3702159 100644 --- a/laces/test/example/components.py +++ b/laces/test/example/components.py @@ -14,7 +14,7 @@ if TYPE_CHECKING: - from typing import Any, Dict, Optional + from typing import Any, Dict, List, Optional from django.utils.safestring import SafeString @@ -112,12 +112,15 @@ def get_context_data( class ListSectionComponent(Component): template_name = "components/list-section.html" - def __init__(self, heading: "HeadingComponent", items: "list[Component]"): + def __init__(self, heading: "HeadingComponent", items: "List[Component]") -> None: super().__init__() self.heading = heading self.items = items - def get_context_data(self, parent_context=None): + def get_context_data( + self, + parent_context: "Optional[RenderContext]" = None, + ) -> "RenderContext": return { "heading": self.heading, "items": self.items, @@ -163,7 +166,10 @@ def render_html( class MediaDefiningComponent(Component): template_name = "components/hello-name.html" - def get_context_data(self, parent_context=None): + def get_context_data( + self, + parent_context: "Optional[RenderContext]" = None, + ) -> "RenderContext": return {"name": "Media"} class Media: @@ -172,7 +178,10 @@ class Media: class HeaderWithMediaComponent(Component): - def render_html(self, parent_context=None): + def render_html( + self, + parent_context: "Optional[RenderContext]" = None, + ) -> "SafeString": return format_html("
Header with Media
") class Media: @@ -181,7 +190,10 @@ class Media: class FooterWithMediaComponent(Component): - def render_html(self, parent_context=None): + def render_html( + self, + parent_context: "Optional[RenderContext]" = None, + ) -> "SafeString": return format_html("
Footer with Media
") class Media: From dfd452939864bc4d1d151cd7435a9352455de4cf Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sat, 10 Feb 2024 13:22:52 -0800 Subject: [PATCH 36/37] Add type hints and better assertion to tests --- laces/test/tests/test_components.py | 34 +++++++++++++++-------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/laces/test/tests/test_components.py b/laces/test/tests/test_components.py index 6a859ee..b26c2a8 100644 --- a/laces/test/tests/test_components.py +++ b/laces/test/tests/test_components.py @@ -5,6 +5,7 @@ desired. More thorough tests can be found in the `laces.tests.test_components` module. """ +from django.forms import widgets from django.test import SimpleTestCase from laces.test.example.components import ( @@ -21,6 +22,7 @@ ReturnsFixedContentComponent, SectionWithHeadingAndParagraphComponent, ) +from laces.tests.test_components import MediaAssertionMixin class TestRendersTemplateWithFixedContentComponent(SimpleTestCase): @@ -253,22 +255,22 @@ def test_render_html(self) -> None: ) -class TestMediaDefiningComponent(SimpleTestCase): - def setUp(self): +class TestMediaDefiningComponent(MediaAssertionMixin, SimpleTestCase): + def setUp(self) -> None: self.component = MediaDefiningComponent() - def test_media(self): - self.assertEqual( - self.component.media._css, - { - "all": [ - "component.css", - ] - }, - ) - self.assertEqual( - self.component.media._js, - [ - "component.js", - ], + def test_media(self) -> None: + self.assertMediaEqual( + self.component.media, + widgets.Media( + css={ + "all": [ + "component.css", + ] + }, + js=[ + "component.js", + "test.js", + ], + ), ) From 9a835014d90c931f0a1ed93a791102918e34ada0 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sat, 10 Feb 2024 13:26:18 -0800 Subject: [PATCH 37/37] Move media assertion mixin to utils module --- laces/test/tests/test_components.py | 2 +- laces/tests/test_components.py | 27 +-------------------------- laces/tests/utils.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 27 deletions(-) create mode 100644 laces/tests/utils.py diff --git a/laces/test/tests/test_components.py b/laces/test/tests/test_components.py index b26c2a8..7b55912 100644 --- a/laces/test/tests/test_components.py +++ b/laces/test/tests/test_components.py @@ -22,7 +22,7 @@ ReturnsFixedContentComponent, SectionWithHeadingAndParagraphComponent, ) -from laces.tests.test_components import MediaAssertionMixin +from laces.tests.utils import MediaAssertionMixin class TestRendersTemplateWithFixedContentComponent(SimpleTestCase): diff --git a/laces/tests/test_components.py b/laces/tests/test_components.py index 450e20a..b5bfeaf 100644 --- a/laces/tests/test_components.py +++ b/laces/tests/test_components.py @@ -11,6 +11,7 @@ from django.utils.safestring import SafeString from laces.components import Component, MediaContainer +from laces.tests.utils import MediaAssertionMixin if TYPE_CHECKING: @@ -19,32 +20,6 @@ from laces.typing import RenderContext -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) - - class TestComponent(MediaAssertionMixin, SimpleTestCase): """Directly test the Component class.""" diff --git a/laces/tests/utils.py b/laces/tests/utils.py new file mode 100644 index 0000000..8bbc57b --- /dev/null +++ b/laces/tests/utils.py @@ -0,0 +1,29 @@ +"""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)