Multi-line tag support, watch component files, and cleanup (#624)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Juro Oravec 2024-08-28 07:46:48 +02:00 committed by GitHub
parent b26a201138
commit ab059f362d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 217 additions and 50 deletions

View file

@ -57,6 +57,7 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
- [Defining HTML/JS/CSS files](#defining-htmljscss-files)
- [Rendering JS/CSS dependencies](#rendering-jscss-dependencies)
- [Available settings](#available-settings)
- [Running with development server](#running-with-development-server)
- [Logging and debugging](#logging-and-debugging)
- [Management Command](#management-command)
- [Writing and sharing component libraries](#writing-and-sharing-component-libraries)
@ -66,9 +67,13 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
## Release notes
**Version 0.94**
- django_components now automatically configures Django to support multi-line tags. (See [Multi-line tags](#multi-line-tags))
- New setting `reload_on_template_change`. Set this to `True` to reload the dev server on changes to component template files. (See [Reload dev server on component file changes](#reload-dev-server-on-component-file-changes))
**Version 0.93**
- Spread operator `...dict` inside template tags. See [Spread operator](#spread-operator))
- Use template tags inside string literals in component inputs. See [Use template tags inside component inputs](#use-template-tags-inside-component-inputs))
- Spread operator `...dict` inside template tags. (See [Spread operator](#spread-operator))
- Use template tags inside string literals in component inputs. (See [Use template tags inside component inputs](#use-template-tags-inside-component-inputs))
- Dynamic slots, fills and provides - The `name` argument for these can now be a variable, a template expression, or via spread operator
- Component library authors can now configure `CONTEXT_BEHAVIOR` and `TAG_FORMATTER` settings independently from user settings.
@ -227,6 +232,7 @@ For a step-by-step guide on deploying production server with static files,
4. Modify `TEMPLATES` section of settings.py as follows:
- _Remove `'APP_DIRS': True,`_
- NOTE: Instead of APP_DIRS, for the same effect, we will use [`django.template.loaders.app_directories.Loader`](https://docs.djangoproject.com/en/5.1/ref/templates/api/#django.template.loaders.app_directories.Loader)
- Add `loaders` to `OPTIONS` list and set it to following value:
```python
@ -239,8 +245,11 @@ For a step-by-step guide on deploying production server with static files,
],
'loaders':[(
'django.template.loaders.cached.Loader', [
# Default Django loader
'django.template.loaders.filesystem.Loader',
# Inluding this is the same as APP_DIRS=True
'django.template.loaders.app_directories.Loader',
# Components loader
'django_components.template_loader.Loader',
]
)],
@ -2209,6 +2218,34 @@ Sweet! Now all the relevant HTML is inside the template, and we can move it to a
> {"attrs": {"my_key:two": 2}}
> ```
### Multi-line tags
By default, Django expects a template tag to be defined on a single line.
However, this can become unwieldy if you have a component with a lot of inputs:
```django
{% component "card" title="Joanne Arc" subtitle="Head of Kitty Relations" date_last_active="2024-09-03" ... %}
```
Instead, when you install django_components, it automatically configures Django
to suport multi-line tags.
So we can rewrite the above as:
```django
{% component "card"
title="Joanne Arc"
subtitle="Head of Kitty Relations"
date_last_active="2024-09-03"
...
%}
```
Much better!
To disable this behavior, set [`COMPONENTS.multiline_tag`](#multiline_tags---enabledisable-multiline-support) to `False`
## Prop drilling and dependency injection (provide / inject)
_New in version 0.80_:
@ -2749,6 +2786,20 @@ COMPONENTS = {
All library settings are handled from a global `COMPONENTS` variable that is read from `settings.py`. By default you don't need it set, there are resonable defaults.
Here's overview of all available settings and their defaults:
```py
COMPONENTS = {
"autodiscover": True,
"context_behavior": "django", # "django" | "isolated"
"libraries": [], # ["mysite.components.forms", ...]
"multiline_tags": True,
"reload_on_template_change": False,
"tag_formatter": "django_components.component_formatter",
"template_cache_size": 128,
}
```
### `libraries` - Load component modules
Configure the locations where components are loaded. To do this, add a `COMPONENTS` variable to you `settings.py` with a list of python paths to load. This allows you to build a structure of components that are independent from your apps.
@ -2801,6 +2852,16 @@ COMPONENTS = {
}
```
### `multiline_tags` - Enable/Disable multiline support
If `True`, template tags can span multiple lines. Default: `True`
```python
COMPONENTS = {
"multiline_tags": True,
}
```
### `template_cache_size` - Tune the template cache
Each time a template is rendered it is cached to a global in-memory cache (using Python's `lru_cache` decorator). This speeds up the next render of the component. As the same component is often used many times on the same page, these savings add up.
@ -2918,6 +2979,13 @@ But since `"cheese"` is not defined there, it's empty.
Notice that the variables defined with the `{% with %}` tag are ignored inside the `{% fill %}` tag with the `"isolated"` mode.
### `reload_on_template_change` - Reload dev server on component file changes
If `True`, configures Django to reload on component files. See
[Reload dev server on component file changes](#reload-dev-server-on-component-file-changes).
NOTE: This setting should be enabled only for the dev environment!
### `tag_formatter` - Change how components are used in templates
Sets the [`TagFormatter`](#available-tagformatters) instance. See the section [Customizing component tags with TagFormatter](#customizing-component-tags-with-tagformatter).
@ -2939,6 +3007,26 @@ COMPONENTS = {
}
```
## Running with development server
### Reload dev server on component file changes
This is relevant if you are using the project structure as shown in our examples, where
HTML, JS, CSS and Python are separate and nested in a directory.
In this case you may notice that when you are running a development server,
the server sometimes does not reload when you change comoponent files.
From relevant [StackOverflow thread](https://stackoverflow.com/a/76722393/9788634):
> TL;DR is that the server won't reload if it thinks the changed file is in a templates directory,
> or in a nested sub directory of a templates directory. This is by design.
To make the dev server reload on all component files, set [`reload_on_template_change`](#reload_on_template_change---reload-dev-server-on-component-file-changes) to `True`.
This configures Django to watch for component files too.
NOTE: This setting should be enabled only for the dev environment!
## Logging and debugging
Django components supports [logging with Django](https://docs.djangoproject.com/en/5.0/howto/logging/#logging-how-to). This can help with troubleshooting.

View file

@ -37,7 +37,22 @@ class SimpleComponent(Component):
class BreadcrumbComponent(Component):
template_name = "mdn_component_template.html"
template: types.django_html = """
<div class="breadcrumb-container">
<nav class="breadcrumbs">
<ol typeof="BreadcrumbList" vocab="https://schema.org/" aria-label="breadcrumbs">
{% for label, url in links %}
<li property="itemListElement" typeof="ListItem">
<a class="breadcrumb-current-page" property="item" typeof="WebPage" href="{{ url }}">
<span property="name">{{ label }}</span>
</a>
<meta property="position" content="4">
</li>
{% endfor %}
</ol>
</nav>
</div>
"""
LINKS = [
(

View file

@ -102,6 +102,14 @@ class AppSettings:
def LIBRARIES(self) -> List:
return self.settings.get("libraries", [])
@property
def MULTILINE_TAGS(self) -> bool:
return self.settings.get("multiline_tags", True)
@property
def RELOAD_ON_TEMPLATE_CHANGE(self) -> bool:
return self.settings.get("reload_on_template_change", False)
@property
def TEMPLATE_CACHE_SIZE(self) -> int:
return self.settings.get("template_cache_size", 128)

View file

@ -1,3 +1,5 @@
import re
from django.apps import AppConfig
@ -8,10 +10,36 @@ class ComponentsConfig(AppConfig):
# to Django's INSTALLED_APPS
def ready(self) -> None:
from django_components.app_settings import app_settings
from django_components.autodiscover import autodiscover, import_libraries
from django_components.autodiscover import autodiscover, get_dirs, import_libraries, search_dirs
from django_components.utils import watch_files_for_autoreload
# Import modules set in `COMPONENTS.libraries` setting
import_libraries()
if app_settings.AUTODISCOVER:
autodiscover()
# Watch template files for changes, so Django dev server auto-reloads
# See https://github.com/EmilStenstrom/django-components/discussions/567#discussioncomment-10273632
# And https://stackoverflow.com/questions/42907285/66673186#66673186
if app_settings.RELOAD_ON_TEMPLATE_CHANGE:
dirs = get_dirs()
component_filepaths = search_dirs(dirs, "**/*")
watch_files_for_autoreload(component_filepaths)
if app_settings.MULTILINE_TAGS:
# Allow tags to span multiple lines. This makes it easier to work with
# components inside Django templates, allowing us syntax like:
# ```html
# {% component "icon"
# icon='outline_chevron_down'
# size=16
# color="text-gray-400"
# attrs:class="ml-2"
# %}{% endcomponent %}
# ```
#
# See https://stackoverflow.com/a/54206609/9788634
from django.template import base
base.tag_re = re.compile(base.tag_re.pattern, re.DOTALL)

View file

@ -232,7 +232,8 @@ def component(parser: Parser, token: Token, registry: ComponentRegistry, tag_nam
tag_name,
parser,
token,
params=True, # Allow many args
params=[],
extra_params=True, # Allow many args
flags=[COMP_ONLY_FLAG],
keywordonly_kwargs=True,
repeatable_kwargs=False,
@ -360,7 +361,8 @@ def _parse_tag(
tag: str,
parser: Parser,
token: Token,
params: Union[List[str], bool] = False,
params: Optional[List[str]] = None,
extra_params: bool = False,
flags: Optional[List[str]] = None,
end_tag: Optional[str] = None,
optional_params: Optional[List[str]] = None,
@ -455,37 +457,36 @@ def _parse_tag(
# modify it much to maintain some sort of compatibility with Django's version of
# `parse_bits`.
# Ideally, Django's parser would be expanded to support our use cases.
if params != True: # noqa F712
params_to_sort = [param for param in params if param not in seen_kwargs]
new_args = []
new_params = []
new_kwargs = []
for index, bit in enumerate(bits):
if is_kwarg(bit) or not len(params_to_sort):
# Pass all remaining bits (including current one) as kwargs
new_kwargs.extend(bits[index:])
break
params_to_sort = [param for param in params if param not in seen_kwargs]
new_args = []
new_params = []
new_kwargs = []
for index, bit in enumerate(bits):
if is_kwarg(bit) or not len(params_to_sort):
# Pass all remaining bits (including current one) as kwargs
new_kwargs.extend(bits[index:])
break
param = params_to_sort.pop(0)
if optional_params and param in optional_params:
mark_kwarg_key(param, False)
new_kwargs.append(f"{param}={bit}")
continue
new_args.append(bit)
new_params.append(param)
param = params_to_sort.pop(0)
if optional_params and param in optional_params:
mark_kwarg_key(param, False)
new_kwargs.append(f"{param}={bit}")
continue
new_args.append(bit)
new_params.append(param)
bits = [*new_args, *new_kwargs]
params = [*new_params, *params_to_sort]
bits = [*new_args, *new_kwargs]
params = [*new_params, *params_to_sort]
# Remove any remaining optional positional args if they were not given
if optional_params:
params = [param for param in params_to_sort if param not in optional_params]
# Remove any remaining optional positional args if they were not given
if optional_params:
params = [param for param in params_to_sort if param not in optional_params]
# Parse args/kwargs that will be passed to the fill
raw_args, raw_kwarg_pairs = parse_bits(
parser=parser,
bits=bits,
params=[] if isinstance(params, bool) else params,
params=[] if extra_params else params,
name=tag_name,
)
@ -516,14 +517,11 @@ def _parse_tag(
kwarg_pairs.append((key, val))
# Allow only as many positional args as given
if params != True and len(args) > len(params): # noqa F712
if not extra_params and len(args) > len(params): # noqa F712
raise TemplateSyntaxError(f"Tag '{tag_name}' received unexpected positional arguments: {args[len(params):]}")
# For convenience, allow to access named args by their name instead of index
if params != True: # noqa F712
named_args = {param: args[index] for index, param in enumerate(params)}
else:
named_args = {}
named_args = {param: args[index] for index, param in enumerate(params)}
# Validate kwargs
kwargs: RuntimeKwargsInput = {}

View file

@ -1,4 +1,7 @@
from typing import Any, Callable, List
from pathlib import Path
from typing import Any, Callable, List, Sequence, Union
from django.utils.autoreload import autoreload_started
# Global counter to ensure that all IDs generated by `gen_id` WILL be unique
_id = 0
@ -23,3 +26,13 @@ def find_last_index(lst: List, predicate: Callable[[Any], bool]) -> Any:
def is_str_wrapped_in_quotes(s: str) -> bool:
return s.startswith(('"', "'")) and s[0] == s[-1] and len(s) >= 2
# See https://github.com/EmilStenstrom/django-components/issues/586#issue-2472678136
def watch_files_for_autoreload(watch_list: Sequence[Union[str, Path]]) -> None:
def autoreload_hook(sender: Any, *args: Any, **kwargs: Any) -> None:
watch = sender.extra_files.add
for file in watch_list:
watch(Path(file))
autoreload_started.connect(autoreload_hook)

View file

@ -1,14 +0,0 @@
<div class="breadcrumb-container">
<nav class="breadcrumbs">
<ol typeof="BreadcrumbList" vocab="https://schema.org/" aria-label="breadcrumbs">
{% for label, url in links %}
<li property="itemListElement" typeof="ListItem">
<a class="breadcrumb-current-page" property="item" typeof="WebPage" href="{{ url }}">
<span property="name">{{ label }}</span>
</a>
<meta property="position" content="4">
</li>
{% endfor %}
</ol>
</nav>
</div>

View file

@ -9,7 +9,7 @@ from django_components import Component, register, registry, types
from .django_test_setup import setup_test_config
from .testutils import BaseTestCase, parametrize_context_behavior
setup_test_config()
setup_test_config({"autodiscover": False})
class SlottedComponent(Component):
@ -480,3 +480,34 @@ class BlockCompatTests(BaseTestCase):
</html>
"""
self.assertHTMLEqual(rendered, expected)
class MultilineTagsTests(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"])
def test_multiline_tags(self):
@register("test_component")
class SimpleComponent(Component):
template: types.django_html = """
Variable: <strong>{{ variable }}</strong>
"""
def get_context_data(self, variable, variable2="default"):
return {
"variable": variable,
"variable2": variable2,
}
template: types.django_html = """
{% load component_tags %}
{% component
"test_component"
123
variable2="abc"
%}
{% endcomponent %}
"""
rendered = Template(template).render(Context())
expected = """
Variable: <strong>123</strong>
"""
self.assertHTMLEqual(rendered, expected)