From ab059f362de88c0562bc9957b488bec24d7c8f4a Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Wed, 28 Aug 2024 07:46:48 +0200 Subject: [PATCH 001/487] 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> --- README.md | 92 ++++++++++++++++++- benchmarks/component_rendering.py | 17 +++- src/django_components/app_settings.py | 8 ++ src/django_components/apps.py | 30 +++++- .../templatetags/component_tags.py | 58 ++++++------ src/django_components/utils.py | 15 ++- tests/templates/mdn_component_template.html | 14 --- tests/test_templatetags.py | 33 ++++++- 8 files changed, 217 insertions(+), 50 deletions(-) delete mode 100644 tests/templates/mdn_component_template.html diff --git a/README.md b/README.md index 7a2061dc..153bf118 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/benchmarks/component_rendering.py b/benchmarks/component_rendering.py index 66dbde86..bfeab52c 100644 --- a/benchmarks/component_rendering.py +++ b/benchmarks/component_rendering.py @@ -37,7 +37,22 @@ class SimpleComponent(Component): class BreadcrumbComponent(Component): - template_name = "mdn_component_template.html" + template: types.django_html = """ + + """ LINKS = [ ( diff --git a/src/django_components/app_settings.py b/src/django_components/app_settings.py index 755b6260..01a4cc39 100644 --- a/src/django_components/app_settings.py +++ b/src/django_components/app_settings.py @@ -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) diff --git a/src/django_components/apps.py b/src/django_components/apps.py index 5cb086db..d25b3dcd 100644 --- a/src/django_components/apps.py +++ b/src/django_components/apps.py @@ -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) diff --git a/src/django_components/templatetags/component_tags.py b/src/django_components/templatetags/component_tags.py index 06047b99..a3eb51d6 100644 --- a/src/django_components/templatetags/component_tags.py +++ b/src/django_components/templatetags/component_tags.py @@ -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 = {} diff --git a/src/django_components/utils.py b/src/django_components/utils.py index e77e9b28..d0b9f768 100644 --- a/src/django_components/utils.py +++ b/src/django_components/utils.py @@ -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) diff --git a/tests/templates/mdn_component_template.html b/tests/templates/mdn_component_template.html deleted file mode 100644 index 992b330f..00000000 --- a/tests/templates/mdn_component_template.html +++ /dev/null @@ -1,14 +0,0 @@ - \ No newline at end of file diff --git a/tests/test_templatetags.py b/tests/test_templatetags.py index af242546..7eb4e524 100644 --- a/tests/test_templatetags.py +++ b/tests/test_templatetags.py @@ -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): """ 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: {{ variable }} + """ + + 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: 123 + """ + self.assertHTMLEqual(rendered, expected) From 8c5b088c31b7166932e18739f882c2eef632f3a4 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Wed, 28 Aug 2024 07:55:21 +0200 Subject: [PATCH 002/487] Update pyproject.toml (#626) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c7ce929e..ef82e81e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "django_components" -version = "0.93" +version = "0.94" requires-python = ">=3.8, <4.0" description = "A way to create simple reusable template components in Django." keywords = ["django", "components", "css", "js", "html"] From e76227b8dff7a020deacefd163f2198cea365c8b Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Thu, 29 Aug 2024 11:28:00 +0200 Subject: [PATCH 003/487] feat: Add dynamic component (#627) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- README.md | 68 +++- src/django_components/__init__.py | 1 + src/django_components/app_settings.py | 6 +- src/django_components/apps.py | 29 +- src/django_components/component.py | 9 +- src/django_components/component_registry.py | 10 +- src/django_components/components/__init__.py | 3 + src/django_components/components/dynamic.py | 89 ++++++ src/django_components/slots.py | 10 +- tests/test_templatetags_component.py | 312 ++++++++++++++++++- 10 files changed, 503 insertions(+), 34 deletions(-) create mode 100644 src/django_components/components/__init__.py create mode 100644 src/django_components/components/dynamic.py diff --git a/README.md b/README.md index 153bf118..7e268c55 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,9 @@ And this is what gets rendered (plus the CSS and Javascript you've specified): - [Using single-file components](#using-single-file-components) - [Use components in templates](#use-components-in-templates) - [Use components outside of templates](#use-components-outside-of-templates) -- [Registering components](#registering-components) - [Use components as views](#use-components-as-views) +- [Pre-defined components](#pre-defined-components) +- [Registering components](#registering-components) - [Autodiscovery](#autodiscovery) - [Using slots in templates](#using-slots-in-templates) - [Accessing data passed to the component](#accessing-data-passed-to-the-component) @@ -67,6 +68,10 @@ And this is what gets rendered (plus the CSS and Javascript you've specified): ## Release notes +**Version 0.95** +- Added support for dynamic components, where the component name is passed as a variable. (See [Dynamic components](#dynamic-components)) +- Changed `Component.input` to raise `RuntimeError` if accessed outside of render context. Previously it returned `None` if unset. + **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)) @@ -850,6 +855,54 @@ class MyComponent(Component): do_something_extra(request, *args, **kwargs) ``` +## Pre-defined components + +### Dynamic components + +If you are writing something like a form component, you may design it such that users +give you the component names, and your component renders it. + +While you can handle this with a series of if / else statements, this is not an extensible solution. + +Instead, you can use **dynamic components**. Dynamic components are used in place of normal components. + +```django +{% load component_tags %} +{% component "dynamic" is=component_name title="Cat Museum" %} + {% fill "content" %} + HELLO_FROM_SLOT_1 + {% endfill %} + {% fill "sidebar" %} + HELLO_FROM_SLOT_2 + {% endfill %} +{% endcomponent %} +``` + +These behave same way as regular components. You pass it the same args, kwargs, and slots as you would +to the component that you want to render. + +The only exception is that also you supply 1-2 additional inputs: +- `is` - Required - The component name or a component class to render +- `registry` - Optional - The `ComponentRegistry` that will be searched if `is` is a component name. If omitted, ALL registries are searched. + +By default, the dynamic component is registered under the name `"dynamic"`. In case of a conflict, you can change the name used for the dynamic components by defining the [`COMPONENTS.dynamic_component_name` setting](#dynamic_component_name). + +If you need to use the dynamic components in Python, you can also import it from `django_components`: +```py +from django_components import DynamicComponent + +comp = SimpleTableComp if is_readonly else TableComp + +output = DynamicComponent.render( + kwargs={ + "is": comp, + # Other kwargs... + }, + # args: [...], + # slots: {...}, +) +``` + ## Registering components In previous examples you could repeatedly see us using `@register()` to "register" @@ -1559,7 +1612,7 @@ This means that you can use `self.input` inside: - `get_template_name` - `get_template_string` -`self.input` is defined only for the duration of `Component.render`, and returns `None` when called outside of this. +`self.input` is defined only for the duration of `Component.render`, and raises `RuntimeError` when called outside of this. `self.input` has the same fields as the input to `Component.render`: @@ -2792,6 +2845,7 @@ Here's overview of all available settings and their defaults: COMPONENTS = { "autodiscover": True, "context_behavior": "django", # "django" | "isolated" + "dynamic_component_name": "dynamic", "libraries": [], # ["mysite.components.forms", ...] "multiline_tags": True, "reload_on_template_change": False, @@ -2852,6 +2906,16 @@ COMPONENTS = { } ``` +### `dynamic_component_name` + +By default, the dynamic component is registered under the name `"dynamic"`. In case of a conflict, use this setting to change the name used for the dynamic components. + +```python +COMPONENTS = { + "dynamic_component_name": "new_dynamic", +} +``` + ### `multiline_tags` - Enable/Disable multiline support If `True`, template tags can span multiple lines. Default: `True` diff --git a/src/django_components/__init__.py b/src/django_components/__init__.py index df97b2e5..c7fbeb99 100644 --- a/src/django_components/__init__.py +++ b/src/django_components/__init__.py @@ -22,6 +22,7 @@ from django_components.component_registry import ( register as register, registry as registry, ) +from django_components.components import DynamicComponent as DynamicComponent from django_components.library import TagProtectedError as TagProtectedError from django_components.slots import ( SlotContent as SlotContent, diff --git a/src/django_components/app_settings.py b/src/django_components/app_settings.py index 01a4cc39..33f61074 100644 --- a/src/django_components/app_settings.py +++ b/src/django_components/app_settings.py @@ -99,7 +99,11 @@ class AppSettings: return self.settings.get("autodiscover", True) @property - def LIBRARIES(self) -> List: + def DYNAMIC_COMPONENT_NAME(self) -> str: + return self.settings.get("dynamic_component_name", "dynamic") + + @property + def LIBRARIES(self) -> List[str]: return self.settings.get("libraries", []) @property diff --git a/src/django_components/apps.py b/src/django_components/apps.py index d25b3dcd..66e392f2 100644 --- a/src/django_components/apps.py +++ b/src/django_components/apps.py @@ -11,6 +11,8 @@ class ComponentsConfig(AppConfig): def ready(self) -> None: from django_components.app_settings import app_settings from django_components.autodiscover import autodiscover, get_dirs, import_libraries, search_dirs + from django_components.component_registry import registry + from django_components.components.dynamic import DynamicComponent from django_components.utils import watch_files_for_autoreload # Import modules set in `COMPONENTS.libraries` setting @@ -27,19 +29,22 @@ class ComponentsConfig(AppConfig): component_filepaths = search_dirs(dirs, "**/*") watch_files_for_autoreload(component_filepaths) + # 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 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) + + # Register the dynamic component under the name as given in settings + registry.register(app_settings.DYNAMIC_COMPONENT_NAME, DynamicComponent) diff --git a/src/django_components/component.py b/src/django_components/component.py index 3f56abc6..1417ade6 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -205,14 +205,17 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co return self.registered_name or self.__class__.__name__ @property - def input(self) -> Optional[RenderInput[ArgsType, KwargsType, SlotsType]]: + def input(self) -> RenderInput[ArgsType, KwargsType, SlotsType]: """ Input holds the data (like arg, kwargs, slots) that were passsed to the current execution of the `render` method. """ + if not len(self._render_stack): + raise RuntimeError(f"{self.name}: Tried to access Component input while outside of rendering execution") + # NOTE: Input is managed as a stack, so if `render` is called within another `render`, # the propertes below will return only the inner-most state. - return self._render_stack[-1] if len(self._render_stack) else None + return self._render_stack[-1] def get_context_data(self, *args: Any, **kwargs: Any) -> DataType: return cast(DataType, {}) @@ -526,6 +529,8 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co component_name=self.name, context_data=slot_context_data, fill_content=fill_content, + # Dynamic component has a special mark do it doesn't raise certain errors + is_dynamic_component=getattr(self, "_is_dynamic_component", False), ) # Available slot fills - this is internal to us diff --git a/src/django_components/component_registry.py b/src/django_components/component_registry.py index 588e29b9..c9c72768 100644 --- a/src/django_components/component_registry.py +++ b/src/django_components/component_registry.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Callable, Dict, NamedTuple, Optional, Set, Type, TypeVar, Union +from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Type, TypeVar, Union from django.template import Library @@ -44,6 +44,12 @@ class InternalRegistrySettings(NamedTuple): TAG_FORMATTER: Union["TagFormatterABC", str] +# We keep track of all registries that exist so that, when users want to +# dynamically resolve component name to component class, they would be able +# to search across all registries. +all_registries: List["ComponentRegistry"] = [] + + class ComponentRegistry: """ Manages which components can be used in the template tags. @@ -88,6 +94,8 @@ class ComponentRegistry: self._settings_input = settings self._settings: Optional[Callable[[], InternalRegistrySettings]] = None + all_registries.append(self) + @property def library(self) -> Library: """ diff --git a/src/django_components/components/__init__.py b/src/django_components/components/__init__.py new file mode 100644 index 00000000..34433285 --- /dev/null +++ b/src/django_components/components/__init__.py @@ -0,0 +1,3 @@ +# flake8: noqa F401 + +from django_components.components.dynamic import DynamicComponent as DynamicComponent diff --git a/src/django_components/components/dynamic.py b/src/django_components/components/dynamic.py new file mode 100644 index 00000000..4ce37b06 --- /dev/null +++ b/src/django_components/components/dynamic.py @@ -0,0 +1,89 @@ +import inspect +from typing import Any, Dict, Optional, Type, Union, cast + +from django_components import Component, ComponentRegistry, NotRegistered, types +from django_components.component_registry import all_registries + + +class DynamicComponent(Component): + """ + Dynamic component - This component takes inputs and renders the outputs depending on the + `is` and `registry` arguments. + + - `is` - required - The component class or registered name of the component that will be + rendered in this place. + + - `registry` - optional - Specify the registry to search for the registered name. If omitted, + all registries are searched. + """ + + _is_dynamic_component = True + + def get_context_data( + self, + *args: Any, + registry: Optional[ComponentRegistry] = None, + **kwargs: Any, + ) -> Dict: + # NOTE: We have to access `is` via kwargs, because it's a special keyword in Python + comp_name_or_class: Union[str, Type[Component]] = kwargs.pop("is", None) + if not comp_name_or_class: + raise TypeError(f"Component '{self.name}' is missing a required argument 'is'") + + comp_class = self._resolve_component(comp_name_or_class, registry) + + comp = comp_class( + registered_name=self.registered_name, + component_id=self.component_id, + outer_context=self.outer_context, + fill_content=self.fill_content, + registry=self.registry, + ) + output = comp.render( + context=self.input.context, + args=args, + kwargs=kwargs, + escape_slots_content=self.input.escape_slots_content, + ) + + return { + "output": output, + } + + template: types.django_html = """ + {{ output }} + """ + + def _resolve_component( + self, + comp_name_or_class: Union[str, Type[Component], Any], + registry: Optional[ComponentRegistry] = None, + ) -> Type[Component]: + component_cls: Optional[Type[Component]] = None + + if not isinstance(comp_name_or_class, str): + # NOTE: When Django template is resolving the variable that refers to the + # component class, it may see that it's callable and evaluate it. Hence, we need + # get check if we've got class or instance. + if inspect.isclass(comp_name_or_class): + component_cls = comp_name_or_class + else: + component_cls = cast(Type[Component], comp_name_or_class.__class__) + + else: + if registry: + component_cls = registry.get(comp_name_or_class) + else: + # Search all registries for the first match + for reg in all_registries: + try: + component_cls = reg.get(comp_name_or_class) + break + except NotRegistered: + continue + + # Raise if none found + if not component_cls: + raise NotRegistered(f"The component '{comp_name_or_class}' was not found") + + return component_cls diff --git a/src/django_components/slots.py b/src/django_components/slots.py index d481af8a..4304c8ff 100644 --- a/src/django_components/slots.py +++ b/src/django_components/slots.py @@ -473,6 +473,7 @@ def resolve_slots( component_name: Optional[str], context_data: Mapping[str, Any], fill_content: Dict[SlotName, FillContent], + is_dynamic_component: bool = False, ) -> Tuple[Dict[SlotId, Slot], Dict[SlotId, SlotFill]]: """ Search the template for all SlotNodes, and associate the slots @@ -546,10 +547,14 @@ def resolve_slots( component_name=component_name, slots=slots, slot_fills=slot_fills, + is_dynamic_component=is_dynamic_component, ) # 4. Detect any errors with slots/fills - _report_slot_errors(slots, slot_fills, component_name) + # NOTE: We ignore errors for the dynamic component, as the underlying component + # will deal with it + if not is_dynamic_component: + _report_slot_errors(slots, slot_fills, component_name) # 5. Find roots of the slot relationships top_level_slot_ids: List[SlotId] = [] @@ -598,6 +603,7 @@ def _resolve_default_slot( component_name: Optional[str], slots: Dict[SlotId, Slot], slot_fills: Dict[SlotName, SlotFill], + is_dynamic_component: bool, ) -> Dict[SlotName, SlotFill]: """Figure out which slot the default fill refers to, and perform checks.""" named_fills = slot_fills.copy() @@ -637,7 +643,7 @@ def _resolve_default_slot( # Check: Only component templates that include a 'default' slot # can be invoked with implicit filling. - if default_fill and not default_slot_encountered: + if default_fill and not default_slot_encountered and not is_dynamic_component: raise TemplateSyntaxError( f"Component '{component_name}' passed default fill content '{default_fill.name}'" f"(i.e. without explicit 'fill' tag), " diff --git a/tests/test_templatetags_component.py b/tests/test_templatetags_component.py index e1c3419d..bf0fad5f 100644 --- a/tests/test_templatetags_component.py +++ b/tests/test_templatetags_component.py @@ -2,12 +2,12 @@ import textwrap from django.template import Context, Template, TemplateSyntaxError -from django_components import Component, NotRegistered, register, registry, types +from django_components import AlreadyRegistered, Component, NotRegistered, 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): @@ -75,18 +75,6 @@ class ComponentTemplateTagTest(BaseTestCase): rendered = template.render(Context({})) self.assertHTMLEqual(rendered, "Variable: variable\n") - @parametrize_context_behavior(["django", "isolated"]) - def test_raises_on_no_registered_components(self): - # Note: No tag registered - - simple_tag_template: types.django_html = """ - {% load component_tags %} - {% component name="test" variable="variable" %}{% endcomponent %} - """ - - with self.assertRaisesMessage(TemplateSyntaxError, "Invalid block tag on line 3: 'component'"): - Template(simple_tag_template) - @parametrize_context_behavior(["django", "isolated"]) def test_call_with_invalid_name(self): registry.register(name="test_one", component=self.SimpleComponent) @@ -199,6 +187,302 @@ class ComponentTemplateTagTest(BaseTestCase): ) +class DynamicComponentTemplateTagTest(BaseTestCase): + class SimpleComponent(Component): + template: types.django_html = """ + Variable: {{ variable }} + """ + + def get_context_data(self, variable, variable2="default"): + return { + "variable": variable, + "variable2": variable2, + } + + class Media: + css = "style.css" + js = "script.js" + + def setUp(self): + super().setUp() + + # Run app installation so the `dynamic` component is defined + from django_components.apps import ComponentsConfig + + ComponentsConfig.ready(None) # type: ignore[arg-type] + + @parametrize_context_behavior(["django", "isolated"]) + def test_basic(self): + registry.register(name="test", component=self.SimpleComponent) + + simple_tag_template: types.django_html = """ + {% load component_tags %} + {% component "dynamic" is="test" variable="variable" %}{% endcomponent %} + """ + + template = Template(simple_tag_template) + rendered = template.render(Context({})) + self.assertHTMLEqual(rendered, "Variable: variable\n") + + @parametrize_context_behavior(["django", "isolated"]) + def test_call_with_invalid_name(self): + registry.register(name="test", component=self.SimpleComponent) + + simple_tag_template: types.django_html = """ + {% load component_tags %} + {% component "dynamic" is="haber_der_baber" variable="variable" %}{% endcomponent %} + """ + + template = Template(simple_tag_template) + with self.assertRaisesMessage(NotRegistered, "The component 'haber_der_baber' was not found"): + template.render(Context({})) + + @parametrize_context_behavior(["django", "isolated"]) + def test_component_called_with_variable_as_name(self): + registry.register(name="test", component=self.SimpleComponent) + + simple_tag_template: types.django_html = """ + {% load component_tags %} + {% with component_name="test" %} + {% component "dynamic" is=component_name variable="variable" %}{% endcomponent %} + {% endwith %} + """ + + template = Template(simple_tag_template) + rendered = template.render(Context({})) + self.assertHTMLEqual(rendered, "Variable: variable\n") + + @parametrize_context_behavior(["django", "isolated"]) + def test_component_called_with_variable_as_spread(self): + registry.register(name="test", component=self.SimpleComponent) + + simple_tag_template: types.django_html = """ + {% load component_tags %} + {% component "dynamic" ...props %}{% endcomponent %} + """ + + template = Template(simple_tag_template) + rendered = template.render( + Context( + { + "props": { + "is": "test", + "variable": "variable", + }, + } + ) + ) + self.assertHTMLEqual(rendered, "Variable: variable\n") + + @parametrize_context_behavior(["django", "isolated"]) + def test_component_as_class(self): + registry.register(name="test", component=self.SimpleComponent) + + simple_tag_template: types.django_html = """ + {% load component_tags %} + {% component "dynamic" is=comp_cls variable="variable" %}{% endcomponent %} + """ + + template = Template(simple_tag_template) + rendered = template.render( + Context( + { + "comp_cls": self.SimpleComponent, + } + ) + ) + self.assertHTMLEqual(rendered, "Variable: variable\n") + + @parametrize_context_behavior( + ["django", "isolated"], + settings={ + "COMPONENTS": { + "tag_formatter": "django_components.component_shorthand_formatter", + "autodiscover": False, + }, + }, + ) + def test_shorthand_formatter(self): + from django_components.apps import ComponentsConfig + + ComponentsConfig.ready(None) # type: ignore[arg-type] + + registry.register(name="test", component=self.SimpleComponent) + + simple_tag_template: types.django_html = """ + {% load component_tags %} + {% dynamic is="test" variable="variable" %}{% enddynamic %} + """ + + template = Template(simple_tag_template) + rendered = template.render(Context({})) + self.assertHTMLEqual(rendered, "Variable: variable\n") + + @parametrize_context_behavior( + ["django", "isolated"], + settings={ + "COMPONENTS": { + "dynamic_component_name": "uno_reverse", + "tag_formatter": "django_components.component_shorthand_formatter", + "autodiscover": False, + }, + }, + ) + def test_component_name_is_configurable(self): + from django_components.apps import ComponentsConfig + + ComponentsConfig.ready(None) # type: ignore[arg-type] + + registry.register(name="test", component=self.SimpleComponent) + + simple_tag_template: types.django_html = """ + {% load component_tags %} + {% uno_reverse is="test" variable="variable" %}{% enduno_reverse %} + """ + + template = Template(simple_tag_template) + rendered = template.render(Context({})) + self.assertHTMLEqual(rendered, "Variable: variable\n") + + @parametrize_context_behavior(["django", "isolated"]) + def test_raises_already_registered_on_name_conflict(self): + with self.assertRaisesMessage(AlreadyRegistered, 'The component "dynamic" has already been registered'): + registry.register(name="dynamic", component=self.SimpleComponent) + + @parametrize_context_behavior(["django", "isolated"]) + def test_component_called_with_default_slot(self): + class SimpleSlottedComponent(Component): + template: types.django_html = """ + {% load component_tags %} + Variable: {{ variable }} + Slot: {% slot "default" default / %} + """ + + def get_context_data(self, variable, variable2="default"): + return { + "variable": variable, + "variable2": variable2, + } + + registry.register(name="test", component=SimpleSlottedComponent) + + simple_tag_template: types.django_html = """ + {% load component_tags %} + {% with component_name="test" %} + {% component "dynamic" is=component_name variable="variable" %} + HELLO_FROM_SLOT + {% endcomponent %} + {% endwith %} + """ + + template = Template(simple_tag_template) + rendered = template.render(Context({})) + self.assertHTMLEqual( + rendered, + """ + Variable: variable + Slot: HELLO_FROM_SLOT + """, + ) + + @parametrize_context_behavior(["django", "isolated"]) + def test_component_called_with_named_slots(self): + class SimpleSlottedComponent(Component): + template: types.django_html = """ + {% load component_tags %} + Variable: {{ variable }} + Slot 1: {% slot "default" default / %} + Slot 2: {% slot "two" / %} + """ + + def get_context_data(self, variable, variable2="default"): + return { + "variable": variable, + "variable2": variable2, + } + + registry.register(name="test", component=SimpleSlottedComponent) + + simple_tag_template: types.django_html = """ + {% load component_tags %} + {% with component_name="test" %} + {% component "dynamic" is=component_name variable="variable" %} + {% fill "default" %} + HELLO_FROM_SLOT_1 + {% endfill %} + {% fill "two" %} + HELLO_FROM_SLOT_2 + {% endfill %} + {% endcomponent %} + {% endwith %} + """ + + template = Template(simple_tag_template) + rendered = template.render(Context({})) + self.assertHTMLEqual( + rendered, + """ + Variable: variable + Slot 1: HELLO_FROM_SLOT_1 + Slot 2: HELLO_FROM_SLOT_2 + """, + ) + + @parametrize_context_behavior(["django", "isolated"]) + def test_raises_on_invalid_slots(self): + class SimpleSlottedComponent(Component): + template: types.django_html = """ + {% load component_tags %} + Variable: {{ variable }} + Slot 1: {% slot "default" default / %} + Slot 2: {% slot "two" / %} + """ + + def get_context_data(self, variable, variable2="default"): + return { + "variable": variable, + "variable2": variable2, + } + + registry.register(name="test", component=SimpleSlottedComponent) + + simple_tag_template: types.django_html = """ + {% load component_tags %} + {% with component_name="test" %} + {% component "dynamic" is=component_name variable="variable" %} + {% fill "default" %} + HELLO_FROM_SLOT_1 + {% endfill %} + {% fill "three" %} + HELLO_FROM_SLOT_2 + {% endfill %} + {% endcomponent %} + {% endwith %} + """ + + template = Template(simple_tag_template) + + with self.assertRaisesMessage( + TemplateSyntaxError, "Component \\'dynamic\\' passed fill that refers to undefined slot: \\'three\\'" + ): + template.render(Context({})) + + @parametrize_context_behavior(["django", "isolated"]) + def test_raises_on_invalid_args(self): + registry.register(name="test", component=self.SimpleComponent) + + simple_tag_template: types.django_html = """ + {% load component_tags %} + {% with component_name="test" %} + {% component "dynamic" is=component_name invalid_variable="variable" %}{% endcomponent %} + {% endwith %} + """ + + template = Template(simple_tag_template) + with self.assertRaisesMessage(TypeError, "got an unexpected keyword argument \\'invalid_variable\\'"): + template.render(Context({})) + + class MultiComponentTests(BaseTestCase): def register_components(self): registry.register("first_component", SlottedComponent) From 682bfc42397c0bdbeb1a2b6ccabb8aca89686d4f Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Thu, 29 Aug 2024 11:33:23 +0200 Subject: [PATCH 004/487] chore: bump v0.95 (#628) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ef82e81e..3baa1c51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "django_components" -version = "0.94" +version = "0.95" requires-python = ">=3.8, <4.0" description = "A way to create simple reusable template components in Django." keywords = ["django", "components", "css", "js", "html"] From 4a9cf7e26daf801ce23145600fc56c0baea51204 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Thu, 29 Aug 2024 23:09:36 +0200 Subject: [PATCH 005/487] feat: validate component inputs if types are given (#629) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- README.md | 226 ++++++++++++++++----- src/django_components/__init__.py | 4 + src/django_components/component.py | 104 +++++++++- src/django_components/slots.py | 2 + src/django_components/types.py | 15 +- src/django_components/utils.py | 86 +++++++- tests/test_component.py | 302 +++++++++++++++++++++++++---- 7 files changed, 641 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 7e268c55..29d957d6 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ And this is what gets rendered (plus the CSS and Javascript you've specified): - [Use components outside of templates](#use-components-outside-of-templates) - [Use components as views](#use-components-as-views) - [Pre-defined components](#pre-defined-components) +- [Typing and validating components](#typing-and-validating-components) +- [Pre-defined components](#pre-defined-components) - [Registering components](#registering-components) - [Autodiscovery](#autodiscovery) - [Using slots in templates](#using-slots-in-templates) @@ -632,58 +634,6 @@ MyComponent.render_to_response( ) ``` -### Adding type hints with Generics - -The `Component` class optionally accepts type parameters -that allow you to specify the types of args, kwargs, slots, and -data. - -```py -from typing import NotRequired, Tuple, TypedDict, SlotFunc - -# Positional inputs - Tuple -Args = Tuple[int, str] - -# Kwargs inputs - Mapping -class Kwargs(TypedDict): - variable: str - another: int - maybe_var: NotRequired[int] - -# Data returned from `get_context_data` - Mapping -class Data(TypedDict): - variable: str - -# The data available to the `my_slot` scoped slot -class MySlotData(TypedDict): - value: int - -# Slot functions - Mapping -class Slots(TypedDict): - # Use SlotFunc for slot functions. - # The generic specifies the `data` dictionary - my_slot: NotRequired[SlotFunc[MySlotData]] - -class Button(Component[Args, Kwargs, Data, Slots]): - def get_context_data(self, variable, another): - return { - "variable": variable, - } -``` - -When you then call `Component.render` or `Component.render_to_response`, you will get type hints: - -```py -Button.render( - # Error: First arg must be `int`, got `float` - args=(1.25, "abc"), - # Error: Key "another" is missing - kwargs={ - "variable": "text", - }, -) -``` - ### Response class of `render_to_response` While `render` method returns a plain string, `render_to_response` wraps the rendered content in a "Response" class. By default, this is `django.http.HttpResponse`. @@ -855,6 +805,178 @@ class MyComponent(Component): do_something_extra(request, *args, **kwargs) ``` +## Typing and validating components + +### Adding type hints with Generics + +The `Component` class optionally accepts type parameters +that allow you to specify the types of args, kwargs, slots, and +data: + +```py +class Button(Component[Args, Kwargs, Data, Slots]): + ... +``` + +- `Args` - Must be a `Tuple` or `Any` +- `Kwargs` - Must be a `TypedDict` or `Any` +- `Data` - Must be a `TypedDict` or `Any` +- `Slots` - Must be a `TypedDict` or `Any` + +Here's a full example: + +```py +from typing import NotRequired, Tuple, TypedDict, SlotContent, SlotFunc + +# Positional inputs +Args = Tuple[int, str] + +# Kwargs inputs +class Kwargs(TypedDict): + variable: str + another: int + maybe_var: NotRequired[int] # May be ommited + +# Data returned from `get_context_data` +class Data(TypedDict): + variable: str + +# The data available to the `my_slot` scoped slot +class MySlotData(TypedDict): + value: int + +# Slots +class Slots(TypedDict): + # Use SlotFunc for slot functions. + # The generic specifies the `data` dictionary + my_slot: NotRequired[SlotFunc[MySlotData]] + # SlotContent == Union[str, SafeString] + another_slot: SlotContent + +class Button(Component[Args, Kwargs, Data, Slots]): + def get_context_data(self, variable, another): + return { + "variable": variable, + } +``` + +When you then call `Component.render` or `Component.render_to_response`, you will get type hints: + +```py +Button.render( + # Error: First arg must be `int`, got `float` + args=(1.25, "abc"), + # Error: Key "another" is missing + kwargs={ + "variable": "text", + }, +) +``` + +#### Usage for Python <3.11 + +On Python 3.8-3.10, use `typing_extensions` + +```py +from typing_extensions import TypedDict, NotRequired +``` + +Additionally on Python 3.8-3.9, also import `annotations`: + +```py +from __future__ import annotations +``` + +Moreover, on 3.10 and less, you may not be able to use `NotRequired`, and instead you will need to mark either all keys are required, or all keys as optional, using TypeDict's `total` kwarg. + +[See PEP-655](https://peps.python.org/pep-0655) for more info. + + +### Passing additional args or kwargs + +You may have a function that supports any number of args or kwargs: + +```py +def get_context_data(self, *args, **kwargs): + ... +``` + +This is not supported with the typed components. + +As a workaround: +- For `*args`, set a positional argument that accepts a list of values: + + ```py + # Tuple of one member of list of strings + Args = Tuple[List[str]] + ``` + +- For `*kwargs`, set a keyword argument that accepts a dictionary of values: + + ```py + class Kwargs(TypedDict): + variable: str + another: int + # Pass any extra keys under `extra` + extra: Dict[str, any] + ``` + +### Handling no args or no kwargs + +To declare that a component accepts no Args, Kwargs, etc, you can use `EmptyTuple` and `EmptyDict` types: + +```py +from django_components import Component, EmptyDict, EmptyTuple + +Args = EmptyTuple +Kwargs = Data = Slots = EmptyDict + +class Button(Component[Args, Kwargs, Data, Slots]): + ... +``` + +### Runtime input validation with types + +> NOTE: Kwargs, slots, and data validation is supported only for Python >=3.11 + +In Python 3.11 and later, when you specify the component types, you will get also runtime validation of the inputs you pass to `Component.render` or `Component.render_to_response`. + +So, using the example from before, if you ignored the type errors and still ran the following code: + +```py +Button.render( + # Error: First arg must be `int`, got `float` + args=(1.25, "abc"), + # Error: Key "another" is missing + kwargs={ + "variable": "text", + }, +) +``` + +This would raise a `TypeError`: + +```txt +Component 'Button' expected positional argument at index 0 to be , got 1.25 of type +``` + +In case you need to skip these errors, you can either set the faulty member to `Any`, e.g.: + +```py +# Changed `int` to `Any` +Args = Tuple[Any, str] +``` + +Or you can replace `Args` with `Any` altogether, to skip the validation of args: + +```py +# Replaced `Args` with `Any` +class Button(Component[Any, Kwargs, Data, Slots]): + ... +``` + +Same applies to kwargs, data, and slots. + ## Pre-defined components ### Dynamic components diff --git a/src/django_components/__init__.py b/src/django_components/__init__.py index c7fbeb99..fa08564c 100644 --- a/src/django_components/__init__.py +++ b/src/django_components/__init__.py @@ -37,6 +37,10 @@ from django_components.tag_formatter import ( component_shorthand_formatter as component_shorthand_formatter, ) import django_components.types as types +from django_components.types import ( + EmptyTuple as EmptyTuple, + EmptyDict as EmptyDict, +) # isort: on diff --git a/src/django_components/component.py b/src/django_components/component.py index 1417ade6..7d008b9d 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -10,6 +10,7 @@ from typing import ( Dict, Generic, List, + Literal, Mapping, Optional, Protocol, @@ -60,7 +61,7 @@ from django_components.slots import ( resolve_fill_nodes, resolve_slots, ) -from django_components.utils import gen_id +from django_components.utils import gen_id, validate_typed_dict, validate_typed_tuple # TODO_DEPRECATE_V1 - REMOVE IN V1, users should use top-level import instead # isort: off @@ -196,6 +197,8 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co self.component_id = component_id or gen_id() self.registry = registry or registry_ self._render_stack: Deque[RenderInput[ArgsType, KwargsType, SlotsType]] = deque() + # None == uninitialized, False == No types, Tuple == types + self._types: Optional[Union[Tuple[Any, Any, Any, Any], Literal[False]]] = None def __init_subclass__(cls, **kwargs: Any) -> None: cls._class_hash = hash(inspect.getfile(cls) + cls.__name__) @@ -491,7 +494,10 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co ) ) + self._validate_inputs() + context_data = self.get_context_data(*args, **kwargs) + self._validate_outputs(context_data) with context.update(context_data): template = self.get_template(context) @@ -578,7 +584,7 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co """Fill component slots outside of template rendering.""" slot_fills = {} for slot_name, content in slots_data.items(): - if isinstance(content, (str, SafeString)): + if not callable(content): content_func = _nodelist_to_slot_render_func( NodeList([TextNode(conditional_escape(content) if escape_content else content)]) ) @@ -599,6 +605,100 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co ) return slot_fills + ###################### + # VALIDATION + ###################### + + def _get_types(self) -> Optional[Tuple[Any, Any, Any, Any]]: + """ + Extract the types passed to the Component class. + + So if a component subclasses Component class like so + + ```py + class MyComp(Component[MyArgs, MyKwargs, Any, MySlots]): + ... + ``` + + Then we want to extract the tuple (MyArgs, MyKwargs, Any, MySlots). + + Returns `None` if types were not provided. That is, the class was subclassed + as: + + ```py + class MyComp(Component): + ... + ``` + """ + # For efficiency, the type extraction is done only once. + # If `self._types` is `False`, that means that the types were not specified. + # If `self._types` is `None`, then this is the first time running this method. + # Otherwise, `self._types` should be a tuple of (Args, Kwargs, Data, Slots) + if self._types == False: # noqa: E712 + return None + elif self._types: + return self._types + + # Since a class can extend multiple classes, e.g. + # + # ```py + # class MyClass(BaseOne, BaseTwo, ...): + # ... + # ``` + # + # Then we need to find the base class that is our `Component` class. + # + # NOTE: __orig_bases__ is a tuple of _GenericAlias + # See https://github.com/python/cpython/blob/709ef004dffe9cee2a023a3c8032d4ce80513582/Lib/typing.py#L1244 + # And https://github.com/python/cpython/issues/101688 + generics_bases: Tuple[Any, ...] = self.__orig_bases__ # type: ignore[attr-defined] + component_generics_base = None + for base in generics_bases: + origin_cls = base.__origin__ + if origin_cls == Component or issubclass(origin_cls, Component): + component_generics_base = base + break + + if not component_generics_base: + # If we get here, it means that the Component class wasn't supplied any generics + self._types = False + return None + + # If we got here, then we've found ourselves the typed Component class, e.g. + # + # `Component(Tuple[int], MyKwargs, MySlots, Any)` + # + # By accessing the __args__, we access individual types between the brackets, so + # + # (Tuple[int], MyKwargs, MySlots, Any) + args_type, kwargs_type, data_type, slots_type = component_generics_base.__args__ + + self._types = args_type, kwargs_type, data_type, slots_type + return self._types + + def _validate_inputs(self) -> None: + + maybe_inputs = self._get_types() + if maybe_inputs is None: + return + args_type, kwargs_type, data_type, slots_type = maybe_inputs + + # Validate args + validate_typed_tuple(self.input.args, args_type, f"Component '{self.name}'", "positional argument") + # Validate kwargs + validate_typed_dict(self.input.kwargs, kwargs_type, f"Component '{self.name}'", "keyword argument") + # Validate slots + validate_typed_dict(self.input.slots, slots_type, f"Component '{self.name}'", "slot") + + def _validate_outputs(self, data: Any) -> None: + maybe_inputs = self._get_types() + if maybe_inputs is None: + return + args_type, kwargs_type, data_type, slots_type = maybe_inputs + + # Validate data + validate_typed_dict(data, data_type, f"Component '{self.name}'", "data") + class ComponentNode(BaseNode): """Django.template.Node subclass that renders a django-components component""" diff --git a/src/django_components/slots.py b/src/django_components/slots.py index 4304c8ff..59fa2afa 100644 --- a/src/django_components/slots.py +++ b/src/django_components/slots.py @@ -18,6 +18,7 @@ from typing import ( Type, TypeVar, Union, + runtime_checkable, ) from django.template import Context, Template @@ -54,6 +55,7 @@ SLOT_DEFAULT_KEYWORD = "default" SlotResult = Union[str, SafeString] +@runtime_checkable class SlotFunc(Protocol, Generic[TSlotData]): def __call__(self, ctx: Context, slot_data: TSlotData, slot_ref: "SlotRef") -> SlotResult: ... # noqa E704 diff --git a/src/django_components/types.py b/src/django_components/types.py index 73f9db75..4558296b 100644 --- a/src/django_components/types.py +++ b/src/django_components/types.py @@ -1,7 +1,14 @@ """Helper types for IDEs.""" +import sys import typing -from typing import Any +from typing import Any, Tuple + +# See https://peps.python.org/pep-0655/#usage-in-python-3-11 +if sys.version_info >= (3, 11): + from typing import TypedDict +else: + from typing_extensions import TypedDict # for Python <3.11 with (Not)Required try: from typing import Annotated # type: ignore @@ -28,3 +35,9 @@ except ImportError: css = Annotated[str, "css"] django_html = Annotated[str, "django_html"] js = Annotated[str, "js"] + +EmptyTuple = Tuple[()] + + +class EmptyDict(TypedDict): + pass diff --git a/src/django_components/utils.py b/src/django_components/utils.py index d0b9f768..102657e2 100644 --- a/src/django_components/utils.py +++ b/src/django_components/utils.py @@ -1,5 +1,6 @@ +import sys from pathlib import Path -from typing import Any, Callable, List, Sequence, Union +from typing import Any, Callable, List, Mapping, Sequence, Tuple, Union, get_type_hints from django.utils.autoreload import autoreload_started @@ -36,3 +37,86 @@ def watch_files_for_autoreload(watch_list: Sequence[Union[str, Path]]) -> None: watch(Path(file)) autoreload_started.connect(autoreload_hook) + + +# NOTE: tuple_type is a _GenericAlias - See https://stackoverflow.com/questions/74412803 +def validate_typed_tuple( + value: Tuple[Any, ...], + tuple_type: Any, + prefix: str, + kind: str, +) -> None: + # `Any` type is the signal that we should skip validation + if tuple_type == Any: + return + + # We do two kinds of validation with the given Tuple type: + # 1. We check whether there are any extra / missing positional args + # 2. We look at the members of the Tuple (which are types themselves), + # and check if our concrete list / tuple has correct types under correct indices. + expected_pos_args = len(tuple_type.__args__) + actual_pos_args = len(value) + if expected_pos_args > actual_pos_args: + # Generate errors like below (listed for searchability) + # `Component 'name' expected 3 positional arguments, got 2` + raise TypeError(f"{prefix} expected {expected_pos_args} {kind}s, got {actual_pos_args}") + + for index, arg_type in enumerate(tuple_type.__args__): + arg = value[index] + if not isinstance(arg, arg_type): + # Generate errors like below (listed for searchability) + # `Component 'name' expected positional argument at index 0 to be , got 123.5 of type ` # noqa: E501 + raise TypeError( + f"{prefix} expected {kind} at index {index} to be {arg_type}, got {arg} of type {type(arg)}" + ) + + +# NOTE: +# - `dict_type` can be a `TypedDict` or `Any` as the types themselves +# - `value` is expected to be TypedDict, the base `TypedDict` type cannot be used +# in function signature (only its subclasses can), so we specify the type as Mapping. +# See https://stackoverflow.com/questions/74412803 +def validate_typed_dict(value: Mapping[str, Any], dict_type: Any, prefix: str, kind: str) -> None: + # `Any` type is the signal that we should skip validation + if dict_type == Any: + return + + # See https://stackoverflow.com/a/76527675 + # And https://stackoverflow.com/a/71231688 + required_kwargs = dict_type.__required_keys__ + unseen_keys = set(value.keys()) + + # For each entry in the TypedDict, we do two kinds of validation: + # 1. We check whether there are any extra / missing keys + # 2. We look at the values of TypedDict entries (which are types themselves), + # and check if our concrete dict has correct types under correct keys. + for key, kwarg_type in get_type_hints(dict_type).items(): + if key not in value: + if key in required_kwargs: + # Generate errors like below (listed for searchability) + # `Component 'name' is missing a required keyword argument 'key'` + # `Component 'name' is missing a required slot argument 'key'` + # `Component 'name' is missing a required data argument 'key'` + raise TypeError(f"{prefix} is missing a required {kind} '{key}'") + else: + unseen_keys.remove(key) + kwarg = value[key] + + # NOTE: `isinstance()` cannot be used with the version of TypedDict prior to 3.11. + # So we do type validation for TypedDicts only in 3.11 and later. + if sys.version_info >= (3, 11) and not isinstance(kwarg, kwarg_type): + # Generate errors like below (listed for searchability) + # `Component 'name' expected keyword argument 'key' to be , got 123.4 of type ` # noqa: E501 + # `Component 'name' expected slot 'key' to be , got 123.4 of type ` + # `Component 'name' expected data 'key' to be , got 123.4 of type ` + raise TypeError( + f"{prefix} expected {kind} '{key}' to be {kwarg_type}, got {kwarg} of type {type(kwarg)}" + ) + + if unseen_keys: + formatted_keys = ", ".join([f"'{key}'" for key in unseen_keys]) + # Generate errors like below (listed for searchability) + # `Component 'name' got unexpected keyword argument keys 'invalid_key'` + # `Component 'name' got unexpected slot keys 'invalid_key'` + # `Component 'name' got unexpected data keys 'invalid_key'` + raise TypeError(f"{prefix} got unexpected {kind} keys {formatted_keys}") diff --git a/tests/test_component.py b/tests/test_component.py index 15617d41..605bb2aa 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -3,13 +3,23 @@ Tests focusing on the Component class. For tests focusing on the `component` tag, see `test_templatetags_component.py` """ -from typing import Dict, Tuple, TypedDict, no_type_check +import sys +from typing import Any, Dict, Tuple, Union, no_type_check + +# See https://peps.python.org/pep-0655/#usage-in-python-3-11 +if sys.version_info >= (3, 11): + from typing import NotRequired, TypedDict +else: + from typing_extensions import NotRequired, TypedDict # for Python <3.11 with (Not)Required + +from unittest import skipIf from django.core.exceptions import ImproperlyConfigured from django.http import HttpRequest, HttpResponse from django.template import Context, RequestContext, Template, TemplateSyntaxError +from django.utils.safestring import SafeString -from django_components import Component, registry, types +from django_components import Component, SlotFunc, registry, types from django_components.slots import SlotRef from .django_test_setup import setup_test_config @@ -18,6 +28,34 @@ from .testutils import BaseTestCase, parametrize_context_behavior setup_test_config({"autodiscover": False}) +# Component typings +CompArgs = Tuple[int, str] + + +class CompData(TypedDict): + variable: str + + +class CompSlots(TypedDict): + my_slot: Union[str, int] + my_slot2: SlotFunc + + +if sys.version_info >= (3, 11): + + class CompKwargs(TypedDict): + variable: str + another: int + optional: NotRequired[int] + +else: + + class CompKwargs(TypedDict, total=False): + variable: str + another: int + optional: NotRequired[int] + + class ComponentTest(BaseTestCase): class ParentComponent(Component): template: types.django_html = """ @@ -186,46 +224,6 @@ class ComponentTest(BaseTestCase): """, ) - def test_typed(self): - TestCompArgs = Tuple[int, str] - - class TestCompKwargs(TypedDict): - variable: str - another: int - - class TestCompData(TypedDict): - abc: int - - class TestCompSlots(TypedDict): - my_slot: str - - class TestComponent(Component[TestCompArgs, TestCompKwargs, TestCompData, TestCompSlots]): - def get_context_data(self, var1, var2, variable, another, **attrs): - return { - "variable": variable, - } - - def get_template(self, context): - template_str: types.django_html = """ - {% load component_tags %} - Variable: {{ variable }} - {% slot 'my_slot' / %} - """ - return Template(template_str) - - rendered = TestComponent.render( - kwargs={"variable": "test", "another": 1}, - args=(123, "str"), - slots={"my_slot": "MY_SLOT"}, - ) - - self.assertHTMLEqual( - rendered, - """ - Variable: test MY_SLOT - """, - ) - def test_input(self): tester = self @@ -269,6 +267,226 @@ class ComponentTest(BaseTestCase): ) +class ComponentValidationTest(BaseTestCase): + def test_validate_input_passes(self): + class TestComponent(Component[CompArgs, CompKwargs, CompData, CompSlots]): + def get_context_data(self, var1, var2, variable, another, **attrs): + return { + "variable": variable, + } + + template: types.django_html = """ + {% load component_tags %} + Variable: {{ variable }} + Slot 1: {% slot "my_slot" / %} + Slot 2: {% slot "my_slot2" / %} + """ + + rendered = TestComponent.render( + kwargs={"variable": "test", "another": 1}, + args=(123, "str"), + slots={ + "my_slot": SafeString("MY_SLOT"), + "my_slot2": lambda ctx, data, ref: "abc", + }, + ) + + self.assertHTMLEqual( + rendered, + """ + Variable: test + Slot 1: MY_SLOT + Slot 2: abc + """, + ) + + @skipIf(sys.version_info < (3, 11), "Requires >= 3.11") + def test_validate_input_fails(self): + class TestComponent(Component[CompArgs, CompKwargs, CompData, CompSlots]): + def get_context_data(self, var1, var2, variable, another, **attrs): + return { + "variable": variable, + } + + template: types.django_html = """ + {% load component_tags %} + Variable: {{ variable }} + Slot 1: {% slot "my_slot" / %} + Slot 2: {% slot "my_slot2" / %} + """ + + with self.assertRaisesMessage(TypeError, "Component 'TestComponent' expected 2 positional arguments, got 1"): + TestComponent.render( + kwargs={"variable": 1, "another": "test"}, # type: ignore + args=(123,), # type: ignore + slots={ + "my_slot": "MY_SLOT", + "my_slot2": lambda ctx, data, ref: "abc", + }, + ) + + with self.assertRaisesMessage(TypeError, "Component 'TestComponent' expected 2 positional arguments, got 0"): + TestComponent.render( + kwargs={"variable": 1, "another": "test"}, # type: ignore + slots={ + "my_slot": "MY_SLOT", + "my_slot2": lambda ctx, data, ref: "abc", + }, + ) + + with self.assertRaisesMessage( + TypeError, + "Component 'TestComponent' expected keyword argument 'variable' to be , got 1 of type ", # noqa: E501 + ): + TestComponent.render( + kwargs={"variable": 1, "another": "test"}, # type: ignore + args=(123, "abc", 456), # type: ignore + slots={ + "my_slot": "MY_SLOT", + "my_slot2": lambda ctx, data, ref: "abc", + }, + ) + + with self.assertRaisesMessage(TypeError, "Component 'TestComponent' expected 2 positional arguments, got 0"): + TestComponent.render() + + with self.assertRaisesMessage( + TypeError, + "Component 'TestComponent' expected keyword argument 'variable' to be , got 1 of type ", # noqa: E501 + ): + TestComponent.render( + kwargs={"variable": 1, "another": "test"}, # type: ignore + args=(123, "str"), + slots={ + "my_slot": "MY_SLOT", + "my_slot2": lambda ctx, data, ref: "abc", + }, + ) + + with self.assertRaisesMessage( + TypeError, "Component 'TestComponent' is missing a required keyword argument 'another'" + ): + TestComponent.render( + kwargs={"variable": "abc"}, # type: ignore + args=(123, "str"), + slots={ + "my_slot": "MY_SLOT", + "my_slot2": lambda ctx, data, ref: "abc", + }, + ) + + with self.assertRaisesMessage( + TypeError, + "Component 'TestComponent' expected slot 'my_slot' to be typing.Union[str, int], got 123.5 of type ", # noqa: E501 + ): + TestComponent.render( + kwargs={"variable": "abc", "another": 1}, + args=(123, "str"), + slots={ + "my_slot": 123.5, # type: ignore + "my_slot2": lambda ctx, data, ref: "abc", + }, + ) + + with self.assertRaisesMessage(TypeError, "Component 'TestComponent' is missing a required slot 'my_slot2'"): + TestComponent.render( + kwargs={"variable": "abc", "another": 1}, + args=(123, "str"), + slots={ + "my_slot": "MY_SLOT", + }, # type: ignore + ) + + def test_validate_input_skipped(self): + class TestComponent(Component[Any, CompKwargs, CompData, Any]): + def get_context_data(self, var1, var2, variable, another, **attrs): + return { + "variable": variable, + } + + template: types.django_html = """ + {% load component_tags %} + Variable: {{ variable }} + Slot 1: {% slot "my_slot" / %} + Slot 2: {% slot "my_slot2" / %} + """ + + rendered = TestComponent.render( + kwargs={"variable": "test", "another": 1}, + args=("123", "str"), # NOTE: Normally should raise + slots={ + "my_slot": 123.5, # NOTE: Normally should raise + "my_slot2": lambda ctx, data, ref: "abc", + }, + ) + + self.assertHTMLEqual( + rendered, + """ + Variable: test + Slot 1: 123.5 + Slot 2: abc + """, + ) + + def test_validate_output_passes(self): + class TestComponent(Component[CompArgs, CompKwargs, CompData, CompSlots]): + def get_context_data(self, var1, var2, variable, another, **attrs): + return { + "variable": variable, + } + + template: types.django_html = """ + {% load component_tags %} + Variable: {{ variable }} + Slot 1: {% slot "my_slot" / %} + Slot 2: {% slot "my_slot2" / %} + """ + + rendered = TestComponent.render( + kwargs={"variable": "test", "another": 1}, + args=(123, "str"), + slots={ + "my_slot": SafeString("MY_SLOT"), + "my_slot2": lambda ctx, data, ref: "abc", + }, + ) + + self.assertHTMLEqual( + rendered, + """ + Variable: test + Slot 1: MY_SLOT + Slot 2: abc + """, + ) + + def test_validate_output_fails(self): + class TestComponent(Component[CompArgs, CompKwargs, CompData, CompSlots]): + def get_context_data(self, var1, var2, variable, another, **attrs): + return { + "variable": variable, + "invalid_key": var1, + } + + template: types.django_html = """ + {% load component_tags %} + Variable: {{ variable }} + Slot 1: {% slot "my_slot" / %} + Slot 2: {% slot "my_slot2" / %} + """ + + with self.assertRaisesMessage(TypeError, "Component 'TestComponent' got unexpected data keys 'invalid_key'"): + TestComponent.render( + kwargs={"variable": "test", "another": 1}, + args=(123, "str"), + slots={ + "my_slot": SafeString("MY_SLOT"), + "my_slot2": lambda ctx, data, ref: "abc", + }, + ) + + class ComponentRenderTest(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_render_minimal(self): From 3c6f478f8ad4717014d6081fd09b12afd43de645 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Stenstr=C3=B6m?= Date: Sat, 31 Aug 2024 10:33:01 +0200 Subject: [PATCH 006/487] Upgrade all the docs dependencies to fix dependency issue. --- requirements-docs.in | 18 ++++++ requirements-docs.txt | 134 +++++++++++++++++++----------------------- 2 files changed, 80 insertions(+), 72 deletions(-) create mode 100644 requirements-docs.in diff --git a/requirements-docs.in b/requirements-docs.in new file mode 100644 index 00000000..eb5d30f1 --- /dev/null +++ b/requirements-docs.in @@ -0,0 +1,18 @@ +markdown-exec +mike +mkdocs +mkdocs-autorefs +mkdocs-gen-files +mkdocs-git-authors-plugin +mkdocs-git-revision-date-localized-plugin +mkdocs-include-markdown-plugin +mkdocs-literate-nav +mkdocs-material +mkdocs-material[imaging] +mkdocs-minify-plugin +mkdocs-redirects +mkdocstrings +mkdocstrings-python +pymdown-extensions +black +django>=4.2 diff --git a/requirements-docs.txt b/requirements-docs.txt index f4655664..2a5dcfaa 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -1,43 +1,26 @@ # -# This file is autogenerated by hatch-pip-compile with Python 3.12 +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: # -# - markdown-exec -# - mike -# - mkdocs -# - mkdocs-autorefs -# - mkdocs-gen-files -# - mkdocs-git-authors-plugin -# - mkdocs-git-revision-date-localized-plugin -# - mkdocs-include-markdown-plugin -# - mkdocs-literate-nav -# - mkdocs-material -# - mkdocs-material[imaging] -# - mkdocs-minify-plugin -# - mkdocs-redirects -# - mkdocstrings -# - mkdocstrings-python -# - pymdown-extensions -# - black -# - django>=4.2 +# pip-compile requirements-docs.in # - asgiref==3.8.1 # via django -babel==2.14.0 +babel==2.16.0 # via # mkdocs-git-revision-date-localized-plugin # mkdocs-material -black==24.3.0 - # via hatch.envs.docs -bracex==2.4 +black==24.8.0 + # via -r requirements-docs.in +bracex==2.5 # via wcmatch -cairocffi==1.6.1 +cairocffi==1.7.1 # via cairosvg cairosvg==2.7.1 # via mkdocs-material -certifi==2024.7.4 +certifi==2024.8.30 # via requests -cffi==1.16.0 +cffi==1.17.0 # via cairocffi charset-normalizer==3.3.2 # via requests @@ -56,23 +39,23 @@ cssselect2==0.7.0 # via cairosvg defusedxml==0.7.1 # via cairosvg -django==5.0.8 - # via hatch.envs.docs +django==5.1 + # via -r requirements-docs.in ghp-import==2.1.0 # via mkdocs gitdb==4.0.11 # via gitpython gitpython==3.1.43 # via mkdocs-git-revision-date-localized-plugin -griffe==0.42.1 +griffe==1.2.0 # via mkdocstrings-python htmlmin2==0.1.13 # via mkdocs-minify-plugin -idna==3.7 +idna==3.8 # via requests importlib-metadata==8.4.0 # via mike -importlib-resources==6.4.0 +importlib-resources==6.4.4 # via mike jinja2==3.1.4 # via @@ -82,16 +65,15 @@ jinja2==3.1.4 # mkdocstrings jsmin==3.0.1 # via mkdocs-minify-plugin -markdown==3.5.2 +markdown==3.7 # via # mkdocs # mkdocs-autorefs # mkdocs-material # mkdocstrings - # mkdocstrings-python # pymdown-extensions -markdown-exec==1.8.0 - # via hatch.envs.docs +markdown-exec==1.9.3 + # via -r requirements-docs.in markupsafe==2.1.5 # via # jinja2 @@ -99,12 +81,14 @@ markupsafe==2.1.5 # mkdocs-autorefs # mkdocstrings mergedeep==1.3.4 - # via mkdocs -mike==2.0.0 - # via hatch.envs.docs -mkdocs==1.5.3 # via - # hatch.envs.docs + # mkdocs + # mkdocs-get-deps +mike==2.1.3 + # via -r requirements-docs.in +mkdocs==1.6.1 + # via + # -r requirements-docs.in # mike # mkdocs-autorefs # mkdocs-gen-files @@ -116,37 +100,40 @@ mkdocs==1.5.3 # mkdocs-minify-plugin # mkdocs-redirects # mkdocstrings -mkdocs-autorefs==1.0.1 +mkdocs-autorefs==1.1.0 # via - # hatch.envs.docs + # -r requirements-docs.in # mkdocstrings + # mkdocstrings-python mkdocs-gen-files==0.5.0 - # via hatch.envs.docs -mkdocs-git-authors-plugin==0.8.0 - # via hatch.envs.docs -mkdocs-git-revision-date-localized-plugin==1.2.4 - # via hatch.envs.docs -mkdocs-include-markdown-plugin==6.0.5 - # via hatch.envs.docs + # via -r requirements-docs.in +mkdocs-get-deps==0.2.0 + # via mkdocs +mkdocs-git-authors-plugin==0.9.0 + # via -r requirements-docs.in +mkdocs-git-revision-date-localized-plugin==1.2.7 + # via -r requirements-docs.in +mkdocs-include-markdown-plugin==6.2.2 + # via -r requirements-docs.in mkdocs-literate-nav==0.6.1 - # via hatch.envs.docs -mkdocs-material==9.5.16 - # via hatch.envs.docs + # via -r requirements-docs.in +mkdocs-material[imaging]==9.5.33 + # via -r requirements-docs.in mkdocs-material-extensions==1.3.1 # via mkdocs-material mkdocs-minify-plugin==0.8.0 - # via hatch.envs.docs + # via -r requirements-docs.in mkdocs-redirects==1.2.1 - # via hatch.envs.docs + # via -r requirements-docs.in mkdocstrings==0.25.2 # via - # hatch.envs.docs + # -r requirements-docs.in # mkdocstrings-python -mkdocstrings-python==1.9.0 - # via hatch.envs.docs +mkdocstrings-python==1.10.9 + # via -r requirements-docs.in mypy-extensions==1.0.0 # via black -packaging==24.0 +packaging==24.1 # via # black # mkdocs @@ -156,39 +143,42 @@ pathspec==0.12.1 # via # black # mkdocs -pillow==10.3.0 +pillow==10.4.0 # via # cairosvg # mkdocs-material -platformdirs==4.2.0 +platformdirs==4.2.2 # via # black - # mkdocs + # mkdocs-get-deps # mkdocstrings pycparser==2.22 # via cffi -pygments==2.17.2 +pygments==2.18.0 # via mkdocs-material pymdown-extensions==10.9 # via - # hatch.envs.docs + # -r requirements-docs.in # markdown-exec # mkdocs-material # mkdocstrings -pyparsing==3.1.2 +pyparsing==3.1.4 # via mike python-dateutil==2.9.0.post0 # via ghp-import pytz==2024.1 # via mkdocs-git-revision-date-localized-plugin -pyyaml==6.0.1 +pyyaml==6.0.2 # via # mike # mkdocs + # mkdocs-get-deps # pymdown-extensions # pyyaml-env-tag pyyaml-env-tag==0.1 - # via mkdocs + # via + # mike + # mkdocs regex==2024.7.24 # via mkdocs-material requests==2.32.3 @@ -197,9 +187,9 @@ six==1.16.0 # via python-dateutil smmap==5.0.1 # via gitdb -sqlparse==0.5.0 +sqlparse==0.5.1 # via django -tinycss2==1.2.1 +tinycss2==1.3.0 # via # cairosvg # cssselect2 @@ -207,13 +197,13 @@ urllib3==2.2.2 # via requests verspec==0.1.0 # via mike -watchdog==4.0.0 +watchdog==5.0.0 # via mkdocs -wcmatch==8.5.1 +wcmatch==9.0 # via mkdocs-include-markdown-plugin webencodings==0.5.1 # via # cssselect2 # tinycss2 -zipp==3.19.1 +zipp==3.20.1 # via importlib-metadata From 0cfc40231bed770cbe00256b2da0b9a9d2f37828 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Sat, 31 Aug 2024 13:38:28 +0200 Subject: [PATCH 007/487] feat: add component hooks (#631) --- README.md | 87 ++++++++++++++++++ src/django_components/component.py | 26 ++++++ tests/test_component.py | 140 +++++++++++++++++++++++++++++ 3 files changed, 253 insertions(+) diff --git a/README.md b/README.md index 29d957d6..887091df 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ And this is what gets rendered (plus the CSS and Javascript you've specified): - [Rendering HTML attributes](#rendering-html-attributes) - [Template tag syntax](#template-tag-syntax) - [Prop drilling and dependency injection (provide / inject)](#prop-drilling-and-dependency-injection-provide--inject) +- [Component hooks](#component-hooks) - [Component context and scope](#component-context-and-scope) - [Customizing component tags with TagFormatter](#customizing-component-tags-with-tagformatter) - [Defining HTML/JS/CSS files](#defining-htmljscss-files) @@ -809,6 +810,8 @@ class MyComponent(Component): ### Adding type hints with Generics +_New in version 0.92_ + The `Component` class optionally accepts type parameters that allow you to specify the types of args, kwargs, slots, and data: @@ -937,6 +940,8 @@ class Button(Component[Args, Kwargs, Data, Slots]): ### Runtime input validation with types +_New in version 0.96_ + > NOTE: Kwargs, slots, and data validation is supported only for Python >=3.11 In Python 3.11 and later, when you specify the component types, you will get also runtime validation of the inputs you pass to `Component.render` or `Component.render_to_response`. @@ -2554,6 +2559,88 @@ renders:
123
``` +## Component hooks + +_New in version 0.96_ + +Component hooks are functions that allow you to intercept the rendering process at specific positions. + +### Available hooks + +- `on_render_before` + + ```py + def on_render_before( + self: Component, + context: Context, + template: Template + ) -> None: + ``` + + Hook that runs just before the component's template is rendered. + + You can use this hook to access or modify the context or the template: + + ```py + def on_render_before(self, context, template) -> None: + # Insert value into the Context + context["from_on_before"] = ":)" + + # Append text into the Template + template.nodelist.append(TextNode("FROM_ON_BEFORE")) + ``` + +- `on_render_after` + + ```py + def on_render_after( + self: Component, + context: Context, + template: Template, + content: str + ) -> None | str | SafeString: + ``` + + Hook that runs just after the component's template was rendered. + It receives the rendered output as the last argument. + + You can use this hook to access the context or the template, but modifying + them won't have any effect. + + To override the content that gets rendered, you can return a string or SafeString from this hook: + + ```py + def on_render_after(self, context, template, content): + # Prepend text to the rendered content + return "Chocolate cookie recipe: " + content + ``` + +### Component hooks example + +You can use hooks together with [provide / inject](#how-to-use-provide--inject) to create components +that accept a list of items via a slot. + +In the example below, each `tab_item` component will be rendered on a separate tab page, but they are all defined in the default slot of the `tabs` component. + +[See here for how it was done](https://github.com/EmilStenstrom/django-components/discussions/540) + +```django +{% component "tabs" %} + {% component "tab_item" header="Tab 1" %} +

+ hello from tab 1 +

+ {% component "button" %} + Click me! + {% endcomponent %} + {% endcomponent %} + + {% component "tab_item" header="Tab 2" %} + Hello this is tab 2 + {% endcomponent %} +{% endcomponent %} +``` + ## Component context and scope By default, context variables are passed down the template as in regular Django - deeper scopes can access the variables from the outer scopes. So if you have several nested forloops, then inside the deep-most loop you can access variables defined by all previous loops. diff --git a/src/django_components/component.py b/src/django_components/component.py index 7d008b9d..ef4e636e 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -169,6 +169,27 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co View = ComponentView + def on_render_before(self, context: Context, template: Template) -> None: + """ + Hook that runs just before the component's template is rendered. + + You can use this hook to access or modify the context or the template. + """ + pass + + def on_render_after(self, context: Context, template: Template, content: str) -> Optional[SlotResult]: + """ + Hook that runs just after the component's template was rendered. + It receives the rendered output as the last argument. + + You can use this hook to access the context or the template, but modifying + them won't have any effect. + + To override the content that gets rendered, you can return a string or SafeString + from this hook. + """ + pass + def __init__( self, registered_name: Optional[str] = None, @@ -563,8 +584,13 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co }, } ): + self.on_render_before(context, template) + rendered_component = template.render(context) + new_output = self.on_render_after(context, template, rendered_component) + rendered_component = new_output if new_output is not None else rendered_component + if is_dependency_middleware_active(): output = RENDERED_COMMENT_TEMPLATE.format(name=self.name) + rendered_component else: diff --git a/tests/test_component.py b/tests/test_component.py index 605bb2aa..df127f6d 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -17,6 +17,7 @@ from unittest import skipIf from django.core.exceptions import ImproperlyConfigured from django.http import HttpRequest, HttpResponse from django.template import Context, RequestContext, Template, TemplateSyntaxError +from django.template.base import TextNode from django.utils.safestring import SafeString from django_components import Component, SlotFunc, registry, types @@ -851,3 +852,142 @@ class ComponentRenderTest(BaseTestCase): rendered_resp.content.decode("utf-8"), "Variable: 123", ) + + +class ComponentHookTest(BaseTestCase): + @parametrize_context_behavior(["django", "isolated"]) + def test_on_render_before(self): + class SimpleComponent(Component): + template: types.django_html = """ + {% load component_tags %} + args: {{ args|safe }} + kwargs: {{ kwargs|safe }} + --- + from_on_before: {{ from_on_before }} + """ + + def get_context_data(self, *args, **kwargs): + return { + "args": args, + "kwargs": kwargs, + } + + def on_render_before(self, context: Context, template: Template) -> None: + # Insert value into the Context + context["from_on_before"] = ":)" + + # Insert text into the Template + template.nodelist.append(TextNode("\n---\nFROM_ON_BEFORE")) + + rendered = SimpleComponent.render() + self.assertHTMLEqual( + rendered, + """ + args: () + kwargs: {} + --- + from_on_before: :) + --- + FROM_ON_BEFORE + """, + ) + + # Check that modifying the context or template does nothing + @parametrize_context_behavior(["django", "isolated"]) + def test_on_render_after(self): + captured_content = None + + class SimpleComponent(Component): + template: types.django_html = """ + {% load component_tags %} + args: {{ args|safe }} + kwargs: {{ kwargs|safe }} + --- + from_on_before: {{ from_on_before }} + """ + + def get_context_data(self, *args, **kwargs): + return { + "args": args, + "kwargs": kwargs, + } + + # Check that modifying the context or template does nothing + def on_render_after(self, context: Context, template: Template, content: str) -> None: + # Insert value into the Context + context["from_on_before"] = ":)" + + # Insert text into the Template + template.nodelist.append(TextNode("\n---\nFROM_ON_BEFORE")) + + nonlocal captured_content + captured_content = content + + rendered = SimpleComponent.render() + + self.assertHTMLEqual( + captured_content, + """ + args: () + kwargs: {} + --- + from_on_before: + """, + ) + self.assertHTMLEqual( + rendered, + """ + args: () + kwargs: {} + --- + from_on_before: + """, + ) + + # Check that modifying the context or template does nothing + @parametrize_context_behavior(["django", "isolated"]) + def test_on_render_after_override_output(self): + captured_content = None + + class SimpleComponent(Component): + template: types.django_html = """ + {% load component_tags %} + args: {{ args|safe }} + kwargs: {{ kwargs|safe }} + --- + from_on_before: {{ from_on_before }} + """ + + def get_context_data(self, *args, **kwargs): + return { + "args": args, + "kwargs": kwargs, + } + + def on_render_after(self, context: Context, template: Template, content: str) -> str: + nonlocal captured_content + captured_content = content + + return "Chocolate cookie recipe: " + content + + rendered = SimpleComponent.render() + + self.assertHTMLEqual( + captured_content, + """ + args: () + kwargs: {} + --- + from_on_before: + """, + ) + self.assertHTMLEqual( + rendered, + """ + Chocolate cookie recipe: + args: () + kwargs: {} + --- + from_on_before: + """, + ) From 254817e235440427b050e5be3d05da689ed5dd22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Stenstr=C3=B6m?= Date: Sat, 31 Aug 2024 14:53:57 +0200 Subject: [PATCH 008/487] Revert "Upgrade all the docs dependencies to fix dependency issue." This reverts commit 3c6f478f8ad4717014d6081fd09b12afd43de645. --- requirements-docs.in | 18 ------ requirements-docs.txt | 134 +++++++++++++++++++++++------------------- 2 files changed, 72 insertions(+), 80 deletions(-) delete mode 100644 requirements-docs.in diff --git a/requirements-docs.in b/requirements-docs.in deleted file mode 100644 index eb5d30f1..00000000 --- a/requirements-docs.in +++ /dev/null @@ -1,18 +0,0 @@ -markdown-exec -mike -mkdocs -mkdocs-autorefs -mkdocs-gen-files -mkdocs-git-authors-plugin -mkdocs-git-revision-date-localized-plugin -mkdocs-include-markdown-plugin -mkdocs-literate-nav -mkdocs-material -mkdocs-material[imaging] -mkdocs-minify-plugin -mkdocs-redirects -mkdocstrings -mkdocstrings-python -pymdown-extensions -black -django>=4.2 diff --git a/requirements-docs.txt b/requirements-docs.txt index 2a5dcfaa..f4655664 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -1,26 +1,43 @@ # -# This file is autogenerated by pip-compile with Python 3.12 -# by the following command: +# This file is autogenerated by hatch-pip-compile with Python 3.12 # -# pip-compile requirements-docs.in +# - markdown-exec +# - mike +# - mkdocs +# - mkdocs-autorefs +# - mkdocs-gen-files +# - mkdocs-git-authors-plugin +# - mkdocs-git-revision-date-localized-plugin +# - mkdocs-include-markdown-plugin +# - mkdocs-literate-nav +# - mkdocs-material +# - mkdocs-material[imaging] +# - mkdocs-minify-plugin +# - mkdocs-redirects +# - mkdocstrings +# - mkdocstrings-python +# - pymdown-extensions +# - black +# - django>=4.2 # + asgiref==3.8.1 # via django -babel==2.16.0 +babel==2.14.0 # via # mkdocs-git-revision-date-localized-plugin # mkdocs-material -black==24.8.0 - # via -r requirements-docs.in -bracex==2.5 +black==24.3.0 + # via hatch.envs.docs +bracex==2.4 # via wcmatch -cairocffi==1.7.1 +cairocffi==1.6.1 # via cairosvg cairosvg==2.7.1 # via mkdocs-material -certifi==2024.8.30 +certifi==2024.7.4 # via requests -cffi==1.17.0 +cffi==1.16.0 # via cairocffi charset-normalizer==3.3.2 # via requests @@ -39,23 +56,23 @@ cssselect2==0.7.0 # via cairosvg defusedxml==0.7.1 # via cairosvg -django==5.1 - # via -r requirements-docs.in +django==5.0.8 + # via hatch.envs.docs ghp-import==2.1.0 # via mkdocs gitdb==4.0.11 # via gitpython gitpython==3.1.43 # via mkdocs-git-revision-date-localized-plugin -griffe==1.2.0 +griffe==0.42.1 # via mkdocstrings-python htmlmin2==0.1.13 # via mkdocs-minify-plugin -idna==3.8 +idna==3.7 # via requests importlib-metadata==8.4.0 # via mike -importlib-resources==6.4.4 +importlib-resources==6.4.0 # via mike jinja2==3.1.4 # via @@ -65,15 +82,16 @@ jinja2==3.1.4 # mkdocstrings jsmin==3.0.1 # via mkdocs-minify-plugin -markdown==3.7 +markdown==3.5.2 # via # mkdocs # mkdocs-autorefs # mkdocs-material # mkdocstrings + # mkdocstrings-python # pymdown-extensions -markdown-exec==1.9.3 - # via -r requirements-docs.in +markdown-exec==1.8.0 + # via hatch.envs.docs markupsafe==2.1.5 # via # jinja2 @@ -81,14 +99,12 @@ markupsafe==2.1.5 # mkdocs-autorefs # mkdocstrings mergedeep==1.3.4 + # via mkdocs +mike==2.0.0 + # via hatch.envs.docs +mkdocs==1.5.3 # via - # mkdocs - # mkdocs-get-deps -mike==2.1.3 - # via -r requirements-docs.in -mkdocs==1.6.1 - # via - # -r requirements-docs.in + # hatch.envs.docs # mike # mkdocs-autorefs # mkdocs-gen-files @@ -100,40 +116,37 @@ mkdocs==1.6.1 # mkdocs-minify-plugin # mkdocs-redirects # mkdocstrings -mkdocs-autorefs==1.1.0 +mkdocs-autorefs==1.0.1 # via - # -r requirements-docs.in + # hatch.envs.docs # mkdocstrings - # mkdocstrings-python mkdocs-gen-files==0.5.0 - # via -r requirements-docs.in -mkdocs-get-deps==0.2.0 - # via mkdocs -mkdocs-git-authors-plugin==0.9.0 - # via -r requirements-docs.in -mkdocs-git-revision-date-localized-plugin==1.2.7 - # via -r requirements-docs.in -mkdocs-include-markdown-plugin==6.2.2 - # via -r requirements-docs.in + # via hatch.envs.docs +mkdocs-git-authors-plugin==0.8.0 + # via hatch.envs.docs +mkdocs-git-revision-date-localized-plugin==1.2.4 + # via hatch.envs.docs +mkdocs-include-markdown-plugin==6.0.5 + # via hatch.envs.docs mkdocs-literate-nav==0.6.1 - # via -r requirements-docs.in -mkdocs-material[imaging]==9.5.33 - # via -r requirements-docs.in + # via hatch.envs.docs +mkdocs-material==9.5.16 + # via hatch.envs.docs mkdocs-material-extensions==1.3.1 # via mkdocs-material mkdocs-minify-plugin==0.8.0 - # via -r requirements-docs.in + # via hatch.envs.docs mkdocs-redirects==1.2.1 - # via -r requirements-docs.in + # via hatch.envs.docs mkdocstrings==0.25.2 # via - # -r requirements-docs.in + # hatch.envs.docs # mkdocstrings-python -mkdocstrings-python==1.10.9 - # via -r requirements-docs.in +mkdocstrings-python==1.9.0 + # via hatch.envs.docs mypy-extensions==1.0.0 # via black -packaging==24.1 +packaging==24.0 # via # black # mkdocs @@ -143,42 +156,39 @@ pathspec==0.12.1 # via # black # mkdocs -pillow==10.4.0 +pillow==10.3.0 # via # cairosvg # mkdocs-material -platformdirs==4.2.2 +platformdirs==4.2.0 # via # black - # mkdocs-get-deps + # mkdocs # mkdocstrings pycparser==2.22 # via cffi -pygments==2.18.0 +pygments==2.17.2 # via mkdocs-material pymdown-extensions==10.9 # via - # -r requirements-docs.in + # hatch.envs.docs # markdown-exec # mkdocs-material # mkdocstrings -pyparsing==3.1.4 +pyparsing==3.1.2 # via mike python-dateutil==2.9.0.post0 # via ghp-import pytz==2024.1 # via mkdocs-git-revision-date-localized-plugin -pyyaml==6.0.2 +pyyaml==6.0.1 # via # mike # mkdocs - # mkdocs-get-deps # pymdown-extensions # pyyaml-env-tag pyyaml-env-tag==0.1 - # via - # mike - # mkdocs + # via mkdocs regex==2024.7.24 # via mkdocs-material requests==2.32.3 @@ -187,9 +197,9 @@ six==1.16.0 # via python-dateutil smmap==5.0.1 # via gitdb -sqlparse==0.5.1 +sqlparse==0.5.0 # via django -tinycss2==1.3.0 +tinycss2==1.2.1 # via # cairosvg # cssselect2 @@ -197,13 +207,13 @@ urllib3==2.2.2 # via requests verspec==0.1.0 # via mike -watchdog==5.0.0 +watchdog==4.0.0 # via mkdocs -wcmatch==9.0 +wcmatch==8.5.1 # via mkdocs-include-markdown-plugin webencodings==0.5.1 # via # cssselect2 # tinycss2 -zipp==3.20.1 +zipp==3.19.1 # via importlib-metadata From aa0dcff6d1b9668f959c35e136768e27b3367875 Mon Sep 17 00:00:00 2001 From: David Linke Date: Sun, 1 Sep 2024 02:06:46 +0200 Subject: [PATCH 009/487] Update requirements-docs --- requirements-docs.txt | 81 ++++++++++++++++++++++++------------------- 1 file changed, 46 insertions(+), 35 deletions(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index f4655664..9e2b7dee 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -23,21 +23,21 @@ asgiref==3.8.1 # via django -babel==2.14.0 +babel==2.16.0 # via # mkdocs-git-revision-date-localized-plugin # mkdocs-material -black==24.3.0 +black==24.8.0 # via hatch.envs.docs -bracex==2.4 +bracex==2.5 # via wcmatch -cairocffi==1.6.1 +cairocffi==1.7.1 # via cairosvg cairosvg==2.7.1 # via mkdocs-material -certifi==2024.7.4 +certifi==2024.8.30 # via requests -cffi==1.16.0 +cffi==1.17.0 # via cairocffi charset-normalizer==3.3.2 # via requests @@ -48,7 +48,9 @@ click==8.1.7 # mkdocstrings colorama==0.4.6 # via + # click # griffe + # mkdocs # mkdocs-material csscompressor==0.9.5 # via mkdocs-minify-plugin @@ -56,7 +58,7 @@ cssselect2==0.7.0 # via cairosvg defusedxml==0.7.1 # via cairosvg -django==5.0.8 +django==5.1 # via hatch.envs.docs ghp-import==2.1.0 # via mkdocs @@ -64,15 +66,15 @@ gitdb==4.0.11 # via gitpython gitpython==3.1.43 # via mkdocs-git-revision-date-localized-plugin -griffe==0.42.1 +griffe==1.2.0 # via mkdocstrings-python htmlmin2==0.1.13 # via mkdocs-minify-plugin -idna==3.7 +idna==3.8 # via requests importlib-metadata==8.4.0 # via mike -importlib-resources==6.4.0 +importlib-resources==6.4.4 # via mike jinja2==3.1.4 # via @@ -82,15 +84,14 @@ jinja2==3.1.4 # mkdocstrings jsmin==3.0.1 # via mkdocs-minify-plugin -markdown==3.5.2 +markdown==3.7 # via # mkdocs # mkdocs-autorefs # mkdocs-material # mkdocstrings - # mkdocstrings-python # pymdown-extensions -markdown-exec==1.8.0 +markdown-exec==1.9.3 # via hatch.envs.docs markupsafe==2.1.5 # via @@ -99,10 +100,12 @@ markupsafe==2.1.5 # mkdocs-autorefs # mkdocstrings mergedeep==1.3.4 - # via mkdocs -mike==2.0.0 + # via + # mkdocs + # mkdocs-get-deps +mike==2.1.3 # via hatch.envs.docs -mkdocs==1.5.3 +mkdocs==1.6.1 # via # hatch.envs.docs # mike @@ -116,21 +119,24 @@ mkdocs==1.5.3 # mkdocs-minify-plugin # mkdocs-redirects # mkdocstrings -mkdocs-autorefs==1.0.1 +mkdocs-autorefs==1.1.0 # via # hatch.envs.docs # mkdocstrings + # mkdocstrings-python mkdocs-gen-files==0.5.0 # via hatch.envs.docs -mkdocs-git-authors-plugin==0.8.0 +mkdocs-get-deps==0.2.0 + # via mkdocs +mkdocs-git-authors-plugin==0.9.0 # via hatch.envs.docs -mkdocs-git-revision-date-localized-plugin==1.2.4 +mkdocs-git-revision-date-localized-plugin==1.2.7 # via hatch.envs.docs -mkdocs-include-markdown-plugin==6.0.5 +mkdocs-include-markdown-plugin==6.2.2 # via hatch.envs.docs mkdocs-literate-nav==0.6.1 # via hatch.envs.docs -mkdocs-material==9.5.16 +mkdocs-material==9.5.34 # via hatch.envs.docs mkdocs-material-extensions==1.3.1 # via mkdocs-material @@ -142,11 +148,11 @@ mkdocstrings==0.25.2 # via # hatch.envs.docs # mkdocstrings-python -mkdocstrings-python==1.9.0 +mkdocstrings-python==1.10.9 # via hatch.envs.docs mypy-extensions==1.0.0 # via black -packaging==24.0 +packaging==24.1 # via # black # mkdocs @@ -156,18 +162,18 @@ pathspec==0.12.1 # via # black # mkdocs -pillow==10.3.0 +pillow==10.4.0 # via # cairosvg # mkdocs-material -platformdirs==4.2.0 +platformdirs==4.2.2 # via # black - # mkdocs + # mkdocs-get-deps # mkdocstrings pycparser==2.22 # via cffi -pygments==2.17.2 +pygments==2.18.0 # via mkdocs-material pymdown-extensions==10.9 # via @@ -175,20 +181,23 @@ pymdown-extensions==10.9 # markdown-exec # mkdocs-material # mkdocstrings -pyparsing==3.1.2 +pyparsing==3.1.4 # via mike python-dateutil==2.9.0.post0 # via ghp-import pytz==2024.1 # via mkdocs-git-revision-date-localized-plugin -pyyaml==6.0.1 +pyyaml==6.0.2 # via # mike # mkdocs + # mkdocs-get-deps # pymdown-extensions # pyyaml-env-tag pyyaml-env-tag==0.1 - # via mkdocs + # via + # mike + # mkdocs regex==2024.7.24 # via mkdocs-material requests==2.32.3 @@ -197,23 +206,25 @@ six==1.16.0 # via python-dateutil smmap==5.0.1 # via gitdb -sqlparse==0.5.0 +sqlparse==0.5.1 # via django -tinycss2==1.2.1 +tinycss2==1.3.0 # via # cairosvg # cssselect2 +tzdata==2024.1 + # via django urllib3==2.2.2 # via requests verspec==0.1.0 # via mike -watchdog==4.0.0 +watchdog==5.0.0 # via mkdocs -wcmatch==8.5.1 +wcmatch==9.0 # via mkdocs-include-markdown-plugin webencodings==0.5.1 # via # cssselect2 # tinycss2 -zipp==3.19.1 +zipp==3.20.1 # via importlib-metadata From 2abfb1dc9c59bc3e7120a58e55be8d01bdc1fa54 Mon Sep 17 00:00:00 2001 From: David Linke Date: Sun, 1 Sep 2024 01:39:29 +0200 Subject: [PATCH 010/487] Fix docs gh-action --- .github/workflows/docs.yml | 72 +++++++++++++++++---------- .github/workflows/publish-to-pypi.yml | 2 +- mkdocs.yml | 3 +- 3 files changed, 49 insertions(+), 28 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e9c3c697..64cf2452 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -2,15 +2,16 @@ name: Docs - build & deploy on: push: - branches: [master] - workflow_dispatch: - inputs: - ref: - description: "The commit SHA, tag, or branch to publish. Uses the default branch if not specified." - default: "" - type: string + tags: + - '[0-9]+.[0-9]+' + branches: + - master + pull_request: + branches: + - main release: types: [published] + workflow_dispatch: jobs: docs: @@ -20,9 +21,11 @@ jobs: pages: write # to deploy to Pages id-token: write # to verify the deployment originates from an appropriate source runs-on: ubuntu-latest - if: github.ref == 'refs/heads/master' && github.repository_owner == 'EmilStenstrom' + # Only run in original repo (not in forks) + if: github.repository == 'EmilStenstrom/django-components' steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python @@ -39,34 +42,51 @@ jobs: - name: Create Virtual Environment run: hatch env create docs - - name: "Check for mkdocs build --strict" - # XXX Enable strict mode once docs are clean - run: | - hatch run docs:build - # hatch run docs:build --strict - # If pull request or not master branch and not a tag - if: github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref != 'refs/heads/master' && !startsWith(github.ref, 'refs/tags/')) || github.event_name == 'workflow_dispatch' - - name: Configure git run: | git config user.name github-actions git config user.email github-actions@github.com - - name: Deploy docs (dev) - if: github.event_name == 'push' && github.ref_name == 'master' && github.ref_type == 'branch' + - name: Print variables for debugging + # XXX this step may be removed + # https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs#github-context + run: | + # env vars used in this action + echo 'event_name : ' ${{ github.event_name }} + echo 'event.action: ' ${{ github.event.action }} + echo 'ref : ' ${{ github.ref }} + echo 'ref_name : ' ${{ github.ref_name }} + echo 'ref_type : ' ${{ github.ref_type }} + echo 'repository : ' ${{ github.repository }} + + # Conditions make sure to select the right step, depending on the job trigger. + # Only one of the steps below will run. The others will be skipped. + + - name: Check docs in pull requests with strict mode + if: github.event_name == 'pull_request' + run: | + # XXX Enable strict mode once docs are clean + echo "Strict check of docs disabled." + # hatch run docs:build --strict + + - name: Build & deploy "dev" docs for a new commit to master + if: github.event_name == 'push' && github.ref_type != 'tag' run: | export SHORT_SHA=$(echo "${GITHUB_SHA}" | cut -c1-7) - hatch run docs:mike deploy dev --update-aliases --title "dev (${SHORT_SHA})" --push --alias-type=redirect + hatch run docs:mike deploy --push --update-aliases --title "dev (${SHORT_SHA})" dev - - name: Deploy docs (tag) + - name: Build & deploy docs for a new tag if: github.ref_type == 'tag' && github.event_name == 'push' run: | - hatch run docs:mike deploy ${{ github.ref_name }} latest --push --update-aliases --alias-type=redirect + # XXX next line removes docs wrongly deployed under latest identifier; remove once latest is used only as alias + hatch run docs:mike delete --push latest + hatch run docs:mike deploy --push --update-aliases ${{ github.ref_name }} latest hatch run docs:mike set-default latest --push - - name: Deploy docs (Released published) - if: github.event_name == 'release' && github.event.action == 'published' && github.ref_type == 'tag' + - name: Build & deploy docs for a new release + if: github.event_name == 'release' run: | - # Version from tag, keep leading v, from github.ref workflow variable - hatch run docs:mike deploy ${{ github.ref_name }} latest --push --update-aliases --alias-type=redirect + # XXX next line removes docs wrongly deployed under latest identifier; remove once latest is used only as alias + hatch run docs:mike delete --push latest + hatch run docs:mike deploy --push --update-aliases ${{ github.ref_name }} latest hatch run docs:mike set-default latest --push diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index 8fcc25e8..27a99d30 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -12,7 +12,7 @@ on: jobs: build: runs-on: ubuntu-latest - + if: github.repository == 'EmilStenstrom/django-components' steps: - name: Checkout the repo uses: actions/checkout@v2 diff --git a/mkdocs.yml b/mkdocs.yml index c234ddf1..622a51c7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -64,12 +64,13 @@ theme: toggle: icon: material/weather-sunny name: Switch to light mode + extra: version: provider: mike default: - - latest - dev + alias: true social: - icon: fontawesome/brands/github link: https://github.com/EmilStenstrom/django-components From 9b2e83954d971d01c440e0639900791ba0dbe5d9 Mon Sep 17 00:00:00 2001 From: David Linke Date: Mon, 2 Sep 2024 15:19:40 +0200 Subject: [PATCH 011/487] Add 2nd version specifier --- .github/workflows/docs.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 64cf2452..0b186fd5 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -3,7 +3,10 @@ name: Docs - build & deploy on: push: tags: + # for versions before 1.0.0 - '[0-9]+.[0-9]+' + # after 1.0.0 + - '[0-9]+.[0-9]+.[0-9]+' branches: - master pull_request: From 79456b32af47d54059f475ea7dd1af5e699f4fa0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Sep 2024 17:27:48 +0000 Subject: [PATCH 012/487] build(deps): bump mkdocs from 1.5.3 to 1.6.1 Bumps [mkdocs](https://github.com/mkdocs/mkdocs) from 1.5.3 to 1.6.1. - [Release notes](https://github.com/mkdocs/mkdocs/releases) - [Commits](https://github.com/mkdocs/mkdocs/compare/1.5.3...1.6.1) --- updated-dependencies: - dependency-name: mkdocs dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index f4655664..e0f9d1bf 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -102,7 +102,7 @@ mergedeep==1.3.4 # via mkdocs mike==2.0.0 # via hatch.envs.docs -mkdocs==1.5.3 +mkdocs==1.6.1 # via # hatch.envs.docs # mike From 580c688806a9cd8420dc78f71b5179f7e4ed3720 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Sep 2024 17:29:43 +0000 Subject: [PATCH 013/487] build(deps): bump watchdog from 4.0.0 to 5.0.0 Bumps [watchdog](https://github.com/gorakhargosh/watchdog) from 4.0.0 to 5.0.0. - [Release notes](https://github.com/gorakhargosh/watchdog/releases) - [Changelog](https://github.com/gorakhargosh/watchdog/blob/master/changelog.rst) - [Commits](https://github.com/gorakhargosh/watchdog/compare/v4.0.0...v5.0.0) --- updated-dependencies: - dependency-name: watchdog dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index e0f9d1bf..63a3148a 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -207,7 +207,7 @@ urllib3==2.2.2 # via requests verspec==0.1.0 # via mike -watchdog==4.0.0 +watchdog==5.0.0 # via mkdocs wcmatch==8.5.1 # via mkdocs-include-markdown-plugin From 60867bab2f56ab700f094703ef1153fce8877ea5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Sep 2024 17:31:41 +0000 Subject: [PATCH 014/487] build(deps): bump mkdocs-git-revision-date-localized-plugin Bumps [mkdocs-git-revision-date-localized-plugin](https://github.com/timvink/mkdocs-git-revision-date-localized-plugin) from 1.2.4 to 1.2.7. - [Release notes](https://github.com/timvink/mkdocs-git-revision-date-localized-plugin/releases) - [Commits](https://github.com/timvink/mkdocs-git-revision-date-localized-plugin/compare/v1.2.4...v1.2.7) --- updated-dependencies: - dependency-name: mkdocs-git-revision-date-localized-plugin dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 63a3148a..4bad6b0b 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -124,7 +124,7 @@ mkdocs-gen-files==0.5.0 # via hatch.envs.docs mkdocs-git-authors-plugin==0.8.0 # via hatch.envs.docs -mkdocs-git-revision-date-localized-plugin==1.2.4 +mkdocs-git-revision-date-localized-plugin==1.2.7 # via hatch.envs.docs mkdocs-include-markdown-plugin==6.0.5 # via hatch.envs.docs From 1b561a1215bf33aeee1848d6445eb07d3b89d293 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Sep 2024 17:33:32 +0000 Subject: [PATCH 015/487] build(deps): bump pyparsing from 3.1.2 to 3.1.4 Bumps [pyparsing](https://github.com/pyparsing/pyparsing) from 3.1.2 to 3.1.4. - [Release notes](https://github.com/pyparsing/pyparsing/releases) - [Changelog](https://github.com/pyparsing/pyparsing/blob/master/CHANGES) - [Commits](https://github.com/pyparsing/pyparsing/compare/pyparsing_3.1.2...3.1.4) --- updated-dependencies: - dependency-name: pyparsing dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 4bad6b0b..e979fab9 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -175,7 +175,7 @@ pymdown-extensions==10.9 # markdown-exec # mkdocs-material # mkdocstrings -pyparsing==3.1.2 +pyparsing==3.1.4 # via mike python-dateutil==2.9.0.post0 # via ghp-import From 0dc571cbdc1ce99c6e0f1fd590d10dfa0b2f4ce5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Sep 2024 17:35:27 +0000 Subject: [PATCH 016/487] build(deps): bump mkdocs-git-authors-plugin from 0.8.0 to 0.9.0 Bumps [mkdocs-git-authors-plugin](https://github.com/timvink/mkdocs-git-authors-plugin) from 0.8.0 to 0.9.0. - [Release notes](https://github.com/timvink/mkdocs-git-authors-plugin/releases) - [Commits](https://github.com/timvink/mkdocs-git-authors-plugin/compare/v0.8.0...v0.9.0) --- updated-dependencies: - dependency-name: mkdocs-git-authors-plugin dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index e979fab9..a74abb19 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -122,7 +122,7 @@ mkdocs-autorefs==1.0.1 # mkdocstrings mkdocs-gen-files==0.5.0 # via hatch.envs.docs -mkdocs-git-authors-plugin==0.8.0 +mkdocs-git-authors-plugin==0.9.0 # via hatch.envs.docs mkdocs-git-revision-date-localized-plugin==1.2.7 # via hatch.envs.docs From 14cb37b3637383afe0058361886a67b5c2c90260 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Sep 2024 17:37:32 +0000 Subject: [PATCH 017/487] build(deps): bump importlib-resources from 6.4.0 to 6.4.4 Bumps [importlib-resources](https://github.com/python/importlib_resources) from 6.4.0 to 6.4.4. - [Release notes](https://github.com/python/importlib_resources/releases) - [Changelog](https://github.com/python/importlib_resources/blob/main/NEWS.rst) - [Commits](https://github.com/python/importlib_resources/compare/v6.4.0...v6.4.4) --- updated-dependencies: - dependency-name: importlib-resources dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index a74abb19..45f08f25 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -72,7 +72,7 @@ idna==3.7 # via requests importlib-metadata==8.4.0 # via mike -importlib-resources==6.4.0 +importlib-resources==6.4.4 # via mike jinja2==3.1.4 # via From 6359852747737087cd8de34ea9e82ad77ff19055 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Sep 2024 17:39:31 +0000 Subject: [PATCH 018/487] build(deps): bump griffe from 0.42.1 to 1.2.0 Bumps [griffe](https://github.com/mkdocstrings/griffe) from 0.42.1 to 1.2.0. - [Release notes](https://github.com/mkdocstrings/griffe/releases) - [Changelog](https://github.com/mkdocstrings/griffe/blob/main/CHANGELOG.md) - [Commits](https://github.com/mkdocstrings/griffe/compare/0.42.1...1.2.0) --- updated-dependencies: - dependency-name: griffe dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 45f08f25..70469c89 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -64,7 +64,7 @@ gitdb==4.0.11 # via gitpython gitpython==3.1.43 # via mkdocs-git-revision-date-localized-plugin -griffe==0.42.1 +griffe==1.2.0 # via mkdocstrings-python htmlmin2==0.1.13 # via mkdocs-minify-plugin From 2d0f270df4f98616f3fe47ce4caa4e2573ffb753 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Wed, 4 Sep 2024 21:31:44 +0200 Subject: [PATCH 019/487] fix: populate RequestContext with context processors (#643) --- src/django_components/component.py | 51 +++++++++++++------ tests/test_component.py | 79 +++++++++++++++++++++++++++++- 2 files changed, 113 insertions(+), 17 deletions(-) diff --git a/src/django_components/component.py b/src/django_components/component.py index ef4e636e..3edea421 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -1,6 +1,7 @@ import inspect import types from collections import deque +from contextlib import contextmanager from dataclasses import dataclass from typing import ( Any, @@ -8,6 +9,7 @@ from typing import ( ClassVar, Deque, Dict, + Generator, Generic, List, Literal, @@ -520,22 +522,7 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co context_data = self.get_context_data(*args, **kwargs) self._validate_outputs(context_data) - with context.update(context_data): - template = self.get_template(context) - _monkeypatch_template(template) - - if context.template is None: - # Associate the newly-created Context with a Template, otherwise we get - # an error when we try to use `{% include %}` tag inside the template? - # See https://github.com/EmilStenstrom/django-components/issues/580 - context.template = template - context.template_name = template.name - - # Set `Template._dc_is_component_nested` based on whether we're currently INSIDE - # the `{% extends %}` tag. - # Part of fix for https://github.com/EmilStenstrom/django-components/issues/508 - template._dc_is_component_nested = bool(context.render_context.get(BLOCK_CONTEXT_KEY)) - + with _prepare_template(self, context, context_data) as template: # Support passing slots explicitly to `render` method if has_slots: fill_content = self._fills_from_slots_data( @@ -843,3 +830,35 @@ def _monkeypatch_template(template: Template) -> None: # See https://stackoverflow.com/a/42154067/9788634 template.render = types.MethodType(_template_render, template) + + +@contextmanager +def _maybe_bind_template(context: Context, template: Template) -> Generator[None, Any, None]: + if context.template is None: + with context.bind_template(template): + yield + else: + yield + + +@contextmanager +def _prepare_template( + component: Component, + context: Context, + context_data: Any, +) -> Generator[Template, Any, None]: + with context.update(context_data): + # Associate the newly-created Context with a Template, otherwise we get + # an error when we try to use `{% include %}` tag inside the template? + # See https://github.com/EmilStenstrom/django-components/issues/580 + # And https://github.com/EmilStenstrom/django-components/issues/634 + template = component.get_template(context) + _monkeypatch_template(template) + + # Set `Template._dc_is_component_nested` based on whether we're currently INSIDE + # the `{% extends %}` tag. + # Part of fix for https://github.com/EmilStenstrom/django-components/issues/508 + template._dc_is_component_nested = bool(context.render_context.get(BLOCK_CONTEXT_KEY)) + + with _maybe_bind_template(context, template): + yield template diff --git a/tests/test_component.py b/tests/test_component.py index df127f6d..f09726c8 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -3,6 +3,7 @@ Tests focusing on the Component class. For tests focusing on the `component` tag, see `test_templatetags_component.py` """ +import re import sys from typing import Any, Dict, Tuple, Union, no_type_check @@ -14,13 +15,16 @@ else: from unittest import skipIf +from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.http import HttpRequest, HttpResponse from django.template import Context, RequestContext, Template, TemplateSyntaxError from django.template.base import TextNode +from django.test import Client +from django.urls import path from django.utils.safestring import SafeString -from django_components import Component, SlotFunc, registry, types +from django_components import Component, ComponentView, SlotFunc, register, registry, types from django_components.slots import SlotRef from .django_test_setup import setup_test_config @@ -29,6 +33,21 @@ from .testutils import BaseTestCase, parametrize_context_behavior setup_test_config({"autodiscover": False}) +# Client for testing endpoints via requests +class CustomClient(Client): + def __init__(self, urlpatterns=None, *args, **kwargs): + import types + + if urlpatterns: + urls_module = types.ModuleType("urls") + urls_module.urlpatterns = urlpatterns # type: ignore + settings.ROOT_URLCONF = urls_module + else: + settings.ROOT_URLCONF = __name__ + settings.SECRET_KEY = "secret" # noqa + super().__init__(*args, **kwargs) + + # Component typings CompArgs = Tuple[int, str] @@ -772,6 +791,7 @@ class ComponentRenderTest(BaseTestCase): ) # See https://github.com/EmilStenstrom/django-components/issues/580 + # And https://github.com/EmilStenstrom/django-components/issues/634 # And https://github.com/EmilStenstrom/django-components/commit/fee26ec1d8b46b5ee065ca1ce6143889b0f96764 @parametrize_context_behavior(["django", "isolated"]) def test_render_with_include_and_request_context(self): @@ -793,6 +813,63 @@ class ComponentRenderTest(BaseTestCase): """, ) + # See https://github.com/EmilStenstrom/django-components/issues/580 + # And https://github.com/EmilStenstrom/django-components/issues/634 + @parametrize_context_behavior(["django", "isolated"]) + def test_request_context_is_populated_from_context_processors(self): + @register("thing") + class Thing(Component): + template: types.django_html = """ + Rendered {{ how }} +
+ CSRF token: {{ csrf_token|default:"No CSRF token" }} +
+ """ + + def get_context_data(self, *args, how: str, **kwargs): + return {"how": how} + + class View(ComponentView): + def get(self, request): + how = "via GET request" + + return self.component.render_to_response( + context=RequestContext(self.request), + kwargs=self.component.get_context_data(how=how), + ) + + client = CustomClient(urlpatterns=[path("test_thing/", Thing.as_view())]) + response = client.get("/test_thing/") + + self.assertEqual(response.status_code, 200) + + # Full response: + # """ + # + # Rendered via GET request + # + #
+ # CSRF token: + #
+ # test_csrf_token + #
+ #
+ # """ + self.assertInHTML( + """ + + Rendered via GET request + + """, + response.content.decode(), + ) + + token_re = re.compile(rb"CSRF token:\s+(?P[0-9a-zA-Z]{64})") + token = token_re.findall(response.content)[0] + + self.assertTrue(token) + self.assertEqual(len(token), 64) + @parametrize_context_behavior(["django", "isolated"]) def test_render_with_extends(self): class SimpleComponent(Component): From e712800f5e9c81686c12a6f83cb965c7bd60f350 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Wed, 4 Sep 2024 21:41:20 +0200 Subject: [PATCH 020/487] feat: add `self` context var and make `is_filled` into attribute (#632) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- README.md | 22 +++++- src/django_components/component.py | 52 +++++++++++--- src/django_components/utils.py | 48 ++++++++++++- tests/test_component.py | 107 ++++++++++++++++++++++++++++- tests/test_context.py | 67 +++++++++++++----- 5 files changed, 265 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 887091df..8ba843f5 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,6 @@ And this is what gets rendered (plus the CSS and Javascript you've specified): - [Use components in templates](#use-components-in-templates) - [Use components outside of templates](#use-components-outside-of-templates) - [Use components as views](#use-components-as-views) -- [Pre-defined components](#pre-defined-components) - [Typing and validating components](#typing-and-validating-components) - [Pre-defined components](#pre-defined-components) - [Registering components](#registering-components) @@ -57,6 +56,7 @@ And this is what gets rendered (plus the CSS and Javascript you've specified): - [Prop drilling and dependency injection (provide / inject)](#prop-drilling-and-dependency-injection-provide--inject) - [Component hooks](#component-hooks) - [Component context and scope](#component-context-and-scope) +- [Pre-defined template variables](#pre-defined-template-variables) - [Customizing component tags with TagFormatter](#customizing-component-tags-with-tagformatter) - [Defining HTML/JS/CSS files](#defining-htmljscss-files) - [Rendering JS/CSS dependencies](#rendering-jscss-dependencies) @@ -1474,7 +1474,7 @@ This produces: _Added in version 0.26._ -> NOTE: In version 0.70, `{% if_filled %}` tags were replaced with `{{ component_vars.is_filled }}` variables. If your slot name contained special characters, see the section "Accessing slot names with special characters". +> NOTE: In version 0.70, `{% if_filled %}` tags were replaced with `{{ component_vars.is_filled }}` variables. If your slot name contained special characters, see the section [Accessing `is_filled` of slot names with special characters](#accessing-is_filled-of-slot-names-with-special-characters). In certain circumstances, you may want the behavior of slot filling to depend on whether or not a particular slot is filled. @@ -2659,6 +2659,24 @@ If you find yourself using the `only` modifier often, you can set the [context_b Components can also access the outer context in their context methods like `get_context_data` by accessing the property `self.outer_context`. +## Pre-defined template variables + +Here is a list of all variables that are automatically available from within the component's template and `on_render_before` / `on_render_after` hooks. + +- `component_vars.is_filled` + + _New in version 0.70_ + + Dictonary describing which slots are filled (`True`) or are not (`False`). + + Example: + + ```django + {% if component_vars.is_filled.my_slot %} + {% slot "my_slot" / %} + {% endif %} + ``` + ## Customizing component tags with TagFormatter _New in version 0.89_ diff --git a/src/django_components/component.py b/src/django_components/component.py index 3edea421..5bcc37bd 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -65,7 +65,7 @@ from django_components.slots import ( ) from django_components.utils import gen_id, validate_typed_dict, validate_typed_tuple -# TODO_DEPRECATE_V1 - REMOVE IN V1, users should use top-level import instead +# TODO_REMOVE_IN_V1 - Users should use top-level import instead # isort: off from django_components.component_registry import AlreadyRegistered as AlreadyRegistered # NOQA from django_components.component_registry import ComponentRegistry as ComponentRegistry # NOQA @@ -94,6 +94,12 @@ class RenderInput(Generic[ArgsType, KwargsType, SlotsType]): escape_slots_content: bool +@dataclass() +class RenderStackItem(Generic[ArgsType, KwargsType, SlotsType]): + input: RenderInput[ArgsType, KwargsType, SlotsType] + is_filled: Optional[Dict[str, bool]] + + class ViewFn(Protocol): def __call__(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Any: ... # noqa: E704 @@ -219,7 +225,7 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co self.fill_content = fill_content or {} self.component_id = component_id or gen_id() self.registry = registry or registry_ - self._render_stack: Deque[RenderInput[ArgsType, KwargsType, SlotsType]] = deque() + self._render_stack: Deque[RenderStackItem[ArgsType, KwargsType, SlotsType]] = deque() # None == uninitialized, False == No types, Tuple == types self._types: Optional[Union[Tuple[Any, Any, Any, Any], Literal[False]]] = None @@ -241,7 +247,29 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co # NOTE: Input is managed as a stack, so if `render` is called within another `render`, # the propertes below will return only the inner-most state. - return self._render_stack[-1] + return self._render_stack[-1].input + + @property + def is_filled(self) -> Dict[str, bool]: + """ + Dictionary describing which slots have or have not been filled. + + This attribute is available for use only within the template as `{{ component_vars.is_filled.slot_name }}`, + and within `on_render_before` and `on_render_after` hooks. + """ + if not len(self._render_stack): + raise RuntimeError( + f"{self.name}: Tried to access Component's `is_filled` attribute " + "while outside of rendering execution" + ) + + ctx = self._render_stack[-1] + if ctx.is_filled is None: + raise RuntimeError( + f"{self.name}: Tried to access Component's `is_filled` attribute " "before slots were resolved" + ) + + return ctx.is_filled def get_context_data(self, *args: Any, **kwargs: Any) -> DataType: return cast(DataType, {}) @@ -508,13 +536,16 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co # to access the provided context, slots, etc. Also required so users can # call `self.inject()` from within `get_context_data()`. self._render_stack.append( - RenderInput( - context=context, - slots=slots, - args=args, - kwargs=kwargs, - escape_slots_content=escape_slots_content, - ) + RenderStackItem( + input=RenderInput( + context=context, + slots=slots, + args=args, + kwargs=kwargs, + escape_slots_content=escape_slots_content, + ), + is_filled=None, + ), ) self._validate_inputs() @@ -557,6 +588,7 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co # to see if given slot was filled, e.g.: # `{% if variable > 8 and component_vars.is_filled.header %}` slot_bools = {slot_fill.escaped_name: slot_fill.is_filled for slot_fill in resolved_fills.values()} + self._render_stack[-1].is_filled = slot_bools with context.update( { diff --git a/src/django_components/utils.py b/src/django_components/utils.py index 102657e2..f357d730 100644 --- a/src/django_components/utils.py +++ b/src/django_components/utils.py @@ -1,4 +1,5 @@ import sys +import typing from pathlib import Path from typing import Any, Callable, List, Mapping, Sequence, Tuple, Union, get_type_hints @@ -39,6 +40,49 @@ def watch_files_for_autoreload(watch_list: Sequence[Union[str, Path]]) -> None: autoreload_started.connect(autoreload_hook) +# Get all types that users may use from the `typing` module. +# +# These are the types that we do NOT try to resolve when it's a typed generic, +# e.g. `Union[int, str]`. +# If we get a typed generic that's NOT part of this set, we assume it's a user-made +# generic, e.g. `Component[Args, Kwargs]`. In such case we assert that a given value +# is an instance of the base class, e.g. `Component`. +_typing_exports = frozenset( + [ + value + for value in typing.__dict__.values() + if isinstance( + value, + ( + typing._SpecialForm, + # Used in 3.8 and 3.9 + getattr(typing, "_GenericAlias", ()), + # Used in 3.11+ (possibly 3.10?) + getattr(typing, "_SpecialGenericAlias", ()), + ), + ) + ] +) + + +def _prepare_type_for_validation(the_type: Any) -> Any: + # If we got a typed generic (AKA "subscripted" generic), e.g. + # `Component[CompArgs, CompKwargs, ...]` + # then we cannot use that generic in `isintance()`, because we get this error: + # `TypeError("Subscripted generics cannot be used with class and instance checks")` + # + # Instead, we resolve the generic to its original class, e.g. `Component`, + # which can then be used in instance assertion. + if hasattr(the_type, "__origin__"): + is_custom_typing = the_type.__origin__ not in _typing_exports + if is_custom_typing: + return the_type.__origin__ + else: + return the_type + else: + return the_type + + # NOTE: tuple_type is a _GenericAlias - See https://stackoverflow.com/questions/74412803 def validate_typed_tuple( value: Tuple[Any, ...], @@ -63,7 +107,8 @@ def validate_typed_tuple( for index, arg_type in enumerate(tuple_type.__args__): arg = value[index] - if not isinstance(arg, arg_type): + arg_type = _prepare_type_for_validation(arg_type) + if sys.version_info >= (3, 11) and not isinstance(arg, arg_type): # Generate errors like below (listed for searchability) # `Component 'name' expected positional argument at index 0 to be , got 123.5 of type ` # noqa: E501 raise TypeError( @@ -101,6 +146,7 @@ def validate_typed_dict(value: Mapping[str, Any], dict_type: Any, prefix: str, k else: unseen_keys.remove(key) kwarg = value[key] + kwarg_type = _prepare_type_for_validation(kwarg_type) # NOTE: `isinstance()` cannot be used with the version of TypedDict prior to 3.11. # So we do type validation for TypedDicts only in 3.11 and later. diff --git a/tests/test_component.py b/tests/test_component.py index f09726c8..d2f9f020 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -5,7 +5,7 @@ For tests focusing on the `component` tag, see `test_templatetags_component.py` import re import sys -from typing import Any, Dict, Tuple, Union, no_type_check +from typing import Any, Dict, List, Tuple, Union, no_type_check # See https://peps.python.org/pep-0655/#usage-in-python-3-11 if sys.version_info >= (3, 11): @@ -506,6 +506,111 @@ class ComponentValidationTest(BaseTestCase): }, ) + def test_handles_components_in_typing(self): + class InnerKwargs(TypedDict): + one: str + + class InnerData(TypedDict): + one: Union[str, int] + self: "InnerComp" # type: ignore[misc] + + InnerComp = Component[Any, InnerKwargs, InnerData, Any] # type: ignore[misc] + + class Inner(InnerComp): + def get_context_data(self, one): + return { + "self": self, + "one": one, + } + + template = "" + + TodoArgs = Tuple[Inner] # type: ignore[misc] + + class TodoKwargs(TypedDict): + inner: Inner + + class TodoData(TypedDict): + one: Union[str, int] + self: "TodoComp" # type: ignore[misc] + inner: str + + TodoComp = Component[TodoArgs, TodoKwargs, TodoData, Any] # type: ignore[misc] + + # NOTE: Since we're using ForwardRef for "TodoComp" and "InnerComp", we need + # to ensure that the actual types are set as globals, so the ForwardRef class + # can resolve them. + globals()["TodoComp"] = TodoComp + globals()["InnerComp"] = InnerComp + + class TestComponent(TodoComp): + def get_context_data(self, var1, inner): + return { + "self": self, + "one": "2123", + # NOTE: All of this is typed + "inner": self.input.kwargs["inner"].render(kwargs={"one": "abc"}), + } + + template: types.django_html = """ + {% load component_tags %} + Name: {{ self.name }} + """ + + rendered = TestComponent.render(args=(Inner(),), kwargs={"inner": Inner()}) + + self.assertHTMLEqual( + rendered, + """ + Name: TestComponent + """, + ) + + def test_handles_typing_module(self): + TodoArgs = Tuple[ + Union[str, int], + Dict[str, int], + List[str], + Tuple[int, Union[str, int]], + ] + + class TodoKwargs(TypedDict): + one: Union[str, int] + two: Dict[str, int] + three: List[str] + four: Tuple[int, Union[str, int]] + + class TodoData(TypedDict): + one: Union[str, int] + two: Dict[str, int] + three: List[str] + four: Tuple[int, Union[str, int]] + + TodoComp = Component[TodoArgs, TodoKwargs, TodoData, Any] + + # NOTE: Since we're using ForwardRef for "TodoComp", we need + # to ensure that the actual types are set as globals, so the ForwardRef class + # can resolve them. + globals()["TodoComp"] = TodoComp + + class TestComponent(TodoComp): + def get_context_data(self, *args, **kwargs): + return { + **kwargs, + } + + template = "" + + TestComponent.render( + args=("str", {"str": 123}, ["a", "b", "c"], (123, "123")), + kwargs={ + "one": "str", + "two": {"str": 123}, + "three": ["a", "b", "c"], + "four": (123, "123"), + }, + ) + class ComponentRenderTest(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) diff --git a/tests/test_context.py b/tests/test_context.py index 2c69a974..3681a2ac 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,6 +1,6 @@ from django.template import Context, Template -from django_components import Component, registry, types +from django_components import Component, register, registry, types from .django_test_setup import setup_test_config from .testutils import BaseTestCase, parametrize_context_behavior @@ -591,20 +591,6 @@ class ContextVarsIsFilledTests(BaseTestCase): """ - class ComponentWithNegatedConditionalSlot(Component): - template: types.django_html = """ - {# Example from django-components/issues/98 #} - {% load component_tags %} -
-
{% slot "title" %}Title{% endslot %}
- {% if not component_vars.is_filled.subtitle %} -
Subtitle not filled!
- {% else %} -
{% slot "alt_subtitle" %}Why would you want this?{% endslot %}
- {% endif %} -
- """ - def setUp(self) -> None: super().setUp() registry.register("is_filled_vars", self.IsFilledVarsComponent) @@ -613,7 +599,6 @@ class ContextVarsIsFilledTests(BaseTestCase): "complex_conditional_slots", self.ComponentWithComplexConditionalSlots, ) - registry.register("negated_conditional_slot", self.ComponentWithNegatedConditionalSlot) @parametrize_context_behavior(["django", "isolated"]) def test_is_filled_vars(self): @@ -679,7 +664,7 @@ class ContextVarsIsFilledTests(BaseTestCase): template: types.django_html = """ {% load component_tags %} {% component "conditional_slots" %} - {% fill "subtitle" %} My subtitle {% endfill %} + {% fill "subtitle" %} My subtitle {% endfill %} {% endcomponent %} """ expected = """ @@ -736,6 +721,21 @@ class ContextVarsIsFilledTests(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_component_with_negated_conditional_slot(self): + @register("negated_conditional_slot") + class ComponentWithNegatedConditionalSlot(Component): + template: types.django_html = """ + {# Example from django-components/issues/98 #} + {% load component_tags %} +
+
{% slot "title" %}Title{% endslot %}
+ {% if not component_vars.is_filled.subtitle %} +
Subtitle not filled!
+ {% else %} +
{% slot "alt_subtitle" %}Why would you want this?{% endslot %}
+ {% endif %} +
+ """ + template: types.django_html = """ {% load component_tags %} {% component "negated_conditional_slot" %} @@ -752,3 +752,36 @@ class ContextVarsIsFilledTests(BaseTestCase): """ rendered = Template(template).render(Context({})) self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["django", "isolated"]) + def test_is_filled_vars_in_hooks(self): + captured_before = None + captured_after = None + + @register("is_filled_vars") + class IsFilledVarsComponent(self.IsFilledVarsComponent): # type: ignore[name-defined] + def on_render_before(self, context: Context, template: Template) -> None: + nonlocal captured_before + captured_before = self.is_filled.copy() + + def on_render_after(self, context: Context, template: Template, content: str) -> None: + nonlocal captured_after + captured_after = self.is_filled.copy() + + template: types.django_html = """ + {% load component_tags %} + {% component "is_filled_vars" %} + bla bla + {% endcomponent %} + """ + Template(template).render(Context()) + + expected = { + "title": True, + "my_title": False, + "my_title_1": False, + "my_title_2": False, + "escape_this_________": False, + } + self.assertEqual(captured_before, expected) + self.assertEqual(captured_after, expected) From 8f13a641ac096e93a0464f048a4fa53e591bb8db Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Wed, 4 Sep 2024 22:15:29 +0200 Subject: [PATCH 021/487] chore: bump v0.96 + release notes (#645) --- README.md | 5 +++++ pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8ba843f5..3fe048bc 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,11 @@ And this is what gets rendered (plus the CSS and Javascript you've specified): ## Release notes +**Version 0.96** +- Run-time type validation for Python 3.11+ - If the `Component` class is typed, e.g. `Component[Args, Kwargs, ...]`, the args, kwargs, slots, and data are validated against the given types. (See [Runtime input validation with types](#runtime-input-validation-with-types)) +- Render hooks - Set `on_render_before` and `on_render_after` methods on `Component` to intercept or modify the template or context before rendering, or the rendered result afterwards. (See [Component hooks](#component-hooks)) +- `component_vars.is_filled` context variable can be accessed from within `on_render_before` and `on_render_after` hooks as `self.is_filled.my_slot` + **Version 0.95** - Added support for dynamic components, where the component name is passed as a variable. (See [Dynamic components](#dynamic-components)) - Changed `Component.input` to raise `RuntimeError` if accessed outside of render context. Previously it returned `None` if unset. diff --git a/pyproject.toml b/pyproject.toml index 3baa1c51..7ffa624c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "django_components" -version = "0.95" +version = "0.96" requires-python = ">=3.8, <4.0" description = "A way to create simple reusable template components in Django." keywords = ["django", "components", "css", "js", "html"] From 91e5a2940d598fce0486cfe0507195ee66c2f306 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Fri, 6 Sep 2024 08:16:58 +0200 Subject: [PATCH 022/487] refactor: fix broken links --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3fe048bc..64932179 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
Today's date is 2015-06-19
``` -[See the example project](./sampleproject) or read on to learn about the details! +[See the example project](https://github.com/EmilStenstrom/django-components/tree/master/sampleproject) or read on to learn about the details! ## Table of Contents @@ -219,7 +219,7 @@ python manage.py runserver ``` For a step-by-step guide on deploying production server with static files, -[see the demo project](./sampleproject/README.md). +[see the demo project](https://github.com/EmilStenstrom/django-components/tree/master/sampleproject). ## Installation From 841dd77e91f94eb3ac472004a9e5f92adc929ecc Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Fri, 6 Sep 2024 22:40:39 +0200 Subject: [PATCH 023/487] refactor: Fix template caching, expose `cached_template`, Component.template API changes (#647) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- README.md | 37 +++++++- sampleproject/components/calendar/calendar.py | 20 ++-- .../components/nested/calendar/calendar.py | 10 +- sampleproject/components/todo/todo.py | 7 +- src/django_components/__init__.py | 1 + src/django_components/component.py | 95 +++++++++++++++---- src/django_components/slots.py | 32 ++++--- src/django_components/template.py | 42 ++++++++ .../templatetags/component_tags.py | 2 +- src/django_components/utils.py | 47 ++++++++- tests/test_component.py | 37 +++++++- tests/test_template.py | 67 +++++++++++++ tests/test_templatetags_slot_fill.py | 2 +- tests/testutils.py | 4 + 14 files changed, 347 insertions(+), 56 deletions(-) create mode 100644 src/django_components/template.py create mode 100644 tests/test_template.py diff --git a/README.md b/README.md index 64932179..354eeed3 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,13 @@ And this is what gets rendered (plus the CSS and Javascript you've specified): ## Release notes +**Version 0.97** +- Fixed template caching. You can now also manually create cached templates with [`cached_template()`](#template_cache_size---tune-the-template-cache) +- The previously undocumented `get_template` was made private. +- In it's place, there's a new `get_template`, which supersedes `get_template_string` (will be removed in v1). The new `get_template` is the same as `get_template_string`, except +it allows to return either a string or a Template instance. +- You now must use only one of `template`, `get_template`, `template_name`, or `get_template_name`. + **Version 0.96** - Run-time type validation for Python 3.11+ - If the `Component` class is typed, e.g. `Component[Args, Kwargs, ...]`, the args, kwargs, slots, and data are validated against the given types. (See [Runtime input validation with types](#runtime-input-validation-with-types)) - Render hooks - Set `on_render_before` and `on_render_after` methods on `Component` to intercept or modify the template or context before rendering, or the rendered result afterwards. (See [Component hooks](#component-hooks)) @@ -93,7 +100,7 @@ And this is what gets rendered (plus the CSS and Javascript you've specified): 🚨📢 **Version 0.92** - BREAKING CHANGE: `Component` class is no longer a subclass of `View`. To configure the `View` class, set the `Component.View` nested class. HTTP methods like `get` or `post` can still be defined directly on `Component` class, and `Component.as_view()` internally calls `Component.View.as_view()`. (See [Modifying the View class](#modifying-the-view-class)) -- The inputs (args, kwargs, slots, context, ...) that you pass to `Component.render()` can be accessed from within `get_context_data`, `get_template_string` and `get_template_name` via `self.input`. (See [Accessing data passed to the component](#accessing-data-passed-to-the-component)) +- The inputs (args, kwargs, slots, context, ...) that you pass to `Component.render()` can be accessed from within `get_context_data`, `get_template` and `get_template_name` via `self.input`. (See [Accessing data passed to the component](#accessing-data-passed-to-the-component)) - Typing: `Component` class supports generics that specify types for `Component.render` (See [Adding type hints with Generics](#adding-type-hints-with-generics)) @@ -383,11 +390,13 @@ from django_components import Component, register @register("calendar") class Calendar(Component): # Templates inside `[your apps]/components` dir and `[project root]/components` dir - # will be automatically found. To customize which template to use based on context - # you can override method `get_template_name` instead of specifying `template_name`. + # will be automatically found. # # `template_name` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS template_name = "template.html" + # Or + def get_template_name(context): + return f"template-{context['name']}.html" # This component takes one parameter, a date string to show in the template def get_context_data(self, date): @@ -1742,7 +1751,7 @@ When you call `Component.render` or `Component.render_to_response`, the inputs t This means that you can use `self.input` inside: - `get_context_data` - `get_template_name` -- `get_template_string` +- `get_template` `self.input` is defined only for the duration of `Component.render`, and raises `RuntimeError` when called outside of this. @@ -3170,6 +3179,26 @@ COMPONENTS = { } ``` +If you want add templates to the cache yourself, you can use `cached_template()`: + +```py +from django_components import cached_template + +cached_template("Variable: {{ variable }}") + +# You can optionally specify Template class, and other Template inputs: +class MyTemplate(Template): + pass + +cached_template( + "Variable: {{ variable }}", + template_cls=MyTemplate, + name=... + origin=... + engine=... +) +``` + ### `context_behavior` - Make components isolated (or not) > NOTE: `context_behavior` and `slot_context_behavior` options were merged in v0.70. diff --git a/sampleproject/components/calendar/calendar.py b/sampleproject/components/calendar/calendar.py index beba8890..b1488583 100644 --- a/sampleproject/components/calendar/calendar.py +++ b/sampleproject/components/calendar/calendar.py @@ -3,10 +3,14 @@ from django_components import Component, register @register("calendar") class Calendar(Component): - # Note that Django will look for templates inside `[your apps]/components` dir and - # `[project root]/components` dir. To customize which template to use based on context - # you can override def get_template_name() instead of specifying the below variable. + # Templates inside `[your apps]/components` dir and `[project root]/components` dir + # will be automatically found. + # + # `template_name` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS template_name = "calendar/calendar.html" + # Or + # def get_template_name(context): + # return f"template-{context['name']}.html" # This component takes one parameter, a date string to show in the template def get_context_data(self, date): @@ -27,10 +31,14 @@ class Calendar(Component): @register("calendar_relative") class CalendarRelative(Component): - # Note that Django will look for templates inside `[your apps]/components` dir and - # `[project root]/components` dir. To customize which template to use based on context - # you can override def get_template_name() instead of specifying the below variable. + # Templates inside `[your apps]/components` dir and `[project root]/components` dir + # will be automatically found. + # + # `template_name` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS template_name = "calendar.html" + # Or + # def get_template_name(context): + # return f"template-{context['name']}.html" # This component takes one parameter, a date string to show in the template def get_context_data(self, date): diff --git a/sampleproject/components/nested/calendar/calendar.py b/sampleproject/components/nested/calendar/calendar.py index 9ec4884c..19f7d4dd 100644 --- a/sampleproject/components/nested/calendar/calendar.py +++ b/sampleproject/components/nested/calendar/calendar.py @@ -3,10 +3,14 @@ from django_components import Component, register @register("calendar_nested") class CalendarNested(Component): - # Note that Django will look for templates inside `[your apps]/components` dir and - # `[project root]/components` dir. To customize which template to use based on context - # you can override def get_template_name() instead of specifying the below variable. + # Templates inside `[your apps]/components` dir and `[project root]/components` dir + # will be automatically found. + # + # `template_name` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS template_name = "calendar.html" + # Or + # def get_template_name(context): + # return f"template-{context['name']}.html" # This component takes one parameter, a date string to show in the template def get_context_data(self, date): diff --git a/sampleproject/components/todo/todo.py b/sampleproject/components/todo/todo.py index 03b88e17..803ab222 100644 --- a/sampleproject/components/todo/todo.py +++ b/sampleproject/components/todo/todo.py @@ -2,8 +2,7 @@ from django_components import Component, register @register("todo") -class Calendar(Component): - # Note that Django will look for templates inside `[your apps]/components` dir and - # `[project root]/components` dir. To customize which template to use based on context - # you can override def get_template_name() instead of specifying the below variable. +class Todo(Component): + # Templates inside `[your apps]/components` dir and `[project root]/components` dir + # will be automatically found. template_name = "todo/todo.html" diff --git a/src/django_components/__init__.py b/src/django_components/__init__.py index fa08564c..65f53b05 100644 --- a/src/django_components/__init__.py +++ b/src/django_components/__init__.py @@ -36,6 +36,7 @@ from django_components.tag_formatter import ( component_formatter as component_formatter, component_shorthand_formatter as component_shorthand_formatter, ) +from django_components.template import cached_template as cached_template import django_components.types as types from django_components.types import ( EmptyTuple as EmptyTuple, diff --git a/src/django_components/component.py b/src/django_components/component.py index 5bcc37bd..3986546d 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -63,6 +63,7 @@ from django_components.slots import ( resolve_fill_nodes, resolve_slots, ) +from django_components.template import cached_template from django_components.utils import gen_id, validate_typed_dict, validate_typed_tuple # TODO_REMOVE_IN_V1 - Users should use top-level import instead @@ -154,10 +155,42 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co # non-null return. _class_hash: ClassVar[int] - template_name: ClassVar[Optional[str]] = None - """Relative filepath to the Django template associated with this component.""" - template: Optional[str] = None - """Inlined Django template associated with this component.""" + template_name: Optional[str] = None + """ + Filepath to the Django template associated with this component. + + The filepath must be relative to either the file where the component class was defined, + or one of the roots of `STATIFILES_DIRS`. + + Only one of `template_name`, `get_template_name`, `template` or `get_template` must be defined. + """ + + def get_template_name(self, context: Context) -> Optional[str]: + """ + Filepath to the Django template associated with this component. + + The filepath must be relative to either the file where the component class was defined, + or one of the roots of `STATIFILES_DIRS`. + + Only one of `template_name`, `get_template_name`, `template` or `get_template` must be defined. + """ + return None + + template: Optional[Union[str, Template]] = None + """ + Inlined Django template associated with this component. Can be a plain string or a Template instance. + + Only one of `template_name`, `get_template_name`, `template` or `get_template` must be defined. + """ + + def get_template(self, context: Context) -> Optional[Union[str, Template]]: + """ + Inlined Django template associated with this component. Can be a plain string or a Template instance. + + Only one of `template_name`, `get_template_name`, `template` or `get_template` must be defined. + """ + return None + js: Optional[str] = None """Inlined JS associated with this component.""" css: Optional[str] = None @@ -274,28 +307,55 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co def get_context_data(self, *args: Any, **kwargs: Any) -> DataType: return cast(DataType, {}) - def get_template_name(self, context: Context) -> Optional[str]: - return self.template_name - - def get_template_string(self, context: Context) -> Optional[str]: - return self.template - # NOTE: When the template is taken from a file (AKA specified via `template_name`), # then we leverage Django's template caching. This means that the same instance # of Template is reused. This is important to keep in mind, because the implication # is that we should treat Templates AND their nodelists as IMMUTABLE. - def get_template(self, context: Context) -> Template: - template_string = self.get_template_string(context) - if template_string is not None: - return Template(template_string) + def _get_template(self, context: Context) -> Template: + # Resolve template name + template_name = self.template_name + if self.template_name is not None: + if self.get_template_name(context) is not None: + raise ImproperlyConfigured( + "Received non-null value from both 'template_name' and 'get_template_name' in" + f" Component {type(self).__name__}. Only one of the two must be set." + ) + else: + template_name = self.get_template_name(context) + + # Resolve template str + template_input = self.template + if self.template is not None: + if self.get_template(context) is not None: + raise ImproperlyConfigured( + "Received non-null value from both 'template' and 'get_template' in" + f" Component {type(self).__name__}. Only one of the two must be set." + ) + else: + # TODO_REMOVE_IN_V1 - Remove `self.get_template_string` in v1 + template_getter = getattr(self, "get_template_string", self.get_template) + template_input = template_getter(context) + + if template_name is not None and template_input is not None: + raise ImproperlyConfigured( + f"Received both 'template_name' and 'template' in Component {type(self).__name__}." + " Only one of the two must be set." + ) - template_name = self.get_template_name(context) if template_name is not None: return get_template(template_name).template + elif template_input is not None: + # We got template string, so we convert it to Template + if isinstance(template_input, str): + template: Template = cached_template(template_input) + else: + template = template_input + + return template + raise ImproperlyConfigured( f"Either 'template_name' or 'template' must be set for Component {type(self).__name__}." - f"Note: this attribute is not required if you are overriding the class's `get_template*()` methods." ) def render_dependencies(self) -> SafeString: @@ -606,7 +666,6 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co self.on_render_before(context, template) rendered_component = template.render(context) - new_output = self.on_render_after(context, template, rendered_component) rendered_component = new_output if new_output is not None else rendered_component @@ -884,7 +943,7 @@ def _prepare_template( # an error when we try to use `{% include %}` tag inside the template? # See https://github.com/EmilStenstrom/django-components/issues/580 # And https://github.com/EmilStenstrom/django-components/issues/634 - template = component.get_template(context) + template = component._get_template(context) _monkeypatch_template(template) # Set `Template._dc_is_component_nested` based on whether we're currently INSIDE diff --git a/src/django_components/slots.py b/src/django_components/slots.py index 59fa2afa..113b1451 100644 --- a/src/django_components/slots.py +++ b/src/django_components/slots.py @@ -3,6 +3,7 @@ import json import re from collections import deque from dataclasses import dataclass +from functools import lru_cache from typing import ( TYPE_CHECKING, Any, @@ -27,7 +28,7 @@ from django.template.defaulttags import CommentNode from django.template.exceptions import TemplateSyntaxError from django.utils.safestring import SafeString, mark_safe -from django_components.app_settings import ContextBehavior +from django_components.app_settings import ContextBehavior, app_settings from django_components.context import ( _FILLED_SLOTS_CONTENT_CONTEXT_KEY, _INJECT_CONTEXT_KEY_PREFIX, @@ -37,6 +38,7 @@ from django_components.context import ( from django_components.expression import RuntimeKwargs, is_identifier from django_components.logger import trace_msg from django_components.node import BaseNode, NodeTraverse, nodelist_has_content, walk_nodelist +from django_components.utils import lazy_cache if TYPE_CHECKING: from django_components.component_registry import ComponentRegistry @@ -332,9 +334,13 @@ class FillNode(BaseNode): return value +# NOTE: There may be more components per template, so using `app_settings.TEMPLATE_CACHE_SIZE` +# is not entirely correct. However, for now it's not worth it adding a separate setting +# to control this cache separately. So we use `TEMPLATE_CACHE_SIZE` so the cache is bounded. +@lazy_cache(lambda: lru_cache(maxsize=app_settings.TEMPLATE_CACHE_SIZE)) def parse_slot_fill_nodes_from_component_nodelist( - component_nodelist: NodeList, - ComponentNodeCls: Type[Node], + nodes: Tuple[Node, ...], + ignored_nodes: Tuple[Type[Node]], ) -> List[FillNode]: """ Given a component body (`django.template.NodeList`), find all slot fills, @@ -355,12 +361,12 @@ def parse_slot_fill_nodes_from_component_nodelist( and `fill "second_fill"`. """ fill_nodes: List[FillNode] = [] - if nodelist_has_content(component_nodelist): + if nodelist_has_content(nodes): for parse_fn in ( _try_parse_as_default_fill, _try_parse_as_named_fill_tag_set, ): - curr_fill_nodes = parse_fn(component_nodelist, ComponentNodeCls) + curr_fill_nodes = parse_fn(nodes, ignored_nodes) if curr_fill_nodes: fill_nodes = curr_fill_nodes break @@ -375,12 +381,12 @@ def parse_slot_fill_nodes_from_component_nodelist( def _try_parse_as_named_fill_tag_set( - nodelist: NodeList, - ComponentNodeCls: Type[Node], + nodes: Tuple[Node, ...], + ignored_nodes: Tuple[Type[Node]], ) -> List[FillNode]: result = [] seen_names: Set[str] = set() - for node in nodelist: + for node in nodes: if isinstance(node, FillNode): # If the fill name was defined statically, then check for no duplicates. maybe_fill_name = node.kwargs.kwargs.get(SLOT_NAME_KWARG) @@ -402,15 +408,15 @@ def _try_parse_as_named_fill_tag_set( def _try_parse_as_default_fill( - nodelist: NodeList, - ComponentNodeCls: Type[Node], + nodes: Tuple[Node, ...], + ignored_nodes: Tuple[Type[Node]], ) -> List[FillNode]: - nodes_stack: List[Node] = list(nodelist) + nodes_stack: List[Node] = list(nodes) while nodes_stack: node = nodes_stack.pop() if isinstance(node, FillNode): return [] - elif isinstance(node, ComponentNodeCls): + elif isinstance(node, ignored_nodes): # Stop searching here, as fill tags are permitted inside component blocks # embedded within a default fill node. continue @@ -419,7 +425,7 @@ def _try_parse_as_default_fill( else: return [ FillNode( - nodelist=nodelist, + nodelist=NodeList(nodes), kwargs=RuntimeKwargs( { # Wrap the default slot name in quotes so it's treated as FilterExpression diff --git a/src/django_components/template.py b/src/django_components/template.py new file mode 100644 index 00000000..a185146b --- /dev/null +++ b/src/django_components/template.py @@ -0,0 +1,42 @@ +from functools import lru_cache +from typing import Any, Optional, Type, TypeVar + +from django.template import Origin, Template +from django.template.base import UNKNOWN_SOURCE + +from django_components.app_settings import app_settings +from django_components.utils import lazy_cache + +TTemplate = TypeVar("TTemplate", bound=Template) + + +# Lazily initialize the cache. The cached function takes only the parts that can +# affect how the template string is processed - Template class, template string, and engine +@lazy_cache(lambda: lru_cache(maxsize=app_settings.TEMPLATE_CACHE_SIZE)) +def _create_template( + template_cls: Type[TTemplate], + template_string: str, + engine: Optional[Any] = None, +) -> TTemplate: + return template_cls(template_string, engine=engine) + + +# Central logic for creating Templates from string, so we can cache the results +def cached_template( + template_string: str, + template_cls: Optional[Type[Template]] = None, + origin: Optional[Origin] = None, + name: Optional[str] = None, + engine: Optional[Any] = None, +) -> Template: + """Create a Template instance that will be cached as per the `TEMPLATE_CACHE_SIZE` setting.""" + template = _create_template(template_cls or Template, template_string, engine) + + # Assign the origin and name separately, so the caching doesn't depend on them + # Since we might be accessing a template from cache, we want to define these only once + if not getattr(template, "_dc_cached", False): + template.origin = origin or Origin(UNKNOWN_SOURCE) + template.name = name + template._dc_cached = True + + return template diff --git a/src/django_components/templatetags/component_tags.py b/src/django_components/templatetags/component_tags.py index a3eb51d6..2d3defcd 100644 --- a/src/django_components/templatetags/component_tags.py +++ b/src/django_components/templatetags/component_tags.py @@ -246,7 +246,7 @@ def component(parser: Parser, token: Token, registry: ComponentRegistry, tag_nam trace_msg("PARSE", "COMP", result.component_name, tag.id) body = tag.parse_body() - fill_nodes = parse_slot_fill_nodes_from_component_nodelist(body, ComponentNode) + fill_nodes = parse_slot_fill_nodes_from_component_nodelist(tuple(body), ignored_nodes=(ComponentNode,)) # Tag all fill nodes as children of this particular component instance for node in fill_nodes: diff --git a/src/django_components/utils.py b/src/django_components/utils.py index f357d730..c4d6857e 100644 --- a/src/django_components/utils.py +++ b/src/django_components/utils.py @@ -1,7 +1,8 @@ +import functools import sys import typing from pathlib import Path -from typing import Any, Callable, List, Mapping, Sequence, Tuple, Union, get_type_hints +from typing import Any, Callable, List, Mapping, Sequence, Tuple, TypeVar, Union, cast, get_type_hints from django.utils.autoreload import autoreload_started @@ -166,3 +167,47 @@ def validate_typed_dict(value: Mapping[str, Any], dict_type: Any, prefix: str, k # `Component 'name' got unexpected slot keys 'invalid_key'` # `Component 'name' got unexpected data keys 'invalid_key'` raise TypeError(f"{prefix} got unexpected {kind} keys {formatted_keys}") + + +TFunc = TypeVar("TFunc", bound=Callable) + + +def lazy_cache( + make_cache: Callable[[], Callable[[Callable], Callable]], +) -> Callable[[TFunc], TFunc]: + """ + Decorator that caches the given function similarly to `functools.lru_cache`. + But the cache is instantiated only at first invocation. + + `cache` argument is a function that generates the cache function, + e.g. `functools.lru_cache()`. + """ + _cached_fn = None + + def decorator(fn: TFunc) -> TFunc: + @functools.wraps(fn) + def wrapper(*args: Any, **kwargs: Any) -> Any: + # Lazily initialize the cache + nonlocal _cached_fn + if not _cached_fn: + # E.g. `lambda: functools.lru_cache(maxsize=app_settings.TEMPLATE_CACHE_SIZE)` + cache = make_cache() + _cached_fn = cache(fn) + + return _cached_fn(*args, **kwargs) + + # Allow to access the LRU cache methods + # See https://stackoverflow.com/a/37654201/9788634 + wrapper.cache_info = lambda: _cached_fn.cache_info() # type: ignore + wrapper.cache_clear = lambda: _cached_fn.cache_clear() # type: ignore + + # And allow to remove the cache instance (mostly for tests) + def cache_remove() -> None: + nonlocal _cached_fn + _cached_fn = None + + wrapper.cache_remove = cache_remove # type: ignore + + return cast(TFunc, wrapper) + + return decorator diff --git a/tests/test_component.py b/tests/test_component.py index d2f9f020..21d45f18 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -76,6 +76,35 @@ else: optional: NotRequired[int] +# TODO_REMOVE_IN_V1 - Superseded by `self.get_template` in v1 +class ComponentOldTemplateApiTest(BaseTestCase): + @parametrize_context_behavior(["django", "isolated"]) + def test_get_template_string(self): + class SimpleComponent(Component): + def get_template_string(self, context): + content: types.django_html = """ + Variable: {{ variable }} + """ + return content + + def get_context_data(self, variable=None): + return { + "variable": variable, + } + + class Media: + css = "style.css" + js = "script.js" + + rendered = SimpleComponent.render(kwargs={"variable": "test"}) + self.assertHTMLEqual( + rendered, + """ + Variable: test + """, + ) + + class ComponentTest(BaseTestCase): class ParentComponent(Component): template: types.django_html = """ @@ -123,7 +152,7 @@ class ComponentTest(BaseTestCase): pass with self.assertRaises(ImproperlyConfigured): - EmptyComponent("empty_component").get_template(Context({})) + EmptyComponent("empty_component")._get_template(Context({})) @parametrize_context_behavior(["django", "isolated"]) def test_template_string_static_inlined(self): @@ -152,7 +181,7 @@ class ComponentTest(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_template_string_dynamic(self): class SimpleComponent(Component): - def get_template_string(self, context): + def get_template(self, context): content: types.django_html = """ Variable: {{ variable }} """ @@ -225,7 +254,7 @@ class ComponentTest(BaseTestCase): ) @parametrize_context_behavior(["django", "isolated"]) - def test_allows_to_override_get_template(self): + def test_allows_to_return_template(self): class TestComponent(Component): def get_context_data(self, variable, **attrs): return { @@ -1037,7 +1066,6 @@ class ComponentRenderTest(BaseTestCase): class ComponentHookTest(BaseTestCase): - @parametrize_context_behavior(["django", "isolated"]) def test_on_render_before(self): class SimpleComponent(Component): template: types.django_html = """ @@ -1075,7 +1103,6 @@ class ComponentHookTest(BaseTestCase): ) # Check that modifying the context or template does nothing - @parametrize_context_behavior(["django", "isolated"]) def test_on_render_after(self): captured_content = None diff --git a/tests/test_template.py b/tests/test_template.py new file mode 100644 index 00000000..aabab640 --- /dev/null +++ b/tests/test_template.py @@ -0,0 +1,67 @@ +from django.template import Context, Template +from django.test import override_settings + +from django_components import Component, cached_template, types + +from .django_test_setup import setup_test_config +from .testutils import BaseTestCase + +setup_test_config({"autodiscover": False}) + + +class TemplateCacheTest(BaseTestCase): + def test_cached_template(self): + template_1 = cached_template("Variable: {{ variable }}") + template_1._test_id = "123" + + template_2 = cached_template("Variable: {{ variable }}") + + self.assertEqual(template_2._test_id, "123") + + def test_cached_template_accepts_class(self): + class MyTemplate(Template): + pass + + template = cached_template("Variable: {{ variable }}", MyTemplate) + self.assertIsInstance(template, MyTemplate) + + @override_settings(COMPONENTS={"template_cache_size": 2}) + def test_cache_discards_old_entries(self): + template_1 = cached_template("Variable: {{ variable }}") + template_1._test_id = "123" + + template_2 = cached_template("Variable2") + template_2._test_id = "456" + + # Templates 1 and 2 should still be available + template_1_copy = cached_template("Variable: {{ variable }}") + self.assertEqual(template_1_copy._test_id, "123") + + template_2_copy = cached_template("Variable2") + self.assertEqual(template_2_copy._test_id, "456") + + # But once we add the third template, template 1 should go + cached_template("Variable3") + + template_1_copy2 = cached_template("Variable: {{ variable }}") + self.assertEqual(hasattr(template_1_copy2, "_test_id"), False) + + def test_component_template_is_cached(self): + class SimpleComponent(Component): + def get_template(self, context): + content: types.django_html = """ + Variable: {{ variable }} + """ + return content + + def get_context_data(self, variable=None): + return { + "variable": variable, + } + + comp = SimpleComponent() + template_1 = comp._get_template(Context({})) + template_1._test_id = "123" + + template_2 = comp._get_template(Context({})) + self.assertEqual(template_2._test_id, "123") diff --git a/tests/test_templatetags_slot_fill.py b/tests/test_templatetags_slot_fill.py index 37bdc16c..5a8e0ed4 100644 --- a/tests/test_templatetags_slot_fill.py +++ b/tests/test_templatetags_slot_fill.py @@ -434,7 +434,7 @@ class ComponentSlottedTemplateTagTest(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_component_template_cannot_have_multiple_default_slots(self): class BadComponent(Component): - def get_template(self, context, template_name: Optional[str] = None) -> Template: + def get_template(self, context): template_str: types.django_html = """ {% load django_components %}
diff --git a/tests/testutils.py b/tests/testutils.py index f534b49c..6251e92b 100644 --- a/tests/testutils.py +++ b/tests/testutils.py @@ -24,6 +24,10 @@ class BaseTestCase(SimpleTestCase): super().tearDown() registry.clear() + from django_components.template import _create_template + + _create_template.cache_remove() # type: ignore[attr-defined] + request = Mock() mock_template = Mock() From 30d04fe1b053e6c5de0b6f34a7758a627517be8c Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Fri, 6 Sep 2024 22:45:25 +0200 Subject: [PATCH 024/487] chore: bump v0.97 (#651) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7ffa624c..f47772c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "django_components" -version = "0.96" +version = "0.97" requires-python = ">=3.8, <4.0" description = "A way to create simple reusable template components in Django." keywords = ["django", "components", "css", "js", "html"] From 97bc371f91869571c7e6ec56248717b02fd44e6e Mon Sep 17 00:00:00 2001 From: David Linke Date: Sat, 7 Sep 2024 12:34:36 +0200 Subject: [PATCH 025/487] Clean up docs action --- .github/workflows/docs.yml | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0b186fd5..e846e7ce 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -3,8 +3,8 @@ name: Docs - build & deploy on: push: tags: - # for versions before 1.0.0 - - '[0-9]+.[0-9]+' + # for versions 0.### (before 1.0.0) + - '0.[0-9]+' # after 1.0.0 - '[0-9]+.[0-9]+.[0-9]+' branches: @@ -47,23 +47,12 @@ jobs: - name: Configure git run: | + # required for "mike deploy" command below which pushes to gh-pages git config user.name github-actions git config user.email github-actions@github.com - - name: Print variables for debugging - # XXX this step may be removed - # https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs#github-context - run: | - # env vars used in this action - echo 'event_name : ' ${{ github.event_name }} - echo 'event.action: ' ${{ github.event.action }} - echo 'ref : ' ${{ github.ref }} - echo 'ref_name : ' ${{ github.ref_name }} - echo 'ref_type : ' ${{ github.ref_type }} - echo 'repository : ' ${{ github.repository }} - # Conditions make sure to select the right step, depending on the job trigger. - # Only one of the steps below will run. The others will be skipped. + # Only one of the steps below will run at a time. The others will be skipped. - name: Check docs in pull requests with strict mode if: github.event_name == 'pull_request' @@ -81,15 +70,11 @@ jobs: - name: Build & deploy docs for a new tag if: github.ref_type == 'tag' && github.event_name == 'push' run: | - # XXX next line removes docs wrongly deployed under latest identifier; remove once latest is used only as alias - hatch run docs:mike delete --push latest hatch run docs:mike deploy --push --update-aliases ${{ github.ref_name }} latest hatch run docs:mike set-default latest --push - name: Build & deploy docs for a new release if: github.event_name == 'release' run: | - # XXX next line removes docs wrongly deployed under latest identifier; remove once latest is used only as alias - hatch run docs:mike delete --push latest hatch run docs:mike deploy --push --update-aliases ${{ github.ref_name }} latest hatch run docs:mike set-default latest --push From 0233a8d7a6826ab9e766b42afa3059fe718dd9ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Stenstr=C3=B6m?= Date: Sun, 8 Sep 2024 10:44:39 +0200 Subject: [PATCH 026/487] Improve pitch. --- README.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 354eeed3..318be911 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,24 @@ [**Docs (Work in progress)**](https://EmilStenstrom.github.io/django-components/latest/) -Create simple reusable template components in Django +Django-components is a package that introduces component-based architecture to Django's server-side rendering. It aims to combine Django's templating system with the modularity seen in modern frontend frameworks. ## Features - +1. 🧩 **Reusability:** Allows creation of self-contained, reusable UI elements. +2. 📦 **Encapsulation:** Each component can include its own HTML, CSS, and JavaScript. +3. 🚀 **Server-side rendering:** Components render on the server, improving initial load times and SEO. +4. 🐍 **Django integration:** Works within the Django ecosystem, using familiar concepts like template tags. +5. ⚡ **Asynchronous loading:** Components can render independently opening up for integration with JS frameworks like HTMX or AlpineJS. -- ✨ **Reusable components**: Create components that can be reused in different parts of your project, or even in different projects. -- 📁 **Single file components**: Keep your Python, CSS, Javascript and HTML in one place (if you wish) -- 🎰 **Slots**: Define slots in your components to make them more flexible. -- 💻 **CLI**: A command line interface to help you create new components. -- 🚀 **Wide compatibility**: Works with [modern and LTS versions of Django](https://emilstenstrom.github.io/django-components/latest/user_guide/requirements_compatibility). -- **Load assets**: Automatically load the right CSS and Javascript files for your components, with [our middleware](https://emilstenstrom.github.io/django-components/latest/user_guide/creating_using_components/middleware). +Potential benefits: +- 🔄 Reduced code duplication +- 🛠️ Improved maintainability through modular design +- 🧠 Easier management of complex UIs +- 🤝 Potential for better collaboration between frontend and backend developers + +Django-components can be particularly useful for larger Django projects that require a more structured approach to UI development, without necessitating a shift to a separate frontend framework. ## Summary From a228f435a2e459a391625ac7c597eb2ead6726a8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Sep 2024 17:43:29 +0000 Subject: [PATCH 027/487] build(deps): bump platformdirs from 4.2.2 to 4.3.2 Bumps [platformdirs](https://github.com/platformdirs/platformdirs) from 4.2.2 to 4.3.2. - [Release notes](https://github.com/platformdirs/platformdirs/releases) - [Changelog](https://github.com/tox-dev/platformdirs/blob/main/CHANGES.rst) - [Commits](https://github.com/platformdirs/platformdirs/compare/4.2.2...4.3.2) --- updated-dependencies: - dependency-name: platformdirs dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 9e2b7dee..2c5f1107 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -166,7 +166,7 @@ pillow==10.4.0 # via # cairosvg # mkdocs-material -platformdirs==4.2.2 +platformdirs==4.3.2 # via # black # mkdocs-get-deps From 3c08e057015f073fe83f9272370ec81c1cb48140 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Sep 2024 17:45:41 +0000 Subject: [PATCH 028/487] build(deps): bump mkdocs-git-revision-date-localized-plugin Bumps [mkdocs-git-revision-date-localized-plugin](https://github.com/timvink/mkdocs-git-revision-date-localized-plugin) from 1.2.7 to 1.2.8. - [Release notes](https://github.com/timvink/mkdocs-git-revision-date-localized-plugin/releases) - [Commits](https://github.com/timvink/mkdocs-git-revision-date-localized-plugin/compare/v1.2.7...v1.2.8) --- updated-dependencies: - dependency-name: mkdocs-git-revision-date-localized-plugin dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 2c5f1107..20461181 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -130,7 +130,7 @@ mkdocs-get-deps==0.2.0 # via mkdocs mkdocs-git-authors-plugin==0.9.0 # via hatch.envs.docs -mkdocs-git-revision-date-localized-plugin==1.2.7 +mkdocs-git-revision-date-localized-plugin==1.2.8 # via hatch.envs.docs mkdocs-include-markdown-plugin==6.2.2 # via hatch.envs.docs From 01479ef50bbce0e7554ada1985585250776fd4c7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Sep 2024 17:47:43 +0000 Subject: [PATCH 029/487] build(deps): bump watchdog from 5.0.0 to 5.0.2 Bumps [watchdog](https://github.com/gorakhargosh/watchdog) from 5.0.0 to 5.0.2. - [Release notes](https://github.com/gorakhargosh/watchdog/releases) - [Changelog](https://github.com/gorakhargosh/watchdog/blob/master/changelog.rst) - [Commits](https://github.com/gorakhargosh/watchdog/compare/v5.0.0...v5.0.2) --- updated-dependencies: - dependency-name: watchdog dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 20461181..874feb9a 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -218,7 +218,7 @@ urllib3==2.2.2 # via requests verspec==0.1.0 # via mike -watchdog==5.0.0 +watchdog==5.0.2 # via mkdocs wcmatch==9.0 # via mkdocs-include-markdown-plugin From 307fe3fc2f2fd5f6ca58767cc3108b2b4146a163 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Sep 2024 17:49:43 +0000 Subject: [PATCH 030/487] build(deps): bump mkdocstrings from 0.25.2 to 0.26.1 Bumps [mkdocstrings](https://github.com/mkdocstrings/mkdocstrings) from 0.25.2 to 0.26.1. - [Release notes](https://github.com/mkdocstrings/mkdocstrings/releases) - [Changelog](https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md) - [Commits](https://github.com/mkdocstrings/mkdocstrings/compare/0.25.2...0.26.1) --- updated-dependencies: - dependency-name: mkdocstrings dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 874feb9a..1f76991f 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -144,7 +144,7 @@ mkdocs-minify-plugin==0.8.0 # via hatch.envs.docs mkdocs-redirects==1.2.1 # via hatch.envs.docs -mkdocstrings==0.25.2 +mkdocstrings==0.26.1 # via # hatch.envs.docs # mkdocstrings-python From 2b971707d2ce667914bb6586444ff29d8b62773e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Sep 2024 17:52:12 +0000 Subject: [PATCH 031/487] build(deps): bump django from 5.1 to 5.1.1 Bumps [django](https://github.com/django/django) from 5.1 to 5.1.1. - [Commits](https://github.com/django/django/compare/5.1...5.1.1) --- updated-dependencies: - dependency-name: django dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 1f76991f..ec1d840a 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -58,7 +58,7 @@ cssselect2==0.7.0 # via cairosvg defusedxml==0.7.1 # via cairosvg -django==5.1 +django==5.1.1 # via hatch.envs.docs ghp-import==2.1.0 # via mkdocs From f50d6042d320dbd1ae80e03e7279d5d1a8312fa0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Sep 2024 17:54:12 +0000 Subject: [PATCH 032/487] build(deps-dev): bump mypy from 1.11.1 to 1.11.2 Bumps [mypy](https://github.com/python/mypy) from 1.11.1 to 1.11.2. - [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md) - [Commits](https://github.com/python/mypy/compare/v1.11.1...v1.11.2) --- updated-dependencies: - dependency-name: mypy dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index c21dd9e0..cc2cf465 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -40,7 +40,7 @@ isort==5.13.2 # via -r requirements-dev.in mccabe==0.7.0 # via flake8 -mypy==1.11.1 +mypy==1.11.2 # via -r requirements-dev.in mypy-extensions==1.0.0 # via From e1382d3ccd11249f6043a3e28215d8e076a104fe Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Wed, 11 Sep 2024 08:45:55 +0200 Subject: [PATCH 033/487] refactor: Remove safer_staticfiles, replace STATICFILES_DIRS with COMPONENTS.dirs, support `[app]/components` (#652) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- README.md | 249 ++++++++++++++---- docs/migrating_from_safer_staticfiles.md | 98 +++++++ sampleproject/README.md | 2 +- sampleproject/components/calendar/calendar.py | 13 +- sampleproject/components/greeting.py | 8 +- .../components/nested/calendar/calendar.py | 11 +- sampleproject/sampleproject/settings.py | 27 +- src/django_components/app_settings.py | 56 +++- src/django_components/autodiscover.py | 24 +- src/django_components/component_media.py | 6 +- src/django_components/finders.py | 154 +++++++++++ .../safer_staticfiles/apps.py | 22 -- src/django_components/template_loader.py | 76 +++++- src/django_components/utils.py | 9 + .../staticfiles.css} | 0 .../staticfiles.html} | 0 .../staticfiles.js} | 0 .../staticfiles.py} | 10 +- tests/django_test_setup.py | 3 +- .../test_app}/__init__.py | 0 tests/test_app/apps.py | 6 + .../components/app_lvl_comp/app_lvl_comp.css | 3 + .../components/app_lvl_comp/app_lvl_comp.html | 5 + .../components/app_lvl_comp/app_lvl_comp.js | 1 + .../components/app_lvl_comp/app_lvl_comp.py | 16 ++ .../app_lvl_comp/app_lvl_comp.css | 3 + .../app_lvl_comp/app_lvl_comp.html | 5 + .../app_lvl_comp/app_lvl_comp.js | 1 + .../app_lvl_comp/app_lvl_comp.py | 16 ++ tests/test_component_media.py | 10 +- tests/test_finders.py | 210 +++++++++++++++ tests/test_registry.py | 8 +- tests/test_safer_staticfiles.py | 103 -------- tests/test_template_loader.py | 143 ++++++++-- 34 files changed, 1034 insertions(+), 264 deletions(-) create mode 100644 docs/migrating_from_safer_staticfiles.md create mode 100644 src/django_components/finders.py delete mode 100644 src/django_components/safer_staticfiles/apps.py rename tests/components/{safer_staticfiles/safer_staticfiles.css => staticfiles/staticfiles.css} (100%) rename tests/components/{safer_staticfiles/safer_staticfiles.html => staticfiles/staticfiles.html} (100%) rename tests/components/{safer_staticfiles/safer_staticfiles.js => staticfiles/staticfiles.js} (100%) rename tests/components/{safer_staticfiles/safer_staticfiles.py => staticfiles/staticfiles.py} (53%) rename {src/django_components/safer_staticfiles => tests/test_app}/__init__.py (100%) create mode 100644 tests/test_app/apps.py create mode 100644 tests/test_app/components/app_lvl_comp/app_lvl_comp.css create mode 100644 tests/test_app/components/app_lvl_comp/app_lvl_comp.html create mode 100644 tests/test_app/components/app_lvl_comp/app_lvl_comp.js create mode 100644 tests/test_app/components/app_lvl_comp/app_lvl_comp.py create mode 100644 tests/test_app/custom_comps_dir/app_lvl_comp/app_lvl_comp.css create mode 100644 tests/test_app/custom_comps_dir/app_lvl_comp/app_lvl_comp.html create mode 100644 tests/test_app/custom_comps_dir/app_lvl_comp/app_lvl_comp.js create mode 100644 tests/test_app/custom_comps_dir/app_lvl_comp/app_lvl_comp.py create mode 100644 tests/test_finders.py delete mode 100644 tests/test_safer_staticfiles.py diff --git a/README.md b/README.md index 318be911..b958f67d 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,16 @@ And this is what gets rendered (plus the CSS and Javascript you've specified): ## Release notes +🚨📢 **Version 0.100** +- BREAKING CHANGE: + - `django_components.safer_staticfiles` app was removed. It is no longer needed. + - Installation changes: + - Instead of defining component directories in `STATICFILES_DIRS`, set them to [`COMPONENTS.dirs`](#dirs). + - You now must define `STATICFILES_FINDERS` + - [See here how to migrate your settings.py](https://github.com/EmilStenstrom/django-components/blob/master/docs/migrating_from_safer_staticfiles.md) +- Beside the top-level `/components` directory, you can now define also app-level components dirs, e.g. `[app]/components` + (See [`COMPONENTS.app_dirs`](#app_dirs)). + **Version 0.97** - Fixed template caching. You can now also manually create cached templates with [`cached_template()`](#template_cache_size---tune-the-template-cache) - The previously undocumented `get_template` was made private. @@ -196,14 +206,25 @@ _You are advised to read this section before using django-components in producti Components can be organized however you prefer. That said, our prefered way is to keep the files of a component close together by bundling them in the same directory. + This means that files containing backend logic, such as Python modules and HTML templates, live in the same directory as static files, e.g. JS and CSS. -If your are using _django.contrib.staticfiles_ to collect static files, no distinction is made between the different kinds of files. +From v0.100 onwards, we keep component files (as defined by [`COMPONENTS.dirs`](#dirs) and [`COMPONENTS.app_dirs`](#app_dirs)) separate from the rest of the static +files (defined by `STATICFILES_DIRS`). That way, the Python and HTML files are NOT exposed by the server. Only the static JS, CSS, and [other common formats](#static_files_allowed). + +> NOTE: If you need to expose different file formats, you can configure these with [`COMPONENTS.static_files_allowed`](#static_files_allowed) +and [`COMPONENTS.static_files_forbidden`](#static_files_forbidden). + + +#### Static files prior to v0.100 + +Prior to v0.100, if your were using _django.contrib.staticfiles_ to collect static files, no distinction was made between the different kinds of files. + As a result, your Python code and templates may inadvertently become available on your static file server. You probably don't want this, as parts of your backend logic will be exposed, posing a **potential security vulnerability**. -As of _v0.27_, django-components ships with an additional installable app _django_components.**safer_staticfiles**_. -It is a drop-in replacement for _django.contrib.staticfiles_. +From _v0.27_ until _v0.100_, django-components shipped with an additional installable app _django_components.**safer_staticfiles**_. +It was a drop-in replacement for _django.contrib.staticfiles_. Its behavior is 100% identical except it ignores .py and .html files, meaning these will not end up on your static files server. To use it, add it to INSTALLED_APPS and remove _django.contrib.staticfiles_. @@ -235,11 +256,11 @@ For a step-by-step guide on deploying production server with static files, ## Installation -1. Install the app into your environment: +1. Install `django_components` into your environment: > `pip install django_components` -2. Then add the app into `INSTALLED_APPS` in settings.py +2. Load `django_components` into Django by adding it into `INSTALLED_APPS` in settings.py: ```python INSTALLED_APPS = [ @@ -248,13 +269,33 @@ For a step-by-step guide on deploying production server with static files, ] ``` -3. Ensure that `BASE_DIR` setting is defined in settings.py: +3. `BASE_DIR` setting is required. Ensure that it is defined in settings.py: ```py BASE_DIR = Path(__file__).resolve().parent.parent ``` -4. Modify `TEMPLATES` section of settings.py as follows: +4. Add / modify [`COMPONENTS.dirs`](#dirs) and / or [`COMPONENTS.app_dirs`](#app_dirs) so django_components knows where to find component HTML, JS and CSS files: + + ```python + COMPONENTS = { + "dirs": [ + ..., + os.path.join(BASE_DIR, "components"), + ], + } + ``` + + If `COMPONENTS.dirs` is omitted, django-components will by default look for a top-level `/components` directory, + `{BASE_DIR}/components`. + + Irrespective of `COMPONENTS.dirs`, django_components will also load components from app-level directories, e.g. `my-app/components/`. + The directories within apps are configured with [`COMPONENTS.app_dirs`](#app_dirs), and the default is `[app]/components`. + + NOTE: The input to `COMPONENTS.dirs` is the same as for `STATICFILES_DIRS`, and the paths must be full paths. [See Django docs](https://docs.djangoproject.com/en/5.0/ref/settings/#staticfiles-dirs). + + +5. Next, to make Django load component HTML files as Django templates, 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) @@ -283,19 +324,18 @@ For a step-by-step guide on deploying production server with static files, ] ``` -5. Modify `STATICFILES_DIRS` (or add it if you don't have it) so django can find your static JS and CSS files: +6. Lastly, be able to serve the component JS and CSS files as static files, modify `STATICFILES_FINDERS` section of settings.py as follows: - ```python - STATICFILES_DIRS = [ - ..., - os.path.join(BASE_DIR, "components"), - ] - ``` +```py +STATICFILES_FINDERS = [ + # Default finders + "django.contrib.staticfiles.finders.FileSystemFinder", + "django.contrib.staticfiles.finders.AppDirectoriesFinder", + # Django components + "django_components.finders.ComponentsFileSystemFinder", +] +``` - If `STATICFILES_DIRS` is omitted or empty, django-components will by default look for - `{BASE_DIR}/components` - - NOTE: The paths in `STATICFILES_DIRS` must be full paths. [See Django docs](https://docs.djangoproject.com/en/5.0/ref/settings/#staticfiles-dirs). ### Optional @@ -397,7 +437,7 @@ class Calendar(Component): # Templates inside `[your apps]/components` dir and `[project root]/components` dir # will be automatically found. # - # `template_name` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS + # `template_name` can be relative to dir where `calendar.py` is, or relative to COMPONENTS.dirs template_name = "template.html" # Or def get_template_name(context): @@ -409,7 +449,7 @@ class Calendar(Component): "date": date, } - # Both `css` and `js` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS + # Both `css` and `js` can be relative to dir where `calendar.py` is, or relative to COMPONENTS.dirs class Media: css = "style.css" js = "script.js" @@ -1230,7 +1270,7 @@ class MyAppConfig(AppConfig): However, there's a simpler way! -By default, the Python files in the `STATICFILES_DIRS` directories are auto-imported in order to auto-register the components. +By default, the Python files in the `COMPONENTS.dirs` directories (or app-level `[app]/components/`) are auto-imported in order to auto-register the components. Autodiscovery occurs when Django is loaded, during the `ready` hook of the `apps.py` file. @@ -1589,17 +1629,17 @@ Consider a component with slot(s). This component may do some processing on the ```py @register("my_comp") class MyComp(Component): - template = """ -
- {% slot "content" default %} - input: {{ input }} - {% endslot %} -
- """ + template = """ +
+ {% slot "content" default %} + input: {{ input }} + {% endslot %} +
+ """ - def get_context_data(self, input): - processed_input = do_something(input) - return {"input": processed_input} + def get_context_data(self, input): + processed_input = do_something(input) + return {"input": processed_input} ``` You may want to design a component so that users of your component can still access the `input` variable, so they don't have to recompute it. @@ -1618,17 +1658,17 @@ To pass the data to the `slot` tag, simply pass them as keyword attributes (`key ```py @register("my_comp") class MyComp(Component): - template = """ -
- {% slot "content" default input=input %} - input: {{ input }} - {% endslot %} -
- """ + template = """ +
+ {% slot "content" default input=input %} + input: {{ input }} + {% endslot %} +
+ """ - def get_context_data(self, input): - processed_input = do_something(input) - return { + def get_context_data(self, input): + processed_input = do_something(input) + return { "input": processed_input, } ``` @@ -1962,13 +2002,13 @@ Assuming that: class_from_var = "from-var" attrs = { - "class": "from-attrs", - "type": "submit", + "class": "from-attrs", + "type": "submit", } defaults = { - "class": "from-defaults", - "role": "button", + "class": "from-defaults", + "role": "button", } ``` @@ -2873,9 +2913,9 @@ class Calendar(Component): In the example above, the files are defined relative to the directory where `component.py` is. -Alternatively, you can specify the file paths relative to the directories set in `STATICFILES_DIRS`. +Alternatively, you can specify the file paths relative to the directories set in `COMPONENTS.dirs` or `COMPONENTS.app_dirs`. -Assuming that `STATICFILES_DIRS` contains path `[project root]/components`, we can rewrite the example as: +Assuming that `COMPONENTS.dirs` contains path `[project root]/components`, we can rewrite the example as: ```py # In a file [project root]/components/calendar/calendar.py @@ -2971,7 +3011,7 @@ In the example [above](#supported-types-for-file-paths), you could see that when This is an extension of Django's [Paths as objects](https://docs.djangoproject.com/en/5.0/topics/forms/media/#paths-as-objects) feature, where "safe" strings are taken as is, and accessed only at render time. -Because of that, the paths defined as "safe" strings are NEVER resolved, neither relative to component's directory, nor relative to `STATICFILES_DIRS`. +Because of that, the paths defined as "safe" strings are NEVER resolved, neither relative to component's directory, nor relative to `COMPONENTS.dirs`. "Safe" strings can be used to lazily resolve a path, or to customize the `'); + * ``` + * + * ```js + * Components.markScriptLoaded("js", '/abc/def'); + * ``` + */ +export const createComponentsManager = () => { + const loadedJs = new Set(); + const loadedCss = new Set(); + const components: Record = {}; + const componentInputs: Record = {}; + + const parseScriptTag = (tag: string) => { + const scriptNode = new DOMParser().parseFromString(tag, 'text/html').querySelector('script'); + if (!scriptNode) { + throw Error( + '[Components] Failed to extract and is a valid HTML' + ); + } + return scriptNode; + }; + + const parseLinkTag = (tag: string) => { + const linkNode = new DOMParser().parseFromString(tag, 'text/html').querySelector('link'); + if (!linkNode) { + throw Error( + '[Components] Failed to extract tag. Make sure that the string contains' + + ' and is a valid HTML' + ); + } + return linkNode; + }; + + // NOTE: The way we turn the string into an HTMLElement, if we then try to + // insert the node into the Document, it will NOT load. So instead we create + // a "); + const bodyAfterFirstLoad = document.body.innerHTML; + + // Does not add it the second time + manager.loadScript('js', ""); + const bodyAfterSecondLoad = document.body.innerHTML; + + // Adds different script + manager.loadScript('js', ""); + const bodyAfterThirdLoad = document.body.innerHTML; + + const headAfterThirdLoad = document.head.innerHTML; + + return { + bodyAfterFirstLoad, + bodyAfterSecondLoad, + bodyAfterThirdLoad, + headBeforeFirstLoad, + headAfterThirdLoad, + }; + }""" + + data = await page.evaluate(test_js) + + self.assertEqual(data["bodyAfterFirstLoad"], '') + self.assertEqual(data["bodyAfterSecondLoad"], '') + self.assertEqual( + data["bodyAfterThirdLoad"], '' + ) + + self.assertEqual(data["headBeforeFirstLoad"], data["headAfterThirdLoad"]) + self.assertEqual(data["headBeforeFirstLoad"], "") + + await page.close() + + @with_playwright + async def test_load_css_scripts(self): + page = await self._create_page_with_dep_manager() + + # JS code that loads a few dependencies, capturing the HTML after each action + test_js: types.js = """() => { + const manager = Components.createComponentsManager(); + + const bodyBeforeFirstLoad = document.body.innerHTML; + + // Adds a script the first time + manager.loadScript('css', ""); + const headAfterFirstLoad = document.head.innerHTML; + + // Does not add it the second time + manager.loadScript('css', ""); + const headAfterSecondLoad = document.head.innerHTML; + + // Adds different script + manager.loadScript('css', ""); + const headAfterThirdLoad = document.head.innerHTML; + + const bodyAfterThirdLoad = document.body.innerHTML; + + return { + headAfterFirstLoad, + headAfterSecondLoad, + headAfterThirdLoad, + bodyBeforeFirstLoad, + bodyAfterThirdLoad, + }; + }""" + + data = await page.evaluate(test_js) + + self.assertEqual(data["headAfterFirstLoad"], '') + self.assertEqual(data["headAfterSecondLoad"], '') + self.assertEqual(data["headAfterThirdLoad"], '') + + self.assertEqual(data["bodyBeforeFirstLoad"], data["bodyAfterThirdLoad"]) + self.assertEqual(data["bodyBeforeFirstLoad"], "") + + await page.close() + + @with_playwright + async def test_does_not_load_script_if_marked_as_loaded(self): + page = await self._create_page_with_dep_manager() + + # JS code that loads a few dependencies, capturing the HTML after each action + test_js: types.js = """() => { + const manager = Components.createComponentsManager(); + + // Adds a script the first time + manager.markScriptLoaded('css', '/one/two'); + manager.markScriptLoaded('js', '/one/three'); + + manager.loadScript('css', ""); + const headAfterFirstLoad = document.head.innerHTML; + + manager.loadScript('js', ""); + const bodyAfterSecondLoad = document.body.innerHTML; + + return { + headAfterFirstLoad, + bodyAfterSecondLoad, + }; + }""" + + data = await page.evaluate(test_js) + + self.assertEqual(data["headAfterFirstLoad"], "") + self.assertEqual(data["bodyAfterSecondLoad"], "") + + await page.close() + + +# Tests for `manager.registerComponent()` / `registerComponentData()` / `callComponent()` +@override_settings(STATIC_URL="static/") +class CallComponentTests(_BaseDepManagerTestCase): + @with_playwright + async def test_calls_component_successfully(self): + page = await self._create_page_with_dep_manager() + + test_js: types.js = """() => { + const manager = Components.createComponentsManager(); + + const compName = 'my_comp'; + const compId = '12345'; + const inputHash = 'input-abc'; + + // Pretend that this HTML belongs to our component + document.body.insertAdjacentHTML('beforeend', '
abc
'); + + let captured = null; + manager.registerComponent(compName, (data, ctx) => { + captured = { ctx, data }; + return 123; + }); + + manager.registerComponentData(compName, inputHash, () => { + return { hello: 'world' }; + }); + + const result = manager.callComponent(compName, compId, inputHash); + + // Serialize the HTML elements + captured.ctx.els = captured.ctx.els.map((el) => el.outerHTML); + + return { + result, + captured, + }; + }""" + + data = await page.evaluate(test_js) + + self.assertEqual(data["result"], 123) + self.assertEqual( + data["captured"], + { + "data": { + "hello": "world", + }, + "ctx": { + "els": ['
abc
'], + "id": "12345", + "name": "my_comp", + }, + }, + ) + + await page.close() + + @with_playwright + async def test_calls_component_successfully_async(self): + page = await self._create_page_with_dep_manager() + + test_js: types.js = """() => { + const manager = Components.createComponentsManager(); + + const compName = 'my_comp'; + const compId = '12345'; + const inputHash = 'input-abc'; + + // Pretend that this HTML belongs to our component + document.body.insertAdjacentHTML('beforeend', '
abc
'); + + manager.registerComponent(compName, (data, ctx) => { + return Promise.resolve(123); + }); + + manager.registerComponentData(compName, inputHash, () => { + return { hello: 'world' }; + }); + + // Should be Promise + const result = manager.callComponent(compName, compId, inputHash); + const isPromise = `${result}` === '[object Promise]'; + + // Wrap the whole response in Promise, so we can add extra fields + return Promise.resolve(result).then((res) => ({ + result: res, + isPromise, + })); + }""" + + data = await page.evaluate(test_js) + + self.assertEqual(data["result"], 123) + self.assertEqual(data["isPromise"], True) + + await page.close() + + @with_playwright + async def test_error_in_component_call_do_not_propagate_sync(self): + page = await self._create_page_with_dep_manager() + + test_js: types.js = """() => { + const manager = Components.createComponentsManager(); + + const compName = 'my_comp'; + const compId = '12345'; + const inputHash = 'input-abc'; + + // Pretend that this HTML belongs to our component + document.body.insertAdjacentHTML('beforeend', '
abc
'); + + manager.registerComponent(compName, (data, ctx) => { + throw Error('Oops!'); + return 123; + }); + + manager.registerComponentData(compName, inputHash, () => { + return { hello: 'world' }; + }); + + const result = manager.callComponent(compName, compId, inputHash); + + return result; + }""" + + data = await page.evaluate(test_js) + + self.assertEqual(data, None) + + await page.close() + + @with_playwright + async def test_error_in_component_call_do_not_propagate_async(self): + page = await self._create_page_with_dep_manager() + + test_js: types.js = """() => { + const manager = Components.createComponentsManager(); + + const compName = 'my_comp'; + const compId = '12345'; + const inputHash = 'input-abc'; + + // Pretend that this HTML belongs to our component + document.body.insertAdjacentHTML('beforeend', '
abc
'); + + manager.registerComponent(compName, async (data, ctx) => { + throw Error('Oops!'); + return 123; + }); + + manager.registerComponentData(compName, inputHash, () => { + return { hello: 'world' }; + }); + + const result = manager.callComponent(compName, compId, inputHash); + return Promise.allSettled([result]); + }""" + + data = await page.evaluate(test_js) + + self.assertEqual(len(data), 1) + self.assertEqual(data[0]["status"], "rejected") + self.assertIsInstance(data[0]["reason"], Error) + self.assertEqual(data[0]["reason"].message, "Oops!") + + await page.close() + + @with_playwright + async def test_raises_if_component_element_not_in_dom(self): + page = await self._create_page_with_dep_manager() + + test_js: types.js = """() => { + const manager = Components.createComponentsManager(); + + const compName = 'my_comp'; + const compId = '12345'; + const inputHash = 'input-abc'; + + manager.registerComponent(compName, (data, ctx) => { + return 123; + }); + + manager.registerComponentData(compName, inputHash, () => { + return { hello: 'world' }; + }); + + // Should raise Error + manager.callComponent(compName, compId, inputHash); + }""" + + with self.assertRaisesMessage( + Error, "Error: [Components] 'my_comp': No elements with component ID '12345' found" + ): + await page.evaluate(test_js) + + await page.close() + + @with_playwright + async def test_raises_if_input_hash_not_registered(self): + page = await self._create_page_with_dep_manager() + + test_js: types.js = """() => { + const manager = Components.createComponentsManager(); + + const compName = 'my_comp'; + const compId = '12345'; + const inputHash = 'input-abc'; + + document.body.insertAdjacentHTML('beforeend', '
abc
'); + + manager.registerComponent(compName, (data, ctx) => { + return Promise.resolve(123); + }); + + // Should raise Error + manager.callComponent(compName, compId, inputHash); + }""" + + with self.assertRaisesMessage(Error, "Error: [Components] 'my_comp': Cannot find input for hash 'input-abc'"): + await page.evaluate(test_js) + + await page.close() + + @with_playwright + async def test_raises_if_component_not_registered(self): + page = await self._create_page_with_dep_manager() + + test_js: types.js = """() => { + const manager = Components.createComponentsManager(); + + const compName = 'my_comp'; + const compId = '12345'; + const inputHash = 'input-abc'; + + document.body.insertAdjacentHTML('beforeend', '
abc
'); + + manager.registerComponentData(compName, inputHash, () => { + return { hello: 'world' }; + }); + + // Should raise Error + manager.callComponent(compName, compId, inputHash); + }""" + + with self.assertRaisesMessage(Error, "Error: [Components] 'my_comp': No component registered for that name"): + await page.evaluate(test_js) + + await page.close() diff --git a/tox.ini b/tox.ini index 7bad46dd..8e80ff18 100644 --- a/tox.ini +++ b/tox.ini @@ -30,6 +30,10 @@ deps = django50: Django>=5.0,<5.1 pytest pytest-xdist + playwright + requests + types-requests + whitenoise commands = pytest {posargs} [testenv:flake8] @@ -42,13 +46,20 @@ deps = isort commands = isort --check-only --diff src/django_components [testenv:coverage] -deps = pytest-coverage +deps = + pytest-coverage + playwright + requests + types-requests + whitenoise commands = coverage run --branch -m pytest coverage report -m --fail-under=97 [testenv:mypy] -deps = mypy +deps = + mypy + types-requests commands = mypy . [testenv:black] From e32cfb27da17171ec241ece3582f541c0fda2c9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Stenstr=C3=B6m?= Date: Sun, 22 Sep 2024 22:33:52 +0200 Subject: [PATCH 051/487] Try upgrading docs dependencies: hatch-pip-compile docs --upgrade --- requirements-docs.txt | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 79fb5e52..0acc921c 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -48,9 +48,7 @@ click==8.1.7 # mkdocstrings colorama==0.4.6 # via - # click # griffe - # mkdocs # mkdocs-material csscompressor==0.9.5 # via mkdocs-minify-plugin @@ -66,13 +64,13 @@ gitdb==4.0.11 # via gitpython gitpython==3.1.43 # via mkdocs-git-revision-date-localized-plugin -griffe==1.2.0 +griffe==1.3.1 # via mkdocstrings-python htmlmin2==0.1.13 # via mkdocs-minify-plugin idna==3.10 # via requests -importlib-metadata==8.4.0 +importlib-metadata==8.5.0 # via mike importlib-resources==6.4.5 # via mike @@ -119,7 +117,7 @@ mkdocs==1.6.1 # mkdocs-minify-plugin # mkdocs-redirects # mkdocstrings -mkdocs-autorefs==1.1.0 +mkdocs-autorefs==1.2.0 # via # hatch.envs.docs # mkdocstrings @@ -130,13 +128,13 @@ mkdocs-get-deps==0.2.0 # via mkdocs mkdocs-git-authors-plugin==0.9.0 # via hatch.envs.docs -mkdocs-git-revision-date-localized-plugin==1.2.8 +mkdocs-git-revision-date-localized-plugin==1.2.9 # via hatch.envs.docs mkdocs-include-markdown-plugin==6.2.2 # via hatch.envs.docs mkdocs-literate-nav==0.6.1 # via hatch.envs.docs -mkdocs-material==9.5.34 +mkdocs-material==9.5.36 # via hatch.envs.docs mkdocs-material-extensions==1.3.1 # via mkdocs-material @@ -166,7 +164,7 @@ pillow==10.4.0 # via # cairosvg # mkdocs-material -platformdirs==4.3.2 +platformdirs==4.3.6 # via # black # mkdocs-get-deps @@ -175,7 +173,7 @@ pycparser==2.22 # via cffi pygments==2.18.0 # via mkdocs-material -pymdown-extensions==10.9 +pymdown-extensions==10.10.1 # via # hatch.envs.docs # markdown-exec @@ -198,7 +196,7 @@ pyyaml-env-tag==0.1 # via # mike # mkdocs -regex==2024.7.24 +regex==2024.9.11 # via mkdocs-material requests==2.32.3 # via mkdocs-material @@ -212,9 +210,7 @@ tinycss2==1.3.0 # via # cairosvg # cssselect2 -tzdata==2024.1 - # via django -urllib3==2.2.2 +urllib3==2.2.3 # via requests verspec==0.1.0 # via mike @@ -226,5 +222,5 @@ webencodings==0.5.1 # via # cssselect2 # tinycss2 -zipp==3.20.1 +zipp==3.20.2 # via importlib-metadata From 4398cbee0bc2d35fba5b94da66de75828a480f29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Stenstr=C3=B6m?= Date: Sun, 22 Sep 2024 23:07:18 +0200 Subject: [PATCH 052/487] Minor README changes made by LLM. --- README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index fc2c7582..5483bc51 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Potential benefits: - 🔄 Reduced code duplication - 🛠️ Improved maintainability through modular design - 🧠 Easier management of complex UIs -- 🤝 Potential for better collaboration between frontend and backend developers +- 🤝 Enhanced collaboration between frontend and backend developers Django-components can be particularly useful for larger Django projects that require a more structured approach to UI development, without necessitating a shift to a separate frontend framework. @@ -201,7 +201,7 @@ This change is done to simplify the API in anticipation of a 1.0 release of djan ## Security notes 🚨 -_You are advised to read this section before using django-components in production._ +_It is strongly recommended to read this section before using django-components in production._ ### Static files @@ -290,7 +290,7 @@ For a step-by-step guide on deploying production server with static files, If `COMPONENTS.dirs` is omitted, django-components will by default look for a top-level `/components` directory, `{BASE_DIR}/components`. - Irrespective of `COMPONENTS.dirs`, django_components will also load components from app-level directories, e.g. `my-app/components/`. + In addition to `COMPONENTS.dirs`, django_components will also load components from app-level directories, such as `my-app/components/`. The directories within apps are configured with [`COMPONENTS.app_dirs`](#app_dirs), and the default is `[app]/components`. NOTE: The input to `COMPONENTS.dirs` is the same as for `STATICFILES_DIRS`, and the paths must be full paths. [See Django docs](https://docs.djangoproject.com/en/5.0/ref/settings/#staticfiles-dirs). @@ -1581,7 +1581,6 @@ This is what our example looks like with `component_vars.is_filled`.
{% endif %} -``` Here's our example with more complex branching. @@ -1799,7 +1798,7 @@ This means that you can use `self.input` inside: - `get_template_name` - `get_template` -`self.input` is defined only for the duration of `Component.render`, and raises `RuntimeError` when called outside of this. +`self.input` is only defined during the execution of `Component.render`, and raises a `RuntimeError` when called outside of this context. `self.input` has the same fields as the input to `Component.render`: From dea8767d8d6cd656538726915d6e44d25241cd07 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Sep 2024 17:34:04 +0000 Subject: [PATCH 053/487] build(deps): bump tox from 4.18.0 to 4.20.0 Bumps [tox](https://github.com/tox-dev/tox) from 4.18.0 to 4.20.0. - [Release notes](https://github.com/tox-dev/tox/releases) - [Changelog](https://github.com/tox-dev/tox/blob/main/docs/changelog.rst) - [Commits](https://github.com/tox-dev/tox/compare/4.18.0...4.20.0) --- updated-dependencies: - dependency-name: tox dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-ci.txt | 6 +++++- requirements-dev.txt | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/requirements-ci.txt b/requirements-ci.txt index b56276a1..2abdc792 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -48,10 +48,14 @@ tox==4.20.0 # tox-gh-actions tox-gh-actions==3.2.0 # via -r requirements-ci.in +types-requests==2.32.0.20240914 + # via -r requirements-ci.in typing-extensions==4.12.2 # via pyee urllib3==2.2.3 - # via requests + # via + # requests + # types-requests virtualenv==20.26.5 # via tox whitenoise==6.7.0 diff --git a/requirements-dev.txt b/requirements-dev.txt index 8d83d26e..1934af0b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,7 +8,7 @@ asgiref==3.8.1 # via django black==24.8.0 # via -r requirements-dev.in -cachetools==5.4.0 +cachetools==5.5.0 # via tox certifi==2024.8.30 # via requests @@ -93,7 +93,7 @@ requests==2.32.3 # via -r requirements-dev.in sqlparse==0.5.0 # via django -tox==4.18.0 +tox==4.20.0 # via -r requirements-dev.in types-requests==2.32.0.20240914 # via -r requirements-dev.in From 5b291420b15e328959ed957b57c455fc688ce108 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Sep 2024 17:00:37 +0000 Subject: [PATCH 054/487] build(deps): bump watchdog from 5.0.2 to 5.0.3 Bumps [watchdog](https://github.com/gorakhargosh/watchdog) from 5.0.2 to 5.0.3. - [Release notes](https://github.com/gorakhargosh/watchdog/releases) - [Changelog](https://github.com/gorakhargosh/watchdog/blob/master/changelog.rst) - [Commits](https://github.com/gorakhargosh/watchdog/compare/v5.0.2...v5.0.3) --- updated-dependencies: - dependency-name: watchdog dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 0acc921c..69a00c3a 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -214,7 +214,7 @@ urllib3==2.2.3 # via requests verspec==0.1.0 # via mike -watchdog==5.0.2 +watchdog==5.0.3 # via mkdocs wcmatch==9.0 # via mkdocs-include-markdown-plugin From c7b4db6c70f5af4ebad7c73247b90d5355db5b32 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Sep 2024 17:03:22 +0000 Subject: [PATCH 055/487] build(deps): bump wcmatch from 9.0 to 10.0 Bumps [wcmatch](https://github.com/facelessuser/wcmatch) from 9.0 to 10.0. - [Release notes](https://github.com/facelessuser/wcmatch/releases) - [Commits](https://github.com/facelessuser/wcmatch/compare/9.0...10.0) --- updated-dependencies: - dependency-name: wcmatch dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 69a00c3a..c1423485 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -216,7 +216,7 @@ verspec==0.1.0 # via mike watchdog==5.0.3 # via mkdocs -wcmatch==9.0 +wcmatch==10.0 # via mkdocs-include-markdown-plugin webencodings==0.5.1 # via From 25776cf8ae1d37c21377c3afeae873c7e71a5334 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Sep 2024 17:06:08 +0000 Subject: [PATCH 056/487] build(deps): bump mkdocs-material from 9.5.36 to 9.5.39 Bumps [mkdocs-material](https://github.com/squidfunk/mkdocs-material) from 9.5.36 to 9.5.39. - [Release notes](https://github.com/squidfunk/mkdocs-material/releases) - [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG) - [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.5.36...9.5.39) --- updated-dependencies: - dependency-name: mkdocs-material dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index c1423485..64c87d27 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -134,7 +134,7 @@ mkdocs-include-markdown-plugin==6.2.2 # via hatch.envs.docs mkdocs-literate-nav==0.6.1 # via hatch.envs.docs -mkdocs-material==9.5.36 +mkdocs-material==9.5.39 # via hatch.envs.docs mkdocs-material-extensions==1.3.1 # via mkdocs-material From a805c91b8e5ea1cc5235f7beab8410bb9a4ed6b5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Sep 2024 17:08:52 +0000 Subject: [PATCH 057/487] build(deps): bump pymdown-extensions from 10.10.1 to 10.11.1 Bumps [pymdown-extensions](https://github.com/facelessuser/pymdown-extensions) from 10.10.1 to 10.11.1. - [Release notes](https://github.com/facelessuser/pymdown-extensions/releases) - [Commits](https://github.com/facelessuser/pymdown-extensions/compare/10.10.1...10.11.1) --- updated-dependencies: - dependency-name: pymdown-extensions dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 64c87d27..c3a7a47d 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -173,7 +173,7 @@ pycparser==2.22 # via cffi pygments==2.18.0 # via mkdocs-material -pymdown-extensions==10.10.1 +pymdown-extensions==10.11.1 # via # hatch.envs.docs # markdown-exec From 170a23e6ad8f1f993e4098e7bb18d32e0fa5d931 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Sep 2024 17:11:34 +0000 Subject: [PATCH 058/487] build(deps): bump bracex from 2.5 to 2.5.post1 Bumps [bracex](https://github.com/facelessuser/bracex) from 2.5 to 2.5.post1. - [Release notes](https://github.com/facelessuser/bracex/releases) - [Commits](https://github.com/facelessuser/bracex/compare/2.5...2.5.post1) --- updated-dependencies: - dependency-name: bracex dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index c3a7a47d..d2b51ec3 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -29,7 +29,7 @@ babel==2.16.0 # mkdocs-material black==24.8.0 # via hatch.envs.docs -bracex==2.5 +bracex==2.5.post1 # via wcmatch cairocffi==1.7.1 # via cairosvg From 00cdcec47d4f679f03179d66a55b9660f5331df8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 17:06:16 +0000 Subject: [PATCH 059/487] build(deps): bump pymdown-extensions from 10.11.1 to 10.11.2 Bumps [pymdown-extensions](https://github.com/facelessuser/pymdown-extensions) from 10.11.1 to 10.11.2. - [Release notes](https://github.com/facelessuser/pymdown-extensions/releases) - [Commits](https://github.com/facelessuser/pymdown-extensions/compare/10.11.1...10.11.2) --- updated-dependencies: - dependency-name: pymdown-extensions dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index d2b51ec3..a82336ac 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -173,7 +173,7 @@ pycparser==2.22 # via cffi pygments==2.18.0 # via mkdocs-material -pymdown-extensions==10.11.1 +pymdown-extensions==10.11.2 # via # hatch.envs.docs # markdown-exec From 6df4ffcd4840ab61a5dcc543d0396fcd997635d9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 17:09:10 +0000 Subject: [PATCH 060/487] build(deps): bump griffe from 1.3.1 to 1.3.2 Bumps [griffe](https://github.com/mkdocstrings/griffe) from 1.3.1 to 1.3.2. - [Release notes](https://github.com/mkdocstrings/griffe/releases) - [Changelog](https://github.com/mkdocstrings/griffe/blob/main/CHANGELOG.md) - [Commits](https://github.com/mkdocstrings/griffe/compare/1.3.1...1.3.2) --- updated-dependencies: - dependency-name: griffe dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index a82336ac..0c710479 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -64,7 +64,7 @@ gitdb==4.0.11 # via gitpython gitpython==3.1.43 # via mkdocs-git-revision-date-localized-plugin -griffe==1.3.1 +griffe==1.3.2 # via mkdocstrings-python htmlmin2==0.1.13 # via mkdocs-minify-plugin From 7a557ac408c56115ed6fcbd12d2873c2b2f751f2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 17:12:16 +0000 Subject: [PATCH 061/487] build(deps): bump django from 5.1 to 5.1.1 Bumps [django](https://github.com/django/django) from 5.1 to 5.1.1. - [Commits](https://github.com/django/django/compare/5.1...5.1.1) --- updated-dependencies: - dependency-name: django dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 1934af0b..69fdadd0 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -24,7 +24,7 @@ colorama==0.4.6 # via tox distlib==0.3.8 # via virtualenv -django==5.1 +django==5.1.1 # via -r requirements-dev.in filelock==3.15.4 # via From 9abba62f26054d2aa02d1f2cd843de30324d373b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 17:15:26 +0000 Subject: [PATCH 062/487] build(deps-dev): bump pre-commit from 3.8.0 to 4.0.0 Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 3.8.0 to 4.0.0. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/main/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v3.8.0...v4.0.0) --- updated-dependencies: - dependency-name: pre-commit dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 69fdadd0..a7d70ef8 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -75,7 +75,7 @@ pluggy==1.5.0 # via # pytest # tox -pre-commit==3.8.0 +pre-commit==4.0.0 # via -r requirements-dev.in pycodestyle==2.12.0 # via flake8 From da3ecbf72515fff3bd04cdeb2bbc0878d38d40da Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 17:18:49 +0000 Subject: [PATCH 063/487] build(deps): bump tox from 4.20.0 to 4.21.2 Bumps [tox](https://github.com/tox-dev/tox) from 4.20.0 to 4.21.2. - [Release notes](https://github.com/tox-dev/tox/releases) - [Changelog](https://github.com/tox-dev/tox/blob/main/docs/changelog.rst) - [Commits](https://github.com/tox-dev/tox/compare/4.20.0...4.21.2) --- updated-dependencies: - dependency-name: tox dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-ci.txt | 4 ++-- requirements-dev.txt | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/requirements-ci.txt b/requirements-ci.txt index 2abdc792..199ff9bb 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -42,7 +42,7 @@ pyproject-api==1.8.0 # via tox requests==2.32.3 # via -r requirements-ci.in -tox==4.20.0 +tox==4.21.2 # via # -r requirements-ci.in # tox-gh-actions @@ -56,7 +56,7 @@ urllib3==2.2.3 # via # requests # types-requests -virtualenv==20.26.5 +virtualenv==20.26.6 # via tox whitenoise==6.7.0 # via -r requirements-ci.in diff --git a/requirements-dev.txt b/requirements-dev.txt index a7d70ef8..8520bece 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -26,7 +26,7 @@ distlib==0.3.8 # via virtualenv django==5.1.1 # via -r requirements-dev.in -filelock==3.15.4 +filelock==3.16.1 # via # tox # virtualenv @@ -64,7 +64,7 @@ packaging==24.1 # tox pathspec==0.12.1 # via black -platformdirs==4.2.2 +platformdirs==4.3.6 # via # black # tox @@ -83,7 +83,7 @@ pyee==12.0.0 # via playwright pyflakes==3.2.0 # via flake8 -pyproject-api==1.7.1 +pyproject-api==1.8.0 # via tox pytest==8.3.3 # via -r requirements-dev.in @@ -93,7 +93,7 @@ requests==2.32.3 # via -r requirements-dev.in sqlparse==0.5.0 # via django -tox==4.20.0 +tox==4.21.2 # via -r requirements-dev.in types-requests==2.32.0.20240914 # via -r requirements-dev.in @@ -105,7 +105,7 @@ urllib3==2.2.3 # via # requests # types-requests -virtualenv==20.26.3 +virtualenv==20.26.6 # via # pre-commit # tox From d1807633abb082d3c41c0193a04c126746a120f4 Mon Sep 17 00:00:00 2001 From: Yassin Rakha Date: Mon, 7 Oct 2024 21:54:42 +0300 Subject: [PATCH 064/487] Add example for accessing outer context in components and recommend best practices --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index 5483bc51..74fd1d19 100644 --- a/README.md +++ b/README.md @@ -2718,6 +2718,30 @@ If you find yourself using the `only` modifier often, you can set the [context_b Components can also access the outer context in their context methods like `get_context_data` by accessing the property `self.outer_context`. +### Example of Accessing Outer Context + +```django +
+ {% component "calender" / %} +
+``` + +Assuming that the rendering context has variables such as `date`, you can use `self.outer_context` to access them from within `get_context_data`. Here's how you might implement it: + +```python +class Calender(Component): + + ... + + def get_context_data(self): + outer_field = self.outer_context["date"] + return { + "date": outer_fields, + } +``` + +However, as a best practice, it’s recommended not to rely on accessing the outer context directly through `self.outer_context`. Instead, explicitly pass the variables to the component. For instance, continue passing the variables in the component tag as shown in the previous examples. + ## Pre-defined template variables Here is a list of all variables that are automatically available from within the component's template and `on_render_before` / `on_render_after` hooks. From c0013c0fe4b63144a3de770d750159557d2c1d7d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 8 Oct 2024 08:46:43 +0200 Subject: [PATCH 065/487] [pre-commit.ci] pre-commit autoupdate (#700) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 31b2e2f2..18541677 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ repos: hooks: - id: isort - repo: https://github.com/psf/black - rev: 24.8.0 + rev: 24.10.0 hooks: - id: black - repo: https://github.com/pycqa/flake8 From ff70be35e409a170ac5c7c0e4507a2efe4070e56 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Thu, 10 Oct 2024 14:37:21 +0200 Subject: [PATCH 066/487] refactor: fix for nested slots (#698) (#699) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- src/django_components/component.py | 7 -- src/django_components/context.py | 4 - src/django_components/slots.py | 68 ++++++----- tests/test_templatetags_slot_fill.py | 166 +++++++++++++++++++++++++++ 4 files changed, 198 insertions(+), 47 deletions(-) diff --git a/src/django_components/component.py b/src/django_components/component.py index a2deb82f..27b60dfa 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -40,7 +40,6 @@ from django_components.component_registry import ComponentRegistry from django_components.component_registry import registry as registry_ from django_components.context import ( _FILLED_SLOTS_CONTENT_CONTEXT_KEY, - _PARENT_COMP_CONTEXT_KEY, _REGISTRY_CONTEXT_KEY, _ROOT_CTX_CONTEXT_KEY, get_injected_context_var, @@ -630,16 +629,10 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co else: fill_content = self.fill_content - # If this is top-level component and it has no parent, use outer context instead - slot_context_data = context_data - if not context[_PARENT_COMP_CONTEXT_KEY]: - slot_context_data = self.outer_context.flatten() - _, resolved_fills = resolve_slots( context, template, component_name=self.name, - context_data=slot_context_data, fill_content=fill_content, # Dynamic component has a special mark do it doesn't raise certain errors is_dynamic_component=getattr(self, "_is_dynamic_component", False), diff --git a/src/django_components/context.py b/src/django_components/context.py index 4e5fb033..288b5fe8 100644 --- a/src/django_components/context.py +++ b/src/django_components/context.py @@ -15,7 +15,6 @@ from django_components.utils import find_last_index _FILLED_SLOTS_CONTENT_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS" _ROOT_CTX_CONTEXT_KEY = "_DJANGO_COMPONENTS_ROOT_CTX" _REGISTRY_CONTEXT_KEY = "_DJANGO_COMPONENTS_REGISTRY" -_PARENT_COMP_CONTEXT_KEY = "_DJANGO_COMPONENTS_PARENT_COMP" _CURRENT_COMP_CONTEXT_KEY = "_DJANGO_COMPONENTS_CURRENT_COMP" _INJECT_CONTEXT_KEY_PREFIX = "_DJANGO_COMPONENTS_INJECT__" @@ -57,9 +56,6 @@ def set_component_id(context: Context, component_id: str) -> None: We use the Context object to pass down info on inside of which component we are currently rendering. """ - # Store the previous component so we can detect if the current component - # is the top-most or not. If it is, then "_parent_component_id" is None - context[_PARENT_COMP_CONTEXT_KEY] = context.get(_CURRENT_COMP_CONTEXT_KEY, None) context[_CURRENT_COMP_CONTEXT_KEY] = component_id diff --git a/src/django_components/slots.py b/src/django_components/slots.py index 113b1451..57ad21e0 100644 --- a/src/django_components/slots.py +++ b/src/django_components/slots.py @@ -124,7 +124,6 @@ class SlotFill(Generic[TSlotData]): escaped_name: str is_filled: bool content_func: SlotFunc[TSlotData] - context_data: Mapping slot_default_var: Optional[SlotDefaultName] slot_data_var: Optional[SlotDataName] @@ -479,7 +478,6 @@ def resolve_slots( context: Context, template: Template, component_name: Optional[str], - context_data: Mapping[str, Any], fill_content: Dict[SlotName, FillContent], is_dynamic_component: bool = False, ) -> Tuple[Dict[SlotId, Slot], Dict[SlotId, SlotFill]]: @@ -497,7 +495,6 @@ def resolve_slots( escaped_name=_escape_slot_name(name), is_filled=True, content_func=fill.content_func, - context_data=context_data, slot_default_var=fill.slot_default_var, slot_data_var=fill.slot_data_var, ) @@ -507,6 +504,7 @@ def resolve_slots( slots: Dict[SlotId, Slot] = {} # This holds info on which slot (key) has which slots nested in it (value list) slot_children: Dict[SlotId, List[SlotId]] = {} + all_nested_slots: Set[SlotId] = set() def on_node(entry: NodeTraverse) -> None: node = entry.node @@ -535,16 +533,17 @@ def resolve_slots( # - 0002: [] # - 0003: [0004] # In other words, the data tells us that slot ID 0001 is PARENT of slot 0002. - curr_entry = entry.parent - while curr_entry and curr_entry.parent is not None: - if not isinstance(curr_entry.node, SlotNode): - curr_entry = curr_entry.parent + parent_slot_entry = entry.parent + while parent_slot_entry is not None: + if not isinstance(parent_slot_entry.node, SlotNode): + parent_slot_entry = parent_slot_entry.parent continue - parent_slot_id = curr_entry.node.node_id + parent_slot_id = parent_slot_entry.node.node_id if parent_slot_id not in slot_children: slot_children[parent_slot_id] = [] slot_children[parent_slot_id].append(node.node_id) + all_nested_slots.add(node.node_id) break walk_nodelist(template.nodelist, on_node, context) @@ -565,10 +564,7 @@ def resolve_slots( _report_slot_errors(slots, slot_fills, component_name) # 5. Find roots of the slot relationships - top_level_slot_ids: List[SlotId] = [] - for node_id, slot in slots.items(): - if node_id not in slot_children or not slot_children[node_id]: - top_level_slot_ids.append(node_id) + top_level_slot_ids: List[SlotId] = [node_id for node_id in slots.keys() if node_id not in all_nested_slots] # 6. Walk from out-most slots inwards, and decide whether and how # we will render each slot. @@ -592,7 +588,6 @@ def resolve_slots( escaped_name=_escape_slot_name(slot.name), is_filled=False, content_func=_nodelist_to_slot_render_func(slot.nodelist), - context_data=context_data, slot_default_var=None, slot_data_var=None, ) @@ -625,29 +620,30 @@ def _resolve_default_slot( # Check for errors for slot in slots.values(): - if slot.is_default: - if default_slot_encountered: - raise TemplateSyntaxError( - "Only one component slot may be marked as 'default'. " - f"To fix, check template '{template_name}' " - f"of component '{component_name}'." - ) - default_slot_encountered = True + if not slot.is_default: + continue - # Here we've identified which slot the default/implicit fill belongs to - if default_fill: - # NOTE: We recreate new instance, passing all fields, instead of using - # `NamedTuple._replace`, because `_replace` is not typed. - named_fills[slot.name] = SlotFill( - is_filled=default_fill.is_filled, - content_func=default_fill.content_func, - context_data=default_fill.context_data, - slot_default_var=default_fill.slot_default_var, - slot_data_var=default_fill.slot_data_var, - # Updated fields - name=slot.name, - escaped_name=_escape_slot_name(slot.name), - ) + if default_slot_encountered: + raise TemplateSyntaxError( + "Only one component slot may be marked as 'default'. " + f"To fix, check template '{template_name}' " + f"of component '{component_name}'." + ) + default_slot_encountered = True + + # Here we've identified which slot the default/implicit fill belongs to + if default_fill: + # NOTE: We recreate new instance, passing all fields, instead of using + # `NamedTuple._replace`, because `_replace` is not typed. + named_fills[slot.name] = SlotFill( + is_filled=default_fill.is_filled, + content_func=default_fill.content_func, + slot_default_var=default_fill.slot_default_var, + slot_data_var=default_fill.slot_data_var, + # Updated fields + name=slot.name, + escaped_name=_escape_slot_name(slot.name), + ) # Check: Only component templates that include a 'default' slot # can be invoked with implicit filling. @@ -725,7 +721,7 @@ def _escape_slot_name(name: str) -> str: def _nodelist_to_slot_render_func(nodelist: NodeList) -> SlotFunc: - def render_func(ctx: Context, kwargs: Dict[str, Any], slot_ref: SlotRef) -> SlotResult: + def render_func(ctx: Context, slot_data: Dict[str, Any], slot_ref: SlotRef) -> SlotResult: return nodelist.render(ctx) return render_func # type: ignore[return-value] diff --git a/tests/test_templatetags_slot_fill.py b/tests/test_templatetags_slot_fill.py index 5a8e0ed4..5e053c88 100644 --- a/tests/test_templatetags_slot_fill.py +++ b/tests/test_templatetags_slot_fill.py @@ -522,6 +522,172 @@ class ComponentSlottedTemplateTagTest(BaseTestCase): ) +# See https://github.com/EmilStenstrom/django-components/issues/698 +class NestedSlotsTests(BaseTestCase): + class NestedSlots(Component): + template: types.django_html = """ + {% load component_tags %} + {% slot 'wrapper' %} +
+ Wrapper Default + {% slot 'parent1' %} +
+ Parent1 Default + {% slot 'child1' %} +
+ Child 1 Default +
+ {% endslot %} +
+ {% endslot %} + {% slot 'parent2' %} +
+ Parent2 Default +
+ {% endslot %} +
+ {% endslot %} + """ + + def setUp(self) -> None: + super().setUp() + registry.register("example", self.NestedSlots) + + @parametrize_context_behavior(["django", "isolated"]) + def test_empty(self): + template_str: types.django_html = """ + {% load component_tags %} + {% component 'example' %} + {% endcomponent %} + """ + + rendered = Template(template_str).render(Context()) + expected = """ +
+ Wrapper Default +
+ Parent1 Default +
+ Child 1 Default +
+
+
+ Parent2 Default +
+
+ """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["django", "isolated"]) + def test_override_outer(self): + template_str: types.django_html = """ + {% load component_tags %} + {% component 'example' %} + {% fill 'wrapper' %} +
+ Entire Wrapper Replaced +
+ {% endfill %} + {% endcomponent %} + """ + + rendered = Template(template_str).render(Context()) + expected = """ +
+ Entire Wrapper Replaced +
+ """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["django", "isolated"]) + def test_override_middle(self): + template_str: types.django_html = """ + {% load component_tags %} + {% component 'example' %} + {% fill 'parent1' %} +
+ Parent1 Replaced +
+ {% endfill %} + {% endcomponent %} + """ + + rendered = Template(template_str).render(Context()) + expected = """ +
+ Wrapper Default +
+ Parent1 Replaced +
+
+ Parent2 Default +
+
+ """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["django", "isolated"]) + def test_override_inner(self): + template_str: types.django_html = """ + {% load component_tags %} + {% component 'example' %} + {% fill 'child1' %} +
+ Child1 Replaced +
+ {% endfill %} + {% endcomponent %} + """ + + rendered = Template(template_str).render(Context()) + expected = """ +
+ Wrapper Default +
+ Parent1 Default +
+ Child1 Replaced +
+
+
+ Parent2 Default +
+
+ """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["django", "isolated"]) + def test_override_all(self): + template_str: types.django_html = """ + {% load component_tags %} + {% component 'example' %} + {% fill 'child1' %} +
+ Child1 Replaced +
+ {% endfill %} + {% fill 'parent1' %} +
+ Parent1 Replaced +
+ {% endfill %} + {% fill 'wrapper' %} +
+ Entire Wrapper Replaced +
+ {% endfill %} + {% endcomponent %} + """ + + rendered = Template(template_str).render(Context()) + expected = """ +
+ Entire Wrapper Replaced +
+ """ + self.assertHTMLEqual(rendered, expected) + + class SlottedTemplateRegressionTests(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_slotted_template_that_uses_missing_variable(self): From 0064de9c78db5eccc7c498e2a6d4c3c5ffa745ec Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Thu, 10 Oct 2024 14:47:07 +0200 Subject: [PATCH 067/487] chore: bump v0.102 (#703) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1c2e9761..7fac6063 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "django_components" -version = "0.101" +version = "0.102" requires-python = ">=3.8, <4.0" description = "A way to create simple reusable template components in Django." keywords = ["django", "components", "css", "js", "html"] From 2a811f38ca7a336806393634c683842996ee0285 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Oct 2024 17:47:22 +0000 Subject: [PATCH 068/487] build(deps): bump mkdocs-material from 9.5.39 to 9.5.40 Bumps [mkdocs-material](https://github.com/squidfunk/mkdocs-material) from 9.5.39 to 9.5.40. - [Release notes](https://github.com/squidfunk/mkdocs-material/releases) - [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG) - [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.5.39...9.5.40) --- updated-dependencies: - dependency-name: mkdocs-material dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 0c710479..1e2da2c4 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -134,7 +134,7 @@ mkdocs-include-markdown-plugin==6.2.2 # via hatch.envs.docs mkdocs-literate-nav==0.6.1 # via hatch.envs.docs -mkdocs-material==9.5.39 +mkdocs-material==9.5.40 # via hatch.envs.docs mkdocs-material-extensions==1.3.1 # via mkdocs-material From bc31243e13c772e246b70edb0427645c9cc0f007 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Oct 2024 17:49:58 +0000 Subject: [PATCH 069/487] build(deps): bump mkdocstrings-python from 1.11.1 to 1.12.1 Bumps [mkdocstrings-python](https://github.com/mkdocstrings/python) from 1.11.1 to 1.12.1. - [Release notes](https://github.com/mkdocstrings/python/releases) - [Changelog](https://github.com/mkdocstrings/python/blob/main/CHANGELOG.md) - [Commits](https://github.com/mkdocstrings/python/compare/1.11.1...1.12.1) --- updated-dependencies: - dependency-name: mkdocstrings-python dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 1e2da2c4..16916623 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -146,7 +146,7 @@ mkdocstrings==0.26.1 # via # hatch.envs.docs # mkdocstrings-python -mkdocstrings-python==1.11.1 +mkdocstrings-python==1.12.1 # via hatch.envs.docs mypy-extensions==1.0.0 # via black From 0971a026ef899ac8ed4e8e812e8814a89746a7ef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Oct 2024 17:52:38 +0000 Subject: [PATCH 070/487] build(deps): bump django from 5.1.1 to 5.1.2 Bumps [django](https://github.com/django/django) from 5.1.1 to 5.1.2. - [Commits](https://github.com/django/django/compare/5.1.1...5.1.2) --- updated-dependencies: - dependency-name: django dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 16916623..46783246 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -56,7 +56,7 @@ cssselect2==0.7.0 # via cairosvg defusedxml==0.7.1 # via cairosvg -django==5.1.1 +django==5.1.2 # via hatch.envs.docs ghp-import==2.1.0 # via mkdocs From 9ef7788e7b56aa6b15f461b1847dca92f64ca78c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Oct 2024 17:55:54 +0000 Subject: [PATCH 071/487] build(deps-dev): bump mypy from 1.11.2 to 1.12.0 Bumps [mypy](https://github.com/python/mypy) from 1.11.2 to 1.12.0. - [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md) - [Commits](https://github.com/python/mypy/compare/v1.11.2...v1.12.0) --- updated-dependencies: - dependency-name: mypy dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 8520bece..c8483bd8 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -48,7 +48,7 @@ isort==5.13.2 # via -r requirements-dev.in mccabe==0.7.0 # via flake8 -mypy==1.11.2 +mypy==1.12.0 # via -r requirements-dev.in mypy-extensions==1.0.0 # via From 732297a6826ee0cf9648655ab6e3b375c7c20f51 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Oct 2024 17:58:25 +0000 Subject: [PATCH 072/487] build(deps): bump mkdocstrings from 0.26.1 to 0.26.2 Bumps [mkdocstrings](https://github.com/mkdocstrings/mkdocstrings) from 0.26.1 to 0.26.2. - [Release notes](https://github.com/mkdocstrings/mkdocstrings/releases) - [Changelog](https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md) - [Commits](https://github.com/mkdocstrings/mkdocstrings/compare/0.26.1...0.26.2) --- updated-dependencies: - dependency-name: mkdocstrings dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 46783246..066f099d 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -142,7 +142,7 @@ mkdocs-minify-plugin==0.8.0 # via hatch.envs.docs mkdocs-redirects==1.2.1 # via hatch.envs.docs -mkdocstrings==0.26.1 +mkdocstrings==0.26.2 # via # hatch.envs.docs # mkdocstrings-python From e911f90c5708408731eca008a632f4ba8d334832 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Oct 2024 17:59:03 +0000 Subject: [PATCH 073/487] build(deps): bump charset-normalizer from 3.3.2 to 3.4.0 Bumps [charset-normalizer](https://github.com/Ousret/charset_normalizer) from 3.3.2 to 3.4.0. - [Release notes](https://github.com/Ousret/charset_normalizer/releases) - [Changelog](https://github.com/jawah/charset_normalizer/blob/master/CHANGELOG.md) - [Commits](https://github.com/Ousret/charset_normalizer/compare/3.3.2...3.4.0) --- updated-dependencies: - dependency-name: charset-normalizer dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 066f099d..314c20ec 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -39,7 +39,7 @@ certifi==2024.8.30 # via requests cffi==1.17.1 # via cairocffi -charset-normalizer==3.3.2 +charset-normalizer==3.4.0 # via requests click==8.1.7 # via From 72477397b022221c302ae2734541db409f243116 Mon Sep 17 00:00:00 2001 From: Tom Larsen Date: Mon, 28 Oct 2024 16:23:37 -0500 Subject: [PATCH 074/487] fix(docs) Replace `shorthand_component_formatter` with `component_shorthand_formatter` in README.md The proper name appears to be [`component_shorthand_formatter`](https://github.com/EmilStenstrom/django-components/blob/7911daa89ed61132b2a819e74769d748efb3ab03/src/django_components/tag_formatter.py#L222) --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 74fd1d19..bc4c416d 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ it allows to return either a string or a Template instance. {% endcomponent %} ``` - While `django_components.shorthand_component_formatter` allows you to write components like so: + While `django_components.component_shorthand_formatter` allows you to write components like so: ```django {% button href="..." disabled %} @@ -2778,7 +2778,7 @@ Click me! You can change this behaviour in the settings under the [`COMPONENTS.tag_formatter`](#tag-formatter-setting). -For example, if you set the tag formatter to `django_components.shorthand_component_formatter`, the components will use their name as the template tags: +For example, if you set the tag formatter to `django_components.component_shorthand_formatter`, the components will use their name as the template tags: ```django {% button href="..." disabled %} @@ -2814,7 +2814,7 @@ django_components provides following predefined TagFormatters: {% component "button" href="..." / %} ``` -- **`ShorthandComponentFormatter` (`django_components.shorthand_component_formatter`)** +- **`ShorthandComponentFormatter` (`django_components.component_shorthand_formatter`)** Uses the component name as start tag, and `end` as an end tag. From aa903172a0c50a25617ac6b92e9b46a3f901c69d Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Wed, 30 Oct 2024 17:46:07 +0100 Subject: [PATCH 075/487] refactor: pin version of playwright in tests --- tox.ini | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 8e80ff18..ffc3dd61 100644 --- a/tox.ini +++ b/tox.ini @@ -30,7 +30,10 @@ deps = django50: Django>=5.0,<5.1 pytest pytest-xdist - playwright + # NOTE: Keep playwright is sync with the version in requirements-ci.txt + # Othrwise we get error: + # playwright._impl._errors.Error: BrowserType.launch: Executable doesn't exist at /home/runner/.cache/ms-playwright/chromium-1140/chrome-linux/chrome + playwright==1.47.0 requests types-requests whitenoise @@ -48,7 +51,8 @@ commands = isort --check-only --diff src/django_components [testenv:coverage] deps = pytest-coverage - playwright + # NOTE: Keep playwright in sync with the version in requirements-ci.txt + playwright==1.47.0 requests types-requests whitenoise From 6fd6770feefeb2b8357132e183d9b366323832b1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 31 Oct 2024 09:59:29 +0000 Subject: [PATCH 076/487] build(deps): bump pyparsing from 3.1.4 to 3.2.0 Bumps [pyparsing](https://github.com/pyparsing/pyparsing) from 3.1.4 to 3.2.0. - [Release notes](https://github.com/pyparsing/pyparsing/releases) - [Changelog](https://github.com/pyparsing/pyparsing/blob/master/CHANGES) - [Commits](https://github.com/pyparsing/pyparsing/compare/3.1.4...3.2.0) --- updated-dependencies: - dependency-name: pyparsing dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 314c20ec..e0d88bbb 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -179,7 +179,7 @@ pymdown-extensions==10.11.2 # markdown-exec # mkdocs-material # mkdocstrings -pyparsing==3.1.4 +pyparsing==3.2.0 # via mike python-dateutil==2.9.0.post0 # via ghp-import From db52027347f4fece45bd62c8aada2f23b2dce314 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 31 Oct 2024 09:59:59 +0000 Subject: [PATCH 077/487] build(deps-dev): bump mypy from 1.12.0 to 1.13.0 Bumps [mypy](https://github.com/python/mypy) from 1.12.0 to 1.13.0. - [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md) - [Commits](https://github.com/python/mypy/compare/v1.12.0...v1.13.0) --- updated-dependencies: - dependency-name: mypy dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index c8483bd8..77629e87 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -48,7 +48,7 @@ isort==5.13.2 # via -r requirements-dev.in mccabe==0.7.0 # via flake8 -mypy==1.12.0 +mypy==1.13.0 # via -r requirements-dev.in mypy-extensions==1.0.0 # via From f713fa80a9b2f921baf514e7c87bc7c8d9692db9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 31 Oct 2024 10:04:57 +0000 Subject: [PATCH 078/487] build(deps): bump mkdocstrings-python from 1.12.1 to 1.12.2 Bumps [mkdocstrings-python](https://github.com/mkdocstrings/python) from 1.12.1 to 1.12.2. - [Release notes](https://github.com/mkdocstrings/python/releases) - [Changelog](https://github.com/mkdocstrings/python/blob/main/CHANGELOG.md) - [Commits](https://github.com/mkdocstrings/python/compare/1.12.1...1.12.2) --- updated-dependencies: - dependency-name: mkdocstrings-python dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index e0d88bbb..0b4a60bc 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -146,7 +146,7 @@ mkdocstrings==0.26.2 # via # hatch.envs.docs # mkdocstrings-python -mkdocstrings-python==1.12.1 +mkdocstrings-python==1.12.2 # via hatch.envs.docs mypy-extensions==1.0.0 # via black From 9b0991ecc5de975f84a9f8de9b0086ba2207e8b0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 31 Oct 2024 10:08:18 +0000 Subject: [PATCH 079/487] build(deps-dev): bump pre-commit from 4.0.0 to 4.0.1 Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 4.0.0 to 4.0.1. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/main/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v4.0.0...v4.0.1) --- updated-dependencies: - dependency-name: pre-commit dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 77629e87..c4f32821 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -75,7 +75,7 @@ pluggy==1.5.0 # via # pytest # tox -pre-commit==4.0.0 +pre-commit==4.0.1 # via -r requirements-dev.in pycodestyle==2.12.0 # via flake8 From 5018f853becc71ca47e0d3089d5b5cb0d9a64a5d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Nov 2024 17:33:34 +0000 Subject: [PATCH 080/487] build(deps): bump markupsafe from 2.1.5 to 3.0.2 Bumps [markupsafe](https://github.com/pallets/markupsafe) from 2.1.5 to 3.0.2. - [Release notes](https://github.com/pallets/markupsafe/releases) - [Changelog](https://github.com/pallets/markupsafe/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/markupsafe/compare/2.1.5...3.0.2) --- updated-dependencies: - dependency-name: markupsafe dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 0b4a60bc..8110cc7a 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -91,7 +91,7 @@ markdown==3.7 # pymdown-extensions markdown-exec==1.9.3 # via hatch.envs.docs -markupsafe==2.1.5 +markupsafe==3.0.2 # via # jinja2 # mkdocs From 9a46e5c7ab834578d74f4dbb90cf7c6ad26aadcc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Nov 2024 17:36:30 +0000 Subject: [PATCH 081/487] build(deps): bump mkdocs-include-markdown-plugin from 6.2.2 to 7.0.0 Bumps [mkdocs-include-markdown-plugin](https://github.com/mondeja/mkdocs-include-markdown-plugin) from 6.2.2 to 7.0.0. - [Release notes](https://github.com/mondeja/mkdocs-include-markdown-plugin/releases) - [Commits](https://github.com/mondeja/mkdocs-include-markdown-plugin/compare/v6.2.2...v7.0.0) --- updated-dependencies: - dependency-name: mkdocs-include-markdown-plugin dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 8110cc7a..d96c3733 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -130,7 +130,7 @@ mkdocs-git-authors-plugin==0.9.0 # via hatch.envs.docs mkdocs-git-revision-date-localized-plugin==1.2.9 # via hatch.envs.docs -mkdocs-include-markdown-plugin==6.2.2 +mkdocs-include-markdown-plugin==7.0.0 # via hatch.envs.docs mkdocs-literate-nav==0.6.1 # via hatch.envs.docs From 99524e986231991ed49c6c2228e8e68fee02a61e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Nov 2024 17:39:10 +0000 Subject: [PATCH 082/487] build(deps): bump mkdocs-git-authors-plugin from 0.9.0 to 0.9.2 Bumps [mkdocs-git-authors-plugin](https://github.com/timvink/mkdocs-git-authors-plugin) from 0.9.0 to 0.9.2. - [Release notes](https://github.com/timvink/mkdocs-git-authors-plugin/releases) - [Commits](https://github.com/timvink/mkdocs-git-authors-plugin/compare/v0.9.0...v0.9.2) --- updated-dependencies: - dependency-name: mkdocs-git-authors-plugin dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index d96c3733..bd7e174e 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -126,7 +126,7 @@ mkdocs-gen-files==0.5.0 # via hatch.envs.docs mkdocs-get-deps==0.2.0 # via mkdocs -mkdocs-git-authors-plugin==0.9.0 +mkdocs-git-authors-plugin==0.9.2 # via hatch.envs.docs mkdocs-git-revision-date-localized-plugin==1.2.9 # via hatch.envs.docs From 6f17a9702347f44eb51aaca00715339a367f8cc3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Nov 2024 17:42:08 +0000 Subject: [PATCH 083/487] build(deps): bump mkdocs-material from 9.5.40 to 9.5.43 Bumps [mkdocs-material](https://github.com/squidfunk/mkdocs-material) from 9.5.40 to 9.5.43. - [Release notes](https://github.com/squidfunk/mkdocs-material/releases) - [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG) - [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.5.40...9.5.43) --- updated-dependencies: - dependency-name: mkdocs-material dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index bd7e174e..1a0f3631 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -134,7 +134,7 @@ mkdocs-include-markdown-plugin==7.0.0 # via hatch.envs.docs mkdocs-literate-nav==0.6.1 # via hatch.envs.docs -mkdocs-material==9.5.40 +mkdocs-material==9.5.43 # via hatch.envs.docs mkdocs-material-extensions==1.3.1 # via mkdocs-material From a87cbba9b14d923b1a36af6dfea8c8559e1f3806 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Nov 2024 17:44:52 +0000 Subject: [PATCH 084/487] build(deps): bump griffe from 1.3.2 to 1.5.1 Bumps [griffe](https://github.com/mkdocstrings/griffe) from 1.3.2 to 1.5.1. - [Release notes](https://github.com/mkdocstrings/griffe/releases) - [Changelog](https://github.com/mkdocstrings/griffe/blob/main/CHANGELOG.md) - [Commits](https://github.com/mkdocstrings/griffe/compare/1.3.2...1.5.1) --- updated-dependencies: - dependency-name: griffe dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 1a0f3631..72da6050 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -64,7 +64,7 @@ gitdb==4.0.11 # via gitpython gitpython==3.1.43 # via mkdocs-git-revision-date-localized-plugin -griffe==1.3.2 +griffe==1.5.1 # via mkdocstrings-python htmlmin2==0.1.13 # via mkdocs-minify-plugin From 8db9213e714b4eb3ac5295b3d39d68c837383003 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Nov 2024 17:48:28 +0000 Subject: [PATCH 085/487] build(deps): bump tox from 4.21.2 to 4.23.2 Bumps [tox](https://github.com/tox-dev/tox) from 4.21.2 to 4.23.2. - [Release notes](https://github.com/tox-dev/tox/releases) - [Changelog](https://github.com/tox-dev/tox/blob/main/docs/changelog.rst) - [Commits](https://github.com/tox-dev/tox/compare/4.21.2...4.23.2) --- updated-dependencies: - dependency-name: tox dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-ci.txt | 2 +- requirements-dev.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements-ci.txt b/requirements-ci.txt index 199ff9bb..2ddc0abe 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -42,7 +42,7 @@ pyproject-api==1.8.0 # via tox requests==2.32.3 # via -r requirements-ci.in -tox==4.21.2 +tox==4.23.2 # via # -r requirements-ci.in # tox-gh-actions diff --git a/requirements-dev.txt b/requirements-dev.txt index c4f32821..c23f801f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -93,7 +93,7 @@ requests==2.32.3 # via -r requirements-dev.in sqlparse==0.5.0 # via django -tox==4.21.2 +tox==4.23.2 # via -r requirements-dev.in types-requests==2.32.0.20240914 # via -r requirements-dev.in From 0339dcd8ab3efc575b329e173f1f784822529bb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Stenstr=C3=B6m?= Date: Wed, 6 Nov 2024 19:48:59 +0100 Subject: [PATCH 086/487] Improve first example the users sees --- README.md | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index bc4c416d..57b90aa0 100644 --- a/README.md +++ b/README.md @@ -23,21 +23,39 @@ Potential benefits: Django-components can be particularly useful for larger Django projects that require a more structured approach to UI development, without necessitating a shift to a separate frontend framework. -## Summary +## Quickstart -It lets you create "template components", that contains both the template, the Javascript and the CSS needed to generate the front end code you need for a modern app. Use components like this: +django-components lets you create reusable blocks of code needed to generate the front end code you need for a modern app. + +Define a component in `components/calendar/calendar.py` like this: +```python +@register("calendar") +class Calendar(Component): + template_name = "template.html" + + def get_context_data(self, date): + return {"date": date} +``` + +With this `template.html` file: ```htmldjango -{% component "calendar" date="2015-06-19" %}{% endcomponent %} +
Today's date is {{ date }}
``` -And this is what gets rendered (plus the CSS and Javascript you've specified): +Use the component like this: + +```htmldjango +{% component "calendar" date="2024-11-06" %}{% endcomponent %} +``` + +And this is what gets rendered: ```html -
Today's date is 2015-06-19
+
Today's date is 2024-11-06
``` -[See the example project](https://github.com/EmilStenstrom/django-components/tree/master/sampleproject) or read on to learn about the details! +Read on to learn about the details! ## Table of Contents From 2c96507872e32500d0a2975f504f1dcb1d0f47b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Stenstr=C3=B6m?= Date: Fri, 8 Nov 2024 21:57:26 +0100 Subject: [PATCH 087/487] Review feedback. --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 57b90aa0..caa8534a 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,9 @@ And this is what gets rendered:
Today's date is 2024-11-06
``` -Read on to learn about the details! +Read on to learn about all the exciting details and configuration possibilities! + +(If you instead prefer to jump right into the code, [check out the example project](https://github.com/EmilStenstrom/django-components/tree/master/sampleproject)) ## Table of Contents From 63cb751cfec17a83530059f43ae02866419aeb51 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Nov 2024 17:05:46 +0000 Subject: [PATCH 088/487] build(deps): bump packaging from 24.1 to 24.2 Bumps [packaging](https://github.com/pypa/packaging) from 24.1 to 24.2. - [Release notes](https://github.com/pypa/packaging/releases) - [Changelog](https://github.com/pypa/packaging/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pypa/packaging/compare/24.1...24.2) --- updated-dependencies: - dependency-name: packaging dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 72da6050..a4e6c580 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -150,7 +150,7 @@ mkdocstrings-python==1.12.2 # via hatch.envs.docs mypy-extensions==1.0.0 # via black -packaging==24.1 +packaging==24.2 # via # black # mkdocs From dcc362c56916cf73c330421aa700dd47816ff096 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Nov 2024 17:08:48 +0000 Subject: [PATCH 089/487] build(deps): bump mkdocs-git-revision-date-localized-plugin Bumps [mkdocs-git-revision-date-localized-plugin](https://github.com/timvink/mkdocs-git-revision-date-localized-plugin) from 1.2.9 to 1.3.0. - [Release notes](https://github.com/timvink/mkdocs-git-revision-date-localized-plugin/releases) - [Commits](https://github.com/timvink/mkdocs-git-revision-date-localized-plugin/compare/v1.2.9...v1.3.0) --- updated-dependencies: - dependency-name: mkdocs-git-revision-date-localized-plugin dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index a4e6c580..9ffa949f 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -128,7 +128,7 @@ mkdocs-get-deps==0.2.0 # via mkdocs mkdocs-git-authors-plugin==0.9.2 # via hatch.envs.docs -mkdocs-git-revision-date-localized-plugin==1.2.9 +mkdocs-git-revision-date-localized-plugin==1.3.0 # via hatch.envs.docs mkdocs-include-markdown-plugin==7.0.0 # via hatch.envs.docs From 1bef4803e99e9a0a2599cb4533495b52888ea11f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Nov 2024 17:11:38 +0000 Subject: [PATCH 090/487] build(deps): bump watchdog from 5.0.3 to 6.0.0 Bumps [watchdog](https://github.com/gorakhargosh/watchdog) from 5.0.3 to 6.0.0. - [Release notes](https://github.com/gorakhargosh/watchdog/releases) - [Changelog](https://github.com/gorakhargosh/watchdog/blob/master/changelog.rst) - [Commits](https://github.com/gorakhargosh/watchdog/compare/v5.0.3...v6.0.0) --- updated-dependencies: - dependency-name: watchdog dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 9ffa949f..7f73fe96 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -214,7 +214,7 @@ urllib3==2.2.3 # via requests verspec==0.1.0 # via mike -watchdog==5.0.3 +watchdog==6.0.0 # via mkdocs wcmatch==10.0 # via mkdocs-include-markdown-plugin From a203e1c49bde181189ad42fd69452ec2d33b0568 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Nov 2024 17:15:30 +0000 Subject: [PATCH 091/487] build(deps): bump types-requests from 2.32.0.20240914 to 2.32.0.20241016 Bumps [types-requests](https://github.com/python/typeshed) from 2.32.0.20240914 to 2.32.0.20241016. - [Commits](https://github.com/python/typeshed/commits) --- updated-dependencies: - dependency-name: types-requests dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-ci.txt | 2 +- requirements-dev.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements-ci.txt b/requirements-ci.txt index 2ddc0abe..455ee855 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -48,7 +48,7 @@ tox==4.23.2 # tox-gh-actions tox-gh-actions==3.2.0 # via -r requirements-ci.in -types-requests==2.32.0.20240914 +types-requests==2.32.0.20241016 # via -r requirements-ci.in typing-extensions==4.12.2 # via pyee diff --git a/requirements-dev.txt b/requirements-dev.txt index c23f801f..3486ba49 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -95,7 +95,7 @@ sqlparse==0.5.0 # via django tox==4.23.2 # via -r requirements-dev.in -types-requests==2.32.0.20240914 +types-requests==2.32.0.20241016 # via -r requirements-dev.in typing-extensions==4.10.0 # via From 1a71dcf4db4b97830806d7928278fe9badddc364 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Nov 2024 17:18:34 +0000 Subject: [PATCH 092/487] build(deps): bump black from 24.8.0 to 24.10.0 Bumps [black](https://github.com/psf/black) from 24.8.0 to 24.10.0. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/24.8.0...24.10.0) --- updated-dependencies: - dependency-name: black dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 7f73fe96..28f483cb 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -27,7 +27,7 @@ babel==2.16.0 # via # mkdocs-git-revision-date-localized-plugin # mkdocs-material -black==24.8.0 +black==24.10.0 # via hatch.envs.docs bracex==2.5.post1 # via wcmatch From 4ae6b358dd677213059dc91a4d793c9202674f00 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Nov 2024 18:33:33 +0000 Subject: [PATCH 093/487] build(deps): bump tinycss2 from 1.3.0 to 1.4.0 Bumps [tinycss2](https://github.com/Kozea/tinycss2) from 1.3.0 to 1.4.0. - [Release notes](https://github.com/Kozea/tinycss2/releases) - [Changelog](https://github.com/Kozea/tinycss2/blob/main/docs/changelog.rst) - [Commits](https://github.com/Kozea/tinycss2/compare/v1.3.0...v1.4.0) --- updated-dependencies: - dependency-name: tinycss2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 28f483cb..8a74abef 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -206,7 +206,7 @@ smmap==5.0.1 # via gitdb sqlparse==0.5.1 # via django -tinycss2==1.3.0 +tinycss2==1.4.0 # via # cairosvg # cssselect2 From 9de6f807031dcf1d84c4ef9a4982d2be5f08f120 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Nov 2024 18:36:39 +0000 Subject: [PATCH 094/487] build(deps): bump pillow from 10.4.0 to 11.0.0 Bumps [pillow](https://github.com/python-pillow/Pillow) from 10.4.0 to 11.0.0. - [Release notes](https://github.com/python-pillow/Pillow/releases) - [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst) - [Commits](https://github.com/python-pillow/Pillow/compare/10.4.0...11.0.0) --- updated-dependencies: - dependency-name: pillow dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 8a74abef..89e15934 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -160,7 +160,7 @@ pathspec==0.12.1 # via # black # mkdocs -pillow==10.4.0 +pillow==11.0.0 # via # cairosvg # mkdocs-material From 3eca2e76cb3510b27baea4d5132de40d845e0619 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Nov 2024 18:39:55 +0000 Subject: [PATCH 095/487] build(deps): bump sqlparse from 0.5.1 to 0.5.2 Bumps [sqlparse](https://github.com/andialbrecht/sqlparse) from 0.5.1 to 0.5.2. - [Release notes](https://github.com/andialbrecht/sqlparse/releases) - [Changelog](https://github.com/andialbrecht/sqlparse/blob/master/CHANGELOG) - [Commits](https://github.com/andialbrecht/sqlparse/compare/0.5.1...0.5.2) --- updated-dependencies: - dependency-name: sqlparse dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 89e15934..3c9d2b57 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -204,7 +204,7 @@ six==1.16.0 # via python-dateutil smmap==5.0.1 # via gitdb -sqlparse==0.5.1 +sqlparse==0.5.2 # via django tinycss2==1.4.0 # via From 5954aa12b00fbd4ab2267f52d70c5399446a79a7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Nov 2024 18:43:01 +0000 Subject: [PATCH 096/487] build(deps): bump mkdocstrings from 0.26.2 to 0.27.0 Bumps [mkdocstrings](https://github.com/mkdocstrings/mkdocstrings) from 0.26.2 to 0.27.0. - [Release notes](https://github.com/mkdocstrings/mkdocstrings/releases) - [Changelog](https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md) - [Commits](https://github.com/mkdocstrings/mkdocstrings/compare/0.26.2...0.27.0) --- updated-dependencies: - dependency-name: mkdocstrings dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 3c9d2b57..6c28f4b4 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -142,7 +142,7 @@ mkdocs-minify-plugin==0.8.0 # via hatch.envs.docs mkdocs-redirects==1.2.1 # via hatch.envs.docs -mkdocstrings==0.26.2 +mkdocstrings==0.27.0 # via # hatch.envs.docs # mkdocstrings-python From 6fc54b14b3bc4be180bc9ce618d1c62e1c6c3b6f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Nov 2024 18:47:00 +0000 Subject: [PATCH 097/487] build(deps): bump playwright from 1.47.0 to 1.48.0 Bumps [playwright](https://github.com/Microsoft/playwright-python) from 1.47.0 to 1.48.0. - [Release notes](https://github.com/Microsoft/playwright-python/releases) - [Commits](https://github.com/Microsoft/playwright-python/compare/v1.47.0...v1.48.0) --- updated-dependencies: - dependency-name: playwright dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-ci.txt | 4 ++-- requirements-dev.txt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements-ci.txt b/requirements-ci.txt index 455ee855..c2c1dce0 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -20,7 +20,7 @@ filelock==3.16.1 # via # tox # virtualenv -greenlet==3.0.3 +greenlet==3.1.1 # via playwright idna==3.10 # via requests @@ -32,7 +32,7 @@ platformdirs==4.3.6 # via # tox # virtualenv -playwright==1.47.0 +playwright==1.48.0 # via -r requirements-ci.in pluggy==1.5.0 # via tox diff --git a/requirements-dev.txt b/requirements-dev.txt index 3486ba49..1cca60f1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -36,7 +36,7 @@ flake8==7.1.1 # flake8-pyproject flake8-pyproject==1.2.3 # via -r requirements-dev.in -greenlet==3.0.3 +greenlet==3.1.1 # via playwright identify==2.5.33 # via pre-commit @@ -69,7 +69,7 @@ platformdirs==4.3.6 # black # tox # virtualenv -playwright==1.47.0 +playwright==1.48.0 # via -r requirements-dev.in pluggy==1.5.0 # via From e169b230e3306beaf547d3927ed13a5eb1ac6d19 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Tue, 19 Nov 2024 10:06:21 +0000 Subject: [PATCH 098/487] chore: update tox.ini --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index ffc3dd61..de14a0ca 100644 --- a/tox.ini +++ b/tox.ini @@ -33,7 +33,7 @@ deps = # NOTE: Keep playwright is sync with the version in requirements-ci.txt # Othrwise we get error: # playwright._impl._errors.Error: BrowserType.launch: Executable doesn't exist at /home/runner/.cache/ms-playwright/chromium-1140/chrome-linux/chrome - playwright==1.47.0 + playwright==1.48.0 requests types-requests whitenoise @@ -52,7 +52,7 @@ commands = isort --check-only --diff src/django_components deps = pytest-coverage # NOTE: Keep playwright in sync with the version in requirements-ci.txt - playwright==1.47.0 + playwright==1.48.0 requests types-requests whitenoise From 9f891453d53b7acabf750af01bf92ea2cb2e1882 Mon Sep 17 00:00:00 2001 From: vb8448 <84413102+vb8448@users.noreply.github.com> Date: Sun, 24 Nov 2024 11:26:38 +0100 Subject: [PATCH 099/487] Update README.md (#766) Updated documentation for dynamic components. Close #764 . --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index caa8534a..62b9daa6 100644 --- a/README.md +++ b/README.md @@ -1085,6 +1085,20 @@ Instead, you can use **dynamic components**. Dynamic components are used in plac {% endcomponent %} ``` +or in case you use the `django_components.component_shorthand_formatter` tag formatter: + +```django +{% dynamic is=component_name title="Cat Museu" %} + {% fill "content" %} + HELLO_FROM_SLOT_1 + {% endfill %} + {% fill "sidebar" %} + HELLO_FROM_SLOT_2 + {% endfill %} +{% enddynamic %} +``` + + These behave same way as regular components. You pass it the same args, kwargs, and slots as you would to the component that you want to render. From 5fd45ab424908f7c3565139709c8eae86bdd9533 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Mon, 25 Nov 2024 09:41:57 +0100 Subject: [PATCH 100/487] chore: Push dev to master to release v0.110 (#767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: skeleton of dependency manager backend (#688) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * refactor: selectolax update and tests cleanup (#702) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * refactor: move release notes to own file (#704) * chore: merge changes from master (#705) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Yassin Rakha Co-authored-by: Emil Stenström fix for nested slots (#698) (#699) * refactor: remove joint {% component_dependencies %} tag (#706) Co-authored-by: Emil Stenström Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * refactor: split up utils file and move utils to util dir (#707) * docs: Move docs inside src/ to allow imports in python scripts (#708) * refactor: Docs prep 1 (#715) * refactor: Document template tags (#716) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * refactor: pass slot fills in template via slots param (#719) * chore: Merge master to dev (#729) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Yassin Rakha Co-authored-by: Emil Stenström Co-authored-by: Tom Larsen fix for nested slots (#698) (#699) * fix: Do not raise error if multiple slots with same name are flagged as default (#727) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * refactor: tag formatter - allow fwd slash in end tag (#730) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * refactor: Use lowercase names for registry settings (#731) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * docs: add docstrings (#732) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * feat: define settings as a data class for type hints, intellisense, and docs (#733) * refactor: fix reload-on-change logic, expose autodiscover's dirs-getting logic, rename settings (#734) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * docs: document settings (#743) * docs: document settings * refactor: fix linter errors * feat: passthrough slots and more (#758) * feat: passthrough slots and more * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * refactor: remove ComponentSlotContext.slots * refactor: update comment * docs: update changelog * refactor: update docstrings * refactor: document and test-cover more changes * refactor: revert fill without name * docs: update README --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * fix: apostrophes in tags (#765) * refactor: fix merge error - duplicate code --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Emil Stenström --- .github/workflows/tests.yml | 1 + .gitignore | 1 - CHANGELOG.md | 585 +++++++++++ README.md | 566 ++++++---- benchmarks/component_rendering.py | 8 +- docs/CHANGELOG.md | 13 - docs/slot_rendering.md | 238 ----- mkdocs.yml | 15 +- pyproject.toml | 5 + requirements-dev.in | 3 +- requirements-dev.txt | 2 + sampleproject/sampleproject/settings.py | 19 +- sampleproject/sampleproject/urls.py | 1 + src/django_components/__init__.py | 98 +- src/django_components/app_settings.py | 648 ++++++++++-- src/django_components/apps.py | 31 +- src/django_components/autodiscover.py | 149 --- src/django_components/autodiscovery.py | 95 ++ src/django_components/component.py | 356 ++++--- src/django_components/component_media.py | 6 +- src/django_components/component_registry.py | 309 ++++-- src/django_components/components/__init__.py | 5 +- src/django_components/components/dynamic.py | 93 +- src/django_components/context.py | 27 +- src/django_components/dependencies.py | 793 ++++++++++++++ src/django_components/finders.py | 8 +- src/django_components/library.py | 27 +- .../management/commands/startcomponent.py | 88 +- src/django_components/middleware.py | 111 +- src/django_components/node.py | 105 +- src/django_components/provide.py | 4 +- src/django_components/slots.py | 968 +++++++++--------- src/django_components/tag_formatter.py | 159 ++- src/django_components/template.py | 36 +- src/django_components/template_loader.py | 81 +- .../templatetags/component_tags.py | 892 ++++++++++++---- src/django_components/types.py | 38 +- src/django_components/urls.py | 7 + src/django_components/util/__init__.py | 0 src/django_components/util/cache.py | 45 + src/django_components/util/html.py | 100 ++ src/django_components/util/loader.py | 240 +++++ src/django_components/{ => util}/logger.py | 8 +- src/django_components/util/misc.py | 79 ++ src/django_components/util/nanoid.py | 29 + src/django_components/util/tag_parser.py | 198 ++++ src/django_components/util/types.py | 143 +++ .../{utils.py => util/validation.py} | 94 +- src/docs/.gitignore | 1 + src/docs/CHANGELOG.md | 6 + {docs => src/docs}/CODE_OF_CONDUCT.md | 0 {docs => src/docs}/README.md | 0 {docs => src/docs}/SUMMARY.md | 0 src/docs/__init__.py | 0 src/docs/devguides/dependency_mgmt.md | 223 ++++ src/docs/devguides/slot_rendering.md | 253 +++++ .../docs/devguides}/slots_and_blocks.md | 0 {docs => src/docs}/license.md | 0 .../docs}/migrating_from_safer_staticfiles.md | 0 {docs => src/docs}/overrides/main.html | 0 src/docs/scripts/__init__.py | 0 .../docs/scripts/reference.py | 2 +- .../relative_file_pathobj.html | 3 + .../relative_file_pathobj.py | 5 +- tests/django_test_setup.py | 1 + .../testserver/components/__init__.py | 80 ++ tests/e2e/testserver/testserver/settings.py | 1 + .../testserver/testserver/static/script.js | 1 + .../testserver/testserver/static/script2.js | 1 + .../testserver/testserver/static/style.css | 3 + .../testserver/testserver/static/style2.css | 3 + tests/e2e/testserver/testserver/urls.py | 12 +- tests/e2e/testserver/testserver/views.py | 51 + tests/e2e/utils.py | 2 +- tests/static_root/staticfiles.json | 3 +- tests/test_autodiscover.py | 52 +- tests/test_component.py | 38 +- tests/test_component_as_view.py | 3 +- tests/test_component_media.py | 614 +++++------ tests/test_context.py | 95 +- tests/test_dependencies.py | 333 ++++++ tests/test_dependency_rendering.py | 568 +++++----- tests/test_dependency_rendering_e2e.py | 217 ++++ tests/test_expression.py | 12 +- tests/test_finders.py | 6 +- tests/test_html.py | 267 +++++ ...test_template_loader.py => test_loader.py} | 166 ++- tests/test_registry.py | 9 +- tests/test_settings.py | 11 +- tests/test_tag_formatter.py | 45 + tests/test_tag_parser.py | 94 ++ tests/test_template_parser.py | 18 +- tests/test_templatetags.py | 81 +- tests/test_templatetags_component.py | 49 +- tests/test_templatetags_slot_fill.py | 842 +++++++++++++-- tests/test_utils.py | 2 +- tests/testutils.py | 38 +- 97 files changed, 8727 insertions(+), 3011 deletions(-) create mode 100644 CHANGELOG.md delete mode 100644 docs/CHANGELOG.md delete mode 100644 docs/slot_rendering.md delete mode 100644 src/django_components/autodiscover.py create mode 100644 src/django_components/autodiscovery.py create mode 100644 src/django_components/dependencies.py create mode 100644 src/django_components/urls.py create mode 100644 src/django_components/util/__init__.py create mode 100644 src/django_components/util/cache.py create mode 100644 src/django_components/util/html.py create mode 100644 src/django_components/util/loader.py rename src/django_components/{ => util}/logger.py (89%) create mode 100644 src/django_components/util/misc.py create mode 100644 src/django_components/util/nanoid.py create mode 100644 src/django_components/util/tag_parser.py create mode 100644 src/django_components/util/types.py rename src/django_components/{utils.py => util/validation.py} (67%) create mode 100644 src/docs/.gitignore create mode 100644 src/docs/CHANGELOG.md rename {docs => src/docs}/CODE_OF_CONDUCT.md (100%) rename {docs => src/docs}/README.md (100%) rename {docs => src/docs}/SUMMARY.md (100%) create mode 100644 src/docs/__init__.py create mode 100644 src/docs/devguides/dependency_mgmt.md create mode 100644 src/docs/devguides/slot_rendering.md rename {docs => src/docs/devguides}/slots_and_blocks.md (100%) rename {docs => src/docs}/license.md (100%) rename {docs => src/docs}/migrating_from_safer_staticfiles.md (100%) rename {docs => src/docs}/overrides/main.html (100%) create mode 100644 src/docs/scripts/__init__.py rename scripts/gen_ref_nav.py => src/docs/scripts/reference.py (96%) create mode 100644 tests/e2e/testserver/testserver/components/__init__.py create mode 100644 tests/e2e/testserver/testserver/static/script.js create mode 100644 tests/e2e/testserver/testserver/static/script2.js create mode 100644 tests/e2e/testserver/testserver/static/style.css create mode 100644 tests/e2e/testserver/testserver/static/style2.css create mode 100644 tests/e2e/testserver/testserver/views.py create mode 100644 tests/test_dependencies.py create mode 100644 tests/test_dependency_rendering_e2e.py create mode 100644 tests/test_html.py rename tests/{test_template_loader.py => test_loader.py} (53%) create mode 100644 tests/test_tag_parser.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c217fe95..451e5cc9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,6 +4,7 @@ on: push: branches: - 'master' + - 'dev' pull_request: workflow_dispatch: diff --git a/.gitignore b/.gitignore index 909e7fd2..1d20d106 100644 --- a/.gitignore +++ b/.gitignore @@ -74,7 +74,6 @@ poetry.lock .DS_Store .python-version site -docs/reference # JS, NPM Dependency directories node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..7c2a8ab0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,585 @@ +# Release notes + +## 🚨📢 v0.110 + +### General + +#### 🚨📢 BREAKING CHANGES + +- Installation changes: + + - If your components include JS or CSS, you now must use the middleware and add django-components' URLs to your `urlpatterns` + (See "[Adding support for JS and CSS](https://github.com/EmilStenstrom/django-components#adding-support-for-js-and-css)") + +- Component typing signature changed from + + ```py + Component[Args, Kwargs, Data, Slots] + ``` + + to + + ```py + Component[Args, Kwargs, Slots, Data, JsData, CssData] + ``` + +- If you rendered a component A with `Component.render()` and then inserted that into another component B, now you must pass `render_dependencies=False` to component A: + + ```py + prerendered_a = CompA.render( + args=[...], + kwargs={...}, + render_dependencies=False, + ) + + html = CompB.render( + kwargs={ + content=prerendered_a, + }, + ) + ``` + +#### Feat + +- Intellisense and mypy validation for settings: + + Instead of defining the `COMPONENTS` settings as a plain dict, you can use `ComponentsSettings`: + + ```py + # settings.py + from django_components import ComponentsSettings + + COMPONENTS = ComponentsSettings( + autodiscover=True, + ... + ) + ``` + +- Use `get_component_dirs()` and `get_component_files()` to get the same list of dirs / files that would be imported by `autodiscover()`, but without actually +importing them. + +#### Refactor + +- For advanced use cases, use can omit the middleware and instead manage component JS and CSS dependencies yourself with [`render_dependencies`](https://github.com/EmilStenstrom/django-components#render_dependencies-and-deep-dive-into-rendering-js--css-without-the-middleware) + +- The [`ComponentRegistry`](../api#django_components.ComponentRegistry) settings [`RegistrySettings`](../api#django_components.RegistrySettings) + were lowercased to align with the global settings: + - `RegistrySettings.CONTEXT_BEHAVIOR` -> `RegistrySettings.context_behavior` + - `RegistrySettings.TAG_FORMATTER` -> `RegistrySettings.tag_formatter` + + The old uppercase settings `CONTEXT_BEHAVIOR` and `TAG_FORMATTER` are deprecated and will be removed in v1. + +- The setting `reload_on_template_change` was renamed to + [`reload_on_file_change`](../settings#django_components.app_settings.ComponentsSettings#reload_on_file_change). + And now it properly triggers server reload when any file in the component dirs change. The old name `reload_on_template_change` + is deprecated and will be removed in v1. + +- The setting `forbidden_static_files` was renamed to + [`static_files_forbidden`](../settings#django_components.app_settings.ComponentsSettings#static_files_forbidden) + to align with [`static_files_allowed`](../settings#django_components.app_settings.ComponentsSettings#static_files_allowed) + The old name `forbidden_static_files` is deprecated and will be removed in v1. + +### Tags + +#### 🚨📢 BREAKING CHANGES + +- `{% component_dependencies %}` tag was removed. Instead, use `{% component_js_dependencies %}` and `{% component_css_dependencies %}` + + - The combined tag was removed to encourage the best practice of putting JS scripts at the end of ``, and CSS styles inside ``. + + On the other hand, co-locating JS script and CSS styles can lead to + a [flash of unstyled content](https://en.wikipedia.org/wiki/Flash_of_unstyled_content), + as either JS scripts will block the rendering, or CSS will load too late. + +- The undocumented keyword arg `preload` of `{% component_js_dependencies %}` and `{% component_css_dependencies %}` tags was removed. + This will be replaced with HTML fragment support. + +#### Fix + +- Allow using forward slash (`/`) when defining custom TagFormatter, + e.g. `{% MyComp %}..{% /MyComp %}`. + +#### Refactor + +- `{% component_dependencies %}` tags are now OPTIONAL - If your components use JS and CSS, but you don't use `{% component_dependencies %}` tags, the JS and CSS will now be, by default, inserted at the end of `` and at the end of `` respectively. + +### Slots + +#### Feat + +- Fills can now be defined within loops (`{% for %}`) or other tags (like `{% with %}`), + or even other templates using `{% include %}`. + + Following is now possible + + ```django + {% component "table" %} + {% for slot_name in slots %} + {% fill name=slot_name %} + {% endfill %} + {% endfor %} + {% endcomponent %} + ``` + +- If you need to access the data or the default content of a default fill, you can + set the `name` kwarg to `"default"`. + + Previously, a default fill would be defined simply by omitting the `{% fill %}` tags: + + ```django + {% component "child" %} + Hello world + {% endcomponent %} + ``` + + But in that case you could not access the slot data or the default content, like it's possible + for named fills: + + ```django + {% component "child" %} + {% fill name="header" data="data" %} + Hello {{ data.user.name }} + {% endfill %} + {% endcomponent %} + ``` + + Now, you can specify default tag by using `name="default"`: + + ```django + {% component "child" %} + {% fill name="default" data="data" %} + Hello {{ data.user.name }} + {% endfill %} + {% endcomponent %} + ``` + +- When inside `get_context_data()` or other component methods, the default fill + can now be accessed as `Component.input.slots["default"]`, e.g.: + + ```py + class MyTable(Component): + def get_context_data(self, *args, **kwargs): + default_slot = self.input.slots["default"] + ... + ``` + +- You can now dynamically pass all slots to a child component. This is similar to + [passing all slots in Vue](https://vue-land.github.io/faq/forwarding-slots#passing-all-slots): + + ```py + class MyTable(Component): + def get_context_data(self, *args, **kwargs): + return { + "slots": self.input.slots, + } + + template: """ +
+ {% component "child" %} + {% for slot_name in slots %} + {% fill name=slot_name data="data" %} + {% slot name=slot_name ...data / %} + {% endfill %} + {% endfor %} + {% endcomponent %} +
+ """ + ``` + +#### Fix + +- Slots defined with `{% fill %}` tags are now properly accessible via `self.input.slots` in `get_context_data()` + +- Do not raise error if multiple slots with same name are flagged as default + +- Slots can now be defined within loops (`{% for %}`) or other tags (like `{% with %}`), + or even other templates using `{% include %}`. + + Previously, following would cause the kwarg `name` to be an empty string: + + ```django + {% for slot_name in slots %} + {% slot name=slot_name %} + {% endfor %} + ``` + +#### Refactor + +- When you define multiple slots with the same name inside a template, + you now have to set the `default` and `required` flags individually. + + ```htmldjango +
+
+ {% slot "image" default required %}Image here{% endslot %} +
+
+ {% slot "image" default required %}Image here{% endslot %} +
+
+ ``` + + This means you can also have multiple slots with the same name but + different conditions. + + E.g. in this example, we have a component that renders a user avatar + - a small circular image with a profile picture of name initials. + + If the component is given `image_src` or `name_initials` variables, + the `image` slot is optional. But if neither of those are provided, + you MUST fill the `image` slot. + + ```htmldjango +
+ {% if image_src %} + {% slot "image" default %} + + {% endslot %} + {% elif name_initials %} + {% slot "image" default required %} +
+ {{ name_initials }} +
+ {% endslot %} + {% else %} + {% slot "image" default required / %} + {% endif %} +
+ ``` + +- The slot fills that were passed to a component and which can be accessed as `Component.input.slots` + can now be passed through the Django template, e.g. as inputs to other tags. + + Internally, django-components handles slot fills as functions. + + Previously, if you tried to pass a slot fill within a template, Django would try to call it as a function. + + Now, something like this is possible: + + ```py + class MyTable(Component): + def get_context_data(self, *args, **kwargs): + return { + "child_slot": self.input.slots["child_slot"], + } + + template: """ +
+ {% component "child" content=child_slot / %} +
+ """ + ``` + + NOTE: Using `{% slot %}` and `{% fill %}` tags is still the preferred method, but the approach above + may be necessary in some complex or edge cases. + +- The `is_filled` variable (and the `{{ component_vars.is_filled }}` context variable) now returns + `False` when you try to access a slot name which has not been defined: + + Before: + + ```django + {{ component_vars.is_filled.header }} -> True + {{ component_vars.is_filled.footer }} -> False + {{ component_vars.is_filled.nonexist }} -> "" (empty string) + ``` + + After: + ```django + {{ component_vars.is_filled.header }} -> True + {{ component_vars.is_filled.footer }} -> False + {{ component_vars.is_filled.nonexist }} -> False + ``` + +- Components no longer raise an error if there are extra slot fills + +- Components will raise error when a slot is doubly-filled. + + E.g. if we have a component with a default slot: + + ```django + {% slot name="content" default / %} + ``` + + Now there is two ways how we can target this slot: Either using `name="default"` + or `name="content"`. + + In case you specify BOTH, the component will raise an error: + + ```django + {% component "child" %} + {% fill slot="default" %} + Hello from default slot + {% endfill %} + {% fill slot="content" data="data" %} + Hello from content slot + {% endfill %} + {% endcomponent %} + ``` + +## 🚨📢 v0.100 + +#### BREAKING CHANGES + +- `django_components.safer_staticfiles` app was removed. It is no longer needed. + +- Installation changes: + + - Instead of defining component directories in `STATICFILES_DIRS`, set them to [`COMPONENTS.dirs`](https://github.com/EmilStenstrom/django-components#dirs). + - You now must define `STATICFILES_FINDERS` + + - [See here how to migrate your settings.py](https://github.com/EmilStenstrom/django-components/blob/master/docs/migrating_from_safer_staticfiles.md) + +#### Feat + +- Beside the top-level `/components` directory, you can now define also app-level components dirs, e.g. `[app]/components` + (See [`COMPONENTS.app_dirs`](https://github.com/EmilStenstrom/django-components#app_dirs)). + +#### Refactor + +- When you call `as_view()` on a component instance, that instance will be passed to `View.as_view()` + +## v0.97 + +#### Fix + +- Fixed template caching. You can now also manually create cached templates with [`cached_template()`](https://github.com/EmilStenstrom/django-components#template_cache_size---tune-the-template-cache) + +#### Refactor + +- The previously undocumented `get_template` was made private. + +- In it's place, there's a new `get_template`, which supersedes `get_template_string` (will be removed in v1). The new `get_template` is the same as `get_template_string`, except + it allows to return either a string or a Template instance. + +- You now must use only one of `template`, `get_template`, `template_name`, or `get_template_name`. + +## v0.96 + +#### Feat + +- Run-time type validation for Python 3.11+ - If the `Component` class is typed, e.g. `Component[Args, Kwargs, ...]`, the args, kwargs, slots, and data are validated against the given types. (See [Runtime input validation with types](https://github.com/EmilStenstrom/django-components#runtime-input-validation-with-types)) + +- Render hooks - Set `on_render_before` and `on_render_after` methods on `Component` to intercept or modify the template or context before rendering, or the rendered result afterwards. (See [Component hooks](https://github.com/EmilStenstrom/django-components#component-hooks)) + +- `component_vars.is_filled` context variable can be accessed from within `on_render_before` and `on_render_after` hooks as `self.is_filled.my_slot` + +## 0.95 + +#### Feat + +- Added support for dynamic components, where the component name is passed as a variable. (See [Dynamic components](https://github.com/EmilStenstrom/django-components#dynamic-components)) + +#### Refactor + +- Changed `Component.input` to raise `RuntimeError` if accessed outside of render context. Previously it returned `None` if unset. + +## v0.94 + +#### Feat + +- django_components now automatically configures Django to support multi-line tags. (See [Multi-line tags](https://github.com/EmilStenstrom/django-components#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](https://github.com/EmilStenstrom/django-components#reload-dev-server-on-component-file-changes)) + +## v0.93 + +#### Feat + +- Spread operator `...dict` inside template tags. (See [Spread operator](https://github.com/EmilStenstrom/django-components#spread-operator)) + +- Use template tags inside string literals in component inputs. (See [Use template tags inside component inputs](https://github.com/EmilStenstrom/django-components#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. + +## 🚨📢 v0.92 + +#### BREAKING CHANGES + +- `Component` class is no longer a subclass of `View`. To configure the `View` class, set the `Component.View` nested class. HTTP methods like `get` or `post` can still be defined directly on `Component` class, and `Component.as_view()` internally calls `Component.View.as_view()`. (See [Modifying the View class](https://github.com/EmilStenstrom/django-components#modifying-the-view-class)) + +#### Feat + +- The inputs (args, kwargs, slots, context, ...) that you pass to `Component.render()` can be accessed from within `get_context_data`, `get_template` and `get_template_name` via `self.input`. (See [Accessing data passed to the component](https://github.com/EmilStenstrom/django-components#accessing-data-passed-to-the-component)) + +- Typing: `Component` class supports generics that specify types for `Component.render` (See [Adding type hints with Generics](https://github.com/EmilStenstrom/django-components#adding-type-hints-with-generics)) + +## v0.90 + +#### Feat + +- All tags (`component`, `slot`, `fill`, ...) now support "self-closing" or "inline" form, where you can omit the closing tag: + + ```django + {# Before #} + {% component "button" %}{% endcomponent %} + {# After #} + {% component "button" / %} + ``` + +- All tags now support the "dictionary key" or "aggregate" syntax (`kwarg:key=val`): + + ```django + {% component "button" attrs:class="hidden" %} + ``` + +- You can change how the components are written in the template with [TagFormatter](https://github.com/EmilStenstrom/django-components#customizing-component-tags-with-tagformatter). + + The default is `django_components.component_formatter`: + + ```django + {% component "button" href="..." disabled %} + Click me! + {% endcomponent %} + ``` + + While `django_components.shorthand_component_formatter` allows you to write components like so: + + ```django + {% button href="..." disabled %} + Click me! + {% endbutton %} + ``` + +## 🚨📢 v0.85 + +#### BREAKING CHANGES + +- Autodiscovery module resolution changed. Following undocumented behavior was removed: + + - Previously, autodiscovery also imported any `[app]/components.py` files, and used `SETTINGS_MODULE` to search for component dirs. + + To migrate from: + + - `[app]/components.py` - Define each module in `COMPONENTS.libraries` setting, + or import each module inside the `AppConfig.ready()` hook in respective `apps.py` files. + + - `SETTINGS_MODULE` - Define component dirs using `STATICFILES_DIRS` + + - Previously, autodiscovery handled relative files in `STATICFILES_DIRS`. To align with Django, `STATICFILES_DIRS` now must be full paths ([Django docs](https://docs.djangoproject.com/en/5.0/ref/settings/#std-setting-STATICFILES_DIRS)). + +## 🚨📢 v0.81 + +#### BREAKING CHANGES + +- The order of arguments to `render_to_response` has changed, to align with the (now public) `render` method of `Component` class. + +#### Feat + +- `Component.render()` is public and documented + +- Slots passed `render_to_response` and `render` can now be rendered also as functions. + +## v0.80 + +#### Feat + +- Vue-like provide/inject with the `{% provide %}` tag and `inject()` method. + +## 🚨📢 v0.79 + +#### BREAKING CHANGES + +- Default value for the `COMPONENTS.context_behavior` setting was changes from `"isolated"` to `"django"`. If you did not set this value explicitly before, this may be a breaking change. See the rationale for change [here](https://github.com/EmilStenstrom/django-components/issues/498). + +## 🚨📢 v0.77 + +#### BREAKING + +- The syntax for accessing default slot content has changed from + + ```django + {% fill "my_slot" as "alias" %} + {{ alias.default }} + {% endfill %} + + ``` + + to + + ```django + {% fill "my_slot" default="alias" %} + {{ alias }} + {% endfill %} + ``` + +## v0.74 + +#### Feat + +- `{% html_attrs %}` tag for formatting data as HTML attributes + +- `prefix:key=val` construct for passing dicts to components + +## 🚨📢 v0.70 + +#### BREAKING CHANGES + +- `{% if_filled "my_slot" %}` tags were replaced with `{{ component_vars.is_filled.my_slot }}` variables. + +- Simplified settings - `slot_context_behavior` and `context_behavior` were merged. See the [documentation](https://github.com/EmilStenstrom/django-components#context-behavior) for more details. + +## v0.67 + +#### Refactor + +- Changed the default way how context variables are resolved in slots. See the [documentation](https://github.com/EmilStenstrom/django-components/tree/0.67#isolate-components-slots) for more details. + +## 🚨📢 v0.50 + +#### BREAKING CHANGES + +- `{% component_block %}` is now `{% component %}`, and `{% component %}` blocks need an ending `{% endcomponent %}` tag. + + The new `python manage.py upgradecomponent` command can be used to upgrade a directory (use `--path` argument to point to each dir) of templates that use components to the new syntax automatically. + + This change is done to simplify the API in anticipation of a 1.0 release of django_components. After 1.0 we intend to be stricter with big changes like this in point releases. + +## v0.34 + +#### Feat + +- Components as views, which allows you to handle requests and render responses from within a component. See the [documentation](https://github.com/EmilStenstrom/django-components#use-components-as-views) for more details. + +## v0.28 + +#### Feat + +- 'implicit' slot filling and the `default` option for `slot` tags. + +## v0.27 + +#### Feat + +- A second installable app `django_components.safer_staticfiles`. It provides the same behavior as `django.contrib.staticfiles` but with extra security guarantees (more info below in [Security Notes](https://github.com/EmilStenstrom/django-components#security-notes)). + +## 🚨📢 v0.26 + +#### BREAKING CHANGES + +- Changed the syntax for `{% slot %}` tags. From now on, we separate defining a slot (`{% slot %}`) from filling a slot with content (`{% fill %}`). This means you will likely need to change a lot of slot tags to fill. + + We understand this is annoying, but it's the only way we can get support for nested slots that fill in other slots, which is a very nice feature to have access to. Hoping that this will feel worth it! + +## v0.22 + +#### Feat + +- All files inside components subdirectores are autoimported to simplify setup. + + An existing project might start to get `AlreadyRegistered` errors because of this. To solve this, either remove your custom loading of components, or set `"autodiscover": False` in `settings.COMPONENTS`. + +## v0.17 + +#### BREAKING CHANGES + +- Renamed `Component.context` and `Component.template` to `get_context_data` and `get_template_name`. The old methods still work, but emit a deprecation warning. + + This change was done to sync naming with Django's class based views, and make using django-components more familiar to Django users. `Component.context` and `Component.template` will be removed when version 1.0 is released. diff --git a/README.md b/README.md index 62b9daa6..1e49cfa3 100644 --- a/README.md +++ b/README.md @@ -96,128 +96,8 @@ Read on to learn about all the exciting details and configuration possibilities! ## Release notes -🚨📢 **Version 0.100** -- BREAKING CHANGE: - - `django_components.safer_staticfiles` app was removed. It is no longer needed. - - Installation changes: - - Instead of defining component directories in `STATICFILES_DIRS`, set them to [`COMPONENTS.dirs`](#dirs). - - You now must define `STATICFILES_FINDERS` - - [See here how to migrate your settings.py](https://github.com/EmilStenstrom/django-components/blob/master/docs/migrating_from_safer_staticfiles.md) -- Beside the top-level `/components` directory, you can now define also app-level components dirs, e.g. `[app]/components` - (See [`COMPONENTS.app_dirs`](#app_dirs)). -- When you call `as_view()` on a component instance, that instance will be passed to `View.as_view()` - -**Version 0.97** -- Fixed template caching. You can now also manually create cached templates with [`cached_template()`](#template_cache_size---tune-the-template-cache) -- The previously undocumented `get_template` was made private. -- In it's place, there's a new `get_template`, which supersedes `get_template_string` (will be removed in v1). The new `get_template` is the same as `get_template_string`, except -it allows to return either a string or a Template instance. -- You now must use only one of `template`, `get_template`, `template_name`, or `get_template_name`. - -**Version 0.96** -- Run-time type validation for Python 3.11+ - If the `Component` class is typed, e.g. `Component[Args, Kwargs, ...]`, the args, kwargs, slots, and data are validated against the given types. (See [Runtime input validation with types](#runtime-input-validation-with-types)) -- Render hooks - Set `on_render_before` and `on_render_after` methods on `Component` to intercept or modify the template or context before rendering, or the rendered result afterwards. (See [Component hooks](#component-hooks)) -- `component_vars.is_filled` context variable can be accessed from within `on_render_before` and `on_render_after` hooks as `self.is_filled.my_slot` - -**Version 0.95** -- Added support for dynamic components, where the component name is passed as a variable. (See [Dynamic components](#dynamic-components)) -- Changed `Component.input` to raise `RuntimeError` if accessed outside of render context. Previously it returned `None` if unset. - -**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)) -- 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. - -🚨📢 **Version 0.92** -- BREAKING CHANGE: `Component` class is no longer a subclass of `View`. To configure the `View` class, set the `Component.View` nested class. HTTP methods like `get` or `post` can still be defined directly on `Component` class, and `Component.as_view()` internally calls `Component.View.as_view()`. (See [Modifying the View class](#modifying-the-view-class)) - -- The inputs (args, kwargs, slots, context, ...) that you pass to `Component.render()` can be accessed from within `get_context_data`, `get_template` and `get_template_name` via `self.input`. (See [Accessing data passed to the component](#accessing-data-passed-to-the-component)) - -- Typing: `Component` class supports generics that specify types for `Component.render` (See [Adding type hints with Generics](#adding-type-hints-with-generics)) - -**Version 0.90** -- All tags (`component`, `slot`, `fill`, ...) now support "self-closing" or "inline" form, where you can omit the closing tag: - ```django - {# Before #} - {% component "button" %}{% endcomponent %} - {# After #} - {% component "button" / %} - ``` -- All tags now support the "dictionary key" or "aggregate" syntax (`kwarg:key=val`): - ```django - {% component "button" attrs:class="hidden" %} - ``` -- You can change how the components are written in the template with [TagFormatter](#customizing-component-tags-with-tagformatter). - - The default is `django_components.component_formatter`: - ```django - {% component "button" href="..." disabled %} - Click me! - {% endcomponent %} - ``` - - While `django_components.component_shorthand_formatter` allows you to write components like so: - - ```django - {% button href="..." disabled %} - Click me! - {% endbutton %} - -🚨📢 **Version 0.85** Autodiscovery module resolution changed. Following undocumented behavior was removed: - -- Previously, autodiscovery also imported any `[app]/components.py` files, and used `SETTINGS_MODULE` to search for component dirs. - - To migrate from: - - `[app]/components.py` - Define each module in `COMPONENTS.libraries` setting, - or import each module inside the `AppConfig.ready()` hook in respective `apps.py` files. - - `SETTINGS_MODULE` - Define component dirs using `STATICFILES_DIRS` -- Previously, autodiscovery handled relative files in `STATICFILES_DIRS`. To align with Django, `STATICFILES_DIRS` now must be full paths ([Django docs](https://docs.djangoproject.com/en/5.0/ref/settings/#std-setting-STATICFILES_DIRS)). - -🚨📢 **Version 0.81** Aligned the `render_to_response` method with the (now public) `render` method of `Component` class. Moreover, slots passed to these can now be rendered also as functions. - -- BREAKING CHANGE: The order of arguments to `render_to_response` has changed. - -**Version 0.80** introduces dependency injection with the `{% provide %}` tag and `inject()` method. - -🚨📢 **Version 0.79** - -- BREAKING CHANGE: Default value for the `COMPONENTS.context_behavior` setting was changes from `"isolated"` to `"django"`. If you did not set this value explicitly before, this may be a breaking change. See the rationale for change [here](https://github.com/EmilStenstrom/django-components/issues/498). - -🚨📢 **Version 0.77** CHANGED the syntax for accessing default slot content. - -- Previously, the syntax was - `{% fill "my_slot" as "alias" %}` and `{{ alias.default }}`. -- Now, the syntax is - `{% fill "my_slot" default="alias" %}` and `{{ alias }}`. - -**Version 0.74** introduces `html_attrs` tag and `prefix:key=val` construct for passing dicts to components. - -🚨📢 **Version 0.70** - -- `{% if_filled "my_slot" %}` tags were replaced with `{{ component_vars.is_filled.my_slot }}` variables. -- Simplified settings - `slot_context_behavior` and `context_behavior` were merged. See the [documentation](#context-behavior) for more details. - -**Version 0.67** CHANGED the default way how context variables are resolved in slots. See the [documentation](https://github.com/EmilStenstrom/django-components/tree/0.67#isolate-components-slots) for more details. - -🚨📢 **Version 0.5** CHANGES THE SYNTAX for components. `component_block` is now `component`, and `component` blocks need an ending `endcomponent` tag. The new `python manage.py upgradecomponent` command can be used to upgrade a directory (use --path argument to point to each dir) of templates that use components to the new syntax automatically. - -This change is done to simplify the API in anticipation of a 1.0 release of django_components. After 1.0 we intend to be stricter with big changes like this in point releases. - -**Version 0.34** adds components as views, which allows you to handle requests and render responses from within a component. See the [documentation](#use-components-as-views) for more details. - -**Version 0.28** introduces 'implicit' slot filling and the `default` option for `slot` tags. - -**Version 0.27** adds a second installable app: _django_components.safer_staticfiles_. It provides the same behavior as _django.contrib.staticfiles_ but with extra security guarantees (more info below in Security Notes). - -**Version 0.26** changes the syntax for `{% slot %}` tags. From now on, we separate defining a slot (`{% slot %}`) from filling a slot with content (`{% fill %}`). This means you will likely need to change a lot of slot tags to fill. We understand this is annoying, but it's the only way we can get support for nested slots that fill in other slots, which is a very nice featuPpre to have access to. Hoping that this will feel worth it! - -**Version 0.22** starts autoimporting all files inside components subdirectores, to simplify setup. An existing project might start to get AlreadyRegistered-errors because of this. To solve this, either remove your custom loading of components, or set "autodiscover": False in settings.COMPONENTS. - -**Version 0.17** renames `Component.context` and `Component.template` to `get_context_data` and `get_template_name`. The old methods still work, but emit a deprecation warning. This change was done to sync naming with Django's class based views, and make using django-components more familiar to Django users. `Component.context` and `Component.template` will be removed when version 1.0 is released. +Read the [Release Notes](https://github.com/EmilStenstrom/django-components/tree/master/CHANGELOG.md) +to see the latest features and fixes. ## Security notes 🚨 @@ -357,6 +237,34 @@ STATICFILES_FINDERS = [ ] ``` +### Adding support for JS and CSS + +If you want to use JS or CSS with components, you will need to: + +1. Add [`ComponentDependencyMiddleware`](#setting-up-componentdependencymiddleware) to `MIDDLEWARE` setting. + +The middleware searches the outgoing HTML for all components that were rendered +to generate the HTML, and adds the JS and CSS associated with those components. + +```py +MIDDLEWARE = [ + ... + "django_components.middleware.ComponentDependencyMiddleware", +] +``` + +Read more in [Rendering JS/CSS dependencies](#rendering-jscss-dependencies). + +2. Add django-component's URL paths to your `urlpatterns`: + +```py +from django.urls import include, path + +urlpatterns = [ + ... + path("", include("django_components.urls")), +] +``` ### Optional @@ -897,7 +805,7 @@ that allow you to specify the types of args, kwargs, slots, and data: ```py -class Button(Component[Args, Kwargs, Data, Slots]): +class Button(Component[Args, Kwargs, Slots, Data, JsData, CssData]): ... ``` @@ -936,7 +844,7 @@ class Slots(TypedDict): # SlotContent == Union[str, SafeString] another_slot: SlotContent -class Button(Component[Args, Kwargs, Data, Slots]): +class Button(Component[Args, Kwargs, Slots, Data, JsData, CssData]): def get_context_data(self, variable, another): return { "variable": variable, @@ -1014,7 +922,7 @@ from django_components import Component, EmptyDict, EmptyTuple Args = EmptyTuple Kwargs = Data = Slots = EmptyDict -class Button(Component[Args, Kwargs, Data, Slots]): +class Button(Component[Args, Kwargs, Slots, Data, JsData, CssData]): ... ``` @@ -1056,7 +964,7 @@ Or you can replace `Args` with `Any` altogether, to skip the validation of args: ```py # Replaced `Args` with `Any` -class Button(Component[Any, Kwargs, Data, Slots]): +class Button(Component[Any, Kwargs, Slots, Data, JsData, CssData]): ... ``` @@ -1250,8 +1158,8 @@ NOTE: The Library instance can be accessed under `library` attribute of `Compone When you are creating an instance of `ComponentRegistry`, you can define the components' behavior within the template. The registry accepts these settings: -- `CONTEXT_BEHAVIOR` -- `TAG_FORMATTER` +- `context_behavior` +- `tag_formatter` ```py from django.template import Library @@ -1261,8 +1169,8 @@ register = library = django.template.Library() comp_registry = ComponentRegistry( library=library, settings=RegistrySettings( - CONTEXT_BEHAVIOR="isolated", - TAG_FORMATTER="django_components.component_formatter", + context_behavior="isolated", + tag_formatter="django_components.component_formatter", ), ) ``` @@ -1346,7 +1254,7 @@ This behavior is similar to [slots in Vue](https://vuejs.org/guide/components/sl In the example below we introduce two block tags that work hand in hand to make this work. These are... - `{% slot %}`/`{% endslot %}`: Declares a new slot in the component template. -- `{% fill %}`/`{% endfill %}`: (Used inside a `component` tag pair.) Fills a declared slot with the specified content. +- `{% fill %}`/`{% endfill %}`: (Used inside a `{% component %}` tag pair.) Fills a declared slot with the specified content. Let's update our calendar component to support more customization. We'll add `slot` tag pairs to its template, _template.html_. @@ -1365,7 +1273,9 @@ When using the component, you specify which slots you want to fill and where you ```htmldjango {% component "calendar" date="2020-06-06" %} - {% fill "body" %}Can you believe it's already {{ date }}??{% endfill %} + {% fill "body" %} + Can you believe it's already {{ date }}?? + {% endfill %} {% endcomponent %} ``` @@ -1382,13 +1292,53 @@ Since the 'header' fill is unspecified, it's taken from the base template. If yo ``` +### Named slots + +As seen in the previouse section, you can use `{% fill slot_name %}` to insert content into a specific +slot. + +You can define fills for multiple slot simply by defining them all within the `{% component %} {% endcomponent %}` +tags: + +```htmldjango +{% component "calendar" date="2020-06-06" %} + {% fill "header" %} + Hi this is header! + {% endfill %} + {% fill "body" %} + Can you believe it's already {{ date }}?? + {% endfill %} +{% endcomponent %} +``` + +You can also use `{% for %}`, `{% with %}`, or other tags (even `{% include %}`) +to construct the `{% fill %}` tags, **as long as these other tags do not leave any text behind!** + +```django +{% component "table" %} + {% for slot_name in slots %} + {% fill name=slot_name %} + {{ slot_name }} + {% endfill %} + {% endfor %} + + {% with slot_name="abc" %} + {% fill name=slot_name %} + {{ slot_name }} + {% endfill %} + {% endwith %} +{% endcomponent %} +``` + ### Default slot _Added in version 0.28_ As you can see, component slots lets you write reusable containers that you fill in when you use a component. This makes for highly reusable components that can be used in different circumstances. -It can become tedious to use `fill` tags everywhere, especially when you're using a component that declares only one slot. To make things easier, `slot` tags can be marked with an optional keyword: `default`. When added to the end of the tag (as shown below), this option lets you pass filling content directly in the body of a `component` tag pair – without using a `fill` tag. Choose carefully, though: a component template may contain at most one slot that is marked as `default`. The `default` option can be combined with other slot options, e.g. `required`. +It can become tedious to use `fill` tags everywhere, especially when you're using a component that declares only one slot. To make things easier, `slot` tags can be marked with an optional keyword: `default`. + +When added to the tag (as shown below), this option lets you pass filling content directly in the body of a `component` tag pair – without using a `fill` tag. Choose carefully, though: a component template may contain at most one slot that is marked as `default`. The `default` option can be combined with other slot options, e.g. `required`. Here's the same example as before, except with default slots and implicit filling. @@ -1422,7 +1372,7 @@ The rendered result (exactly the same as before): ``` -You may be tempted to combine implicit fills with explicit `fill` tags. This will not work. The following component template will raise an error when compiled. +You may be tempted to combine implicit fills with explicit `fill` tags. This will not work. The following component template will raise an error when rendered. ```htmldjango {# DON'T DO THIS #} @@ -1432,26 +1382,33 @@ You may be tempted to combine implicit fills with explicit `fill` tags. This wil {% endcomponent %} ``` -By contrast, it is permitted to use `fill` tags in nested components, e.g.: +Instead, you can use a named fill with name `default` to target the default fill: ```htmldjango +{# THIS WORKS #} {% component "calendar" date="2020-06-06" %} - {% component "beautiful-box" %} - {% fill "content" %} Can you believe it's already {{ date }}?? {% endfill %} - {% endcomponent %} + {% fill "header" %}Totally new header!{% endfill %} + {% fill "default" %} + Can you believe it's already {{ date }}?? + {% endfill %} {% endcomponent %} ``` -This is fine too: +NOTE: If you doubly-fill a slot, that is, that both `{% fill "default" %}` and `{% fill "header" %}` +would point to the same slot, this will raise an error when rendered. -```htmldjango -{% component "calendar" date="2020-06-06" %} - {% fill "header" %} - {% component "calendar-header" %} - Super Special Calendar Header - {% endcomponent %} - {% endfill %} -{% endcomponent %} +#### Accessing default slot in Python + +Since the default slot is stored under the slot name `default`, you can access the default slot +like so: + +```py +class MyTable(Component): + def get_context_data(self, *args, **kwargs): + default_slot = self.input.slots["default"] + return { + "default_slot": default_slot, + } ``` ### Render fill in multiple places @@ -1498,9 +1455,7 @@ This renders: #### Default and required slots If you use a slot multiple times, you can still mark the slot as `default` or `required`. -For that, you must mark ONLY ONE of the identical slots. - -We recommend to mark the first occurence for consistency, e.g.: +For that, you must mark each slot individually, e.g.: ```htmldjango
@@ -1508,12 +1463,12 @@ We recommend to mark the first occurence for consistency, e.g.: {% slot "image" default required %}Image here{% endslot %}
- {% slot "image" %}Image here{% endslot %} + {% slot "image" default required %}Image here{% endslot %}
``` -Which you can then use are regular default slot: +Which you can then use as regular default slot: ```htmldjango {% component "calendar" date="2020-06-06" %} @@ -1521,6 +1476,39 @@ Which you can then use are regular default slot: {% endcomponent %} ``` +Since each slot is tagged individually, you can have multiple slots +with the same name but different conditions. + +E.g. in this example, we have a component that renders a user avatar +- a small circular image with a profile picture of name initials. + +If the component is given `image_src` or `name_initials` variables, +the `image` slot is optional. But if neither of those are provided, +you MUST fill the `image` slot. + +```htmldjango +
+ {% if image_src %} + {% slot "image" default %} + + {% endslot %} + {% elif name_initials %} + {% slot "image" default %} +
+ {{ name_initials }} +
+ {% endslot %} + {% else %} + {% slot "image" default required / %} + {% endif %} +
+``` + ### Accessing original content of slots _Added in version 0.26_ @@ -1564,6 +1552,16 @@ This produces: ``` +To access the original content of a default slot, set the name to `default`: + +```htmldjango +{% component "calendar" date="2020-06-06" %} + {% fill "default" default="slot_default" %} + {{ slot_default }}. Have a great day! + {% endfill %} +{% endcomponent %} +``` + ### Conditional slots _Added in version 0.26._ @@ -1615,6 +1613,7 @@ This is what our example looks like with `component_vars.is_filled`. {% endif %} +``` Here's our example with more complex branching. @@ -1654,6 +1653,20 @@ However, you can still define slots with other special characters. In such case, So a slot named `"my super-slot :)"` will be available as `component_vars.is_filled.my_super_slot___`. +Same applies when you are accessing `is_filled` from within the Python, e.g.: + +```py +class MyTable(Component): + def on_render_before(self, context, template) -> None: + # ✅ Works + if self.is_filled["my_super_slot___"]: + # Do something + + # ❌ Does not work + if self.is_filled["my super-slot :)"]: + # Do something +``` + ### Scoped slots _Added in version 0.76_: @@ -1715,8 +1728,8 @@ the slot data. In the example below, we set it to `data`: ```django {% component "my_comp" %} - {% fill "content" data="data" %} - {{ data.input }} + {% fill "content" data="slot_data" %} + {{ slot_data.input }} {% endfill %} {% endcomponent %} ``` @@ -1727,8 +1740,8 @@ So this works: ```django {% component "my_comp" %} - {% fill "content" data="data" %} - {{ data.input }} + {% fill "default" data="slot_data" %} + {{ slot_data.input }} {% endfill %} {% endcomponent %} ``` @@ -1823,6 +1836,31 @@ So it's possible to define a `name` key on a dictionary, and then spread that on {% slot ...slot_props / %} ``` +### Pass through all the slots + +You can dynamically pass all slots to a child component. This is similar to +[passing all slots in Vue](https://vue-land.github.io/faq/forwarding-slots#passing-all-slots): + +```py +class MyTable(Component): + def get_context_data(self, *args, **kwargs): + return { + "slots": self.input.slots, + } + + template: """ +
+ {% component "child" %} + {% for slot_name in slots %} + {% fill name=slot_name data="data" %} + {% slot name=slot_name ...data / %} + {% endfill %} + {% endfor %} + {% endcomponent %} +
+ """ +``` + ## Accessing data passed to the component When you call `Component.render` or `Component.render_to_response`, the inputs to these methods can be accessed from within the instance under `self.input`. @@ -1831,6 +1869,8 @@ This means that you can use `self.input` inside: - `get_context_data` - `get_template_name` - `get_template` +- `on_render_before` +- `on_render_after` `self.input` is only defined during the execution of `Component.render`, and raises a `RuntimeError` when called outside of this context. @@ -1841,7 +1881,7 @@ class TestComponent(Component): def get_context_data(self, var1, var2, variable, another, **attrs): assert self.input.args == (123, "str") assert self.input.kwargs == {"variable": "test", "another": 1} - assert self.input.slots == {"my_slot": "MY_SLOT"} + assert self.input.slots == {"my_slot": ...} assert isinstance(self.input.context, Context) return { @@ -1855,6 +1895,8 @@ rendered = TestComponent.render( ) ``` +NOTE: The slots in `self.input.slots` are normalized to slot functions. + ## Rendering HTML attributes _New in version 0.74_: @@ -2794,6 +2836,16 @@ Here is a list of all variables that are automatically available from within the {% endif %} ``` + This is equivalent to checking if a given key is among the slot fills: + + ```py + class MyTable(Component): + def get_context_data(self, *args, **kwargs): + return { + "my_slot_filled": "my_slot" in self.input.slots + } + ``` + ## Customizing component tags with TagFormatter _New in version 0.89_ @@ -3146,15 +3198,72 @@ NOTE: The instance of the `Media` class (or it's subclass) is available under `C ## Rendering JS/CSS dependencies -The JS and CSS files included in components are not automatically rendered. -Instead, use the following tags to specify where to render the dependencies: +If: +1. Your components use JS and CSS, whether inlined via `Component.js/css` or via `Component.Media.js/css`, +2. And you use the `ComponentDependencyMiddleware` middleware -- `component_dependencies` - Renders both JS and CSS -- `component_js_dependencies` - Renders only JS -- `component_css_dependencies` - Reneders only CSS +Then, by default, the components' JS and CSS will be automatically inserted into the HTML: +- CSS styles will be inserted at the end of the `` +- JS scripts will be inserted at the end of the `` -JS files are rendered as `""" -@override_settings(COMPONENTS={"RENDER_DEPENDENCIES": True}) class RenderBenchmarks(BaseTestCase): def setUp(self): registry.clear() @@ -122,7 +120,9 @@ class RenderBenchmarks(BaseTestCase): def test_middleware_time_with_dependency_for_small_page(self): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} {% component 'test_component' %} {% slot "header" %} {% component 'inner_component' variable='foo' %}{% endcomponent %} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md deleted file mode 100644 index 2e473032..00000000 --- a/docs/CHANGELOG.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -hide: - - toc ---- - -# Release notes - -{! - include-markdown "../README.md" - start="## Release notes" - end='## ' - heading-offset=1 -!} \ No newline at end of file diff --git a/docs/slot_rendering.md b/docs/slot_rendering.md deleted file mode 100644 index ecde6127..00000000 --- a/docs/slot_rendering.md +++ /dev/null @@ -1,238 +0,0 @@ -# Slot rendering - -This doc serves as a primer on how component slots and fills are resolved. - -## Flow - -1. Imagine you have a template. Some kind of text, maybe HTML: - ```django - | ------ - | --------- - | ---- - | ------- - ``` - -2. The template may contain some vars, tags, etc - ```django - | -- {{ my_var }} -- - | --------- - | ---- - | ------- - ``` - -3. The template also contains some slots, etc - ```django - | -- {{ my_var }} -- - | --------- - | -- {% slot "myslot" %} --- - | -- {% endslot %} --- - | ---- - | -- {% slot "myslot2" %} --- - | -- {% endslot %} --- - | ------- - ``` - -4. Slots may be nested - ```django - | -- {{ my_var }} -- - | -- ABC - | -- {% slot "myslot" %} --- - | ----- DEF {{ my_var }} - | ----- {% slot "myslot_inner" %} - | -------- GHI {{ my_var }} - | ----- {% endslot %} - | -- {% endslot %} --- - | ---- - | -- {% slot "myslot2" %} --- - | ---- JKL {{ my_var }} - | -- {% endslot %} --- - | ------- - ``` - -5. Some slots may be inside fills for other components - ```django - | -- {{ my_var }} -- - | -- ABC - | -- {% slot "myslot" %}--- - | ----- DEF {{ my_var }} - | ----- {% slot "myslot_inner" %} - | -------- GHI {{ my_var }} - | ----- {% endslot %} - | -- {% endslot %} --- - | ------ - | -- {% component "mycomp" %} --- - | ---- {% slot "myslot" %} --- - | ------- JKL {{ my_var }} - | ------- {% slot "myslot_inner" %} - | ---------- MNO {{ my_var }} - | ------- {% endslot %} - | ---- {% endslot %} --- - | -- {% endcomponent %} --- - | ---- - | -- {% slot "myslot2" %} --- - | ---- PQR {{ my_var }} - | -- {% endslot %} --- - | ------- - ``` - -5. I want to render the slots with `{% fill %}` tag that were defined OUTSIDE of this template. How do I do that? - - 1. Traverse the template to collect ALL slots - - NOTE: I will also look inside `{% slot %}` and `{% fill %}` tags, since they are all still - defined within the same TEMPLATE. - - I should end up with a list like this: - ```txt - - Name: "myslot" - ID 0001 - Content: - | ----- DEF {{ my_var }} - | ----- {% slot "myslot_inner" %} - | -------- GHI {{ my_var }} - | ----- {% endslot %} - - Name: "myslot_inner" - ID 0002 - Content: - | -------- GHI {{ my_var }} - - Name: "myslot" - ID 0003 - Content: - | ------- JKL {{ my_var }} - | ------- {% slot "myslot_inner" %} - | ---------- MNO {{ my_var }} - | ------- {% endslot %} - - Name: "myslot_inner" - ID 0004 - Content: - | ---------- MNO {{ my_var }} - - Name: "myslot2" - ID 0005 - Content: - | ---- PQR {{ my_var }} - ``` - - 2. Note the relationships - which slot is nested in which one - - I should end up with a graph-like data like: - ```txt - - 0001: [0002] - - 0002: [] - - 0003: [0004] - - 0004: [] - - 0005: [] - ``` - - In other words, the data tells us that slot ID `0001` is PARENT of slot `0002`. - - This is important, because, IF parent template provides slot fill for slot 0001, - then we DON'T NEED TO render it's children, AKA slot 0002. - - 3. Find roots of the slot relationships - - The data from previous step can be understood also as a collection of - directled acyclig graphs (DAG), e.g.: - - ```txt - 0001 --> 0002 - 0003 --> 0004 - 0005 - ``` - - So we find the roots (`0001`, `0003`, `0005`), AKA slots that are NOT nested in other slots. - We do so by going over ALL entries from previous step. Those IDs which are NOT - mentioned in ANY of the lists are the roots. - - Because of the nature of nested structures, there cannot be any cycles. - - 4. Recursively render slots, starting from roots. - 1. First we take each of the roots. - - 2. Then we check if there is a slot fill for given slot name. - - 3. If YES we replace the slot node with the fill node. - - Note: We assume slot fills are ALREADY RENDERED! - ```django - | ----- {% slot "myslot_inner" %} - | -------- GHI {{ my_var }} - | ----- {% endslot %} - ``` - becomes - ```django - | ----- Bla bla - | -------- Some Other Content - | ----- ... - ``` - We don't continue further, because inner slots have been overriden! - - 4. If NO, then we will replace slot nodes with their children, e.g.: - ```django - | ---- {% slot "myslot" %} --- - | ------- JKL {{ my_var }} - | ------- {% slot "myslot_inner" %} - | ---------- MNO {{ my_var }} - | ------- {% endslot %} - | ---- {% endslot %} --- - ``` - Becomes - ```django - | ------- JKL {{ my_var }} - | ------- {% slot "myslot_inner" %} - | ---------- MNO {{ my_var }} - | ------- {% endslot %} - ``` - - 5. We check if the slot includes any children `{% slot %}` tags. If YES, then continue with step 4. for them, and wait until they finish. - - 5. At this point, ALL slots should be rendered and we should have something like this: - ```django - | -- {{ my_var }} -- - | -- ABC - | ----- DEF {{ my_var }} - | -------- GHI {{ my_var }} - | ------ - | -- {% component "mycomp" %} --- - | ------- JKL {{ my_var }} - | ---- {% component "mycomp" %} --- - | ---------- MNO {{ my_var }} - | ---- {% endcomponent %} --- - | -- {% endcomponent %} --- - | ---- - | -- {% component "mycomp2" %} --- - | ---- PQR {{ my_var }} - | -- {% endcomponent %} --- - | ---- - ``` - - NOTE: Inserting fills into {% slots %} should NOT introduce new {% slots %}, as the fills should be already rendered! - -## Using the correct context in {% slot/fill %} tags - -In previous section, we said that the `{% fill %}` tags should be already rendered by the time they are inserted into the `{% slot %}` tags. - -This is not quite true. To help you understand, consider this complex case: - -```django -| -- {% for var in [1, 2, 3] %} --- -| ---- {% component "mycomp2" %} --- -| ------ {% fill "first" %} -| ------- STU {{ my_var }} -| ------- {{ var }} -| ------ {% endfill %} -| ------ {% fill "second" %} -| -------- {% component var=var my_var=my_var %} -| ---------- VWX {{ my_var }} -| -------- {% endcomponent %} -| ------ {% endfill %} -| ---- {% endcomponent %} --- -| -- {% endfor %} --- -| ------- -``` - -We want the forloop variables to be available inside the `{% fill %}` tags. Because of that, however, we CANNOT render the fills/slots in advance. - -Instead, our solution is closer to [how Vue handles slots](https://vuejs.org/guide/components/slots.html#scoped-slots). In Vue, slots are effectively functions that accept a context variables and render some content. - -While we do not wrap the logic in a function, we do PREPARE IN ADVANCE: -1. The content that should be rendered for each slot -2. The context variables from `get_context_data()` - -Thus, once we reach the `{% slot %}` node, in it's `render()` method, we access the data above, and, depending on the `context_behavior` setting, include the current context or not. For more info, see `SlotNode.render()`. diff --git a/mkdocs.yml b/mkdocs.yml index 622a51c7..58a0a6a9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -5,15 +5,14 @@ site_url: https://emilstenstrom.github.io/django-components/ repo_url: https://github.com/EmilStenstrom/django-components repo_name: EmilStenstrom/django-components -edit_uri: https://github.com/EmilStenstrom/django-components/edit/master/docs/ +edit_uri: https://github.com/EmilStenstrom/django-components/edit/master/src/docs/ dev_addr: "127.0.0.1:9000" site_dir: site -docs_dir: docs +docs_dir: src/docs watch: - src - - docs - mkdocs.yml - README.md - scripts @@ -26,7 +25,7 @@ validation: theme: name: "material" - custom_dir: docs/overrides + custom_dir: src/docs/overrides features: - content.action.edit - content.action.view @@ -117,7 +116,7 @@ plugins: closing_tag: "!}" - gen-files: scripts: - - scripts/gen_ref_nav.py + - src/docs/scripts/reference.py - literate-nav: nav_file: SUMMARY.md tab_length: 2 @@ -175,13 +174,7 @@ plugins: signature_crossrefs: true summary: true unwrap_annotated: true - # show_root_heading: true - # show_signature_annotations: true show_if_no_docstring: false - # separate_signature: true line_length: 140 - # merge_init_into_class: true show_submodules: true docstring_style: google - # docstring_options: - # ignore_init_summary: true diff --git a/pyproject.toml b/pyproject.toml index 7fac6063..52bb24f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ classifiers = [ ] dependencies = [ 'Django>=4.2', + 'selectolax>=0.3.24', ] license = {text = "MIT"} @@ -79,6 +80,10 @@ exclude = [ '.tox', 'build', ] +per-file-ignores = [ + 'tests/test_component_media.py:E501', + 'tests/test_dependency_rendering.py:E501', +] [tool.mypy] check_untyped_defs = true diff --git a/requirements-dev.in b/requirements-dev.in index c8f01700..6238e9f1 100644 --- a/requirements-dev.in +++ b/requirements-dev.in @@ -10,4 +10,5 @@ mypy playwright requests types-requests -whitenoise \ No newline at end of file +whitenoise +selectolax \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index 1cca60f1..18464dd5 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -91,6 +91,8 @@ pyyaml==6.0.1 # via pre-commit requests==2.32.3 # via -r requirements-dev.in +selectolax==0.3.21 + # via -r requirements-dev.in sqlparse==0.5.0 # via django tox==4.23.2 diff --git a/sampleproject/sampleproject/settings.py b/sampleproject/sampleproject/settings.py index f77b9c1e..c8bbb313 100644 --- a/sampleproject/sampleproject/settings.py +++ b/sampleproject/sampleproject/settings.py @@ -5,6 +5,8 @@ import sys from pathlib import Path from typing import List +from django_components import ComponentsSettings + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -44,6 +46,7 @@ MIDDLEWARE = [ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "django_components.middleware.ComponentDependencyMiddleware", ] ROOT_URLCONF = "sampleproject.urls" @@ -87,14 +90,14 @@ STATICFILES_FINDERS = [ WSGI_APPLICATION = "sampleproject.wsgi.application" -COMPONENTS = { - # "autodiscover": True, - "dirs": [BASE_DIR / "components"], - # "app_dirs": ["components"], - # "libraries": [], - # "template_cache_size": 128, - # "context_behavior": "isolated", # "django" | "isolated" -} +COMPONENTS = ComponentsSettings( + # autodiscover=True, + dirs=[BASE_DIR / "components"], + # app_dirs=["components"], + # libraries=[], + # template_cache_size=128, + # context_behavior="isolated", # "django" | "isolated" +) # Database diff --git a/sampleproject/sampleproject/urls.py b/sampleproject/sampleproject/urls.py index ff2faa2c..966f02c6 100644 --- a/sampleproject/sampleproject/urls.py +++ b/sampleproject/sampleproject/urls.py @@ -3,4 +3,5 @@ from django.urls import include, path urlpatterns = [ path("", include("calendarapp.urls")), path("", include("components.urls")), + path("", include("django_components.urls")), ] diff --git a/src/django_components/__init__.py b/src/django_components/__init__.py index 65f53b05..b076b6cf 100644 --- a/src/django_components/__init__.py +++ b/src/django_components/__init__.py @@ -1,49 +1,73 @@ -# flake8: noqa F401 """Main package for Django Components.""" -import django - # Public API +# NOTE: Middleware is exposed via django_components.middleware +# NOTE: Some of the documentation is generated based on these exports # isort: off -from django_components.app_settings import ContextBehavior as ContextBehavior -from django_components.autodiscover import ( - autodiscover as autodiscover, - import_libraries as import_libraries, -) -from django_components.component import ( - Component as Component, - ComponentView as ComponentView, -) +from django_components.app_settings import ContextBehavior, ComponentsSettings +from django_components.autodiscovery import autodiscover, import_libraries +from django_components.component import Component, ComponentVars, ComponentView from django_components.component_registry import ( - AlreadyRegistered as AlreadyRegistered, - ComponentRegistry as ComponentRegistry, - NotRegistered as NotRegistered, - RegistrySettings as RegistrySettings, - register as register, - registry as registry, -) -from django_components.components import DynamicComponent as DynamicComponent -from django_components.library import TagProtectedError as TagProtectedError -from django_components.slots import ( - SlotContent as SlotContent, - SlotFunc as SlotFunc, + AlreadyRegistered, + ComponentRegistry, + NotRegistered, + RegistrySettings, + register, + registry, ) +from django_components.components import DynamicComponent +from django_components.dependencies import render_dependencies +from django_components.library import TagProtectedError +from django_components.slots import SlotContent, Slot, SlotFunc, SlotRef, SlotResult from django_components.tag_formatter import ( - ComponentFormatter as ComponentFormatter, - ShorthandComponentFormatter as ShorthandComponentFormatter, - TagFormatterABC as TagFormatterABC, - TagResult as TagResult, - component_formatter as component_formatter, - component_shorthand_formatter as component_shorthand_formatter, + ComponentFormatter, + ShorthandComponentFormatter, + TagFormatterABC, + TagResult, + component_formatter, + component_shorthand_formatter, ) -from django_components.template import cached_template as cached_template +from django_components.template import cached_template import django_components.types as types -from django_components.types import ( - EmptyTuple as EmptyTuple, - EmptyDict as EmptyDict, -) +from django_components.util.loader import ComponentFileEntry, get_component_dirs, get_component_files +from django_components.util.types import EmptyTuple, EmptyDict # isort: on -if django.VERSION < (3, 2): - default_app_config = "django_components.apps.ComponentsConfig" + +__all__ = [ + "AlreadyRegistered", + "autodiscover", + "cached_template", + "ContextBehavior", + "ComponentsSettings", + "Component", + "ComponentFileEntry", + "ComponentFormatter", + "ComponentRegistry", + "ComponentVars", + "ComponentView", + "component_formatter", + "component_shorthand_formatter", + "DynamicComponent", + "EmptyTuple", + "EmptyDict", + "get_component_dirs", + "get_component_files", + "import_libraries", + "NotRegistered", + "register", + "registry", + "RegistrySettings", + "render_dependencies", + "ShorthandComponentFormatter", + "SlotContent", + "Slot", + "SlotFunc", + "SlotRef", + "SlotResult", + "TagFormatterABC", + "TagProtectedError", + "TagResult", + "types", +] diff --git a/src/django_components/app_settings.py b/src/django_components/app_settings.py index 29885759..948db6ac 100644 --- a/src/django_components/app_settings.py +++ b/src/django_components/app_settings.py @@ -1,24 +1,60 @@ import re +from dataclasses import dataclass from enum import Enum +from os import PathLike from pathlib import Path -from typing import TYPE_CHECKING, Dict, List, Tuple, Union +from typing import ( + TYPE_CHECKING, + Callable, + Generic, + List, + Literal, + NamedTuple, + Optional, + Sequence, + Tuple, + TypeVar, + Union, + cast, +) from django.conf import settings +from django_components.util.misc import default + if TYPE_CHECKING: from django_components.tag_formatter import TagFormatterABC +T = TypeVar("T") + + +ContextBehaviorType = Literal["django", "isolated"] + + class ContextBehavior(str, Enum): + """ + Configure how (and whether) the context is passed to the component fills + and what variables are available inside the [`{% fill %}`](../template_tags#fill) tags. + + Also see [Component context and scope](../../concepts/fundamentals/component_context_scope#context-behavior). + + **Options:** + + - `django`: With this setting, component fills behave as usual Django tags. + - `isolated`: This setting makes the component fills behave similar to Vue or React. + """ + DJANGO = "django" """ With this setting, component fills behave as usual Django tags. That is, they enrich the context, and pass it along. 1. Component fills use the context of the component they are within. - 2. Variables from `get_context_data` are available to the component fill. + 2. Variables from [`Component.get_context_data()`](../api#django_components.Component.get_context_data) + are available to the component fill. - Example: + **Example:** Given this template ```django @@ -30,13 +66,13 @@ class ContextBehavior(str, Enum): {% endwith %} ``` - and this context returned from the `get_context_data()` method - ```py + and this context returned from the `Component.get_context_data()` method + ```python { "my_var": 123 } ``` Then if component "my_comp" defines context - ```py + ```python { "my_var": 456 } ``` @@ -56,9 +92,10 @@ class ContextBehavior(str, Enum): ISOLATED = "isolated" """ This setting makes the component fills behave similar to Vue or React, where - the fills use EXCLUSIVELY the context variables defined in `get_context_data`. + the fills use EXCLUSIVELY the context variables defined in + [`Component.get_context_data()`](../api#django_components.Component.get_context_data). - Example: + **Example:** Given this template ```django @@ -71,12 +108,12 @@ class ContextBehavior(str, Enum): ``` and this context returned from the `get_context_data()` method - ```py + ```python { "my_var": 123 } ``` Then if component "my_comp" defines context - ```py + ```python { "my_var": 456 } ``` @@ -91,95 +128,565 @@ class ContextBehavior(str, Enum): """ -class AppSettings: +# This is the source of truth for the settings that are available. If the documentation +# or the defaults do NOT match this, they should be updated. +class ComponentsSettings(NamedTuple): + """ + Settings available for django_components. + + **Example:** + + ```python + COMPONENTS = ComponentsSettings( + autodiscover=False, + dirs = [BASE_DIR / "components"], + ) + ``` + """ + + autodiscover: Optional[bool] = None + """ + Toggle whether to run [autodiscovery](../../concepts/fundamentals/autodiscovery) at the Django server startup. + + Defaults to `True` + + ```python + COMPONENTS = ComponentsSettings( + autodiscover=False, + ) + ``` + """ + + dirs: Optional[Sequence[Union[str, PathLike, Tuple[str, str], Tuple[str, PathLike]]]] = None + """ + Specify the directories that contain your components. + + Defaults to `[Path(settings.BASE_DIR) / "components"]`. That is, the root `components/` app. + + Directories must be full paths, same as with + [STATICFILES_DIRS](https://docs.djangoproject.com/en/5.1/ref/settings/#std-setting-STATICFILES_DIRS). + + These locations are searched during [autodiscovery](../../concepts/fundamentals/autodiscovery), + or when you [define HTML, JS, or CSS as separate files](../../concepts/fundamentals/defining_js_css_html_files). + + ```python + COMPONENTS = ComponentsSettings( + dirs=[BASE_DIR / "components"], + ) + ``` + + Set to empty list to disable global components directories: + + ```python + COMPONENTS = ComponentsSettings( + dirs=[], + ) + ``` + """ + + app_dirs: Optional[Sequence[str]] = None + """ + Specify the app-level directories that contain your components. + + Defaults to `["components"]`. That is, for each Django app, we search `/components/` for components. + + The paths must be relative to app, e.g.: + + ```python + COMPONENTS = ComponentsSettings( + app_dirs=["my_comps"], + ) + ``` + + To search for `/my_comps/`. + + These locations are searched during [autodiscovery](../../concepts/fundamentals/autodiscovery), + or when you [define HTML, JS, or CSS as separate files](../../concepts/fundamentals/defining_js_css_html_files). + + Set to empty list to disable app-level components: + + ```python + COMPONENTS = ComponentsSettings( + app_dirs=[], + ) + ``` + """ + + context_behavior: Optional[ContextBehaviorType] = None + """ + Configure whether, inside a component template, you can use variables from the outside + ([`"django"`](../api#django_components.ContextBehavior.DJANGO)) + or not ([`"isolated"`](../api#django_components.ContextBehavior.ISOLATED)). + This also affects what variables are available inside the [`{% fill %}`](../template_tags#fill) + tags. + + Also see [Component context and scope](../../concepts/fundamentals/component_context_scope#context-behavior). + + Defaults to `"django"`. + + ```python + COMPONENTS = ComponentsSettings( + context_behavior="isolated", + ) + ``` + + > NOTE: `context_behavior` and `slot_context_behavior` options were merged in v0.70. + > + > If you are migrating from BEFORE v0.67, set `context_behavior` to `"django"`. + > From v0.67 to v0.78 (incl) the default value was `"isolated"`. + > + > For v0.79 and later, the default is again `"django"`. See the rationale for change + > [here](https://github.com/EmilStenstrom/django-components/issues/498). + """ + + dynamic_component_name: Optional[str] = None + """ + By default, the [dynamic component](../components#django_components.components.dynamic.DynamicComponent) + is registered under the name `"dynamic"`. + + In case of a conflict, you can use this setting to change the component name used for + the dynamic components. + + ```python + # settings.py + COMPONENTS = ComponentsSettings( + dynamic_component_name="my_dynamic", + ) + ``` + + After which you will be able to use the dynamic component with the new name: + + ```django + {% component "my_dynamic" is=table_comp data=table_data headers=table_headers %} + {% fill "pagination" %} + {% component "pagination" / %} + {% endfill %} + {% endcomponent %} + ``` + """ + + libraries: Optional[List[str]] = None + """ + Configure extra python modules that should be loaded. + + This may be useful if you are not using the [autodiscovery feature](../../concepts/fundamentals/autodiscovery), + or you need to load components from non-standard locations. Thus you can have + a structure of components that is independent from your apps. + + Expects a list of python module paths. Defaults to empty list. + + **Example:** + + ```python + COMPONENTS = ComponentsSettings( + libraries=[ + "mysite.components.forms", + "mysite.components.buttons", + "mysite.components.cards", + ], + ) + ``` + + This would be the equivalent of importing these modules from within Django's + [`AppConfig.ready()`](https://docs.djangoproject.com/en/5.1/ref/applications/#django.apps.AppConfig.ready): + + ```python + class MyAppConfig(AppConfig): + def ready(self): + import "mysite.components.forms" + import "mysite.components.buttons" + import "mysite.components.cards" + ``` + + # Manually loading libraries + + In the rare case that you need to manually trigger the import of libraries, you can use + the [`import_libraries()`](../api/#django_components.import_libraries) function: + + ```python + from django_components import import_libraries + + import_libraries() + ``` + """ + + multiline_tags: Optional[bool] = None + """ + Enable / disable + [multiline support for template tags](../../concepts/fundamentals/template_tag_syntax#multiline-tags). + If `True`, template tags like `{% component %}` or `{{ my_var }}` can span multiple lines. + + Defaults to `True`. + + Disable this setting if you are making custom modifications to Django's + regular expression for parsing templates at `django.template.base.tag_re`. + + ```python + COMPONENTS = ComponentsSettings( + multiline_tags=False, + ) + ``` + """ + + # TODO_REMOVE_IN_V1 + reload_on_template_change: Optional[bool] = None + """Deprecated. Use + [`COMPONENTS.reload_on_file_change`](../settings/#django_components.app_settings.ComponentsSettings.reload_on_file_change) + instead.""" # noqa: E501 + + reload_on_file_change: Optional[bool] = None + """ + This is relevant if you are using the project structure where + HTML, JS, CSS and Python are in separate files 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 component files. + + Django's native [live reload](https://stackoverflow.com/a/66023029/9788634) logic + handles only Python files and HTML template files. It does NOT reload when other + file types change or when template files are nested more than one level deep. + + The setting `reload_on_file_change` fixes this, reloading the dev server even when your component's + HTML, JS, or CSS changes. + + If `True`, django_components configures Django to reload when files inside + [`COMPONENTS.dirs`](../settings/#django_components.app_settings.ComponentsSettings.dirs) + or + [`COMPONENTS.app_dirs`](../settings/#django_components.app_settings.ComponentsSettings.app_dirs) + change. + + See [Reload dev server on component file changes](../../guides/setup/dev_server_setup/#reload-dev-server-on-component-file-changes). + + Defaults to `False`. + + !!! warning + + This setting should be enabled only for the dev environment! + """ # noqa: E501 + + static_files_allowed: Optional[List[Union[str, re.Pattern]]] = None + """ + A list of file extensions (including the leading dot) that define which files within + [`COMPONENTS.dirs`](../settings/#django_components.app_settings.ComponentsSettings.dirs) + or + [`COMPONENTS.app_dirs`](../settings/#django_components.app_settings.ComponentsSettings.app_dirs) + are treated as [static files](https://docs.djangoproject.com/en/5.1/howto/static-files/). + + If a file is matched against any of the patterns, it's considered a static file. Such files are collected + when running [`collectstatic`](https://docs.djangoproject.com/en/5.1/ref/contrib/staticfiles/#collectstatic), + and can be accessed under the + [static file endpoint](https://docs.djangoproject.com/en/5.1/ref/settings/#static-url). + + You can also pass in compiled regexes ([`re.Pattern`](https://docs.python.org/3/library/re.html#re.Pattern)) + for more advanced patterns. + + By default, JS, CSS, and common image and font file formats are considered static files: + + ```python + COMPONENTS = ComponentsSettings( + static_files_allowed=[ + ".css", + ".js", ".jsx", ".ts", ".tsx", + # Images + ".apng", ".png", ".avif", ".gif", ".jpg", + ".jpeg", ".jfif", ".pjpeg", ".pjp", ".svg", + ".webp", ".bmp", ".ico", ".cur", ".tif", ".tiff", + # Fonts + ".eot", ".ttf", ".woff", ".otf", ".svg", + ], + ) + ``` + + !!! warning + + Exposing your Python files can be a security vulnerability. + See [Security notes](../../overview/security_notes). + """ + + # TODO_REMOVE_IN_V1 + forbidden_static_files: Optional[List[Union[str, re.Pattern]]] = None + """Deprecated. Use + [`COMPONENTS.static_files_forbidden`](../settings/#django_components.app_settings.ComponentsSettings.static_files_forbidden) + instead.""" # noqa: E501 + + static_files_forbidden: Optional[List[Union[str, re.Pattern]]] = None + """ + A list of file extensions (including the leading dot) that define which files within + [`COMPONENTS.dirs`](../settings/#django_components.app_settings.ComponentsSettings.dirs) + or + [`COMPONENTS.app_dirs`](../settings/#django_components.app_settings.ComponentsSettings.app_dirs) + will NEVER be treated as [static files](https://docs.djangoproject.com/en/5.1/howto/static-files/). + + If a file is matched against any of the patterns, it will never be considered a static file, + even if the file matches a pattern in + [`static_files_allowed`](../settings/#django_components.app_settings.ComponentsSettings.static_files_allowed). + + Use this setting together with + [`static_files_allowed`](../settings/#django_components.app_settings.ComponentsSettings.static_files_allowed) + for a fine control over what file types will be exposed. + + You can also pass in compiled regexes ([`re.Pattern`](https://docs.python.org/3/library/re.html#re.Pattern)) + for more advanced patterns. + + By default, any HTML and Python are considered NOT static files: + + ```python + COMPONENTS = ComponentsSettings( + static_files_forbidden=[ + ".html", ".django", ".dj", ".tpl", + # Python files + ".py", ".pyc", + ], + ) + ``` + + !!! warning + + Exposing your Python files can be a security vulnerability. + See [Security notes](../../overview/security_notes). + """ + + tag_formatter: Optional[Union["TagFormatterABC", str]] = None + """ + Configure what syntax is used inside Django templates to render components. + See the [available tag formatters](../tag_formatters). + + Defaults to `"django_components.component_formatter"`. + + Learn more about [Customizing component tags with TagFormatter](../../concepts/advanced/tag_formatter). + + Can be set either as direct reference: + + ```python + from django_components import component_formatter + + COMPONENTS = ComponentsSettings( + "tag_formatter": component_formatter + ) + ``` + + Or as an import string; + + ```python + COMPONENTS = ComponentsSettings( + "tag_formatter": "django_components.component_formatter" + ) + ``` + + **Examples:** + + - `"django_components.component_formatter"` + + Set + + ```python + COMPONENTS = ComponentsSettings( + "tag_formatter": "django_components.component_formatter" + ) + ``` + + To write components like this: + + ```django + {% component "button" href="..." %} + Click me! + {% endcomponent %} + ``` + + - `django_components.component_shorthand_formatter` + + Set + + ```python + COMPONENTS = ComponentsSettings( + "tag_formatter": "django_components.component_shorthand_formatter" + ) + ``` + + To write components like this: + + ```django + {% button href="..." %} + Click me! + {% endbutton %} + ``` + """ + + template_cache_size: Optional[int] = None + """ + Configure the maximum amount of Django templates to be cached. + + Defaults to `128`. + + Each time a [Django template](https://docs.djangoproject.com/en/5.1/ref/templates/api/#django.template.Template) + is rendered, it is cached to a global in-memory cache (using Python's + [`lru_cache`](https://docs.python.org/3/library/functools.html#functools.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. + + By default the cache holds 128 component templates in memory, which should be enough for most sites. + But if you have a lot of components, or if you are overriding + [`Component.get_template()`](../api#django_components.Component.get_template) + to render many dynamic templates, you can increase this number. + + ```python + COMPONENTS = ComponentsSettings( + template_cache_size=256, + ) + ``` + + To remove the cache limit altogether and cache everything, set `template_cache_size` to `None`. + + ```python + COMPONENTS = ComponentsSettings( + template_cache_size=None, + ) + ``` + + If you want to add templates to the cache yourself, you can use + [`cached_template()`](../api/#django_components.cached_template): + + ```python + from django_components import cached_template + + cached_template("Variable: {{ variable }}") + + # You can optionally specify Template class, and other Template inputs: + class MyTemplate(Template): + pass + + cached_template( + "Variable: {{ variable }}", + template_cls=MyTemplate, + name=... + origin=... + engine=... + ) + ``` + """ + + +# NOTE: Some defaults depend on the Django settings, which may not yet be +# initialized at the time that these settings are generated. For such cases +# we define the defaults as a factory function, and use the `Dynamic` class to +# mark such fields. +@dataclass(frozen=True) +class Dynamic(Generic[T]): + getter: Callable[[], T] + + +# This is the source of truth for the settings defaults. If the documentation +# does NOT match it, the documentation should be updated. +# +# NOTE: Because we need to access Django settings to generate default dirs +# for `COMPONENTS.dirs`, we do it lazily. +# NOTE 2: We show the defaults in the documentation, together with the comments +# (except for the `Dynamic` instances and comments like `type: ignore`). +# So `fmt: off` turns off Black formatting and `snippet:defaults` allows +# us to extract the snippet from the file. +# +# fmt: off +# --snippet:defaults-- +defaults = ComponentsSettings( + autodiscover=True, + context_behavior=ContextBehavior.DJANGO.value, # "django" | "isolated" + # Root-level "components" dirs, e.g. `/path/to/proj/components/` + dirs=Dynamic(lambda: [Path(settings.BASE_DIR) / "components"]), # type: ignore[arg-type] + # App-level "components" dirs, e.g. `[app]/components/` + app_dirs=["components"], + dynamic_component_name="dynamic", + libraries=[], # E.g. ["mysite.components.forms", ...] + multiline_tags=True, + reload_on_file_change=False, + static_files_allowed=[ + ".css", + ".js", ".jsx", ".ts", ".tsx", + # Images + ".apng", ".png", ".avif", ".gif", ".jpg", + ".jpeg", ".jfif", ".pjpeg", ".pjp", ".svg", + ".webp", ".bmp", ".ico", ".cur", ".tif", ".tiff", + # Fonts + ".eot", ".ttf", ".woff", ".otf", ".svg", + ], + static_files_forbidden=[ + # See https://marketplace.visualstudio.com/items?itemName=junstyle.vscode-django-support + ".html", ".django", ".dj", ".tpl", + # Python files + ".py", ".pyc", + ], + tag_formatter="django_components.component_formatter", + template_cache_size=128, +) +# --endsnippet:defaults-- +# fmt: on + + +class InternalSettings: @property - def settings(self) -> Dict: - return getattr(settings, "COMPONENTS", {}) + def _settings(self) -> ComponentsSettings: + data = getattr(settings, "COMPONENTS", {}) + return ComponentsSettings(**data) if not isinstance(data, ComponentsSettings) else data @property def AUTODISCOVER(self) -> bool: - return self.settings.get("autodiscover", True) + return default(self._settings.autodiscover, cast(bool, defaults.autodiscover)) @property - def DIRS(self) -> List[Union[str, Tuple[str, str]]]: - base_dir_path = Path(settings.BASE_DIR) - return self.settings.get("dirs", [base_dir_path / "components"]) + def DIRS(self) -> Sequence[Union[str, PathLike, Tuple[str, str], Tuple[str, PathLike]]]: + # For DIRS we use a getter, because default values uses Django settings, + # which may not yet be initialized at the time these settings are generated. + default_fn = cast(Dynamic[Sequence[Union[str, Tuple[str, str]]]], defaults.dirs) + default_dirs = default_fn.getter() + return default(self._settings.dirs, default_dirs) @property - def APP_DIRS(self) -> List[str]: - return self.settings.get("app_dirs", ["components"]) + def APP_DIRS(self) -> Sequence[str]: + return default(self._settings.app_dirs, cast(List[str], defaults.app_dirs)) @property def DYNAMIC_COMPONENT_NAME(self) -> str: - return self.settings.get("dynamic_component_name", "dynamic") + return default(self._settings.dynamic_component_name, cast(str, defaults.dynamic_component_name)) @property def LIBRARIES(self) -> List[str]: - return self.settings.get("libraries", []) + return default(self._settings.libraries, cast(List[str], defaults.libraries)) @property def MULTILINE_TAGS(self) -> bool: - return self.settings.get("multiline_tags", True) + return default(self._settings.multiline_tags, cast(bool, defaults.multiline_tags)) @property - def RELOAD_ON_TEMPLATE_CHANGE(self) -> bool: - return self.settings.get("reload_on_template_change", False) + def RELOAD_ON_FILE_CHANGE(self) -> bool: + val = self._settings.reload_on_file_change + # TODO_REMOVE_IN_V1 + if val is None: + val = self._settings.reload_on_template_change + + return default(val, cast(bool, defaults.reload_on_file_change)) @property def TEMPLATE_CACHE_SIZE(self) -> int: - return self.settings.get("template_cache_size", 128) + return default(self._settings.template_cache_size, cast(int, defaults.template_cache_size)) @property - def STATIC_FILES_ALLOWED(self) -> List[Union[str, re.Pattern]]: - default_static_files = [ - ".css", - ".js", - # Images - See https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types#common_image_file_types # noqa: E501 - ".apng", - ".png", - ".avif", - ".gif", - ".jpg", - ".jpeg", - ".jfif", - ".pjpeg", - ".pjp", - ".svg", - ".webp", - ".bmp", - ".ico", - ".cur", - ".tif", - ".tiff", - # Fonts - See https://stackoverflow.com/q/30572159/9788634 - ".eot", - ".ttf", - ".woff", - ".otf", - ".svg", - ] - return self.settings.get("static_files_allowed", default_static_files) + def STATIC_FILES_ALLOWED(self) -> Sequence[Union[str, re.Pattern]]: + return default(self._settings.static_files_allowed, cast(List[str], defaults.static_files_allowed)) @property - def STATIC_FILES_FORBIDDEN(self) -> List[Union[str, re.Pattern]]: - default_forbidden_static_files = [ - ".html", - # See https://marketplace.visualstudio.com/items?itemName=junstyle.vscode-django-support - ".django", - ".dj", - ".tpl", - # Python files - ".py", - ".pyc", - ] - return self.settings.get("forbidden_static_files", default_forbidden_static_files) + def STATIC_FILES_FORBIDDEN(self) -> Sequence[Union[str, re.Pattern]]: + val = self._settings.static_files_forbidden + # TODO_REMOVE_IN_V1 + if val is None: + val = self._settings.forbidden_static_files + + return default(val, cast(List[str], defaults.static_files_forbidden)) @property def CONTEXT_BEHAVIOR(self) -> ContextBehavior: - raw_value = self.settings.get("context_behavior", ContextBehavior.DJANGO.value) + raw_value = cast(str, default(self._settings.context_behavior, defaults.context_behavior)) return self._validate_context_behavior(raw_value) - def _validate_context_behavior(self, raw_value: ContextBehavior) -> ContextBehavior: + def _validate_context_behavior(self, raw_value: Union[ContextBehavior, str]) -> ContextBehavior: try: return ContextBehavior(raw_value) except ValueError: @@ -188,7 +695,8 @@ class AppSettings: @property def TAG_FORMATTER(self) -> Union["TagFormatterABC", str]: - return self.settings.get("tag_formatter", "django_components.component_formatter") + tag_formatter = default(self._settings.tag_formatter, cast(str, defaults.tag_formatter)) + return cast(Union["TagFormatterABC", str], tag_formatter) -app_settings = AppSettings() +app_settings = InternalSettings() diff --git a/src/django_components/apps.py b/src/django_components/apps.py index 3fe26e34..f2fa6677 100644 --- a/src/django_components/apps.py +++ b/src/django_components/apps.py @@ -1,6 +1,9 @@ import re +from pathlib import Path +from typing import Any from django.apps import AppConfig +from django.utils.autoreload import file_changed, trigger_reload class ComponentsConfig(AppConfig): @@ -10,10 +13,9 @@ 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, get_dirs, import_libraries, search_dirs + from django_components.autodiscovery import autodiscover, import_libraries from django_components.component_registry import registry from django_components.components.dynamic import DynamicComponent - from django_components.utils import watch_files_for_autoreload # Import modules set in `COMPONENTS.libraries` setting import_libraries() @@ -21,13 +23,10 @@ class ComponentsConfig(AppConfig): if app_settings.AUTODISCOVER: autodiscover() - # Watch template files for changes, so Django dev server auto-reloads + # Auto-reload Django dev server when any component files changes # 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(include_apps=False) - component_filepaths = search_dirs(dirs, "**/*") - watch_files_for_autoreload(component_filepaths) + if app_settings.RELOAD_ON_FILE_CHANGE: + _watch_component_files_for_autoreload() # Allow tags to span multiple lines. This makes it easier to work with # components inside Django templates, allowing us syntax like: @@ -48,3 +47,19 @@ class ComponentsConfig(AppConfig): # Register the dynamic component under the name as given in settings registry.register(app_settings.DYNAMIC_COMPONENT_NAME, DynamicComponent) + + +# See https://github.com/EmilStenstrom/django-components/issues/586#issue-2472678136 +def _watch_component_files_for_autoreload() -> None: + from django_components.util.loader import get_component_dirs + + component_dirs = set(get_component_dirs()) + + def template_changed(sender: Any, file_path: Path, **kwargs: Any) -> None: + # Reload dev server if any of the files within `COMPONENTS.dirs` or `COMPONENTS.app_dirs` changed + for dir_path in file_path.parents: + if dir_path in component_dirs: + trigger_reload(file_path) + return + + file_changed.connect(template_changed) diff --git a/src/django_components/autodiscover.py b/src/django_components/autodiscover.py deleted file mode 100644 index e7e80c02..00000000 --- a/src/django_components/autodiscover.py +++ /dev/null @@ -1,149 +0,0 @@ -import glob -import importlib -import os -from pathlib import Path -from typing import Callable, List, Optional, Union - -from django.apps import apps -from django.conf import settings - -from django_components.app_settings import app_settings -from django_components.logger import logger -from django_components.template_loader import get_dirs - - -def autodiscover( - map_module: Optional[Callable[[str], str]] = None, -) -> List[str]: - """ - Search for component files and import them. Returns a list of module - paths of imported files. - - Autodiscover searches in the locations as defined by `Loader.get_dirs`. - - You can map the module paths with `map_module` function. This serves - as an escape hatch for when you need to use this function in tests. - """ - dirs = get_dirs(include_apps=False) - component_filepaths = search_dirs(dirs, "**/*.py") - logger.debug(f"Autodiscover found {len(component_filepaths)} files in component directories.") - - if hasattr(settings, "BASE_DIR") and settings.BASE_DIR: - project_root = str(settings.BASE_DIR) - else: - # Fallback for getting the root dir, see https://stackoverflow.com/a/16413955/9788634 - project_root = os.path.abspath(os.path.dirname(__name__)) - - modules: List[str] = [] - - # We handle dirs from `COMPONENTS.dirs` and from individual apps separately. - # - # Because for dirs in `COMPONENTS.dirs`, we assume they will be nested under `BASE_DIR`, - # and that `BASE_DIR` is the current working dir (CWD). So the path relatively to `BASE_DIR` - # is ALSO the python import path. - for filepath in component_filepaths: - module_path = _filepath_to_python_module(filepath, project_root, None) - # Ignore files starting with dot `.` or files in dirs that start with dot. - # - # If any of the parts of the path start with a dot, e.g. the filesystem path - # is `./abc/.def`, then this gets converted to python module as `abc..def` - # - # NOTE: This approach also ignores files: - # - with two dots in the middle (ab..cd.py) - # - an extra dot at the end (abcd..py) - # - files outside of the parent component (../abcd.py). - # But all these are NOT valid python modules so that's fine. - if ".." in module_path: - continue - - modules.append(module_path) - - # For for apps, the directories may be outside of the project, e.g. in case of third party - # apps. So we have to resolve the python import path relative to the package name / the root - # import path for the app. - # See https://github.com/EmilStenstrom/django-components/issues/669 - for conf in apps.get_app_configs(): - for app_dir in app_settings.APP_DIRS: - comps_path = Path(conf.path).joinpath(app_dir) - if not comps_path.exists(): - continue - app_component_filepaths = search_dirs([comps_path], "**/*.py") - for filepath in app_component_filepaths: - app_component_module = _filepath_to_python_module(filepath, conf.path, conf.name) - modules.append(app_component_module) - - return _import_modules(modules, map_module) - - -def import_libraries( - map_module: Optional[Callable[[str], str]] = None, -) -> List[str]: - """ - Import modules set in `COMPONENTS.libraries` setting. - - You can map the module paths with `map_module` function. This serves - as an escape hatch for when you need to use this function in tests. - """ - from django_components.app_settings import app_settings - - return _import_modules(app_settings.LIBRARIES, map_module) - - -def _import_modules( - modules: List[str], - map_module: Optional[Callable[[str], str]] = None, -) -> List[str]: - imported_modules: List[str] = [] - for module_name in modules: - if map_module: - module_name = map_module(module_name) - - # This imports the file and runs it's code. So if the file defines any - # django components, they will be registered. - logger.debug(f'Importing module "{module_name}"') - importlib.import_module(module_name) - imported_modules.append(module_name) - return imported_modules - - -def _filepath_to_python_module( - file_path: Union[Path, str], - root_fs_path: Union[str, Path], - root_module_path: Optional[str], -) -> str: - """ - Derive python import path from the filesystem path. - - Example: - - If project root is `/path/to/project` - - And file_path is `/path/to/project/app/components/mycomp.py` - - Then the path relative to project root is `app/components/mycomp.py` - - Which we then turn into python import path `app.components.mycomp` - """ - rel_path = os.path.relpath(file_path, start=root_fs_path) - rel_path_without_suffix = str(Path(rel_path).with_suffix("")) - - # NOTE: `Path` normalizes paths to use `/` as separator, while `os.path` - # uses `os.path.sep`. - sep = os.path.sep if os.path.sep in rel_path_without_suffix else "/" - module_name = rel_path_without_suffix.replace(sep, ".") - - # Combine with the base module path - full_module_name = f"{root_module_path}.{module_name}" if root_module_path else module_name - if full_module_name.endswith(".__init__"): - full_module_name = full_module_name[:-9] # Remove the trailing `.__init__ - - return full_module_name - - -def search_dirs(dirs: List[Path], search_glob: str) -> List[Path]: - """ - Search the directories for the given glob pattern. Glob search results are returned - as a flattened list. - """ - matched_files: List[Path] = [] - for directory in dirs: - for path in glob.iglob(str(Path(directory) / search_glob), recursive=True): - matched_files.append(Path(path)) - - return matched_files diff --git a/src/django_components/autodiscovery.py b/src/django_components/autodiscovery.py new file mode 100644 index 00000000..08b53316 --- /dev/null +++ b/src/django_components/autodiscovery.py @@ -0,0 +1,95 @@ +import importlib +from typing import Callable, List, Optional + +from django_components.util.loader import get_component_files +from django_components.util.logger import logger + + +def autodiscover( + map_module: Optional[Callable[[str], str]] = None, +) -> List[str]: + """ + Search for all python files in + [`COMPONENTS.dirs`](../settings#django_components.app_settings.ComponentsSettings.dirs) + and + [`COMPONENTS.app_dirs`](../settings#django_components.app_settings.ComponentsSettings.app_dirs) + and import them. + + See [Autodiscovery](../../concepts/fundamentals/autodiscovery). + + Args: + map_module (Callable[[str], str], optional): Map the module paths with `map_module` function.\ + This serves as an escape hatch for when you need to use this function in tests. + + Returns: + List[str]: A list of module paths of imported files. + + To get the same list of modules that `autodiscover()` would return, but without importing them, use + [`get_component_files()`](../api#django_components.get_component_files): + + ```python + from django_components import get_component_files + + modules = get_component_files(".py") + ``` + """ + modules = get_component_files(".py") + logger.debug(f"Autodiscover found {len(modules)} files in component directories.") + return _import_modules([entry.dot_path for entry in modules], map_module) + + +def import_libraries( + map_module: Optional[Callable[[str], str]] = None, +) -> List[str]: + """ + Import modules set in + [`COMPONENTS.libraries`](../settings#django_components.app_settings.ComponentsSettings.libraries) + setting. + + See [Autodiscovery](../../concepts/fundamentals/autodiscovery). + + Args: + map_module (Callable[[str], str], optional): Map the module paths with `map_module` function.\ + This serves as an escape hatch for when you need to use this function in tests. + + Returns: + List[str]: A list of module paths of imported files. + + **Examples:** + + Normal usage - load libraries after Django has loaded + ```python + from django_components import import_libraries + + class MyAppConfig(AppConfig): + def ready(self): + import_libraries() + ``` + + Potential usage in tests + ```python + from django_components import import_libraries + + import_libraries(lambda path: path.replace("tests.", "myapp.")) + ``` + """ + from django_components.app_settings import app_settings + + return _import_modules(app_settings.LIBRARIES, map_module) + + +def _import_modules( + modules: List[str], + map_module: Optional[Callable[[str], str]] = None, +) -> List[str]: + imported_modules: List[str] = [] + for module_name in modules: + if map_module: + module_name = map_module(module_name) + + # This imports the file and runs it's code. So if the file defines any + # django components, they will be registered. + logger.debug(f'Importing module "{module_name}"') + importlib.import_module(module_name) + imported_modules.append(module_name) + return imported_modules diff --git a/src/django_components/component.py b/src/django_components/component.py index 27b60dfa..46b5f52c 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -14,6 +14,7 @@ from typing import ( List, Literal, Mapping, + NamedTuple, Optional, Protocol, Tuple, @@ -31,7 +32,6 @@ from django.template.context import Context from django.template.loader import get_template from django.template.loader_tags import BLOCK_CONTEXT_KEY from django.utils.html import conditional_escape -from django.utils.safestring import SafeString, mark_safe from django.views import View from django_components.app_settings import ContextBehavior @@ -39,31 +39,31 @@ from django_components.component_media import ComponentMediaInput, MediaMeta from django_components.component_registry import ComponentRegistry from django_components.component_registry import registry as registry_ from django_components.context import ( - _FILLED_SLOTS_CONTENT_CONTEXT_KEY, + _COMPONENT_SLOT_CTX_CONTEXT_KEY, _REGISTRY_CONTEXT_KEY, _ROOT_CTX_CONTEXT_KEY, get_injected_context_var, make_isolated_context_copy, - prepare_context, ) +from django_components.dependencies import RenderType, cache_inlined_css, cache_inlined_js, postprocess_component_html from django_components.expression import Expression, RuntimeKwargs, safe_resolve_list -from django_components.logger import trace_msg -from django_components.middleware import is_dependency_middleware_active from django_components.node import BaseNode from django_components.slots import ( - DEFAULT_SLOT_KEY, - FillContent, - FillNode, + ComponentSlotContext, + Slot, SlotContent, + SlotFunc, + SlotIsFilled, SlotName, SlotRef, SlotResult, _nodelist_to_slot_render_func, - resolve_fill_nodes, - resolve_slots, + resolve_fills, ) from django_components.template import cached_template -from django_components.utils import gen_id, validate_typed_dict, validate_typed_tuple +from django_components.util.logger import trace_msg +from django_components.util.misc import gen_id +from django_components.util.validation import validate_typed_dict, validate_typed_tuple # TODO_REMOVE_IN_V1 - Users should use top-level import instead # isort: off @@ -75,14 +75,18 @@ from django_components.component_registry import registry as registry # NOQA # isort: on -RENDERED_COMMENT_TEMPLATE = "" COMP_ONLY_FLAG = "only" # Define TypeVars for args and kwargs ArgsType = TypeVar("ArgsType", bound=tuple, contravariant=True) KwargsType = TypeVar("KwargsType", bound=Mapping[str, Any], contravariant=True) -DataType = TypeVar("DataType", bound=Mapping[str, Any], covariant=True) SlotsType = TypeVar("SlotsType", bound=Mapping[SlotName, SlotContent]) +DataType = TypeVar("DataType", bound=Mapping[str, Any], covariant=True) +JsDataType = TypeVar("JsDataType", bound=Mapping[str, Any]) +CssDataType = TypeVar("CssDataType", bound=Mapping[str, Any]) + +# Rename, so we can use `type()` inside functions with kwrags of the same name +_type = type @dataclass(frozen=True) @@ -91,19 +95,53 @@ class RenderInput(Generic[ArgsType, KwargsType, SlotsType]): args: ArgsType kwargs: KwargsType slots: SlotsType - escape_slots_content: bool + type: RenderType + render_dependencies: bool @dataclass() class RenderStackItem(Generic[ArgsType, KwargsType, SlotsType]): input: RenderInput[ArgsType, KwargsType, SlotsType] - is_filled: Optional[Dict[str, bool]] + is_filled: Optional[SlotIsFilled] class ViewFn(Protocol): def __call__(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Any: ... # noqa: E704 +class ComponentVars(NamedTuple): + """ + Type for the variables available inside the component templates. + + All variables here are scoped under `component_vars.`, so e.g. attribute + `is_filled` on this class is accessible inside the template as: + + ```django + {{ component_vars.is_filled }} + ``` + """ + + is_filled: Dict[str, bool] + """ + Dictonary describing which component slots are filled (`True`) or are not (`False`). + + New in version 0.70 + + Use as `{{ component_vars.is_filled }}` + + Example: + + ```django + {# Render wrapping HTML only if the slot is defined #} + {% if component_vars.is_filled.my_slot %} +
+ {% slot "my_slot" / %} +
+ {% endif %} + ``` + """ + + class ComponentMeta(MediaMeta): def __new__(mcs, name: str, bases: Tuple[Type, ...], attrs: Dict[str, Any]) -> Type: # NOTE: Skip template/media file resolution when then Component class ITSELF @@ -149,10 +187,13 @@ class ComponentView(View, metaclass=ComponentViewMeta): self.component = component -class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=ComponentMeta): - # Either template_name or template must be set on subclass OR subclass must implement get_template() with - # non-null return. - _class_hash: ClassVar[int] +class Component( + Generic[ArgsType, KwargsType, SlotsType, DataType, JsDataType, CssDataType], + metaclass=ComponentMeta, +): + # ##################################### + # PUBLIC API (Configurable by users) + # ##################################### template_name: Optional[str] = None """ @@ -190,6 +231,9 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co """ return None + def get_context_data(self, *args: Any, **kwargs: Any) -> DataType: + return cast(DataType, {}) + js: Optional[str] = None """Inlined JS associated with this component.""" css: Optional[str] = None @@ -201,14 +245,17 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co NOTE: This field is generated from Component.Media class. """ media_class: Media = Media - response_class = HttpResponse - """This allows to configure what class is used to generate response from `render_to_response`""" - Media = ComponentMediaInput """Defines JS and CSS media files associated with this component.""" + response_class = HttpResponse + """This allows to configure what class is used to generate response from `render_to_response`""" View = ComponentView + # ##################################### + # PUBLIC API - HOOKS + # ##################################### + def on_render_before(self, context: Context, template: Template) -> None: """ Hook that runs just before the component's template is rendered. @@ -230,12 +277,17 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co """ pass + # ##################################### + # MISC + # ##################################### + + _class_hash: ClassVar[int] + def __init__( self, registered_name: Optional[str] = None, component_id: Optional[str] = None, outer_context: Optional[Context] = None, - fill_content: Optional[Dict[str, FillContent]] = None, registry: Optional[ComponentRegistry] = None, # noqa F811 ): # When user first instantiates the component class before calling @@ -255,12 +307,11 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co self.registered_name: Optional[str] = registered_name self.outer_context: Context = outer_context or Context() - self.fill_content = fill_content or {} self.component_id = component_id or gen_id() self.registry = registry or registry_ self._render_stack: Deque[RenderStackItem[ArgsType, KwargsType, SlotsType]] = deque() # None == uninitialized, False == No types, Tuple == types - self._types: Optional[Union[Tuple[Any, Any, Any, Any], Literal[False]]] = None + self._types: Optional[Union[Tuple[Any, Any, Any, Any, Any, Any], Literal[False]]] = None def __init_subclass__(cls, **kwargs: Any) -> None: cls._class_hash = hash(inspect.getfile(cls) + cls.__name__) @@ -283,7 +334,7 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co return self._render_stack[-1].input @property - def is_filled(self) -> Dict[str, bool]: + def is_filled(self) -> SlotIsFilled: """ Dictionary describing which slots have or have not been filled. @@ -304,9 +355,6 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co return ctx.is_filled - def get_context_data(self, *args: Any, **kwargs: Any) -> DataType: - return cast(DataType, {}) - # NOTE: When the template is taken from a file (AKA specified via `template_name`), # then we leverage Django's template caching. This means that the same instance # of Template is reused. This is important to keep in mind, because the implication @@ -358,32 +406,6 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co f"Either 'template_name' or 'template' must be set for Component {type(self).__name__}." ) - def render_dependencies(self) -> SafeString: - """Helper function to render all dependencies for a component.""" - dependencies = [] - - css_deps = self.render_css_dependencies() - if css_deps: - dependencies.append(css_deps) - - js_deps = self.render_js_dependencies() - if js_deps: - dependencies.append(js_deps) - - return mark_safe("\n".join(dependencies)) - - def render_css_dependencies(self) -> SafeString: - """Render only CSS dependencies available in the media class or provided as a string.""" - if self.css is not None: - return mark_safe(f"") - return mark_safe("\n".join(self.media.render_css())) - - def render_js_dependencies(self) -> SafeString: - """Render only JS dependencies available in the media class or provided as a string.""" - if self.js is not None: - return mark_safe(f"") - return mark_safe("\n".join(self.media.render_js())) - def inject(self, key: str, default: Optional[Any] = None) -> Any: """ Use this method to retrieve the data that was passed to a `{% provide %}` tag @@ -449,6 +471,10 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co # Allow the View class to access this component via `self.component` return comp.View.as_view(**initkwargs, component=comp) + # ##################################### + # RENDERING + # ##################################### + @classmethod def render_to_response( cls, @@ -457,6 +483,7 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co escape_slots_content: bool = True, args: Optional[ArgsType] = None, kwargs: Optional[KwargsType] = None, + type: RenderType = "document", *response_args: Any, **response_kwargs: Any, ) -> HttpResponse: @@ -481,6 +508,10 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co is rendered. The keys on the context can be accessed from within the template. - NOTE: In "isolated" mode, context is NOT accessible, and data MUST be passed via component's args and kwargs. + - `type` - Configure how to handle JS and CSS dependencies. + - `"document"` (default) - JS dependencies are inserted into `{% component_js_dependencies %}`, + or to the end of the `` tag. CSS dependencies are inserted into + `{% component_css_dependencies %}`, or the end of the `` tag. Any additional args and kwargs are passed to the `response_class`. @@ -509,6 +540,8 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co context=context, slots=slots, escape_slots_content=escape_slots_content, + type=type, + render_dependencies=True, ) return cls.response_class(content, *response_args, **response_kwargs) @@ -520,6 +553,8 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co kwargs: Optional[KwargsType] = None, slots: Optional[SlotsType] = None, escape_slots_content: bool = True, + type: RenderType = "document", + render_dependencies: bool = True, ) -> str: """ Render the component into a string. @@ -537,6 +572,11 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co is rendered. The keys on the context can be accessed from within the template. - NOTE: In "isolated" mode, context is NOT accessible, and data MUST be passed via component's args and kwargs. + - `type` - Configure how to handle JS and CSS dependencies. + - `"document"` (default) - JS dependencies are inserted into `{% component_js_dependencies %}`, + or to the end of the `` tag. CSS dependencies are inserted into + `{% component_css_dependencies %}`, or the end of the `` tag. + - `render_dependencies` - Set this to `False` if you want to insert the resulting HTML into another component. Example: ```py @@ -560,7 +600,7 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co else: comp = cls() - return comp._render(context, args, kwargs, slots, escape_slots_content) + return comp._render(context, args, kwargs, slots, escape_slots_content, type, render_dependencies) # This is the internal entrypoint for the render function def _render( @@ -570,11 +610,13 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co kwargs: Optional[KwargsType] = None, slots: Optional[SlotsType] = None, escape_slots_content: bool = True, + type: RenderType = "document", + render_dependencies: bool = True, ) -> str: try: - return self._render_impl(context, args, kwargs, slots, escape_slots_content) + return self._render_impl(context, args, kwargs, slots, escape_slots_content, type, render_dependencies) except Exception as err: - raise type(err)(f"An error occured while rendering component '{self.name}':\n{repr(err)}") from err + raise _type(err)(f"An error occured while rendering component '{self.name}':\n{repr(err)}") from err def _render_impl( self, @@ -583,20 +625,24 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co kwargs: Optional[KwargsType] = None, slots: Optional[SlotsType] = None, escape_slots_content: bool = True, + type: RenderType = "document", + render_dependencies: bool = True, ) -> str: - has_slots = slots is not None + # NOTE: We must run validation before we normalize the slots, because the normalization + # wraps them in functions. + self._validate_inputs(args or (), kwargs or {}, slots or {}) # Allow to provide no args/kwargs/slots/context args = cast(ArgsType, args or ()) kwargs = cast(KwargsType, kwargs or {}) - slots = cast(SlotsType, slots or {}) + slots_untyped = self._normalize_slot_fills(slots or {}, escape_slots_content) + slots = cast(SlotsType, slots_untyped) context = context or Context() # Allow to provide a dict instead of Context # NOTE: This if/else is important to avoid nested Contexts, # See https://github.com/EmilStenstrom/django-components/issues/414 context = context if isinstance(context, Context) else Context(context) - prepare_context(context, self.component_id) # By adding the current input to the stack, we temporarily allow users # to access the provided context, slots, etc. Also required so users can @@ -608,71 +654,72 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co slots=slots, args=args, kwargs=kwargs, - escape_slots_content=escape_slots_content, + type=type, + render_dependencies=render_dependencies, ), is_filled=None, ), ) - self._validate_inputs() - context_data = self.get_context_data(*args, **kwargs) - self._validate_outputs(context_data) + self._validate_outputs(data=context_data) + + # Process JS and CSS files + cache_inlined_js(self.__class__, self.js or "") + cache_inlined_css(self.__class__, self.css or "") with _prepare_template(self, context, context_data) as template: - # Support passing slots explicitly to `render` method - if has_slots: - fill_content = self._fills_from_slots_data( - slots, - escape_slots_content, - ) - else: - fill_content = self.fill_content - - _, resolved_fills = resolve_slots( - context, - template, - component_name=self.name, - fill_content=fill_content, - # Dynamic component has a special mark do it doesn't raise certain errors - is_dynamic_component=getattr(self, "_is_dynamic_component", False), - ) - - # Available slot fills - this is internal to us - updated_slots = { - **context.get(_FILLED_SLOTS_CONTENT_CONTEXT_KEY, {}), - **resolved_fills, - } - # For users, we expose boolean variables that they may check # to see if given slot was filled, e.g.: # `{% if variable > 8 and component_vars.is_filled.header %}` - slot_bools = {slot_fill.escaped_name: slot_fill.is_filled for slot_fill in resolved_fills.values()} - self._render_stack[-1].is_filled = slot_bools + is_filled = SlotIsFilled(slots_untyped) + self._render_stack[-1].is_filled = is_filled + + component_slot_ctx = ComponentSlotContext( + component_name=self.name, + template_name=template.name, + fills=slots_untyped, + is_dynamic_component=getattr(self, "_is_dynamic_component", False), + # This field will be modified from within `SlotNodes.render()`: + # - The `default_slot` will be set to the first slot that has the `default` attribute set. + # If multiple slots have the `default` attribute set, yet have different name, then + # we will raise an error. + default_slot=None, + ) with context.update( { # Private context fields _ROOT_CTX_CONTEXT_KEY: self.outer_context, - _FILLED_SLOTS_CONTENT_CONTEXT_KEY: updated_slots, + _COMPONENT_SLOT_CTX_CONTEXT_KEY: component_slot_ctx, _REGISTRY_CONTEXT_KEY: self.registry, # NOTE: Public API for variables accessible from within a component's template # See https://github.com/EmilStenstrom/django-components/issues/280#issuecomment-2081180940 - "component_vars": { - "is_filled": slot_bools, - }, + "component_vars": ComponentVars( + is_filled=is_filled, + ), } ): self.on_render_before(context, template) - rendered_component = template.render(context) - new_output = self.on_render_after(context, template, rendered_component) - rendered_component = new_output if new_output is not None else rendered_component + # Get the component's HTML + html_content = template.render(context) - if is_dependency_middleware_active(): - output = RENDERED_COMMENT_TEMPLATE.format(name=self.name) + rendered_component - else: - output = rendered_component + # After we've rendered the contents, we now know what slots were there, + # and thus we can validate that. + component_slot_ctx.post_render_validation() + + # Allow to optionally override/modify the rendered content + new_output = self.on_render_after(context, template, html_content) + html_content = new_output if new_output is not None else html_content + + output = postprocess_component_html( + component_cls=self.__class__, + component_id=self.component_id, + html_content=html_content, + type=type, + render_dependencies=render_dependencies, + ) # After rendering is done, remove the current state from the stack, which means # properties like `self.context` will no longer return the current state. @@ -680,51 +727,57 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co return output - def _fills_from_slots_data( + def _normalize_slot_fills( self, - slots_data: Mapping[SlotName, SlotContent], + fills: Mapping[SlotName, SlotContent], escape_content: bool = True, - ) -> Dict[SlotName, FillContent]: - """Fill component slots outside of template rendering.""" - slot_fills = {} - for slot_name, content in slots_data.items(): - if not callable(content): - content_func = _nodelist_to_slot_render_func( - NodeList([TextNode(conditional_escape(content) if escape_content else content)]) + ) -> Dict[SlotName, Slot]: + # Preprocess slots to escape content if `escape_content=True` + norm_fills = {} + + # NOTE: `gen_escaped_content_func` is defined as a separate function, instead of being inlined within + # the forloop, because the value the forloop variable points to changes with each loop iteration. + def gen_escaped_content_func(content: SlotFunc) -> Slot: + def content_fn(ctx: Context, slot_data: Dict, slot_ref: SlotRef) -> SlotResult: + rendered = content(ctx, slot_data, slot_ref) + return conditional_escape(rendered) if escape_content else rendered + + slot = Slot(content_func=cast(SlotFunc, content_fn)) + return slot + + for slot_name, content in fills.items(): + if content is None: + continue + elif not callable(content): + slot = _nodelist_to_slot_render_func( + slot_name, + NodeList([TextNode(conditional_escape(content) if escape_content else content)]), + data_var=None, + default_var=None, ) else: + slot = gen_escaped_content_func(content) - def content_func( # type: ignore[misc] - ctx: Context, - kwargs: Dict[str, Any], - slot_ref: SlotRef, - ) -> SlotResult: - rendered = content(ctx, kwargs, slot_ref) - return conditional_escape(rendered) if escape_content else rendered + norm_fills[slot_name] = slot - slot_fills[slot_name] = FillContent( - content_func=content_func, - slot_default_var=None, - slot_data_var=None, - ) - return slot_fills + return norm_fills - ###################### + # ##################################### # VALIDATION - ###################### + # ##################################### - def _get_types(self) -> Optional[Tuple[Any, Any, Any, Any]]: + def _get_types(self) -> Optional[Tuple[Any, Any, Any, Any, Any, Any]]: """ Extract the types passed to the Component class. So if a component subclasses Component class like so ```py - class MyComp(Component[MyArgs, MyKwargs, Any, MySlots]): + class MyComp(Component[MyArgs, MyKwargs, MySlots, MyData, MyJsData, MyCssData]): ... ``` - Then we want to extract the tuple (MyArgs, MyKwargs, Any, MySlots). + Then we want to extract the tuple (MyArgs, MyKwargs, MySlots, MyData, MyJsData, MyCssData). Returns `None` if types were not provided. That is, the class was subclassed as: @@ -770,35 +823,34 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co # If we got here, then we've found ourselves the typed Component class, e.g. # - # `Component(Tuple[int], MyKwargs, MySlots, Any)` + # `Component(Tuple[int], MyKwargs, MySlots, Any, Any, Any)` # # By accessing the __args__, we access individual types between the brackets, so # - # (Tuple[int], MyKwargs, MySlots, Any) - args_type, kwargs_type, data_type, slots_type = component_generics_base.__args__ + # (Tuple[int], MyKwargs, MySlots, Any, Any, Any) + args_type, kwargs_type, slots_type, data_type, js_data_type, css_data_type = component_generics_base.__args__ - self._types = args_type, kwargs_type, data_type, slots_type + self._types = args_type, kwargs_type, slots_type, data_type, js_data_type, css_data_type return self._types - def _validate_inputs(self) -> None: - + def _validate_inputs(self, args: Tuple, kwargs: Any, slots: Any) -> None: maybe_inputs = self._get_types() if maybe_inputs is None: return - args_type, kwargs_type, data_type, slots_type = maybe_inputs + args_type, kwargs_type, slots_type, data_type, js_data_type, css_data_type = maybe_inputs # Validate args - validate_typed_tuple(self.input.args, args_type, f"Component '{self.name}'", "positional argument") + validate_typed_tuple(args, args_type, f"Component '{self.name}'", "positional argument") # Validate kwargs - validate_typed_dict(self.input.kwargs, kwargs_type, f"Component '{self.name}'", "keyword argument") + validate_typed_dict(kwargs, kwargs_type, f"Component '{self.name}'", "keyword argument") # Validate slots - validate_typed_dict(self.input.slots, slots_type, f"Component '{self.name}'", "slot") + validate_typed_dict(slots, slots_type, f"Component '{self.name}'", "slot") def _validate_outputs(self, data: Any) -> None: maybe_inputs = self._get_types() if maybe_inputs is None: return - args_type, kwargs_type, data_type, slots_type = maybe_inputs + args_type, kwargs_type, slots_type, data_type, js_data_type, css_data_type = maybe_inputs # Validate data validate_typed_dict(data, data_type, f"Component '{self.name}'", "data") @@ -814,14 +866,13 @@ class ComponentNode(BaseNode): kwargs: RuntimeKwargs, registry: ComponentRegistry, # noqa F811 isolated_context: bool = False, - fill_nodes: Optional[List[FillNode]] = None, + nodelist: Optional[NodeList] = None, node_id: Optional[str] = None, ) -> None: - super().__init__(nodelist=NodeList(fill_nodes), args=args, kwargs=kwargs, node_id=node_id) + super().__init__(nodelist=nodelist or NodeList(), args=args, kwargs=kwargs, node_id=node_id) self.name = name self.isolated_context = isolated_context - self.fill_nodes = fill_nodes or [] self.registry = registry def __repr__(self) -> str: @@ -841,34 +892,27 @@ class ComponentNode(BaseNode): args = safe_resolve_list(context, self.args) kwargs = self.kwargs.resolve(context) - is_default_slot = len(self.fill_nodes) == 1 and self.fill_nodes[0].is_implicit - if is_default_slot: - fill_content: Dict[str, FillContent] = { - DEFAULT_SLOT_KEY: FillContent( - content_func=_nodelist_to_slot_render_func(self.fill_nodes[0].nodelist), - slot_data_var=None, - slot_default_var=None, - ), - } - else: - fill_content = resolve_fill_nodes(context, self.fill_nodes, self.name) + slot_fills = resolve_fills(context, self.nodelist, self.name) component: Component = component_cls( registered_name=self.name, outer_context=context, - fill_content=fill_content, component_id=self.node_id, registry=self.registry, ) # Prevent outer context from leaking into the template of the component - if self.isolated_context or self.registry.settings.CONTEXT_BEHAVIOR == ContextBehavior.ISOLATED: + if self.isolated_context or self.registry.settings.context_behavior == ContextBehavior.ISOLATED: context = make_isolated_context_copy(context) output = component._render( context=context, args=args, kwargs=kwargs, + slots=slot_fills, + # NOTE: When we render components inside the template via template tags, + # do NOT render deps, because this may be decided by outer component + render_dependencies=False, ) trace_msg("RENDR", "COMP", self.name, self.node_id, "...Done!") diff --git a/src/django_components/component_media.py b/src/django_components/component_media.py index 164e5f61..dc6d7091 100644 --- a/src/django_components/component_media.py +++ b/src/django_components/component_media.py @@ -6,8 +6,8 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, List, MutableMapping, Opt from django.forms.widgets import Media, MediaDefiningClass from django.utils.safestring import SafeData -from django_components.autodiscover import get_dirs -from django_components.logger import logger +from django_components.util.loader import get_component_dirs +from django_components.util.logger import logger if TYPE_CHECKING: from django_components.component import Component @@ -273,7 +273,7 @@ def _resolve_component_relative_files(attrs: MutableMapping) -> None: # Prepare all possible directories we need to check when searching for # component's template and media files - components_dirs = get_dirs() + components_dirs = get_component_dirs() # Get the directory where the component class is defined try: diff --git a/src/django_components/component_registry.py b/src/django_components/component_registry.py index c9c72768..027bca97 100644 --- a/src/django_components/component_registry.py +++ b/src/django_components/component_registry.py @@ -1,22 +1,40 @@ -from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Type, TypeVar, Union +from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Type, Union from django.template import Library -from django_components.app_settings import ContextBehavior, app_settings +from django_components.app_settings import ContextBehaviorType, app_settings from django_components.library import is_tag_protected, mark_protected_tags, register_tag_from_formatter from django_components.tag_formatter import TagFormatterABC, get_tag_formatter if TYPE_CHECKING: - from django_components.component import Component - -_TComp = TypeVar("_TComp", bound=Type["Component"]) + from django_components.component import ( + ArgsType, + Component, + CssDataType, + DataType, + JsDataType, + KwargsType, + SlotsType, + ) class AlreadyRegistered(Exception): + """ + Raised when you try to register a [Component](../api#django_components#Component), + but it's already registered with given + [ComponentRegistry](../api#django_components.ComponentRegistry). + """ + pass class NotRegistered(Exception): + """ + Raised when you try to access a [Component](../api#django_components#Component), + but it's NOT registered with given + [ComponentRegistry](../api#django_components.ComponentRegistry). + """ + pass @@ -35,13 +53,77 @@ class ComponentRegistryEntry(NamedTuple): class RegistrySettings(NamedTuple): - CONTEXT_BEHAVIOR: Optional[ContextBehavior] = None + """ + Configuration for a [`ComponentRegistry`](../api#django_components.ComponentRegistry). + + These settings define how the components registered with this registry will behave when rendered. + + ```python + from django_components import ComponentRegistry, RegistrySettings + + registry_settings = RegistrySettings( + context_behavior="django", + tag_formatter="django_components.component_shorthand_formatter", + ) + + registry = ComponentRegistry(settings=registry_settings) + ``` + """ + + context_behavior: Optional[ContextBehaviorType] = None + """ + Same as the global + [`COMPONENTS.context_behavior`](../settings#django_components.app_settings.ComponentsSettings.context_behavior) + setting, but for this registry. + + If omitted, defaults to the global + [`COMPONENTS.context_behavior`](../settings#django_components.app_settings.ComponentsSettings.context_behavior) + setting. + """ + + # TODO_REMOVE_IN_V1 + CONTEXT_BEHAVIOR: Optional[ContextBehaviorType] = None + """ + _Deprecated. Use `context_behavior` instead. Will be removed in v1._ + + Same as the global + [`COMPONENTS.context_behavior`](../settings#django_components.app_settings.ComponentsSettings.context_behavior) + setting, but for this registry. + + If omitted, defaults to the global + [`COMPONENTS.context_behavior`](../settings#django_components.app_settings.ComponentsSettings.context_behavior) + setting. + """ + + tag_formatter: Optional[Union["TagFormatterABC", str]] = None + """ + Same as the global + [`COMPONENTS.tag_formatter`](../settings#django_components.app_settings.ComponentsSettings.tag_formatter) + setting, but for this registry. + + If omitted, defaults to the global + [`COMPONENTS.tag_formatter`](../settings#django_components.app_settings.ComponentsSettings.tag_formatter) + setting. + """ + + # TODO_REMOVE_IN_V1 TAG_FORMATTER: Optional[Union["TagFormatterABC", str]] = None + """ + _Deprecated. Use `tag_formatter` instead. Will be removed in v1._ + + Same as the global + [`COMPONENTS.tag_formatter`](../settings#django_components.app_settings.ComponentsSettings.tag_formatter) + setting, but for this registry. + + If omitted, defaults to the global + [`COMPONENTS.tag_formatter`](../settings#django_components.app_settings.ComponentsSettings.tag_formatter) + setting. + """ class InternalRegistrySettings(NamedTuple): - CONTEXT_BEHAVIOR: ContextBehavior - TAG_FORMATTER: Union["TagFormatterABC", str] + context_behavior: ContextBehaviorType + tag_formatter: Union["TagFormatterABC", str] # We keep track of all registries that exist so that, when users want to @@ -52,21 +134,43 @@ all_registries: List["ComponentRegistry"] = [] class ComponentRegistry: """ - Manages which components can be used in the template tags. + Manages [components](../api#django_components.Component) and makes them available + in the template, by default as [`{% component %}`](../template_tags#component) + tags. - Each ComponentRegistry instance is associated with an instance - of Django's Library. So when you register or unregister a component - to/from a component registry, behind the scenes the registry - automatically adds/removes the component's template tag to/from - the Library. + ```django + {% component "my_comp" key=value %} + {% endcomponent %} + ``` - The Library instance can be set at instantiation. If omitted, then - the default Library instance from django_components is used. The - Library instance can be accessed under `library` attribute. + To enable a component to be used in a template, the component must be registered with a component registry. - Example: + When you register a component to a registry, behind the scenes the registry + automatically adds the component's template tag (e.g. `{% component %}` to + the [`Library`](https://docs.djangoproject.com/en/5.1/howto/custom-template-tags/#code-layout). + And the opposite happens when you unregister a component - the tag is removed. - ```py + See [Registering components](../../concepts/advanced/component_registry). + + Args: + library (Library, optional): Django\ + [`Library`](https://docs.djangoproject.com/en/5.1/howto/custom-template-tags/#code-layout)\ + associated with this registry. If omitted, the default Library instance from django_components is used. + settings (Union[RegistrySettings, Callable[[ComponentRegistry], RegistrySettings]], optional): Configure\ + how the components registered with this registry will behave when rendered.\ + See [`RegistrySettings`](../api#django_components.RegistrySettings). Can be either\ + a static value or a callable that returns the settings. If omitted, the settings from\ + [`COMPONENTS`](../settings#django_components.app_settings.ComponentsSettings) are used. + + **Notes:** + + - The default registry is available as [`django_components.registry`](../api#django_components.registry). + - The default registry is used when registering components with [`@register`](../api#django_components.register) + decorator. + + **Example:** + + ```python # Use with default Library registry = ComponentRegistry() @@ -81,6 +185,33 @@ class ComponentRegistry: registry.clear() registry.get() ``` + + # Using registry to share components + + You can use component registry for isolating or "packaging" components: + + 1. Create new instance of `ComponentRegistry` and Library: + ```django + my_comps = Library() + my_comps_reg = ComponentRegistry(library=my_comps) + ``` + + 2. Register components to the registry: + ```django + my_comps_reg.register("my_button", ButtonComponent) + my_comps_reg.register("my_card", CardComponent) + ``` + + 3. In your target project, load the Library associated with the registry: + ```django + {% load my_comps %} + ``` + + 4. Use the registered components in your templates: + ```django + {% component "button" %} + {% endcomponent %} + ``` """ def __init__( @@ -99,7 +230,8 @@ class ComponentRegistry: @property def library(self) -> Library: """ - The template tag library with which the component registry is associated. + The template tag [`Library`](https://docs.djangoproject.com/en/5.1/howto/custom-template-tags/#code-layout) + that is associated with the registry. """ # Lazily use the default library if none was passed if self._library is not None: @@ -118,6 +250,9 @@ class ComponentRegistry: @property def settings(self) -> InternalRegistrySettings: + """ + [Registry settings](../api#django_components.RegistrySettings) configured for this registry. + """ # This is run on subsequent calls if self._settings is not None: # NOTE: Registry's settings can be a function, so we always take @@ -136,10 +271,16 @@ class ComponentRegistry: else: settings_input = self._settings_input + if settings_input: + context_behavior = settings_input.context_behavior or settings_input.CONTEXT_BEHAVIOR + tag_formatter = settings_input.tag_formatter or settings_input.TAG_FORMATTER + else: + context_behavior = None + tag_formatter = None + return InternalRegistrySettings( - CONTEXT_BEHAVIOR=(settings_input and settings_input.CONTEXT_BEHAVIOR) - or app_settings.CONTEXT_BEHAVIOR, - TAG_FORMATTER=(settings_input and settings_input.TAG_FORMATTER) or app_settings.TAG_FORMATTER, + context_behavior=context_behavior or app_settings.CONTEXT_BEHAVIOR.value, + tag_formatter=tag_formatter or app_settings.TAG_FORMATTER, ) self._settings = get_settings @@ -149,19 +290,27 @@ class ComponentRegistry: def register(self, name: str, component: Type["Component"]) -> None: """ - Register a component with this registry under the given name. + Register a [`Component`](../api#django_components.Component) class + with this registry under the given name. A component MUST be registered before it can be used in a template such as: ```django - {% component "my_comp" %}{% endcomponent %} + {% component "my_comp" %} + {% endcomponent %} ``` - Raises `AlreadyRegistered` if a different component was already registered - under the same name. + Args: + name (str): The name under which the component will be registered. Required. + component (Type[Component]): The component class to register. Required. - Example: + **Raises:** - ```py + - [`AlreadyRegistered`](../exceptions#django_components.AlreadyRegistered) + if a different component was already registered under the same name. + + **Example:** + + ```python registry.register("button", ButtonComponent) ``` """ @@ -182,19 +331,22 @@ class ComponentRegistry: def unregister(self, name: str) -> None: """ - Unlinks a previously-registered component from the registry under the given name. + Unregister the [`Component`](../api#django_components.Component) class + that was registered under the given name. - Once a component is unregistered, it CANNOT be used in a template anymore. - Following would raise an error: - ```django - {% component "my_comp" %}{% endcomponent %} - ``` + Once a component is unregistered, it is no longer available in the templates. - Raises `NotRegistered` if the given name is not registered. + Args: + name (str): The name under which the component is registered. Required. - Example: + **Raises:** - ```py + - [`NotRegistered`](../exceptions#django_components.NotRegistered) + if the given name is not registered. + + **Example:** + + ```python # First register component registry.register("button", ButtonComponent) # Then unregister @@ -227,13 +379,23 @@ class ComponentRegistry: def get(self, name: str) -> Type["Component"]: """ - Retrieve a component class registered under the given name. + Retrieve a [`Component`](../api#django_components.Component) + class registered under the given name. - Raises `NotRegistered` if the given name is not registered. + Args: + name (str): The name under which the component was registered. Required. - Example: + Returns: + Type[Component]: The component class registered under the given name. - ```py + **Raises:** + + - [`NotRegistered`](../exceptions#django_components.NotRegistered) + if the given name is not registered. + + **Example:** + + ```python # First register component registry.register("button", ButtonComponent) # Then get @@ -248,11 +410,14 @@ class ComponentRegistry: def all(self) -> Dict[str, Type["Component"]]: """ - Retrieve all registered component classes. + Retrieve all registered [`Component`](../api#django_components.Component) classes. - Example: + Returns: + Dict[str, Type[Component]]: A dictionary of component names to component classes - ```py + **Example:** + + ```python # First register components registry.register("button", ButtonComponent) registry.register("card", CardComponent) @@ -273,7 +438,7 @@ class ComponentRegistry: Example: - ```py + ```python # First register components registry.register("button", ButtonComponent) registry.register("card", CardComponent) @@ -308,19 +473,25 @@ class ComponentRegistry: # This variable represents the global component registry registry: ComponentRegistry = ComponentRegistry() """ -The default and global component registry. Use this instance to directly -register or remove components: +The default and global [component registry](./#django_components.ComponentRegistry). +Use this instance to directly register or remove components: -```py +See [Registering components](../../concepts/advanced/component_registry). + +```python # Register components registry.register("button", ButtonComponent) registry.register("card", CardComponent) + # Get single registry.get("button") + # Get all registry.all() + # Unregister single registry.unregister("button") + # Unregister all registry.clear() ``` @@ -330,23 +501,43 @@ registry.clear() _the_registry = registry -def register(name: str, registry: Optional[ComponentRegistry] = None) -> Callable[[_TComp], _TComp]: +def register(name: str, registry: Optional[ComponentRegistry] = None) -> Callable[ + [Type["Component[ArgsType, KwargsType, SlotsType, DataType, JsDataType, CssDataType]"]], + Type["Component[ArgsType, KwargsType, SlotsType, DataType, JsDataType, CssDataType]"], +]: """ - Class decorator to register a component. + Class decorator for registering a [component](./#django_components.Component) + to a [component registry](./#django_components.ComponentRegistry). - Usage: + See [Registering components](../../concepts/advanced/component_registry). + + Args: + name (str): Registered name. This is the name by which the component will be accessed\ + from within a template when using the [`{% component %}`](../template_tags#component) tag. Required. + registry (ComponentRegistry, optional): Specify the [registry](./#django_components.ComponentRegistry)\ + to which to register this component. If omitted, component is registered to the default registry. + + Raises: + AlreadyRegistered: If there is already a component registered under the same name. + + **Examples**: + + ```python + from django_components import Component, register - ```py @register("my_component") class MyComponent(Component): ... ``` - Optionally specify which `ComponentRegistry` the component should be registered to by - setting the `registry` kwarg: + Specifing [`ComponentRegistry`](./#django_components.ComponentRegistry) the component + should be registered to by setting the `registry` kwarg: - ```py - my_lib = django.template.Library() + ```python + from django.template import Library + from django_components import Component, ComponentRegistry, register + + my_lib = Library() my_reg = ComponentRegistry(library=my_lib) @register("my_component", registry=my_reg) @@ -357,7 +548,9 @@ def register(name: str, registry: Optional[ComponentRegistry] = None) -> Callabl if registry is None: registry = _the_registry - def decorator(component: _TComp) -> _TComp: + def decorator( + component: Type["Component[ArgsType, KwargsType, SlotsType, DataType, JsDataType, CssDataType]"], + ) -> Type["Component[ArgsType, KwargsType, SlotsType, DataType, JsDataType, CssDataType]"]: registry.register(name=name, component=component) return component diff --git a/src/django_components/components/__init__.py b/src/django_components/components/__init__.py index 34433285..8a7a4e1b 100644 --- a/src/django_components/components/__init__.py +++ b/src/django_components/components/__init__.py @@ -1,3 +1,4 @@ -# flake8: noqa F401 +# NOTE: Components exported here are documented in +from django_components.components.dynamic import DynamicComponent -from django_components.components.dynamic import DynamicComponent as DynamicComponent +__all__ = ["DynamicComponent"] diff --git a/src/django_components/components/dynamic.py b/src/django_components/components/dynamic.py index 4ce37b06..b438676a 100644 --- a/src/django_components/components/dynamic.py +++ b/src/django_components/components/dynamic.py @@ -7,14 +7,82 @@ from django_components.component_registry import all_registries class DynamicComponent(Component): """ - Dynamic component - This component takes inputs and renders the outputs depending on the - `is` and `registry` arguments. + This component is given a registered name or a reference to another component, + and behaves as if the other component was in its place. - - `is` - required - The component class or registered name of the component that will be - rendered in this place. + The args, kwargs, and slot fills are all passed down to the underlying component. - - `registry` - optional - Specify the registry to search for the registered name. If omitted, - all registries are searched. + Args: + is (str | Type[Component]): Component that should be rendered. Either a registered name of a component, + or a [Component](../api#django_components.Component) class directly. Required. + registry (ComponentRegistry, optional): Specify the [registry](../api#django_components.ComponentRegistry)\ + to search for the registered name. If omitted, all registries are searched until the first match. + *args: Additional data passed to the component. + **kwargs: Additional data passed to the component. + + **Slots:** + + * Any slots, depending on the actual component. + + **Examples:** + + Django + ```django + {% component "dynamic" is=table_comp data=table_data headers=table_headers %} + {% fill "pagination" %} + {% component "pagination" / %} + {% endfill %} + {% endcomponent %} + ``` + + Python + ```py + from django_components import DynamicComponent + + DynamicComponent.render( + kwargs={ + "is": table_comp, + "data": table_data, + "headers": table_headers, + }, + slots={ + "pagination": PaginationComponent.render( + render_dependencies=False, + ), + }, + ) + ``` + + # Use cases + + Dynamic components are suitable if you are writing something like a form component. You may design + it such that users give you a list of input types, and you render components depending on the input types. + + While you could handle this with a series of if / else statements, that's not an extensible approach. + Instead, you can use the dynamic component in place of normal components. + + # Component name + + By default, the dynamic component is registered under the name `"dynamic"`. In case of a conflict, + you can set the + [`COMPONENTS.dynamic_component_name`](../settings#django_components.app_settings.ComponentsSettings.dynamic_component_name) + setting to change the name used for the dynamic components. + + ```py + # settings.py + COMPONENTS = ComponentsSettings( + dynamic_component_name="my_dynamic", + ) + ``` + + After which you will be able to use the dynamic component with the new name: + ```django + {% component "my_dynamic" is=table_comp data=table_data headers=table_headers %} + {% fill "pagination" %} + {% component "pagination" / %} + {% endfill %} + {% endcomponent %} + ``` """ _is_dynamic_component = True @@ -32,27 +100,30 @@ class DynamicComponent(Component): comp_class = self._resolve_component(comp_name_or_class, registry) + # NOTE: Slots are passed at component instantiation comp = comp_class( registered_name=self.registered_name, component_id=self.component_id, outer_context=self.outer_context, - fill_content=self.fill_content, registry=self.registry, ) output = comp.render( context=self.input.context, args=args, kwargs=kwargs, - escape_slots_content=self.input.escape_slots_content, + slots=self.input.slots, + # NOTE: Since we're accessing slots as `self.input.slots`, the content of slot functions + # was already escaped (if set so). + escape_slots_content=False, + type=self.input.type, + render_dependencies=self.input.render_dependencies, ) return { "output": output, } - template: types.django_html = """ - {{ output }} - """ + template: types.django_html = """{{ output }}""" def _resolve_component( self, diff --git a/src/django_components/context.py b/src/django_components/context.py index 288b5fe8..4a222569 100644 --- a/src/django_components/context.py +++ b/src/django_components/context.py @@ -10,34 +10,19 @@ from typing import Any, Dict, Optional from django.template import Context, TemplateSyntaxError -from django_components.utils import find_last_index +from django_components.util.misc import find_last_index -_FILLED_SLOTS_CONTENT_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS" +_COMPONENT_SLOT_CTX_CONTEXT_KEY = "_DJANGO_COMPONENTS_COMPONENT_SLOT_CTX" _ROOT_CTX_CONTEXT_KEY = "_DJANGO_COMPONENTS_ROOT_CTX" _REGISTRY_CONTEXT_KEY = "_DJANGO_COMPONENTS_REGISTRY" -_CURRENT_COMP_CONTEXT_KEY = "_DJANGO_COMPONENTS_CURRENT_COMP" _INJECT_CONTEXT_KEY_PREFIX = "_DJANGO_COMPONENTS_INJECT__" -def prepare_context( - context: Context, - component_id: str, -) -> None: - """Initialize the internal context state.""" - # Initialize mapping dicts within this rendering run. - # This is shared across the whole render chain, thus we set it only once. - if _FILLED_SLOTS_CONTENT_CONTEXT_KEY not in context: - context[_FILLED_SLOTS_CONTENT_CONTEXT_KEY] = {} - - set_component_id(context, component_id) - - def make_isolated_context_copy(context: Context) -> Context: context_copy = context.new() copy_forloop_context(context, context_copy) # Pass through our internal keys - context_copy[_FILLED_SLOTS_CONTENT_CONTEXT_KEY] = context.get(_FILLED_SLOTS_CONTENT_CONTEXT_KEY, {}) context_copy[_REGISTRY_CONTEXT_KEY] = context.get(_REGISTRY_CONTEXT_KEY, None) if _ROOT_CTX_CONTEXT_KEY in context: context_copy[_ROOT_CTX_CONTEXT_KEY] = context[_ROOT_CTX_CONTEXT_KEY] @@ -51,14 +36,6 @@ def make_isolated_context_copy(context: Context) -> Context: return context_copy -def set_component_id(context: Context, component_id: str) -> None: - """ - We use the Context object to pass down info on inside of which component - we are currently rendering. - """ - context[_CURRENT_COMP_CONTEXT_KEY] = component_id - - def copy_forloop_context(from_context: Context, to_context: Context) -> None: """Forward the info about the current loop""" # Note that the ForNode (which implements for loop behavior) does not diff --git a/src/django_components/dependencies.py b/src/django_components/dependencies.py new file mode 100644 index 00000000..8dcbe469 --- /dev/null +++ b/src/django_components/dependencies.py @@ -0,0 +1,793 @@ +"""All code related to management of component dependencies (JS and CSS scripts)""" + +import json +import re +import sys +from abc import ABC, abstractmethod +from functools import lru_cache +from hashlib import md5 +from typing import ( + TYPE_CHECKING, + Callable, + Dict, + Iterable, + List, + Literal, + NamedTuple, + Optional, + Set, + Tuple, + Type, + TypeVar, + Union, + cast, +) +from weakref import WeakValueDictionary + +from asgiref.sync import iscoroutinefunction, markcoroutinefunction +from django.forms import Media +from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed, HttpResponseNotFound, StreamingHttpResponse +from django.http.response import HttpResponseBase +from django.templatetags.static import static +from django.urls import path, reverse +from django.utils.decorators import sync_and_async_middleware +from django.utils.safestring import SafeString, mark_safe +from selectolax.lexbor import LexborHTMLParser + +import django_components.types as types +from django_components.util.html import parse_document_or_nodes, parse_multiroot_html, parse_node +from django_components.util.misc import escape_js_string_literal, get_import_path + +if TYPE_CHECKING: + from django_components.component import Component + + +ScriptType = Literal["css", "js"] +RenderType = Literal["document", "fragment"] + + +######################################################### +# 1. Cache the inlined component JS and CSS scripts, +# so they can be referenced and retrieved later via +# an ID. +######################################################### + + +class ComponentMediaCacheABC(ABC): + @abstractmethod + def get(self, key: str) -> Optional[str]: ... # noqa: #704 + + @abstractmethod + def has(self, key: str) -> bool: ... # noqa: #704 + + @abstractmethod + def set(self, key: str, value: str) -> None: ... # noqa: #704 + + +class InMemoryComponentMediaCache(ComponentMediaCacheABC): + def __init__(self) -> None: + self._data: Dict[str, str] = {} + + def get(self, key: str) -> Optional[str]: + return self._data.get(key, None) + + def has(self, key: str) -> bool: + return key in self._data + + def set(self, key: str, value: str) -> None: + self._data[key] = value + + +comp_media_cache = InMemoryComponentMediaCache() + + +# NOTE: Initially, we fetched components by their registered name, but that didn't work +# for multiple registries and unregistered components. +# +# To have unique identifiers that works across registries, we rely +# on component class' module import path (e.g. `path.to.my.MyComponent`). +# +# But we also don't want to expose the module import paths to the outside world, as +# that information could be potentially exploited. So, instead, each component is +# associated with a hash that's derived from its module import path, ensuring uniqueness, +# consistency and privacy. +# +# E.g. `path.to.my.secret.MyComponent` -> `MyComponent_ab01f32` +# +# The associations are defined as WeakValue map, so deleted components can be garbage +# collected and automatically deleted from the dict. +if sys.version_info < (3, 9): + comp_hash_mapping: WeakValueDictionary = WeakValueDictionary() +else: + comp_hash_mapping: WeakValueDictionary[str, Type["Component"]] = WeakValueDictionary() + + +# Convert Component class to something like `TableComp_a91d03` +@lru_cache(None) +def _hash_comp_cls(comp_cls: Type["Component"]) -> str: + full_name = get_import_path(comp_cls) + comp_cls_hash = md5(full_name.encode()).hexdigest()[0:6] + return comp_cls.__name__ + "_" + comp_cls_hash + + +def _gen_cache_key( + comp_cls_hash: str, + script_type: ScriptType, +) -> str: + return f"__components:{comp_cls_hash}:{script_type}" + + +def _is_script_in_cache( + comp_cls: Type["Component"], + script_type: ScriptType, +) -> bool: + comp_cls_hash = _hash_comp_cls(comp_cls) + cache_key = _gen_cache_key(comp_cls_hash, script_type) + return comp_media_cache.has(cache_key) + + +def _cache_script( + comp_cls: Type["Component"], + script: str, + script_type: ScriptType, +) -> None: + """ + Given a component and it's inlined JS or CSS, store the JS/CSS in a cache, + so it can be retrieved via URL endpoint. + """ + comp_cls_hash = _hash_comp_cls(comp_cls) + + # E.g. `__components:MyButton:js:df7c6d10` + if script_type in ("js", "css"): + cache_key = _gen_cache_key(comp_cls_hash, script_type) + else: + raise ValueError(f"Unexpected script_type '{script_type}'") + + # NOTE: By setting the script in the cache, we will be able to retrieve it + # via the endpoint, e.g. when we make a request to `/components/cache/MyComp_ab0c2d.js`. + comp_media_cache.set(cache_key, script.strip()) + + +def cache_inlined_js(comp_cls: Type["Component"], content: str) -> None: + if not _is_nonempty_str(comp_cls.js): + return + + # Prepare the script that's common to all instances of the same component + # E.g. `my_table.js` + if not _is_script_in_cache(comp_cls, "js"): + _cache_script( + comp_cls=comp_cls, + script=content, + script_type="js", + ) + + +def cache_inlined_css(comp_cls: Type["Component"], content: str) -> None: + if not _is_nonempty_str(comp_cls.js): + return + + # Prepare the script that's common to all instances of the same component + if not _is_script_in_cache(comp_cls, "css"): + # E.g. `my_table.css` + _cache_script( + comp_cls=comp_cls, + script=content, + script_type="css", + ) + + +######################################################### +# 2. Modify the HTML to use the same IDs defined in previous +# step for the inlined CSS and JS scripts, so the scripts +# can be applied to the correct HTML elements. And embed +# component + JS/CSS relationships as HTML comments. +######################################################### + + +class Dependencies(NamedTuple): + # NOTE: We pass around the component CLASS, so the dependencies logic is not + # dependent on ComponentRegistries + component_cls: Type["Component"] + component_id: str + + +def _insert_component_comment( + content: str, + deps: Dependencies, +) -> str: + """ + Given some textual content, prepend it with a short string that + will be used by the ComponentDependencyMiddleware to collect all + declared JS / CSS scripts. + """ + # Add components to the cache + comp_cls_hash = _hash_comp_cls(deps.component_cls) + comp_hash_mapping[comp_cls_hash] = deps.component_cls + + data = f"{comp_cls_hash},{deps.component_id}" + + # NOTE: It's important that we put the comment BEFORE the content, so we can + # use the order of comments to evaluate components' instance JS code in the correct order. + output = mark_safe(COMPONENT_DEPS_COMMENT.format(data=data)) + content + return output + + +# Anything and everything that needs to be done with a Component's HTML +# script in order to support running JS and CSS per-instance. +def postprocess_component_html( + component_cls: Type["Component"], + component_id: str, + html_content: str, + type: RenderType, + render_dependencies: bool, +) -> str: + # NOTE: To better understand the next section, consider this: + # + # We define and cache the component's JS and CSS at the same time as + # when we render the HTML. However, the resulting HTML MAY OR MAY NOT + # be used in another component. + # + # IF the component's HTML IS used in another component, and the other + # component want to render the JS or CSS dependencies (e.g. inside ), + # then it's only at that point when we want to access the data about + # which JS and CSS scripts is the component's HTML associated with. + # + # This happens AFTER the rendering context, so there's no Context to rely on. + # + # Hence, we store the info about associated JS and CSS right in the HTML itself. + # As an HTML comment ``. Thus, the inner component can be used as many times + # and in different components, and they will all know to fetch also JS and CSS of the + # inner components. + + # Mark the generated HTML so that we will know which JS and CSS + # scripts are associated with it. + output = _insert_component_comment( + html_content, + Dependencies( + component_cls=component_cls, + component_id=component_id, + ), + ) + + if render_dependencies: + output = _render_dependencies(output, type) + return output + + +######################################################### +# 3. Given a FINAL HTML composed of MANY components, +# process all the HTML dependency comments (created in +# previous step), obtaining ALL JS and CSS scripts +# required by this HTML document. And post-process them, +# so the scripts are either inlined into the HTML, or +# fetched when the HTML is loaded in the browser. +######################################################### + + +TContent = TypeVar("TContent", bound=Union[bytes, str]) + + +CSS_DEPENDENCY_PLACEHOLDER = '' +JS_DEPENDENCY_PLACEHOLDER = '' + +CSS_PLACEHOLDER_BYTES = bytes(CSS_DEPENDENCY_PLACEHOLDER, encoding="utf-8") +JS_PLACEHOLDER_BYTES = bytes(JS_DEPENDENCY_PLACEHOLDER, encoding="utf-8") + +COMPONENT_DEPS_COMMENT = "" +# E.g. `` +COMPONENT_COMMENT_REGEX = re.compile(rb"") +# E.g. `table,123` +SCRIPT_NAME_REGEX = re.compile(rb"^(?P[\w\-\./]+?),(?P[\w]+?)$") +PLACEHOLDER_REGEX = re.compile( + r"{css_placeholder}|{js_placeholder}".format( + css_placeholder=CSS_DEPENDENCY_PLACEHOLDER, + js_placeholder=JS_DEPENDENCY_PLACEHOLDER, + ).encode() +) + + +def render_dependencies(content: TContent, type: RenderType = "document") -> TContent: + """ + Given a string that contains parts that were rendered by components, + this function inserts all used JS and CSS. + + By default, the string is parsed as an HTML and: + - CSS is inserted at the end of `` (if present) + - JS is inserted at the end of `` (if present) + + If you used `{% component_js_dependencies %}` or `{% component_css_dependencies %}`, + then the JS and CSS will be inserted only at these locations. + + Example: + ```python + def my_view(request): + template = Template(''' + {% load components %} + + + + +

{{ table_name }}

+ {% component "table" name=table_name / %} + + + ''') + + html = template.render( + Context({ + "table_name": request.GET["name"], + }) + ) + + # This inserts components' JS and CSS + processed_html = render_dependencies(html) + + return HttpResponse(processed_html) + ``` + """ + is_safestring = isinstance(content, SafeString) + + if isinstance(content, str): + content_ = content.encode() + else: + content_ = cast(bytes, content) + + content_, js_dependencies, css_dependencies = _process_dep_declarations(content_, type) + + # Replace the placeholders with the actual content + did_find_js_placeholder = False + did_find_css_placeholder = False + + def on_replace_match(match: "re.Match[bytes]") -> bytes: + nonlocal did_find_css_placeholder + nonlocal did_find_js_placeholder + + if match[0] == CSS_PLACEHOLDER_BYTES: + replacement = css_dependencies + did_find_css_placeholder = True + elif match[0] == JS_PLACEHOLDER_BYTES: + replacement = js_dependencies + did_find_js_placeholder = True + else: + raise RuntimeError( + "Unexpected error: Regex for component dependencies processing" + f" matched unknown string '{match[0].decode()}'" + ) + return replacement + + content_ = PLACEHOLDER_REGEX.sub(on_replace_match, content_) + + # By default, if user didn't specify any `{% component_dependencies %}`, + # then try to insert the JS scripts at the end of and CSS sheets at the end + # of + if type == "document" and (not did_find_js_placeholder or not did_find_css_placeholder): + tree = parse_document_or_nodes(content_.decode()) + + if isinstance(tree, LexborHTMLParser): + did_modify_html = False + + if not did_find_css_placeholder and tree.head: + css_elems = parse_multiroot_html(css_dependencies.decode()) + for css_elem in css_elems: + tree.head.insert_child(css_elem) # type: ignore # TODO: Update to selectolax 0.3.25 + did_modify_html = True + + if not did_find_js_placeholder and tree.body: + js_elems = parse_multiroot_html(js_dependencies.decode()) + for js_elem in js_elems: + tree.body.insert_child(js_elem) # type: ignore # TODO: Update to selectolax 0.3.25 + did_modify_html = True + + transformed = cast(str, tree.html) + if did_modify_html: + content_ = transformed.encode() + + # Return the same type as we were given + output = content_.decode() if isinstance(content, str) else content_ + output = mark_safe(output) if is_safestring else output + return cast(TContent, output) + + +# Renamed so we can access use this function where there's kwarg of the same name +_render_dependencies = render_dependencies + + +# Overview of this function: +# 1. We extract all HTML comments like ``. +# 2. We look up the corresponding component classes +# 3. For each component class we get the component's inlined JS and CSS, +# and the JS and CSS from `Media.js/css` +# 4. We add our client-side JS logic into the mix (`django_components/django_components.min.js`) +# - For fragments, we would skip this step. +# 5. For all the above JS and CSS, we figure out which JS / CSS needs to be inserted directly +# into the HTML, and which can be loaded with the client-side manager. +# - Components' inlined JS is inserted directly into the HTML as `") + elif script_type == "css": + script = mark_safe(f"") + return script + + +def _gen_exec_script( + to_load_js_tags: List[str], + to_load_css_tags: List[str], + loaded_js_urls: List[str], + loaded_css_urls: List[str], +) -> str: + # Generate JS expression like so: + # ```js + # Promise.all([ + # Components.manager.loadScript("js", ''), + # Components.manager.loadScript("js", ''), + # Components.manager.loadScript("css", ''), + # ]); + # ``` + # + # or + # + # ```js + # Components.manager.markScriptLoaded("css", "/abc/def1.css"), + # Components.manager.markScriptLoaded("css", "/abc/def2.css"), + # Components.manager.markScriptLoaded("js", "/abc/def3.js"), + # ``` + # + # NOTE: It would be better to pass only the URL itself for `loadScript`, instead of a whole tag. + # But because we allow users to specify the Media class, and thus users can + # configure how the `` or `" + return exec_script + + +def _escape_js(js: str, eval: bool = True) -> str: + escaped_js = escape_js_string_literal(js) + # `unescapeJs` is the function we call in the browser to parse the escaped JS + escaped_js = f"Components.unescapeJs(`{escaped_js}`)" + return f"eval({escaped_js})" if eval else escaped_js + + +######################################################### +# 4. Endpoints for fetching the JS / CSS scripts from within +# the browser, as defined from previous steps. +######################################################### + + +CACHE_ENDPOINT_NAME = "components_cached_script" +_CONTENT_TYPES = {"js": "text/javascript", "css": "text/css"} + + +def _get_content_types(script_type: ScriptType) -> str: + if script_type not in _CONTENT_TYPES: + raise ValueError(f"Unknown script_type '{script_type}'") + + return _CONTENT_TYPES[script_type] + + +def cached_script_view( + req: HttpRequest, + comp_cls_hash: str, + script_type: ScriptType, +) -> HttpResponse: + if req.method != "GET": + return HttpResponseNotAllowed(["GET"]) + + # Otherwise check if the file is among the dynamically generated files in the cache + cache_key = _gen_cache_key(comp_cls_hash, script_type) + script = comp_media_cache.get(cache_key) + + if script is None: + return HttpResponseNotFound() + + content_type = _get_content_types(script_type) + return HttpResponse(content=script, content_type=content_type) + + +urlpatterns = [ + # E.g. `/components/cache/table.js/` + path("cache/./", cached_script_view, name=CACHE_ENDPOINT_NAME), +] + + +######################################################### +# 5. Middleware that automatically applies the dependency- +# aggregating logic on all HTML responses. +######################################################### + + +@sync_and_async_middleware +class ComponentDependencyMiddleware: + """ + Middleware that inserts CSS/JS dependencies for all rendered + components at points marked with template tags. + """ + + def __init__(self, get_response: "Callable[[HttpRequest], HttpResponse]") -> None: + self.get_response = get_response + + # NOTE: Required to work with async + if iscoroutinefunction(self.get_response): + markcoroutinefunction(self) + + def __call__(self, request: HttpRequest) -> HttpResponseBase: + if iscoroutinefunction(self): + return self.__acall__(request) + + response = self.get_response(request) + response = self.process_response(response) + return response + + # NOTE: Required to work with async + async def __acall__(self, request: HttpRequest) -> HttpResponseBase: + response = await self.get_response(request) + response = self.process_response(response) + return response + + def process_response(self, response: HttpResponse) -> HttpResponse: + if not isinstance(response, StreamingHttpResponse) and response.get("Content-Type", "").startswith( + "text/html" + ): + response.content = render_dependencies(response.content, type="document") + + return response diff --git a/src/django_components/finders.py b/src/django_components/finders.py index 71262749..06029459 100644 --- a/src/django_components/finders.py +++ b/src/django_components/finders.py @@ -9,8 +9,8 @@ from django.core.files.storage import FileSystemStorage from django.utils._os import safe_join from django_components.app_settings import app_settings -from django_components.template_loader import get_dirs -from django_components.utils import any_regex_match, no_regex_match +from django_components.util.loader import get_component_dirs +from django_components.util.misc import any_regex_match, no_regex_match # To keep track on which directories the finder has searched the static files. searched_locations = [] @@ -29,12 +29,12 @@ class ComponentsFileSystemFinder(BaseFinder): Differences: - This finder uses `COMPONENTS.dirs` setting to locate files instead of `STATICFILES_DIRS`. - Whether a file within `COMPONENTS.dirs` is considered a STATIC file is configured - by `COMPONENTS.static_files_allowed` and `COMPONENTS.forbidden_static_files`. + by `COMPONENTS.static_files_allowed` and `COMPONENTS.static_files_forbidden`. - If `COMPONENTS.dirs` is not set, defaults to `settings.BASE_DIR / "components"` """ def __init__(self, app_names: Any = None, *args: Any, **kwargs: Any) -> None: - component_dirs = [str(p) for p in get_dirs()] + component_dirs = [str(p) for p in get_component_dirs()] # NOTE: The rest of the __init__ is the same as `django.contrib.staticfiles.finders.FileSystemFinder`, # but using our locations instead of STATICFILES_DIRS. diff --git a/src/django_components/library.py b/src/django_components/library.py index 54dc0aac..70ae94eb 100644 --- a/src/django_components/library.py +++ b/src/django_components/library.py @@ -12,11 +12,36 @@ if TYPE_CHECKING: class TagProtectedError(Exception): + """ + The way the [`TagFormatter`](../../concepts/advanced/tag_formatter) works is that, + based on which start and end tags are used for rendering components, + the [`ComponentRegistry`](../api#django_components.ComponentRegistry) behind the scenes + [un-/registers the template tags](https://docs.djangoproject.com/en/5.1/howto/custom-template-tags/#registering-the-tag) + with the associated instance of Django's + [`Library`](https://docs.djangoproject.com/en/5.1/howto/custom-template-tags/#code-layout). + + In other words, if I have registered a component `"table"`, and I use the shorthand + syntax: + + ```django + {% table ... %} + {% endtable %} + ``` + + Then [`ComponentRegistry`](../api#django_components.ComponentRegistry) + registers the tag `table` onto the Django's Library instance. + + However, that means that if we registered a component `"slot"`, then we would overwrite + the [`{% slot %}`](../template_tags#slot) tag from django_components. + + Thus, this exception is raised when a component is attempted to be registered under + a forbidden name, such that it would overwrite one of django_component's own template tags. + """ # noqa: E501 + pass PROTECTED_TAGS = [ - "component_dependencies", "component_css_dependencies", "component_js_dependencies", "fill", diff --git a/src/django_components/management/commands/startcomponent.py b/src/django_components/management/commands/startcomponent.py index fb6d6003..a4a43267 100644 --- a/src/django_components/management/commands/startcomponent.py +++ b/src/django_components/management/commands/startcomponent.py @@ -7,49 +7,117 @@ from django.core.management.base import BaseCommand, CommandError, CommandParser class Command(BaseCommand): - help = "Creates a new component" + """ + ### Management Command Usage + + To use the command, run the following command in your terminal: + + ```bash + python manage.py startcomponent --path --js --css --template --force --verbose --dry-run + ``` + + Replace ``, ``, ``, ``, and `` with your desired values. + + ### Management Command Examples + + Here are some examples of how you can use the command: + + #### Creating a Component with Default Settings + + To create a component with the default settings, you only need to provide the name of the component: + + ```bash + python manage.py startcomponent my_component + ``` + + This will create a new component named `my_component` in the `components` directory of your Django project. The JavaScript, CSS, and template files will be named `script.js`, `style.css`, and `template.html`, respectively. + + #### Creating a Component with Custom Settings + + You can also create a component with custom settings by providing additional arguments: + + ```bash + python manage.py startcomponent new_component --path my_components --js my_script.js --css my_style.css --template my_template.html + ``` + + This will create a new component named `new_component` in the `my_components` directory. The JavaScript, CSS, and template files will be named `my_script.js`, `my_style.css`, and `my_template.html`, respectively. + + #### Overwriting an Existing Component + + If you want to overwrite an existing component, you can use the `--force` option: + + ```bash + python manage.py startcomponent my_component --force + ``` + + This will overwrite the existing `my_component` if it exists. + + #### Simulating Component Creation + + If you want to simulate the creation of a component without actually creating any files, you can use the `--dry-run` option: + + ```bash + python manage.py startcomponent my_component --dry-run + ``` + + This will simulate the creation of `my_component` without creating any files. + """ # noqa: E501 + + help = "Create a new django component." def add_arguments(self, parser: CommandParser) -> None: - parser.add_argument("name", type=str, help="The name of the component to create") + parser.add_argument( + "name", + type=str, + help="The name of the component to create. This is a required argument.", + ) parser.add_argument( "--path", type=str, - help="The path to the components directory", + help=( + "The path to the component's directory. This is an optional argument. If not provided, " + "the command will use the `COMPONENTS.dirs` setting from your Django settings." + ), default=None, ) parser.add_argument( "--js", type=str, - help="The name of the javascript file", + help="The name of the JavaScript file. This is an optional argument. The default value is `script.js`.", default="script.js", ) parser.add_argument( "--css", type=str, - help="The name of the style file", + help="The name of the CSS file. This is an optional argument. The default value is `style.css`.", default="style.css", ) parser.add_argument( "--template", type=str, - help="The name of the template file", + help="The name of the template file. This is an optional argument. The default value is `template.html`.", default="template.html", ) parser.add_argument( "--force", action="store_true", - help="Overwrite existing files if they exist", + help="This option allows you to overwrite existing files if they exist. This is an optional argument.", ) parser.add_argument( "--verbose", action="store_true", - help="Print additional information during component creation", + help=( + "This option allows the command to print additional information during component " + "creation. This is an optional argument." + ), ) parser.add_argument( "--dry-run", action="store_true", - help="Simulate component creation without actually creating any files", - default=False, + help=( + "This option allows you to simulate component creation without actually creating any files. " + "This is an optional argument. The default value is `False`." + ), ) def handle(self, *args: Any, **kwargs: Any) -> None: diff --git a/src/django_components/middleware.py b/src/django_components/middleware.py index 44fc32e8..b18e2e41 100644 --- a/src/django_components/middleware.py +++ b/src/django_components/middleware.py @@ -1,109 +1,4 @@ -import re -from collections.abc import Callable -from typing import TYPE_CHECKING, Iterable +# These middlewares are part of public API +from django_components.dependencies import ComponentDependencyMiddleware -from asgiref.sync import iscoroutinefunction, markcoroutinefunction -from django.conf import settings -from django.forms import Media -from django.http import HttpRequest, HttpResponse, StreamingHttpResponse -from django.http.response import HttpResponseBase -from django.utils.decorators import sync_and_async_middleware - -from django_components.component_registry import registry - -if TYPE_CHECKING: - from django_components.component import Component - -RENDERED_COMPONENTS_CONTEXT_KEY = "_COMPONENT_DEPENDENCIES" -CSS_DEPENDENCY_PLACEHOLDER = '' -JS_DEPENDENCY_PLACEHOLDER = '' - -SCRIPT_TAG_REGEX = re.compile("[\w\-/]+?) -->") -PLACEHOLDER_REGEX = re.compile( - rb"" - rb'|' - rb'|' -) - - -@sync_and_async_middleware -class ComponentDependencyMiddleware: - """Middleware that inserts CSS/JS dependencies for all rendered components at points marked with template tags.""" - - dependency_regex = COMPONENT_COMMENT_REGEX - - def __init__(self, get_response: "Callable[[HttpRequest], HttpResponse]") -> None: - self.get_response = get_response - - if iscoroutinefunction(self.get_response): - markcoroutinefunction(self) - - def __call__(self, request: HttpRequest) -> HttpResponseBase: - - if iscoroutinefunction(self): - return self.__acall__(request) - - response = self.get_response(request) - response = self.process_response(response) - return response - - async def __acall__(self, request: HttpRequest) -> HttpResponseBase: - - response = await self.get_response(request) - response = self.process_response(response) - return response - - def process_response(self, response: HttpResponse) -> HttpResponse: - if ( - getattr(settings, "COMPONENTS", {}).get("RENDER_DEPENDENCIES", False) - and not isinstance(response, StreamingHttpResponse) - and response.get("Content-Type", "").startswith("text/html") - ): - response.content = process_response_content(response.content) - - return response - - -def process_response_content(content: bytes) -> bytes: - component_names_seen = {match.group("name") for match in COMPONENT_COMMENT_REGEX.finditer(content)} - all_components = [registry.get(name.decode("utf-8"))("") for name in component_names_seen] - all_media = join_media(all_components) - js_dependencies = b"".join(media.encode("utf-8") for media in all_media.render_js()) - css_dependencies = b"".join(media.encode("utf-8") for media in all_media.render_css()) - return PLACEHOLDER_REGEX.sub(DependencyReplacer(css_dependencies, js_dependencies), content) - - -def add_module_attribute_to_scripts(scripts: str) -> str: - return re.sub(SCRIPT_TAG_REGEX, ' + + ``` + + But this has a number of issues: + + - The JS scripts would run for each instance of the component. + - Bloating of the HTML file, as each inlined JS or CSS would be included fully for each component. + - While this sound OK, this could really bloat the HTML files if we used a UI component library for the basic building blocks like buttons, lists, cards, etc. + +## Flow + +So the solution should address all the points above. To achieve that, we manage the JS / CSS dependencies ourselves in the browser. So when a full HTML document is loaded, we keep track of which JS and CSS have been loaded. And when an HTML fragment is inserted, we check which JS / CSS dependencies it has, and load only those that have NOT been loaded yet. + +This is how we achieve that: + +1. When a component is rendered, it inserts an HTML comment containing metadata about the rendered component. + + So a template like this + + ```django + {% load component_tags %} +
+ {% component "my_table" / %} +
+ {% component "button" %} + Click me! + {% endcomponent %} + ``` + + May actually render: + + ```html +
+ + + ... +
+
+ + + ``` + + Each `` comment includes comma-separated data - a unique hash for the component class, e.g. `my_table_10bc2c`, and the component ID, e.g. `c020ad`. + + This way, we or the user can freely pass the rendered around or transform it, treating it as a string to add / remove / replace bits. As long as the `` comments remain in the rendered string, we will be able to deduce which JS and CSS dependencies the component needs. + +2. Post-process the rendered HTML, extracting the `` comments, and instead inserting the corresponding JS and CSS dependencies. + + If we dealt only with JS, then we could get away with processing the `` comments on the client (browser). However, the CSS needs to be processed still on the server, so the browser receives CSS styles already inserted as `", + rendered, ) - - def test_html_and_js(self): - class HTMLJSComponent(Component): - template = "
Content
" - js = "console.log('HTML and JS only');" - - comp = HTMLJSComponent("html_js_component") - self.assertHTMLEqual( - comp.render(Context({})), - "
Content
", - ) - self.assertHTMLEqual( - comp.render_js_dependencies(), - "", - ) - - def test_html_inline_and_css_js_files(self): - class HTMLStringFileCSSJSComponent(Component): - template = "
Content
" - - class Media: - css = "path/to/style.css" - js = "path/to/script.js" - - comp = HTMLStringFileCSSJSComponent("html_string_file_css_js_component") - self.assertHTMLEqual( - comp.render(Context({})), - "
Content
", - ) - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - """, - ) - - def test_html_js_inline_and_css_file(self): - class HTMLStringFileCSSJSComponent(Component): - template = "
Content
" - js = "console.log('HTML and JS only');" - - class Media: - css = "path/to/style.css" - - comp = HTMLStringFileCSSJSComponent("html_string_file_css_js_component") - self.assertHTMLEqual( - comp.render(Context({})), - "
Content
", - ) - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - """, - ) - - def test_html_css_inline_and_js_file(self): - class HTMLStringFileCSSJSComponent(Component): - template = "
Content
" - css = ".html-string-file { color: blue; }" - - class Media: - js = "path/to/script.js" - - comp = HTMLStringFileCSSJSComponent("html_string_file_css_js_component") - self.assertHTMLEqual( - comp.render(Context({})), - "
Content
", - ) - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - """, + self.assertInHTML( + "", + rendered, ) def test_html_variable(self): @@ -156,117 +89,78 @@ class InlineComponentTest(BaseTestCase): class ComponentMediaTests(BaseTestCase): - def test_css_and_js(self): - class SimpleComponent(Component): - template: types.django_html = """ - Variable: {{ variable }} - """ - - class Media: - css = "style.css" - js = "script.js" - - comp = SimpleComponent("simple_component") - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - """, - ) - - def test_css_only(self): - class SimpleComponent(Component): - template: types.django_html = """ - Variable: {{ variable }} - """ - - class Media: - css = "style.css" - - comp = SimpleComponent("simple_component") - - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - """, - ) - - def test_js_only(self): - class SimpleComponent(Component): - template: types.django_html = """ - Variable: {{ variable }} - """ - - class Media: - js = "script.js" - - comp = SimpleComponent("simple_component") - - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - """, - ) - def test_empty_media(self): class SimpleComponent(Component): template: types.django_html = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} Variable: {{ variable }} """ class Media: pass - comp = SimpleComponent("simple_component") + rendered = SimpleComponent.render() - self.assertHTMLEqual(comp.render_dependencies(), "") + self.assertEqual(rendered.count("{{ variable }} - """ - - comp = SimpleComponent("simple_component") - - self.assertHTMLEqual(comp.render_dependencies(), "") + self.assertEqual(rendered.count(" - - - """, + rendered = SimpleComponent.render() + + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + + # Command to load the JS from Media.js + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, ) def test_css_js_as_string(self): class SimpleComponent(Component): + template = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + """ + class Media: css = "path/to/style.css" js = "path/to/script.js" - comp = SimpleComponent("") - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - """, + rendered = SimpleComponent.render() + + self.assertInHTML('', rendered) + + # Command to load the JS from Media.js + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, ) def test_css_as_dict(self): class SimpleComponent(Component): + template = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + """ + class Media: css = { "all": "path/to/style.css", @@ -275,15 +169,16 @@ class ComponentMediaTests(BaseTestCase): } js = ["path/to/script.js"] - comp = SimpleComponent("") - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - - - """, + rendered = SimpleComponent.render() + + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + + # Command to load the JS from Media.js + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, ) def test_media_custom_render_js(self): @@ -292,22 +187,31 @@ class ComponentMediaTests(BaseTestCase): tags: list[str] = [] for path in self._js: # type: ignore[attr-defined] abs_path = self.absolute_path(path) # type: ignore[attr-defined] - tags.append(f'') + tags.append(f'') return tags class SimpleComponent(Component): + template = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + """ + media_class = MyMedia class Media: js = ["path/to/script.js", "path/to/script2.js"] - comp = SimpleComponent() - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - """, + rendered = SimpleComponent.render() + + # Command to load the JS from Media.js + self.assertIn( + "Components.unescapeJs(\\`&lt;script defer src=&quot;path/to/script.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, + ) + self.assertIn( + "Components.unescapeJs(\\`&lt;script defer src=&quot;path/to/script2.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, ) def test_media_custom_render_css(self): @@ -317,10 +221,16 @@ class ComponentMediaTests(BaseTestCase): media = sorted(self._css) # type: ignore[attr-defined] for medium in media: for path in self._css[medium]: # type: ignore[attr-defined] - tags.append(f'') + tags.append(f'') return tags class SimpleComponent(Component): + template = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + """ + media_class = MyMedia class Media: @@ -330,15 +240,11 @@ class ComponentMediaTests(BaseTestCase): "screen": "path/to/style3.css", } - comp = SimpleComponent() - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - - """, - ) + rendered = SimpleComponent.render() + + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) class MediaPathAsObjectTests(BaseTestCase): @@ -377,6 +283,12 @@ class MediaPathAsObjectTests(BaseTestCase): return format_html('', static(self.static_path)) class SimpleComponent(Component): + template = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + """ + class Media: css = { "all": [ @@ -395,20 +307,29 @@ class MediaPathAsObjectTests(BaseTestCase): "path/to/script4.js", # Formatted by Media.render_js ] - comp = SimpleComponent() - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - - + rendered = SimpleComponent.render() - - - - - """, + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + + # Command to load the JS from Media.js + self.assertIn( + "Components.unescapeJs(\\`&lt;script js_tag src=&quot;path/to/script.js&quot; type=&quot;module&quot;&gt;&lt;/script&gt;\\`)", + rendered, + ) + self.assertIn( + "Components.unescapeJs(\\`&lt;script hi src=&quot;path/to/script2.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, + ) + self.assertIn( + "Components.unescapeJs(\\`&lt;script type=&quot;module&quot; src=&quot;path/to/script3.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, + ) + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script4.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, ) def test_pathlike(self): @@ -425,6 +346,12 @@ class MediaPathAsObjectTests(BaseTestCase): return self.path class SimpleComponent(Component): + template = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + """ + class Media: css = { "all": [ @@ -442,19 +369,25 @@ class MediaPathAsObjectTests(BaseTestCase): "path/to/script3.js", ] - comp = SimpleComponent() - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - - + rendered = SimpleComponent.render() - - - - """, + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + + # Command to load the JS from Media.js + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, + ) + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script2.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, + ) + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script3.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, ) def test_str(self): @@ -467,6 +400,12 @@ class MediaPathAsObjectTests(BaseTestCase): pass class SimpleComponent(Component): + template = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + """ + class Media: css = { "all": [ @@ -483,18 +422,21 @@ class MediaPathAsObjectTests(BaseTestCase): "path/to/script2.js", ] - comp = SimpleComponent() - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - - + rendered = SimpleComponent.render() - - - """, + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + + # Command to load the JS from Media.js + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, + ) + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script2.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, ) def test_bytes(self): @@ -507,6 +449,12 @@ class MediaPathAsObjectTests(BaseTestCase): pass class SimpleComponent(Component): + template = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + """ + class Media: css = { "all": [ @@ -523,22 +471,31 @@ class MediaPathAsObjectTests(BaseTestCase): "path/to/script2.js", ] - comp = SimpleComponent() - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - - + rendered = SimpleComponent.render() - - - """, + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + + # Command to load the JS from Media.js + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, + ) + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script2.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, ) def test_function(self): class SimpleComponent(Component): + template = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + """ + class Media: css = [ lambda: mark_safe(''), # Literal @@ -553,20 +510,29 @@ class MediaPathAsObjectTests(BaseTestCase): lambda: b"calendar/script3.js", ] - comp = SimpleComponent() - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - - + rendered = SimpleComponent.render() - - - - - """, + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + + # Command to load the JS from Media.js + self.assertIn( + "Components.unescapeJs(\\`&lt;script hi src=&quot;calendar/script.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, + ) + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;calendar/script1.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, + ) + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;calendar/script2.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, + ) + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;calendar/script3.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, ) @override_settings(STATIC_URL="static/") @@ -574,6 +540,12 @@ class MediaPathAsObjectTests(BaseTestCase): """Test that all the different ways of defining media files works with Django's staticfiles""" class SimpleComponent(Component): + template = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + """ + class Media: css = [ mark_safe(f''), # Literal @@ -590,22 +562,30 @@ class MediaPathAsObjectTests(BaseTestCase): lambda: "calendar/script4.js", ] - comp = SimpleComponent() - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - - - + rendered = SimpleComponent.render() - - - - - - """, + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + + # Command to load the JS from Media.js + self.assertIn( + "Components.unescapeJs(\\`&lt;script hi src=&quot;/static/calendar/script.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, + ) + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;/static/calendar/script1.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, + ) + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;/static/calendar/script2.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, + ) + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;/static/calendar/script3.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, ) @@ -632,26 +612,32 @@ class MediaStaticfilesTests(BaseTestCase): tags: list[str] = [] for path in self._js: # type: ignore[attr-defined] abs_path = self.absolute_path(path) # type: ignore[attr-defined] - tags.append(f'') + tags.append(f'') return tags class SimpleComponent(Component): + template = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + """ + media_class = MyMedia class Media: css = "calendar/style.css" js = "calendar/script.js" - comp = SimpleComponent() + rendered = SimpleComponent.render() # NOTE: Since we're using the default storage class for staticfiles, the files should # be searched as specified above (e.g. `calendar/script.js`) inside `static_root` dir. - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - """, + self.assertInHTML('', rendered) + + # Command to load the JS from Media.js + self.assertIn( + "Components.unescapeJs(\\`&lt;script defer src=&quot;/static/calendar/script.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, ) # For context see https://github.com/EmilStenstrom/django-components/issues/522 @@ -688,26 +674,34 @@ class MediaStaticfilesTests(BaseTestCase): tags: list[str] = [] for path in self._js: # type: ignore[attr-defined] abs_path = self.absolute_path(path) # type: ignore[attr-defined] - tags.append(f'') + tags.append(f'') return tags class SimpleComponent(Component): + template = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + """ + media_class = MyMedia class Media: css = "calendar/style.css" js = "calendar/script.js" - comp = SimpleComponent() + rendered = SimpleComponent.render() # NOTE: Since we're using ManifestStaticFilesStorage, we expect the rendered media to link # to the files as defined in staticfiles.json - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - """, + self.assertInHTML( + '', rendered + ) + + # Command to load the JS from Media.js + self.assertIn( + "Components.unescapeJs(\\`&lt;script defer src=&quot;/static/calendar/script.e1815e23e0ec.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, ) @@ -776,22 +770,30 @@ class MediaRelativePathTests(BaseTestCase): registry.unregister(comp_name) template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} - {% component name='relative_file_component' variable=variable %} - {% endcomponent %} + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + {% component name='relative_file_component' variable=variable / %} """ template = Template(template_str) - rendered = template.render(Context({"variable": "test"})) - self.assertHTMLEqual( - rendered, + rendered = render_dependencies(template.render(Context({"variable": "test"}))) + + self.assertInHTML('', rendered) + + self.assertInHTML( """ - -
""", + rendered, + ) + + # Command to load the JS from Media.js + self.assertIn( + "Components.unescapeJs(\\`&lt;link href=&quot;relative_file/relative_file.css&quot; media=&quot;all&quot; rel=&quot;stylesheet&quot;&gt;\\`)", + rendered, ) # Settings required for autodiscover to work @@ -811,7 +813,9 @@ class MediaRelativePathTests(BaseTestCase): registry.unregister("relative_file_pathobj_component") template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} {% component 'parent_component' %} {% fill 'content' %} {% component name='relative_file_component' variable='hello' %} @@ -848,17 +852,19 @@ class MediaRelativePathTests(BaseTestCase): # Fix the paths, since the "components" dir is nested with autodiscover_with_cleanup(map_module=lambda p: f"tests.{p}" if p.startswith("components") else p): - # Mark the PathObj instances of 'relative_file_pathobj_component' so they won raise - # error PathObj.__str__ is triggered. + # Mark the PathObj instances of 'relative_file_pathobj_component' so they won't raise + # error if PathObj.__str__ is triggered. CompCls = registry.get("relative_file_pathobj_component") CompCls.Media.js[0].throw_on_calling_str = False # type: ignore CompCls.Media.css["all"][0].throw_on_calling_str = False # type: ignore - rendered = CompCls().render_dependencies() - self.assertHTMLEqual( + rendered = CompCls.render(kwargs={"variable": "abc"}) + + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + + # Command to load the JS from Media.js + self.assertIn( + "Components.unescapeJs(\\`&lt;script type=&quot;module&quot; src=&quot;relative_file_pathobj.js&quot;&gt;&lt;/script&gt;\\`)", rendered, - """ - - - """, ) diff --git a/tests/test_context.py b/tests/test_context.py index 3681a2ac..5c7efda0 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -21,10 +21,6 @@ class SimpleComponent(Component): def get_context_data(self, variable=None): return {"variable": variable} if variable is not None else {} - @staticmethod - def expected_output(variable_value): - return "Variable: < strong > {} < / strong >".format(variable_value) - class VariableDisplay(Component): template: types.django_html = """ @@ -98,7 +94,7 @@ class ContextTests(BaseTestCase): self, ): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component 'parent_component' %}{% endcomponent %} """ template = Template(template_str) @@ -118,7 +114,6 @@ class ContextTests(BaseTestCase): ): template_str: types.django_html = """ {% load component_tags %} - {% component_dependencies %} {% component name='parent_component' %}{% endcomponent %} """ template = Template(template_str) @@ -134,7 +129,7 @@ class ContextTests(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_nested_component_context_shadows_parent_with_filled_slots(self): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component 'parent_component' %} {% fill 'content' %} {% component name='variable_display' shadowing_variable='shadow_from_slot' new_variable='unique_from_slot' %} @@ -157,7 +152,6 @@ class ContextTests(BaseTestCase): def test_nested_component_instances_have_unique_context_with_filled_slots(self): template_str: types.django_html = """ {% load component_tags %} - {% component_dependencies %} {% component 'parent_component' %} {% fill 'content' %} {% component name='variable_display' shadowing_variable='shadow_from_slot' new_variable='unique_from_slot' %} @@ -181,7 +175,6 @@ class ContextTests(BaseTestCase): ): template_str: types.django_html = """ {% load component_tags %} - {% component_dependencies %} {% component name='parent_component' %}{% endcomponent %} """ template = Template(template_str) @@ -200,7 +193,7 @@ class ContextTests(BaseTestCase): self, ): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component 'parent_component' %} {% fill 'content' %} {% component name='variable_display' shadowing_variable='shadow_from_slot' new_variable='unique_from_slot' %} @@ -250,7 +243,7 @@ class ParentArgsTests(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_parent_args_can_be_drawn_from_context(self): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component 'parent_with_args' parent_value=parent_value %} {% endcomponent %} """ @@ -276,7 +269,7 @@ class ParentArgsTests(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_parent_args_available_outside_slots(self): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component 'parent_with_args' parent_value='passed_in' %}{%endcomponent %} """ template = Template(template_str) @@ -297,7 +290,7 @@ class ParentArgsTests(BaseTestCase): first_val, second_val = context_behavior_data template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component 'parent_with_args' parent_value='passed_in' %} {% fill 'content' %} {% component name='variable_display' shadowing_variable='value_from_slot' new_variable=inner_parent_value %} @@ -331,7 +324,7 @@ class ContextCalledOnceTests(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_one_context_call_with_simple_component(self): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component name='incrementer' %}{% endcomponent %} """ template = Template(template_str) @@ -427,7 +420,7 @@ class ComponentsCanAccessOuterContext(BaseTestCase): ) def test_simple_component_can_use_outer_context(self, context_behavior_data): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component 'simple_component' %}{% endcomponent %} """ template = Template(template_str) @@ -448,7 +441,7 @@ class IsolatedContextTests(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_simple_component_can_pass_outer_context_in_args(self): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component 'simple_component' variable only %}{% endcomponent %} """ template = Template(template_str) @@ -458,7 +451,7 @@ class IsolatedContextTests(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_simple_component_cannot_use_outer_context(self): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component 'simple_component' only %}{% endcomponent %} """ template = Template(template_str) @@ -476,7 +469,7 @@ class IsolatedContextSettingTests(BaseTestCase): self, ): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component 'simple_component' variable %}{% endcomponent %} """ template = Template(template_str) @@ -488,7 +481,7 @@ class IsolatedContextSettingTests(BaseTestCase): self, ): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component 'simple_component' %}{% endcomponent %} """ template = Template(template_str) @@ -500,7 +493,7 @@ class IsolatedContextSettingTests(BaseTestCase): self, ): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component 'simple_component' variable %} {% endcomponent %} """ @@ -513,7 +506,7 @@ class IsolatedContextSettingTests(BaseTestCase): self, ): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component 'simple_component' %} {% endcomponent %} """ @@ -538,7 +531,7 @@ class OuterContextPropertyTests(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_outer_context_property_with_component(self): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component 'outer_context_component' only %}{% endcomponent %} """ template = Template(template_str) @@ -551,12 +544,17 @@ class ContextVarsIsFilledTests(BaseTestCase): template: types.django_html = """ {% load component_tags %}
- {% slot "title" default %}{% endslot %} - {% slot "my_title" %}{% endslot %} - {% slot "my title 1" %}{% endslot %} - {% slot "my-title-2" %}{% endslot %} - {% slot "escape this: #$%^*()" %}{% endslot %} - {{ component_vars.is_filled|safe }} + {% slot "title" default / %} + {% slot "my-title" / %} + {% slot "my-title-1" / %} + {% slot "my-title-2" / %} + {% slot "escape this: #$%^*()" / %} + + title: {{ component_vars.is_filled.title }} + my_title: {{ component_vars.is_filled.my_title }} + my_title_1: {{ component_vars.is_filled.my_title_1 }} + my_title_2: {{ component_vars.is_filled.my_title_2 }} + escape_this_________: {{ component_vars.is_filled.escape_this_________ }}
""" @@ -593,7 +591,6 @@ class ContextVarsIsFilledTests(BaseTestCase): def setUp(self) -> None: super().setUp() - registry.register("is_filled_vars", self.IsFilledVarsComponent) registry.register("conditional_slots", self.ComponentWithConditionalSlots) registry.register( "complex_conditional_slots", @@ -602,28 +599,34 @@ class ContextVarsIsFilledTests(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_is_filled_vars(self): + registry.register("is_filled_vars", self.IsFilledVarsComponent) + template: types.django_html = """ {% load component_tags %} {% component "is_filled_vars" %} - {% fill "title" %}{% endfill %} - {% fill "my-title-2" %}{% endfill %} - {% fill "escape this: #$%^*()" %}{% endfill %} + {% fill "title" / %} + {% fill "my-title-2" / %} + {% fill "escape this: #$%^*()" / %} {% endcomponent %} """ + rendered = Template(template).render(Context()) + expected = """
- {'title': True, - 'my_title': False, - 'my_title_1': False, - 'my_title_2': True, - 'escape_this_________': True} + title: True + my_title: False + my_title_1: False + my_title_2: True + escape_this_________: True
""" self.assertHTMLEqual(rendered, expected) @parametrize_context_behavior(["django", "isolated"]) def test_is_filled_vars_default(self): + registry.register("is_filled_vars", self.IsFilledVarsComponent) + template: types.django_html = """ {% load component_tags %} {% component "is_filled_vars" %} @@ -634,11 +637,11 @@ class ContextVarsIsFilledTests(BaseTestCase): expected = """
bla bla - {'title': True, - 'my_title': False, - 'my_title_1': False, - 'my_title_2': False, - 'escape_this_________': False} + title: False + my_title: False + my_title_1: False + my_title_2: False + escape_this_________: False
""" self.assertHTMLEqual(rendered, expected) @@ -776,12 +779,6 @@ class ContextVarsIsFilledTests(BaseTestCase): """ Template(template).render(Context()) - expected = { - "title": True, - "my_title": False, - "my_title_1": False, - "my_title_2": False, - "escape_this_________": False, - } + expected = {"default": True} self.assertEqual(captured_before, expected) self.assertEqual(captured_after, expected) diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py new file mode 100644 index 00000000..83c8a1b2 --- /dev/null +++ b/tests/test_dependencies.py @@ -0,0 +1,333 @@ +from unittest.mock import Mock + +from django.http import HttpResponseNotModified +from django.template import Context, Template +from selectolax.lexbor import LexborHTMLParser + +from django_components import Component, registry, render_dependencies, types +from django_components.components.dynamic import DynamicComponent +from django_components.middleware import ComponentDependencyMiddleware + +from .django_test_setup import setup_test_config +from .testutils import BaseTestCase, create_and_process_template_response + +setup_test_config({"autodiscover": False}) + + +class SimpleComponent(Component): + template: types.django_html = """ + Variable: {{ variable }} + """ + + css: types.css = """ + .xyz { + color: red; + } + """ + + js: types.js = """ + console.log("xyz"); + """ + + def get_context_data(self, variable, variable2="default"): + return { + "variable": variable, + "variable2": variable2, + } + + class Media: + css = "style.css" + js = "script.js" + + +class RenderDependenciesTests(BaseTestCase): + def test_standalone_render_dependencies(self): + registry.register(name="test", component=SimpleComponent) + + template_str: types.django_html = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + {% component 'test' variable='foo' / %} + """ + template = Template(template_str) + rendered_raw = template.render(Context({})) + + # Placeholders + self.assertEqual(rendered_raw.count(''), 1) + self.assertEqual(rendered_raw.count(''), 1) + + self.assertEqual(rendered_raw.count("', rendered, count=1) + + self.assertInHTML("", rendered, count=1) # Inlined CSS + self.assertInHTML( + "", rendered, count=1 + ) # Inlined JS + + self.assertInHTML('', rendered, count=1) # Media.css + + def test_middleware_renders_dependencies(self): + registry.register(name="test", component=SimpleComponent) + + template_str: types.django_html = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + {% component 'test' variable='foo' / %} + """ + template = Template(template_str) + rendered = create_and_process_template_response(template, use_middleware=True) + + # Dependency manager script + self.assertInHTML('', rendered, count=1) + + self.assertInHTML("", rendered, count=1) # Inlined CSS + self.assertInHTML( + "", rendered, count=1 + ) # Inlined JS + + self.assertInHTML('', rendered, count=1) # Media.css + self.assertEqual(rendered.count("', rendered, count=1) + + self.assertInHTML("", rendered, count=1) # Inlined CSS + self.assertInHTML( + "", rendered, count=1 + ) # Inlined JS + + self.assertInHTML('', rendered, count=1) # Media.css + self.assertEqual(rendered.count("', rendered_raw, count=0) + + self.assertInHTML("", rendered_raw, count=0) # Inlined CSS + self.assertInHTML('', rendered_raw, count=0) # Media.css + + self.assertInHTML( + "", + rendered_raw, + count=0, + ) # Inlined JS + + def test_component_render_to_response_renders_dependencies(self): + class SimpleComponentWithDeps(SimpleComponent): + template: types.django_html = ( + """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + """ + + SimpleComponent.template + ) + + registry.register(name="test", component=SimpleComponentWithDeps) + + response = SimpleComponentWithDeps.render_to_response( + kwargs={"variable": "foo"}, + ) + rendered = response.content.decode() + + # Dependency manager script + self.assertInHTML('', rendered, count=1) + + self.assertInHTML("", rendered, count=1) # Inlined CSS + self.assertInHTML( + "", rendered, count=1 + ) # Inlined JS + + self.assertEqual(rendered.count(''), 1) # Media.css + self.assertEqual(rendered.count(" + + + + {% component "test" variable="foo" / %} + + + """ + rendered_raw = Template(template_str).render(Context({})) + rendered = render_dependencies(rendered_raw) + + self.assertEqual(rendered.count(" + + + + """, + rendered, + count=1, + ) + + rendered_body = LexborHTMLParser(rendered).body.html # type: ignore[union-attr] + + self.assertInHTML( + """""", + rendered_body, + count=1, + ) + + def test_does_not_insert_styles_and_script_to_default_places_if_overriden(self): + registry.register(name="test", component=SimpleComponent) + + template_str: types.django_html = """ + {% load component_tags %} + + + + {% component_js_dependencies %} + + + {% component "test" variable="foo" / %} + {% component_css_dependencies %} + + + """ + rendered_raw = Template(template_str).render(Context({})) + rendered = render_dependencies(rendered_raw) + + self.assertEqual(rendered.count(" + Variable: foo + + + + + """, + rendered, + count=1, + ) + + rendered_head = LexborHTMLParser(rendered).head.html # type: ignore[union-attr] + + self.assertInHTML( + """""", + rendered_head, + count=1, + ) + + +class MiddlewareTests(BaseTestCase): + def test_middleware_response_without_content_type(self): + response = HttpResponseNotModified() + middleware = ComponentDependencyMiddleware(get_response=lambda _: response) + request = Mock() + self.assertEqual(response, middleware(request=request)) + + def test_middleware_response_with_components_with_slash_dash_and_underscore( + self, + ): + registry.register("dynamic", DynamicComponent) + + component_names = [ + "test-component", + "test/component", + "test_component", + ] + for component_name in component_names: + registry.register(name=component_name, component=SimpleComponent) + template_str: types.django_html = """ + {% load component_tags %} + {% component_css_dependencies %} + {% component_js_dependencies %} + {% component "dynamic" is=component_name variable='value' / %} + """ + template = Template(template_str) + rendered = create_and_process_template_response( + template, context=Context({"component_name": component_name}) + ) + + # Dependency manager script (empty) + self.assertInHTML('', rendered, count=1) + + # Inlined JS + self.assertInHTML( + "", rendered, count=1 + ) + # Inlined CSS + self.assertInHTML("", rendered, count=1) + # Media.css + self.assertInHTML('', rendered, count=1) + + self.assertEqual(rendered.count("Variable: value"), 1) diff --git a/tests/test_dependency_rendering.py b/tests/test_dependency_rendering.py index e23186e3..c9b41d0f 100644 --- a/tests/test_dependency_rendering.py +++ b/tests/test_dependency_rendering.py @@ -1,16 +1,22 @@ -from unittest.mock import Mock +""" +Here we check that the logic around dependency rendering outputs correct HTML. +During actual rendering, the HTML is then picked up by the JS-side dependency manager. +""" + +import re -from django.http import HttpResponseNotModified from django.template import Template -from django.test import override_settings from django_components import Component, registry, types -from django_components.middleware import ComponentDependencyMiddleware from .django_test_setup import setup_test_config from .testutils import BaseTestCase, create_and_process_template_response -setup_test_config() +setup_test_config({"autodiscover": False}) + + +def to_spaces(s: str): + return re.compile(r"\s+").sub(" ", s) class SimpleComponent(Component): @@ -29,19 +35,56 @@ class SimpleComponent(Component): js = "script.js" -class SimpleComponentAlternate(Component): +class SimpleComponentNested(Component): template: types.django_html = """ - Variable: {{ variable }} + {% load component_tags %} +
+ {% component "inner" variable=variable / %} + {% slot "default" default / %} +
+ """ + + css: types.css = """ + .my-class { + color: red; + } + """ + + js: types.js = """ + console.log("Hello"); """ def get_context_data(self, variable): return {} class Media: - css = "style2.css" + css = ["style.css", "style2.css"] js = "script2.js" +class OtherComponent(Component): + template: types.django_html = """ + XYZ: {{ variable }} + """ + + css: types.css = """ + .xyz { + color: red; + } + """ + + js: types.js = """ + console.log("xyz"); + """ + + def get_context_data(self, variable): + return {} + + class Media: + css = "xyz1.css" + js = "xyz1.js" + + class SimpleComponentWithSharedDependency(Component): template: types.django_html = """ Variable: {{ variable }} @@ -65,22 +108,29 @@ class MultistyleComponent(Component): js = ["script.js", "script2.js"] -@override_settings(COMPONENTS={"RENDER_DEPENDENCIES": True}) -class ComponentMediaRenderingTests(BaseTestCase): +class DependencyRenderingTests(BaseTestCase): def test_no_dependencies_when_no_components_used(self): registry.register(name="test", component=SimpleComponent) template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} """ template = Template(template_str) rendered = create_and_process_template_response(template) - self.assertInHTML('', rendered, count=1) + + self.assertEqual(rendered.count("', rendered, count=0) + + # Dependency manager script + self.assertInHTML('', rendered, count=1) + + self.assertEqual(rendered.count("', - rendered, - count=0, - ) - def test_preload_dependencies_render_when_no_components_used(self): + self.assertEqual(rendered.count("', rendered, count=1) - self.assertInHTML( - '', - rendered, - count=1, + + # Dependency manager script + self.assertInHTML('', rendered, count=1) + + self.assertEqual(rendered.count(''), 1) # Media.css + self.assertEqual(rendered.count("', - rendered, - count=1, + self.assertEqual( + rendered.count( + r"const toLoadCssScripts = [Components.unescapeJs(\`&lt;link href=&quot;style.css&quot; media=&quot;all&quot; rel=&quot;stylesheet&quot;&gt;\`)];" + ), + 1, ) - def test_single_component_dependencies_render_when_used(self): - registry.register(name="test", component=SimpleComponent) - - template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} - {% component 'test' variable='foo' %}{% endcomponent %} - """ - template = Template(template_str) - rendered = create_and_process_template_response(template) - self.assertInHTML( - '', - rendered, - count=1, - ) - self.assertInHTML('', rendered, count=1) + + self.assertEqual(rendered.count(''), 1) # Media.css + self.assertEqual(rendered.count("', rendered, count=1) - - def test_preload_dependencies_render_once_when_used(self): - registry.register(name="test", component=SimpleComponent) - - template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies preload='test' %} - {% component 'test' variable='foo' %}{% endcomponent %} - """ - template = Template(template_str) - rendered = create_and_process_template_response(template) - self.assertInHTML( - '', - rendered, - count=1, + self.assertEqual( + rendered.count( + r"const toLoadCssScripts = [Components.unescapeJs(\`&lt;link href=&quot;style.css&quot; media=&quot;all&quot; rel=&quot;stylesheet&quot;&gt;\`)];" + ), + 1, ) - self.assertInHTML('', rendered, count=0) + + self.assertEqual(rendered.count("'), 1) # Media.css def test_single_component_js_dependencies(self): registry.register(name="test", component=SimpleComponent) @@ -228,227 +278,167 @@ class ComponentMediaRenderingTests(BaseTestCase): """ template = Template(template_str) rendered = create_and_process_template_response(template) - self.assertInHTML('', rendered, count=1) + + # CSS NOT included + self.assertEqual(rendered.count("', rendered, count=1) - self.assertInHTML('', rendered, count=1) - def test_all_css_dependencies_are_rendered_for_component_with_multiple_dependencies( - self, - ): - registry.register(name="test", component=MultistyleComponent) - template_str: types.django_html = """ - {% load component_tags %}{% component_css_dependencies %} - {% component 'test' %}{% endcomponent %} - """ - template = Template(template_str) - rendered = create_and_process_template_response(template) - self.assertInHTML('', rendered, count=1) + + self.assertEqual(rendered.count("', - rendered, - count=1, + + # Dependency manager script + # NOTE: Should be present only ONCE! + self.assertInHTML('', rendered, count=1) + + self.assertEqual(rendered.count(".xyz { color: red; }", rendered, count=1) + self.assertInHTML("", rendered, count=1) + + # Components' Media.css + # NOTE: Each of these should be present only ONCE! + self.assertInHTML('', rendered, count=1) + self.assertInHTML('', rendered, count=1) + self.assertInHTML('', rendered, count=1) + + self.assertEqual( + rendered.count( + "const loadedJsScripts = ["/components/cache/OtherComponent_6329ae.js/", "/components/cache/SimpleComponentNested_f02d32.js/"];" + ), + 1, ) - self.assertInHTML( - '', - rendered, - count=0, + self.assertEqual( + rendered.count( + "const loadedCssScripts = ["/components/cache/OtherComponent_6329ae.css/", "/components/cache/SimpleComponentNested_f02d32.css/", "style.css", "style2.css", "xyz1.css"];" + ), + 1, + ) + self.assertEqual( + rendered.count( + r"const toLoadJsScripts = [Components.unescapeJs(\`&lt;script src=&quot;script.js&quot;&gt;&lt;/script&gt;\`), Components.unescapeJs(\`&lt;script src=&quot;script2.js&quot;&gt;&lt;/script&gt;\`), Components.unescapeJs(\`&lt;script src=&quot;xyz1.js&quot;&gt;&lt;/script&gt;\`)];" + ), + 1, + ) + self.assertEqual( + rendered.count( + r"const toLoadCssScripts = [Components.unescapeJs(\`&lt;link href=&quot;style.css&quot; media=&quot;all&quot; rel=&quot;stylesheet&quot;&gt;\`), Components.unescapeJs(\`&lt;link href=&quot;style2.css&quot; media=&quot;all&quot; rel=&quot;stylesheet&quot;&gt;\`), Components.unescapeJs(\`&lt;link href=&quot;xyz1.css&quot; media=&quot;all&quot; rel=&quot;stylesheet&quot;&gt;\`)];" + ), + 1, ) - def test_correct_js_dependencies_with_multiple_components(self): - registry.register(name="test1", component=SimpleComponent) - registry.register(name="test2", component=SimpleComponentAlternate) + def test_multiple_components_all_placeholders_removed(self): + registry.register(name="inner", component=SimpleComponent) + registry.register(name="outer", component=SimpleComponentNested) + registry.register(name="test", component=SimpleComponentWithSharedDependency) template_str: types.django_html = """ - {% load component_tags %}{% component_js_dependencies %} - {% component 'test1' 'variable' %}{% endcomponent %} - """ - template = Template(template_str) - rendered = create_and_process_template_response(template) - self.assertInHTML('' - '' - "Variable: value\n" - ), - ) diff --git a/tests/test_dependency_rendering_e2e.py b/tests/test_dependency_rendering_e2e.py new file mode 100644 index 00000000..a81c6fd4 --- /dev/null +++ b/tests/test_dependency_rendering_e2e.py @@ -0,0 +1,217 @@ +""" +Here we check that all parts of managing JS and CSS dependencies work together +in an actual browser. +""" + +from playwright.async_api import Page + +from django_components import types +from tests.django_test_setup import setup_test_config +from tests.e2e.utils import TEST_SERVER_URL, with_playwright +from tests.testutils import BaseTestCase + +setup_test_config({"autodiscover": False}) + + +# NOTE: All views, components, and associated JS and CSS are defined in +# `tests/e2e/testserver/testserver` +class E2eDependencyRenderingTests(BaseTestCase): + @with_playwright + async def test_single_component_dependencies(self): + single_comp_url = TEST_SERVER_URL + "/single" + + page: Page = await self.browser.new_page() + await page.goto(single_comp_url) + + test_js: types.js = """() => { + const bodyHTML = document.body.innerHTML; + + const innerEl = document.querySelector(".inner"); + const innerFontSize = globalThis.getComputedStyle(innerEl).getPropertyValue('font-size'); + + const myStyleEl = document.querySelector(".my-style"); + const myStyleBg = globalThis.getComputedStyle(myStyleEl).getPropertyValue('background'); + + return { + bodyHTML, + componentJsMsg: globalThis.testSimpleComponent, + scriptJsMsg: globalThis.testMsg, + innerFontSize, + myStyleBg, + }; + }""" + + data = await page.evaluate(test_js) + + # Check that the actual HTML content was loaded + self.assertIn('Variable: foo', data["bodyHTML"]) + self.assertInHTML('
123
', data["bodyHTML"], count=1) + self.assertInHTML('
xyz
', data["bodyHTML"], count=1) + + # Check components' inlined JS got loaded + self.assertEqual(data["componentJsMsg"], "kapowww!") + + # Check JS from Media.js got loaded + self.assertEqual(data["scriptJsMsg"], {"hello": "world"}) + + # Check components' inlined CSS got loaded + self.assertEqual(data["innerFontSize"], "4px") + + # Check CSS from Media.css got loaded + self.assertIn("rgb(0, 0, 255)", data["myStyleBg"]) # AKA 'background: blue' + + await page.close() + + @with_playwright + async def test_multiple_component_dependencies(self): + single_comp_url = TEST_SERVER_URL + "/multi" + + page: Page = await self.browser.new_page() + await page.goto(single_comp_url) + + test_js: types.js = """() => { + const bodyHTML = document.body.innerHTML; + + // Get the stylings defined via CSS + const innerEl = document.querySelector(".inner"); + const innerFontSize = globalThis.getComputedStyle(innerEl).getPropertyValue('font-size'); + + const outerEl = document.querySelector(".outer"); + const outerFontSize = globalThis.getComputedStyle(outerEl).getPropertyValue('font-size'); + + const otherEl = document.querySelector(".other"); + const otherDisplay = globalThis.getComputedStyle(otherEl).getPropertyValue('display'); + + const myStyleEl = document.querySelector(".my-style"); + const myStyleBg = globalThis.getComputedStyle(myStyleEl).getPropertyValue('background'); + + const myStyle2El = document.querySelector(".my-style2"); + const myStyle2Color = globalThis.getComputedStyle(myStyle2El).getPropertyValue('color'); + + return { + bodyHTML, + component1JsMsg: globalThis.testSimpleComponent, + component2JsMsg: globalThis.testSimpleComponentNested, + component3JsMsg: globalThis.testOtherComponent, + scriptJs1Msg: globalThis.testMsg, + scriptJs2Msg: globalThis.testMsg2, + innerFontSize, + outerFontSize, + myStyleBg, + myStyle2Color, + otherDisplay, + }; + }""" + + data = await page.evaluate(test_js) + + # Check that the actual HTML content was loaded + self.assertInHTML( + """ +
+ Variable: variable + XYZ: variable_inner +
+
123
+
xyz
+ """, + data["bodyHTML"], + count=1, + ) + + # Check components' inlined JS got loaded + self.assertEqual(data["component1JsMsg"], "kapowww!") + self.assertEqual(data["component2JsMsg"], "bongo!") + self.assertEqual(data["component3JsMsg"], "wowzee!") + + # Check JS from Media.js got loaded + self.assertEqual(data["scriptJs1Msg"], {"hello": "world"}) + self.assertEqual(data["scriptJs2Msg"], {"hello2": "world2"}) + + # Check components' inlined CSS got loaded + self.assertEqual(data["innerFontSize"], "4px") + self.assertEqual(data["outerFontSize"], "40px") + self.assertEqual(data["otherDisplay"], "flex") + + # Check CSS from Media.css got loaded + self.assertIn("rgb(0, 0, 255)", data["myStyleBg"]) # AKA 'background: blue' + self.assertEqual("rgb(255, 0, 0)", data["myStyle2Color"]) # AKA 'color: red' + + await page.close() + + @with_playwright + async def test_renders_css_nojs_env(self): + single_comp_url = TEST_SERVER_URL + "/multi" + + page: Page = await self.browser.new_page(java_script_enabled=False) + await page.goto(single_comp_url) + + test_js: types.js = """() => { + const bodyHTML = document.body.innerHTML; + + // Get the stylings defined via CSS + const innerEl = document.querySelector(".inner"); + const innerFontSize = globalThis.getComputedStyle(innerEl).getPropertyValue('font-size'); + + const outerEl = document.querySelector(".outer"); + const outerFontSize = globalThis.getComputedStyle(outerEl).getPropertyValue('font-size'); + + const otherEl = document.querySelector(".other"); + const otherDisplay = globalThis.getComputedStyle(otherEl).getPropertyValue('display'); + + const myStyleEl = document.querySelector(".my-style"); + const myStyleBg = globalThis.getComputedStyle(myStyleEl).getPropertyValue('background'); + + const myStyle2El = document.querySelector(".my-style2"); + const myStyle2Color = globalThis.getComputedStyle(myStyle2El).getPropertyValue('color'); + + return { + bodyHTML, + component1JsMsg: globalThis.testSimpleComponent, + component2JsMsg: globalThis.testSimpleComponentNested, + component3JsMsg: globalThis.testOtherComponent, + scriptJs1Msg: globalThis.testMsg, + scriptJs2Msg: globalThis.testMsg2, + innerFontSize, + outerFontSize, + myStyleBg, + myStyle2Color, + otherDisplay, + }; + }""" + + data = await page.evaluate(test_js) + + # Check that the actual HTML content was loaded + self.assertInHTML( + """ +
+ Variable: variable + XYZ: variable_inner +
+
123
+
xyz
+ """, + data["bodyHTML"], + count=1, + ) + + # Check components' inlined JS did NOT get loaded + self.assertEqual(data["component1JsMsg"], None) + self.assertEqual(data["component2JsMsg"], None) + self.assertEqual(data["component3JsMsg"], None) + + # Check JS from Media.js did NOT get loaded + self.assertEqual(data["scriptJs1Msg"], None) + self.assertEqual(data["scriptJs2Msg"], None) + + # Check components' inlined CSS got loaded + self.assertEqual(data["innerFontSize"], "4px") + self.assertEqual(data["outerFontSize"], "40px") + self.assertEqual(data["otherDisplay"], "flex") + + # Check CSS from Media.css got loaded + self.assertIn("rgb(0, 0, 255)", data["myStyleBg"]) # AKA 'background: blue' + self.assertEqual("rgb(255, 0, 0)", data["myStyle2Color"]) # AKA 'color: red' + + await page.close() diff --git a/tests/test_expression.py b/tests/test_expression.py index 38c77b24..659a2aee 100644 --- a/tests/test_expression.py +++ b/tests/test_expression.py @@ -150,7 +150,7 @@ class DynamicExprTests(BaseTestCase): self.assertEqual( rendered.strip(), - "
lorem
\n
True
\n
[{'a': 1}, {'a': 2}]
", + "\n
lorem
\n
True
\n
[{'a': 1}, {'a': 2}]
", # noqa: E501 ) @parametrize_context_behavior(["django", "isolated"]) @@ -220,7 +220,7 @@ class DynamicExprTests(BaseTestCase): self.assertEqual( rendered.strip(), - "
lorem ipsum dolor
\n
True
\n
[{'a': 1}, {'a': 2}]
\n
{'a': 3}
", # noqa E501 + "\n
lorem ipsum dolor
\n
True
\n
[{'a': 1}, {'a': 2}]
\n
{'a': 3}
", # noqa E501 ) @parametrize_context_behavior(["django", "isolated"]) @@ -290,7 +290,7 @@ class DynamicExprTests(BaseTestCase): self.assertEqual( rendered.strip(), - "
\n
abc
\n
\n
", # noqa E501 + "\n
\n
abc
\n
\n
", # noqa E501 ) @parametrize_context_behavior(["django", "isolated"]) @@ -364,7 +364,7 @@ class DynamicExprTests(BaseTestCase): self.assertEqual( rendered.strip(), - "
lorem ipsum dolor
\n
lorem ipsum dolor [{'a': 1}]
\n
True
\n
[{'a': 1}, {'a': 2}]
\n
{'a': 3}
", # noqa E501 + "\n
lorem ipsum dolor
\n
lorem ipsum dolor [{'a': 1}]
\n
True
\n
[{'a': 1}, {'a': 2}]
\n
{'a': 3}
", # noqa E501 ) @parametrize_context_behavior(["django", "isolated"]) @@ -408,7 +408,7 @@ class DynamicExprTests(BaseTestCase): self.assertEqual( rendered.strip(), - '
"
\n
{%}
\n
True
', + '\n
"
\n
{%}
\n
True
', # noqa: E501 ) @parametrize_context_behavior(["django", "isolated"]) @@ -457,7 +457,7 @@ class DynamicExprTests(BaseTestCase): self.assertEqual( rendered.strip(), - "
\n
3
\n
True
\n
\n
True
", # noqa E501 + "\n
\n
3
\n
True
\n
\n
True
", # noqa E501 ) diff --git a/tests/test_finders.py b/tests/test_finders.py index 5594127e..e8a267bb 100644 --- a/tests/test_finders.py +++ b/tests/test_finders.py @@ -124,7 +124,7 @@ class StaticFilesFinderTests(SimpleTestCase): "static_files_allowed": [ ".js", ], - "forbidden_static_files": [], + "static_files_forbidden": [], }, STATICFILES_FINDERS=[ # Default finders @@ -153,7 +153,7 @@ class StaticFilesFinderTests(SimpleTestCase): "static_files_allowed": [ re.compile(r".*"), ], - "forbidden_static_files": [ + "static_files_forbidden": [ re.compile(r"\.(?:js)$"), ], }, @@ -185,7 +185,7 @@ class StaticFilesFinderTests(SimpleTestCase): ".js", ".css", ], - "forbidden_static_files": [ + "static_files_forbidden": [ ".js", ], }, diff --git a/tests/test_html.py b/tests/test_html.py new file mode 100644 index 00000000..1d6b7a25 --- /dev/null +++ b/tests/test_html.py @@ -0,0 +1,267 @@ +from typing import List, cast + +from django.test import TestCase +from selectolax.lexbor import LexborHTMLParser, LexborNode + +from django_components.util.html import ( + is_html_parser_fragment, + parse_document_or_nodes, + parse_multiroot_html, + parse_node, +) + +from .django_test_setup import setup_test_config + +setup_test_config({"autodiscover": False}) + + +class HtmlTests(TestCase): + def test_parse_node(self): + node = parse_node( + """ +
+
    +
  • Hi
  • +
+
+ """ + ) + node.attrs["id"] = "my-id" # type: ignore[index] + node.css("li")[0].attrs["class"] = "item" # type: ignore[index] + + self.assertHTMLEqual( + node.html, + """ +
+
    +
  • Hi
  • +
+
+ """, + ) + + def test_parse_multiroot_html(self): + html = """ +
+
    +
  • Hi
  • +
+
+
+
+ 42 +
+
+ + Hello + + """ + nodes = parse_multiroot_html(html) + + self.assertHTMLEqual( + nodes[0].html, + """ +
+
    +
  • Hi
  • +
+
+ """, + ) + self.assertHTMLEqual( + nodes[1].html, + """ +
+
+ 42 +
+
+ """, + ) + self.assertHTMLEqual( + nodes[2].html, + """ + + Hello + + """, + ) + + def test_is_html_parser_fragment(self): + fragment_html = """ +
+
    +
  • Hi
  • +
+
+
+
+ 42 +
+
+ + Hello + + """ + fragment_tree = LexborHTMLParser(fragment_html) + fragment_result = is_html_parser_fragment(fragment_html, fragment_tree) + + self.assertEqual(fragment_result, True) + + doc_html = """ + + + + + + +
+
    +
  • Hi
  • +
+
+ + + """ + doc_tree = LexborHTMLParser(doc_html) + doc_result = is_html_parser_fragment(doc_html, doc_tree) + + self.assertEqual(doc_result, False) + + def test_parse_document_or_nodes__fragment(self): + fragment_html = """ +
+
    +
  • Hi
  • +
+
+
+
+ 42 +
+
+ + Hello + + """ + fragment_result = cast(List[LexborNode], parse_document_or_nodes(fragment_html)) + + self.assertHTMLEqual( + fragment_result[0].html, + """ +
+
    +
  • Hi
  • +
+
+ """, + ) + self.assertHTMLEqual( + fragment_result[1].html, + """ +
+
+ 42 +
+
+ """, + ) + self.assertHTMLEqual( + fragment_result[2].html, + """ + + Hello + + """, + ) + + def test_parse_document_or_nodes__mixed(self): + fragment_html = """ + +
+
    +
  • Hi
  • +
+
+
+
+ 42 +
+
+ + Hello + + """ + fragment_result = cast(List[LexborNode], parse_document_or_nodes(fragment_html)) + + self.assertHTMLEqual( + fragment_result[0].html, + """ + + """, + ) + self.assertHTMLEqual( + fragment_result[1].html, + """ +
+
    +
  • Hi
  • +
+
+ """, + ) + self.assertHTMLEqual( + fragment_result[2].html, + """ +
+
+ 42 +
+
+ """, + ) + self.assertHTMLEqual( + fragment_result[3].html, + """ + + Hello + + """, + ) + + def test_parse_document_or_nodes__doc(self): + doc_html = """ + + + + + + +
+
    +
  • Hi
  • +
+
+ + + """ + fragment_result = cast(LexborHTMLParser, parse_document_or_nodes(doc_html)) + + self.assertHTMLEqual( + fragment_result.html, + """ + + + + + + +
+
    +
  • Hi
  • +
+
+ + + """, + ) diff --git a/tests/test_template_loader.py b/tests/test_loader.py similarity index 53% rename from tests/test_template_loader.py rename to tests/test_loader.py index e3dae731..6babbe01 100644 --- a/tests/test_template_loader.py +++ b/tests/test_loader.py @@ -1,11 +1,12 @@ +import os import re from pathlib import Path from unittest.mock import MagicMock, patch -from django.template.engine import Engine +from django.conf import settings from django.test import override_settings -from django_components.template_loader import Loader, get_dirs +from django_components.util.loader import _filepath_to_python_module, get_component_dirs, get_component_files from .django_test_setup import setup_test_config from .testutils import BaseTestCase @@ -13,14 +14,12 @@ from .testutils import BaseTestCase setup_test_config({"autodiscover": False}) -class TemplateLoaderTest(BaseTestCase): +class ComponentDirsTest(BaseTestCase): @override_settings( BASE_DIR=Path(__file__).parent.resolve(), ) def test_get_dirs__base_dir(self): - current_engine = Engine.get_default() - loader = Loader(current_engine) - dirs = sorted(loader.get_dirs()) + dirs = sorted(get_component_dirs()) apps_dirs = [dirs[0], dirs[2]] own_dirs = [dirs[1], *dirs[3:]] @@ -43,9 +42,7 @@ class TemplateLoaderTest(BaseTestCase): BASE_DIR=Path(__file__).parent.resolve() / "test_structures" / "test_structure_1", # noqa ) def test_get_dirs__base_dir__complex(self): - current_engine = Engine.get_default() - loader = Loader(current_engine) - dirs = sorted(loader.get_dirs()) + dirs = sorted(get_component_dirs()) apps_dirs = dirs[:2] own_dirs = dirs[2:] @@ -69,10 +66,10 @@ class TemplateLoaderTest(BaseTestCase): ("with_not_str_alias", 3), ], # noqa ) - @patch("django_components.template_loader.logger.warning") + @patch("django_components.util.loader.logger.warning") def test_get_dirs__components_dirs(self, mock_warning: MagicMock): mock_warning.reset_mock() - dirs = sorted(get_dirs()) + dirs = sorted(get_component_dirs()) apps_dirs = [dirs[0], dirs[2]] own_dirs = [dirs[1], *dirs[3:]] @@ -101,7 +98,7 @@ class TemplateLoaderTest(BaseTestCase): }, ) def test_get_dirs__components_dirs__empty(self): - dirs = sorted(get_dirs()) + dirs = sorted(get_component_dirs()) apps_dirs = dirs @@ -117,10 +114,8 @@ class TemplateLoaderTest(BaseTestCase): }, ) def test_get_dirs__componenents_dirs__raises_on_relative_path_1(self): - current_engine = Engine.get_default() - loader = Loader(current_engine) with self.assertRaisesMessage(ValueError, "COMPONENTS.dirs must contain absolute paths"): - loader.get_dirs() + get_component_dirs() @override_settings( BASE_DIR=Path(__file__).parent.resolve(), @@ -129,10 +124,8 @@ class TemplateLoaderTest(BaseTestCase): }, ) def test_get_dirs__component_dirs__raises_on_relative_path_2(self): - current_engine = Engine.get_default() - loader = Loader(current_engine) with self.assertRaisesMessage(ValueError, "COMPONENTS.dirs must contain absolute paths"): - loader.get_dirs() + get_component_dirs() @override_settings( BASE_DIR=Path(__file__).parent.resolve(), @@ -141,9 +134,7 @@ class TemplateLoaderTest(BaseTestCase): }, ) def test_get_dirs__app_dirs(self): - current_engine = Engine.get_default() - loader = Loader(current_engine) - dirs = sorted(loader.get_dirs()) + dirs = sorted(get_component_dirs()) apps_dirs = dirs[1:] own_dirs = dirs[:1] @@ -168,9 +159,7 @@ class TemplateLoaderTest(BaseTestCase): }, ) def test_get_dirs__app_dirs_empty(self): - current_engine = Engine.get_default() - loader = Loader(current_engine) - dirs = sorted(loader.get_dirs()) + dirs = sorted(get_component_dirs()) own_dirs = dirs @@ -190,9 +179,7 @@ class TemplateLoaderTest(BaseTestCase): }, ) def test_get_dirs__app_dirs_not_found(self): - current_engine = Engine.get_default() - loader = Loader(current_engine) - dirs = sorted(loader.get_dirs()) + dirs = sorted(get_component_dirs()) own_dirs = dirs @@ -210,9 +197,7 @@ class TemplateLoaderTest(BaseTestCase): INSTALLED_APPS=("django_components", "tests.test_app_nested.app"), ) def test_get_dirs__nested_apps(self): - current_engine = Engine.get_default() - loader = Loader(current_engine) - dirs = sorted(loader.get_dirs()) + dirs = sorted(get_component_dirs()) apps_dirs = [dirs[0], *dirs[2:]] own_dirs = [dirs[1]] @@ -230,3 +215,124 @@ class TemplateLoaderTest(BaseTestCase): / "components", ], ) + + +class ComponentFilesTest(BaseTestCase): + @override_settings( + BASE_DIR=Path(__file__).parent.resolve(), + ) + def test_get_files__py(self): + files = sorted(get_component_files(".py")) + + dot_paths = [f.dot_path for f in files] + file_paths = [str(f.filepath) for f in files] + + self.assertEqual( + dot_paths, + [ + "components", + "components.multi_file.multi_file", + "components.relative_file.relative_file", + "components.relative_file_pathobj.relative_file_pathobj", + "components.single_file", + "components.staticfiles.staticfiles", + "components.urls", + "django_components.components", + "django_components.components.dynamic", + "tests.test_app.components.app_lvl_comp.app_lvl_comp", + ], + ) + + self.assertEqual( + [ + file_paths[0].endswith("tests/components/__init__.py"), + file_paths[1].endswith("tests/components/multi_file/multi_file.py"), + file_paths[2].endswith("tests/components/relative_file/relative_file.py"), + file_paths[3].endswith("tests/components/relative_file_pathobj/relative_file_pathobj.py"), + file_paths[4].endswith("tests/components/single_file.py"), + file_paths[5].endswith("tests/components/staticfiles/staticfiles.py"), + file_paths[6].endswith("tests/components/urls.py"), + file_paths[7].endswith("django_components/components/__init__.py"), + file_paths[8].endswith("django_components/components/dynamic.py"), + file_paths[9].endswith("tests/test_app/components/app_lvl_comp/app_lvl_comp.py"), + ], + [True for _ in range(len(file_paths))], + ) + + @override_settings( + BASE_DIR=Path(__file__).parent.resolve(), + ) + def test_get_files__js(self): + files = sorted(get_component_files(".js")) + + dot_paths = [f.dot_path for f in files] + file_paths = [str(f.filepath) for f in files] + + print(file_paths) + + self.assertEqual( + dot_paths, + [ + "components.relative_file.relative_file", + "components.relative_file_pathobj.relative_file_pathobj", + "components.staticfiles.staticfiles", + "tests.test_app.components.app_lvl_comp.app_lvl_comp", + ], + ) + + self.assertEqual( + [ + file_paths[0].endswith("tests/components/relative_file/relative_file.js"), + file_paths[1].endswith("tests/components/relative_file_pathobj/relative_file_pathobj.js"), + file_paths[2].endswith("tests/components/staticfiles/staticfiles.js"), + file_paths[3].endswith("tests/test_app/components/app_lvl_comp/app_lvl_comp.js"), + ], + [True for _ in range(len(file_paths))], + ) + + +class TestFilepathToPythonModule(BaseTestCase): + def test_prepares_path(self): + base_path = str(settings.BASE_DIR) + + the_path = os.path.join(base_path, "tests.py") + self.assertEqual( + _filepath_to_python_module(the_path, base_path, None), + "tests", + ) + + the_path = os.path.join(base_path, "tests/components/relative_file/relative_file.py") + self.assertEqual( + _filepath_to_python_module(the_path, base_path, None), + "tests.components.relative_file.relative_file", + ) + + def test_handles_nonlinux_paths(self): + base_path = str(settings.BASE_DIR).replace("/", "//") + + with patch("os.path.sep", new="//"): + the_path = os.path.join(base_path, "tests.py") + self.assertEqual( + _filepath_to_python_module(the_path, base_path, None), + "tests", + ) + + the_path = os.path.join(base_path, "tests//components//relative_file//relative_file.py") + self.assertEqual( + _filepath_to_python_module(the_path, base_path, None), + "tests.components.relative_file.relative_file", + ) + + base_path = str(settings.BASE_DIR).replace("//", "\\") + with patch("os.path.sep", new="\\"): + the_path = os.path.join(base_path, "tests.py") + self.assertEqual( + _filepath_to_python_module(the_path, base_path, None), + "tests", + ) + + the_path = os.path.join(base_path, "tests\\components\\relative_file\\relative_file.py") + self.assertEqual( + _filepath_to_python_module(the_path, base_path, None), + "tests.components.relative_file.relative_file", + ) diff --git a/tests/test_registry.py b/tests/test_registry.py index e71a5c19..1f8e8511 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -150,8 +150,8 @@ class MultipleComponentRegistriesTest(BaseTestCase): registry_a = ComponentRegistry( library=library_a, settings=RegistrySettings( - CONTEXT_BEHAVIOR=ContextBehavior.ISOLATED, - TAG_FORMATTER=component_shorthand_formatter, + context_behavior=ContextBehavior.ISOLATED.value, + tag_formatter=component_shorthand_formatter, ), ) @@ -159,8 +159,8 @@ class MultipleComponentRegistriesTest(BaseTestCase): registry_b = ComponentRegistry( library=library_b, settings=RegistrySettings( - CONTEXT_BEHAVIOR=ContextBehavior.DJANGO, - TAG_FORMATTER=component_formatter, + context_behavior=ContextBehavior.DJANGO.value, + tag_formatter=component_formatter, ), ) @@ -228,7 +228,6 @@ class ProtectedTagsTest(unittest.TestCase): @override_settings(COMPONENTS={"tag_formatter": "django_components.component_shorthand_formatter"}) def test_raises_on_overriding_our_tags(self): for tag in [ - "component_dependencies", "component_css_dependencies", "component_js_dependencies", "fill", diff --git a/tests/test_settings.py b/tests/test_settings.py index 837112ab..f10c17ed 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -2,12 +2,13 @@ from pathlib import Path from django.test import override_settings +from django_components import ComponentsSettings from django_components.app_settings import app_settings from .django_test_setup import setup_test_config from .testutils import BaseTestCase -setup_test_config() +setup_test_config(components={"autodiscover": False}) class SettingsTestCase(BaseTestCase): @@ -27,3 +28,11 @@ class SettingsTestCase(BaseTestCase): @override_settings(BASE_DIR=Path("base_dir")) def test_works_when_base_dir_is_path(self): self.assertEqual(app_settings.DIRS, [Path("base_dir/components")]) + + @override_settings(COMPONENTS={"context_behavior": "isolated"}) + def test_settings_as_dict(self): + self.assertEqual(app_settings.CONTEXT_BEHAVIOR, "isolated") + + @override_settings(COMPONENTS=ComponentsSettings(context_behavior="isolated")) + def test_settings_as_instance(self): + self.assertEqual(app_settings.CONTEXT_BEHAVIOR, "isolated") diff --git a/tests/test_tag_formatter.py b/tests/test_tag_formatter.py index 96c8ef78..0d6c769e 100644 --- a/tests/test_tag_formatter.py +++ b/tests/test_tag_formatter.py @@ -19,6 +19,11 @@ class MultiwordBlockEndTagFormatter(ShorthandComponentFormatter): return f"end {name}" +class SlashEndTagFormatter(ShorthandComponentFormatter): + def end_tag(self, name): + return f"/{name}" + + # Create a TagFormatter class to validate the public interface def create_validator_tag_formatter(tag_name: str): class ValidatorTagFormatter(ShorthandComponentFormatter): @@ -259,6 +264,46 @@ class ComponentTagTests(BaseTestCase): """, ) + @parametrize_context_behavior( + cases=["django", "isolated"], + settings={ + "COMPONENTS": { + "tag_formatter": SlashEndTagFormatter(), + }, + }, + ) + def test_forward_slash_in_end_tag(self): + @register("simple") + class SimpleComponent(Component): + template: types.django_html = """ + {% load component_tags %} + hello1 +
+ {% slot "content" default %} SLOT_DEFAULT {% endslot %} +
+ hello2 + """ + + template = Template( + """ + {% load component_tags %} + {% simple %} + OVERRIDEN! + {% /simple %} + """ + ) + rendered = template.render(Context()) + self.assertHTMLEqual( + rendered, + """ + hello1 +
+ OVERRIDEN! +
+ hello2 + """, + ) + @parametrize_context_behavior( cases=["django", "isolated"], settings={ diff --git a/tests/test_tag_parser.py b/tests/test_tag_parser.py new file mode 100644 index 00000000..a5f63360 --- /dev/null +++ b/tests/test_tag_parser.py @@ -0,0 +1,94 @@ +from django_components.util.tag_parser import TagAttr, parse_tag_attrs + +from .django_test_setup import setup_test_config +from .testutils import BaseTestCase + +setup_test_config({"autodiscover": False}) + + +class TagParserTests(BaseTestCase): + def test_tag_parser(self): + _, attrs = parse_tag_attrs("component 'my_comp' key=val key2='val2 two' ") + self.assertEqual( + attrs, + [ + TagAttr(key=None, value="component", start_index=0, quoted=False), + TagAttr(key=None, value="my_comp", start_index=10, quoted=True), + TagAttr(key="key", value="val", start_index=20, quoted=False), + TagAttr(key="key2", value="val2 two", start_index=28, quoted=True), + ], + ) + + def test_tag_parser_nested_quotes(self): + _, attrs = parse_tag_attrs("component 'my_comp' key=val key2='val2 \"two\"' text=\"organisation's\" ") + self.assertEqual( + attrs, + [ + TagAttr(key=None, value="component", start_index=0, quoted=False), + TagAttr(key=None, value="my_comp", start_index=10, quoted=True), + TagAttr(key="key", value="val", start_index=20, quoted=False), + TagAttr(key="key2", value='val2 "two"', start_index=28, quoted=True), + TagAttr(key="text", value="organisation's", start_index=46, quoted=True), + ], + ) + + def test_tag_parser_trailing_quote_single(self): + _, attrs = parse_tag_attrs("component 'my_comp' key=val key2='val2 \"two\"' text=\"organisation's\" 'abc") + + self.assertEqual( + attrs, + [ + TagAttr(key=None, value="component", start_index=0, quoted=False), + TagAttr(key=None, value="my_comp", start_index=10, quoted=True), + TagAttr(key="key", value="val", start_index=20, quoted=False), + TagAttr(key="key2", value='val2 "two"', start_index=28, quoted=True), + TagAttr(key="text", value="organisation's", start_index=46, quoted=True), + TagAttr(key=None, value="'abc", start_index=68, quoted=False), + ], + ) + + def test_tag_parser_trailing_quote_double(self): + _, attrs = parse_tag_attrs('component "my_comp" key=val key2="val2 \'two\'" text=\'organisation"s\' "abc') + self.assertEqual( + attrs, + [ + TagAttr(key=None, value="component", start_index=0, quoted=False), + TagAttr(key=None, value="my_comp", start_index=10, quoted=True), + TagAttr(key="key", value="val", start_index=20, quoted=False), + TagAttr(key="key2", value="val2 'two'", start_index=28, quoted=True), + TagAttr(key="text", value='organisation"s', start_index=46, quoted=True), + TagAttr(key=None, value='"abc', start_index=68, quoted=False), + ], + ) + + def test_tag_parser_trailing_quote_as_value_single(self): + _, attrs = parse_tag_attrs( + "component 'my_comp' key=val key2='val2 \"two\"' text=\"organisation's\" value='abc" + ) + self.assertEqual( + attrs, + [ + TagAttr(key=None, value="component", start_index=0, quoted=False), + TagAttr(key=None, value="my_comp", start_index=10, quoted=True), + TagAttr(key="key", value="val", start_index=20, quoted=False), + TagAttr(key="key2", value='val2 "two"', start_index=28, quoted=True), + TagAttr(key="text", value="organisation's", start_index=46, quoted=True), + TagAttr(key="value", value="'abc", start_index=68, quoted=False), + ], + ) + + def test_tag_parser_trailing_quote_as_value_double(self): + _, attrs = parse_tag_attrs( + 'component "my_comp" key=val key2="val2 \'two\'" text=\'organisation"s\' value="abc' + ) + self.assertEqual( + attrs, + [ + TagAttr(key=None, value="component", start_index=0, quoted=False), + TagAttr(key=None, value="my_comp", start_index=10, quoted=True), + TagAttr(key="key", value="val", start_index=20, quoted=False), + TagAttr(key="key2", value="val2 'two'", start_index=28, quoted=True), + TagAttr(key="text", value='organisation"s', start_index=46, quoted=True), + TagAttr(key="value", value='"abc', start_index=68, quoted=False), + ], + ) diff --git a/tests/test_template_parser.py b/tests/test_template_parser.py index 79d65fe8..aaceeaf7 100644 --- a/tests/test_template_parser.py +++ b/tests/test_template_parser.py @@ -8,7 +8,7 @@ from django_components.expression import ( safe_resolve_dict, safe_resolve_list, ) -from django_components.templatetags.component_tags import _parse_tag +from django_components.templatetags.component_tags import TagSpec, _parse_tag from .django_test_setup import setup_test_config from .testutils import BaseTestCase, parametrize_context_behavior @@ -21,22 +21,28 @@ class ParserTest(BaseTestCase): template_str = "{% component 42 myvar key='val' key2=val2 %}" tokens = Lexer(template_str).tokenize() parser = Parser(tokens=tokens) - tag = _parse_tag("component", parser, parser.tokens[0], params=["num", "var"], keywordonly_kwargs=True) + spec = TagSpec( + tag="component", + pos_or_keyword_args=["num", "var"], + keywordonly_args=True, + ) + tag = _parse_tag(parser, parser.tokens[0], tag_spec=spec) ctx = {"myvar": {"a": "b"}, "val2": 1} args = safe_resolve_list(ctx, tag.args) named_args = safe_resolve_dict(ctx, tag.named_args) kwargs = tag.kwargs.resolve(ctx) - self.assertListEqual(args, [42, {"a": "b"}]) - self.assertDictEqual(named_args, {"num": 42, "var": {"a": "b"}}) - self.assertDictEqual(kwargs, {"key": "val", "key2": 1}) + self.assertListEqual(args, []) + self.assertDictEqual(named_args, {}) + self.assertDictEqual(kwargs, {"num": 42, "var": {"a": "b"}, "key": "val", "key2": 1}) def test_parses_special_kwargs(self): template_str = "{% component date=date @lol=2 na-me=bzz @event:na-me.mod=bzz #my-id=True %}" tokens = Lexer(template_str).tokenize() parser = Parser(tokens=tokens) - tag = _parse_tag("component", parser, parser.tokens[0], keywordonly_kwargs=True) + spec = TagSpec(tag="component", keywordonly_args=True) + tag = _parse_tag(parser, parser.tokens[0], tag_spec=spec) ctx = Context({"date": 2024, "bzz": "fzz"}) args = safe_resolve_list(ctx, tag.args) diff --git a/tests/test_templatetags.py b/tests/test_templatetags.py index b455fed8..e174d6ee 100644 --- a/tests/test_templatetags.py +++ b/tests/test_templatetags.py @@ -30,6 +30,8 @@ class TemplateInstrumentationTest(BaseTestCase): def setUp(self): """Emulate Django test instrumentation for TestCase (see setup_test_environment)""" + super().setUp() + from django.test.utils import instrumented_test_render self.saved_render_method = Template._render @@ -514,26 +516,83 @@ class MultilineTagsTests(BaseTestCase): class NestedTagsTests(BaseTestCase): + class SimpleComponent(Component): + template: types.django_html = """ + Variable: {{ var }} + """ + + def get_context_data(self, var): + return { + "var": var, + } + # See https://github.com/EmilStenstrom/django-components/discussions/671 @parametrize_context_behavior(["django", "isolated"]) def test_nested_tags(self): - @register("test_component") - class SimpleComponent(Component): - template: types.django_html = """ - Variable: {{ var }} - """ - - def get_context_data(self, var): - return { - "var": var, - } + registry.register("test", self.SimpleComponent) template: types.django_html = """ {% load component_tags %} - {% component "test_component" var="{% lorem 1 w %}" %}{% endcomponent %} + {% component "test" var="{% lorem 1 w %}" %}{% endcomponent %} """ rendered = Template(template).render(Context()) expected = """ Variable: lorem """ self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["django", "isolated"]) + def test_nested_quote_single(self): + registry.register("test", self.SimpleComponent) + + template: types.django_html = """ + {% load component_tags %} + {% component "test" var=_("organisation's") %} {% endcomponent %} + """ + rendered = Template(template).render(Context()) + expected = """ + Variable: organisation's + """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["django", "isolated"]) + def test_nested_quote_single_self_closing(self): + registry.register("test", self.SimpleComponent) + + template: types.django_html = """ + {% load component_tags %} + {% component "test" var=_("organisation's") / %} + """ + rendered = Template(template).render(Context()) + expected = """ + Variable: organisation's + """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["django", "isolated"]) + def test_nested_quote_double(self): + registry.register("test", self.SimpleComponent) + + template: types.django_html = """ + {% load component_tags %} + {% component "test" var=_('organisation"s') %} {% endcomponent %} + """ + rendered = Template(template).render(Context()) + expected = """ + Variable: organisation"s + """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["django", "isolated"]) + def test_nested_quote_double_self_closing(self): + registry.register("test", self.SimpleComponent) + + template: types.django_html = """ + {% load component_tags %} + {% component "test" var=_('organisation"s') / %} + """ + rendered = Template(template).render(Context()) + expected = """ + Variable: organisation"s + """ + self.assertHTMLEqual(rendered, expected) diff --git a/tests/test_templatetags_component.py b/tests/test_templatetags_component.py index bf0fad5f..5d6c92ed 100644 --- a/tests/test_templatetags_component.py +++ b/tests/test_templatetags_component.py @@ -429,7 +429,7 @@ class DynamicComponentTemplateTagTest(BaseTestCase): ) @parametrize_context_behavior(["django", "isolated"]) - def test_raises_on_invalid_slots(self): + def test_ignores_invalid_slots(self): class SimpleSlottedComponent(Component): template: types.django_html = """ {% load component_tags %} @@ -461,11 +461,15 @@ class DynamicComponentTemplateTagTest(BaseTestCase): """ template = Template(simple_tag_template) - - with self.assertRaisesMessage( - TemplateSyntaxError, "Component \\'dynamic\\' passed fill that refers to undefined slot: \\'three\\'" - ): - template.render(Context({})) + rendered = template.render(Context({})) + self.assertHTMLEqual( + rendered, + """ + Variable: variable + Slot 1: HELLO_FROM_SLOT_1 + Slot 2: + """, + ) @parametrize_context_behavior(["django", "isolated"]) def test_raises_on_invalid_args(self): @@ -664,7 +668,7 @@ class ComponentTemplateSyntaxErrorTests(BaseTestCase): Template(template_str) @parametrize_context_behavior(["django", "isolated"]) - def test_text_outside_fill_tag_is_not_error(self): + def test_text_outside_fill_tag_is_not_error_when_no_fill_tags(self): # As of v0.28 this is valid, provided the component registered under "test" # contains a slot tag marked as 'default'. This is verified outside # template compilation time. @@ -677,21 +681,28 @@ class ComponentTemplateSyntaxErrorTests(BaseTestCase): Template(template_str) @parametrize_context_behavior(["django", "isolated"]) - def test_nonfill_block_outside_fill_tag_is_error(self): - with self.assertRaises(TemplateSyntaxError): - template_str: types.django_html = """ - {% load component_tags %} - {% component "test" %} - {% if True %} - {% fill "header" %}{% endfill %} - {% endif %} - {% endcomponent %} - """ - Template(template_str) + def test_text_outside_fill_tag_is_error_when_fill_tags(self): + template_str: types.django_html = """ + {% load component_tags %} + {% component "test" %} + {% lorem 3 w random %} + {% fill "header" %}{% endfill %} + {% endcomponent %} + """ + template = Template(template_str) + + with self.assertRaisesMessage( + TemplateSyntaxError, + "Illegal content passed to component 'test'. Explicit 'fill' tags cannot occur alongside other text", + ): + template.render(Context()) @parametrize_context_behavior(["django", "isolated"]) def test_unclosed_component_is_error(self): - with self.assertRaises(TemplateSyntaxError): + with self.assertRaisesMessage( + TemplateSyntaxError, + "Unclosed tag on line 3: 'component'", + ): template_str: types.django_html = """ {% load component_tags %} {% component "test" %} diff --git a/tests/test_templatetags_slot_fill.py b/tests/test_templatetags_slot_fill.py index 5e053c88..6f01d63a 100644 --- a/tests/test_templatetags_slot_fill.py +++ b/tests/test_templatetags_slot_fill.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List, Optional from django.template import Context, Template, TemplateSyntaxError -from django_components import Component, register, registry, types +from django_components import Component, Slot, register, registry, types from .django_test_setup import setup_test_config from .testutils import BaseTestCase, parametrize_context_behavior @@ -31,7 +31,7 @@ class SlottedComponentWithContext(SlottedComponent): ####################### -class ComponentSlottedTemplateTagTest(BaseTestCase): +class ComponentSlotTests(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_slotted_template_basic(self): registry.register(name="test1", component=SlottedComponent) @@ -255,9 +255,284 @@ class ComponentSlottedTemplateTagTest(BaseTestCase): """ template = Template(template_str) - with self.assertRaises(TemplateSyntaxError): - template.render(Context({})) + with self.assertRaisesMessage(TemplateSyntaxError, "Slot 'title' is marked as 'required'"): + template.render(Context()) + # NOTE: This is relevant only for the "isolated" mode + @parametrize_context_behavior(["isolated"]) + def test_slots_of_top_level_comps_can_access_full_outer_ctx(self): + class SlottedComponent(Component): + template: types.django_html = """ + {% load component_tags %} +
+
{% slot "main" default %}Easy to override{% endslot %}
+
+ """ + + def get_context_data(self, name: Optional[str] = None) -> Dict[str, Any]: + return { + "name": name, + } + + registry.register("test", SlottedComponent) + + template_str: types.django_html = """ + {% load component_tags %} + + {% component "test" %} + ABC: {{ name }} {{ some }} + {% endcomponent %} + + """ + self.template = Template(template_str) + + nested_ctx = Context() + # Check that the component can access vars across different context layers + nested_ctx.push({"some": "var"}) + nested_ctx.push({"name": "carl"}) + rendered = self.template.render(nested_ctx) + + self.assertHTMLEqual( + rendered, + """ + +
+
ABC: carl var
+
+ + """, + ) + + @parametrize_context_behavior(["django", "isolated"]) + def test_target_default_slot_as_named(self): + @register("test") + class Comp(Component): + template: types.django_html = """ + {% load component_tags %} +
+

{% slot "title" default %}Default title{% endslot %}

+

{% slot "subtitle" %}Default subtitle{% endslot %}

+
+ """ + + template_str: types.django_html = """ + {% load component_tags %} + {% component 'test' %} + {% fill "default" %}Custom title{% endfill %} + {% endcomponent %} + """ + template = Template(template_str) + + rendered = template.render(Context()) + self.assertHTMLEqual( + rendered, + """ +
+

Custom title

+

Default subtitle

+
+ """, + ) + + @parametrize_context_behavior(["django", "isolated"]) + def test_raises_on_doubly_filled_slot__same_name(self): + @register("test") + class Comp(Component): + template: types.django_html = """ + {% load component_tags %} +
+

{% slot "title" default %}Default title{% endslot %}

+

{% slot "subtitle" %}Default subtitle{% endslot %}

+
+ """ + + template_str: types.django_html = """ + {% load component_tags %} + {% component 'test' %} + {% fill "title" %}Custom title{% endfill %} + {% fill "title" %}Another title{% endfill %} + {% endcomponent %} + """ + template = Template(template_str) + + with self.assertRaisesMessage( + TemplateSyntaxError, + "Multiple fill tags cannot target the same slot name in component 'test': " + "Detected duplicate fill tag name 'title'", + ): + template.render(Context()) + + @parametrize_context_behavior(["django", "isolated"]) + def test_raises_on_doubly_filled_slot__named_and_default(self): + @register("test") + class Comp(Component): + template: types.django_html = """ + {% load component_tags %} +
+

{% slot "title" default %}Default title{% endslot %}

+

{% slot "subtitle" %}Default subtitle{% endslot %}

+
+ """ + + template_str: types.django_html = """ + {% load component_tags %} + {% component 'test' %} + {% fill "default" %}Custom title{% endfill %} + {% fill "title" %}Another title{% endfill %} + {% endcomponent %} + """ + template = Template(template_str) + + with self.assertRaisesMessage( + TemplateSyntaxError, + "Slot 'title' of component 'test' was filled twice: once explicitly and once implicitly as 'default'", + ): + template.render(Context()) + + @parametrize_context_behavior(["django", "isolated"]) + def test_raises_on_doubly_filled_slot__named_and_default_2(self): + @register("test") + class Comp(Component): + template: types.django_html = """ + {% load component_tags %} +
+

{% slot "default" default %}Default title{% endslot %}

+

{% slot "subtitle" %}Default subtitle{% endslot %}

+
+ """ + + template_str: types.django_html = """ + {% load component_tags %} + {% component 'test' %} + {% fill "default" %}Custom title{% endfill %} + {% fill "default" %}Another title{% endfill %} + {% endcomponent %} + """ + template = Template(template_str) + + with self.assertRaisesMessage( + TemplateSyntaxError, + "Multiple fill tags cannot target the same slot name in component 'test': " + "Detected duplicate fill tag name 'default'", + ): + template.render(Context()) + + @parametrize_context_behavior(["django", "isolated"]) + def test_multiple_slots_with_same_name_different_flags(self): + class TestComp(Component): + def get_context_data(self, required: bool) -> Any: + return {"required": required} + + template: types.django_html = """ + {% load component_tags %} +
+ {% if required %} +
{% slot "main" required %}1{% endslot %}
+ {% endif %} +
{% slot "main" default %}2{% endslot %}
+
+ """ + + # 1. Specify the non-required slot by its name + rendered1 = TestComp.render( + kwargs={"required": False}, + slots={ + "main": "MAIN", + }, + render_dependencies=False, + ) + + # 2. Specify the non-required slot by the "default" name + rendered2 = TestComp.render( + kwargs={"required": False}, + slots={ + "default": "MAIN", + }, + render_dependencies=False, + ) + + self.assertInHTML(rendered1, "
MAIN
") + self.assertInHTML(rendered2, "
MAIN
") + + # 3. Specify the required slot by its name + rendered3 = TestComp.render( + kwargs={"required": True}, + slots={ + "main": "MAIN", + }, + render_dependencies=False, + ) + self.assertInHTML(rendered3, "
MAIN
MAIN
") + + # 4. RAISES: Specify the required slot by the "default" name + # This raises because the slot that is marked as 'required' is NOT marked as 'default'. + with self.assertRaisesMessage( + TemplateSyntaxError, + "Slot 'main' is marked as 'required'", + ): + TestComp.render( + kwargs={"required": True}, + slots={ + "default": "MAIN", + }, + render_dependencies=False, + ) + + @parametrize_context_behavior(["django", "isolated"]) + def test_slot_in_include(self): + @register("slotted") + class SlottedWithIncludeComponent(Component): + template: types.django_html = """ + {% load component_tags %} + {% include 'slotted_template.html' %} + """ + + template_str: types.django_html = """ + {% load component_tags %} + {% component "slotted" %} + {% fill "header" %}Custom header{% endfill %} + {% fill "main" %}Custom main{% endfill %} + {% fill "footer" %}Custom footer{% endfill %} + {% endcomponent %} + """ + + rendered = Template(template_str).render(Context({})) + + expected = """ + +
Custom header
+
Custom main
+
Custom footer
+
+ """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["django", "isolated"]) + def test_slot_in_include_raises_if_isolated(self): + @register("broken_component") + class BrokenComponent(Component): + template: types.django_html = """ + {% load component_tags %} + {% include 'slotted_template.html' with context=None only %} + """ + + template_str: types.django_html = """ + {% load component_tags %} + {% component "broken_component" %} + {% fill "header" %}Custom header {% endfill %} + {% fill "main" %}Custom main{% endfill %} + {% fill "footer" %}Custom footer{% endfill %} + {% endcomponent %} + """ + + with self.assertRaisesMessage( + TemplateSyntaxError, + "Encountered a SlotNode outside of a ComponentNode context.", + ): + Template(template_str).render(Context({})) + + +class ComponentSlotDefaultTests(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_default_slot_is_fillable_by_implicit_fill_content(self): @register("test_comp") @@ -311,6 +586,59 @@ class ComponentSlottedTemplateTagTest(BaseTestCase): rendered = template.render(Context({})) self.assertHTMLEqual(rendered, expected) + @parametrize_context_behavior(["django", "isolated"]) + def test_multiple_default_slots_with_same_name(self): + @register("test_comp") + class ComponentWithDefaultSlot(Component): + template: types.django_html = """ + {% load component_tags %} +
+
{% slot "main" default %}1{% endslot %}
+
{% slot "main" default %}2{% endslot %}
+
+ """ + + template_str: types.django_html = """ + {% load component_tags %} + {% component 'test_comp' %} + {% fill "main" %}

This fills the 'main' slot.

{% endfill %} + {% endcomponent %} + """ + template = Template(template_str) + expected = """ +
+

This fills the 'main' slot.

+

This fills the 'main' slot.

+
+ """ + rendered = template.render(Context({})) + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["django", "isolated"]) + def test_multiple_default_slots_with_different_names(self): + @register("test_comp") + class ComponentWithDefaultSlot(Component): + template: types.django_html = """ + {% load component_tags %} +
+
{% slot "main" default %}1{% endslot %}
+
{% slot "other" default %}2{% endslot %}
+
+ """ + + template_str: types.django_html = """ + {% load component_tags %} + {% component 'test_comp' %} + {% fill "main" %}

This fills the 'main' slot.

{% endfill %} + {% endcomponent %} + """ + template = Template(template_str) + + with self.assertRaisesMessage( + TemplateSyntaxError, "Only one component slot may be marked as 'default', found 'main' and 'other'" + ): + template.render(Context({})) + @parametrize_context_behavior(["django", "isolated"]) def test_error_raised_when_default_and_required_slot_not_filled(self): @register("test_comp") @@ -330,8 +658,8 @@ class ComponentSlottedTemplateTagTest(BaseTestCase): """ template = Template(template_str) - with self.assertRaises(TemplateSyntaxError): - template.render(Context({})) + with self.assertRaisesMessage(TemplateSyntaxError, "Slot 'main' is marked as 'required'"): + template.render(Context()) @parametrize_context_behavior(["django", "isolated"]) def test_fill_tag_can_occur_within_component_nested_in_implicit_fill(self): @@ -382,7 +710,10 @@ class ComponentSlottedTemplateTagTest(BaseTestCase): """ - with self.assertRaises(TemplateSyntaxError): + with self.assertRaisesMessage( + TemplateSyntaxError, + "Illegal content passed to component 'test_comp'. Explicit 'fill' tags cannot occur alongside other text", + ): template_str: types.django_html = """ {% load component_tags %} {% component 'test_comp' %} @@ -390,7 +721,7 @@ class ComponentSlottedTemplateTagTest(BaseTestCase):

And add this too!

{% endcomponent %} """ - Template(template_str) + Template(template_str).render(Context({})) @parametrize_context_behavior(["django", "isolated"]) def test_comments_permitted_inside_implicit_fill_content(self): @@ -428,63 +759,26 @@ class ComponentSlottedTemplateTagTest(BaseTestCase): """ template = Template(template_str) - with self.assertRaises(TemplateSyntaxError): - template.render(Context({})) - - @parametrize_context_behavior(["django", "isolated"]) - def test_component_template_cannot_have_multiple_default_slots(self): - class BadComponent(Component): - def get_template(self, context): - template_str: types.django_html = """ - {% load django_components %} -
- {% slot "icon" %} {% endslot default %} - {% slot "description" %} {% endslot default %} -
- """ - return Template(template_str) - - c = BadComponent("name") - - with self.assertRaises(TemplateSyntaxError): - c.render(Context({})) - - @parametrize_context_behavior(["django", "isolated"]) - def test_slot_name_fill_typo_gives_helpful_error_message(self): - registry.register(name="test1", component=SlottedComponent) - - template_str: types.django_html = """ - {% load component_tags %} - {% component "test1" %} - {% fill "haeder" %} - Custom header - {% endfill %} - {% fill "main" %} - main content - {% endfill %} - {% endcomponent %} - """ - template = Template(template_str) - with self.assertRaisesMessage( TemplateSyntaxError, - ( - "Component 'test1' passed fill that refers to undefined slot: 'haeder'.\\n" - "Unfilled slot names are: ['footer', 'header'].\\n" - "Did you mean 'header'?" - ), + "Component 'test_comp' passed default fill content (i.e. without explicit 'name' kwarg), " + "even though none of its slots is marked as 'default'", ): - template.render(Context({})) + template.render(Context()) - # NOTE: This is relevant only for the "isolated" mode - @parametrize_context_behavior(["isolated"]) - def test_slots_of_top_level_comps_can_access_full_outer_ctx(self): + +class PassthroughSlotsTest(BaseTestCase): + @parametrize_context_behavior(["isolated", "django"]) + def test_if_for(self): + @register("test") class SlottedComponent(Component): template: types.django_html = """ {% load component_tags %} -
-
{% slot "main" default %}Easy to override{% endslot %}
-
+ +
{% slot "header" %}Default header{% endslot %}
+
{% slot "main" %}Default main{% endslot %}
+
{% slot "footer" %}Default footer{% endslot %}
+
""" def get_context_data(self, name: Optional[str] = None) -> Dict[str, Any]: @@ -492,35 +786,271 @@ class ComponentSlottedTemplateTagTest(BaseTestCase): "name": name, } - registry.register("test", SlottedComponent) - template_str: types.django_html = """ {% load component_tags %} - - {% component "test" %} - ABC: {{ name }} {{ some }} - {% endcomponent %} - + {% component "test" %} + {% if slot_names %} + {% for slot in slot_names %} + {% fill name=slot default="default" %} + OVERRIDEN_SLOT "{{ slot }}" - INDEX {{ forloop.counter0 }} - ORIGINAL "{{ default }}" + {% endfill %} + {% endfor %} + {% endif %} + + {% if 1 > 2 %} + {% fill "footer" %} + FOOTER + {% endfill %} + {% endif %} + {% endcomponent %} """ - self.template = Template(template_str) - - nested_ctx = Context() - # Check that the component can access vars across different context layers - nested_ctx.push({"some": "var"}) - nested_ctx.push({"name": "carl"}) - rendered = self.template.render(nested_ctx) + template = Template(template_str) + rendered = template.render(Context({"slot_names": ["header", "main"]})) self.assertHTMLEqual( rendered, """ - -
-
ABC: carl var
-
- + +
+ OVERRIDEN_SLOT "header" - INDEX 0 - ORIGINAL "Default header" +
+
+ OVERRIDEN_SLOT "main" - INDEX 1 - ORIGINAL "Default main" +
+
+ Default footer +
+
""", ) + @parametrize_context_behavior(["isolated", "django"]) + def test_with(self): + @register("test") + class SlottedComponent(Component): + template: types.django_html = """ + {% load component_tags %} + +
{% slot "header" %}Default header{% endslot %}
+
{% slot "main" %}Default main{% endslot %}
+
{% slot "footer" %}Default footer{% endslot %}
+
+ """ + + def get_context_data(self, name: Optional[str] = None) -> Dict[str, Any]: + return { + "name": name, + } + + template_str: types.django_html = """ + {% load component_tags %} + {% component "test" %} + {% with slot="header" %} + {% fill name=slot default="default" %} + OVERRIDEN_SLOT "{{ slot }}" - ORIGINAL "{{ default }}" + {% endfill %} + {% endwith %} + {% endcomponent %} + """ + template = Template(template_str) + + rendered = template.render(Context()) + self.assertHTMLEqual( + rendered, + """ + +
+ OVERRIDEN_SLOT "header" - ORIGINAL "Default header" +
+
Default main
+
Default footer
+
+ """, + ) + + @parametrize_context_behavior(["isolated", "django"]) + def test_if_for_raises_on_content_outside_fill(self): + @register("test") + class SlottedComponent(Component): + template: types.django_html = """ + {% load component_tags %} + +
{% slot "header" %}Default header{% endslot %}
+
{% slot "main" %}Default main{% endslot %}
+
{% slot "footer" %}Default footer{% endslot %}
+
+ """ + + def get_context_data(self, name: Optional[str] = None) -> Dict[str, Any]: + return { + "name": name, + } + + template_str: types.django_html = """ + {% load component_tags %} + {% component "test" %} + {% if slot_names %} + {% for slot in slot_names %} + {{ forloop.counter0 }} + {% fill name=slot default="default" %} + OVERRIDEN_SLOT + {% endfill %} + {% endfor %} + {% endif %} + + {% if 1 > 2 %} + {% fill "footer" %} + FOOTER + {% endfill %} + {% endif %} + {% endcomponent %} + """ + template = Template(template_str) + + with self.assertRaisesMessage(TemplateSyntaxError, "Illegal content passed to component 'test'"): + template.render(Context({"slot_names": ["header", "main"]})) + + @parametrize_context_behavior(["django", "isolated"]) + def test_slots_inside_loops(self): + @register("test_comp") + class OuterComp(Component): + def get_context_data(self, name: Optional[str] = None) -> Dict[str, Any]: + return { + "slots": ["header", "main", "footer"], + } + + template: types.django_html = """ + {% load component_tags %} + {% for slot_name in slots %} +
+ {% slot name=slot_name %} + {{ slot_name }} + {% endslot %} +
+ {% endfor %} + """ + + template_str: types.django_html = """ + {% load component_tags %} + {% component "test_comp" %} + {% fill "header" %} + CUSTOM HEADER + {% endfill %} + {% fill "main" %} + CUSTOM MAIN + {% endfill %} + {% endcomponent %} + """ + template = Template(template_str) + rendered = template.render(Context()) + + expected = """ +
CUSTOM HEADER
+
CUSTOM MAIN
+
footer
+ """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["django", "isolated"]) + def test_passthrough_slots(self): + registry.register("slotted", SlottedComponent) + + @register("test_comp") + class OuterComp(Component): + def get_context_data(self, name: Optional[str] = None) -> Dict[str, Any]: + return { + "slots": self.input.slots, + } + + template: types.django_html = """ + {% load component_tags %} +
+ {% component "slotted" %} + {% for slot_name in slots %} + {% fill name=slot_name %} + {% slot name=slot_name / %} + {% endfill %} + {% endfor %} + {% endcomponent %} +
+ """ + + template_str: types.django_html = """ + {% load component_tags %} + {% component "test_comp" %} + {% fill "header" %} + CUSTOM HEADER + {% endfill %} + {% fill "main" %} + CUSTOM MAIN + {% endfill %} + {% endcomponent %} + """ + template = Template(template_str) + rendered = template.render(Context()) + + expected = """ +
+ +
CUSTOM HEADER
+
CUSTOM MAIN
+
Default footer
+
+
+ """ + self.assertHTMLEqual(rendered, expected) + + # NOTE: Ideally we'd (optionally) raise an error / warning here, but it's not possible + # with current implementation. So this tests serves as a documentation of the current behavior. + @parametrize_context_behavior(["django", "isolated"]) + def test_passthrough_slots_unknown_fills_ignored(self): + registry.register("slotted", SlottedComponent) + + @register("test_comp") + class OuterComp(Component): + def get_context_data(self, name: Optional[str] = None) -> Dict[str, Any]: + return { + "slots": self.input.slots, + } + + template: types.django_html = """ + {% load component_tags %} +
+ {% component "slotted" %} + {% for slot_name in slots %} + {% fill name=slot_name %} + {% slot name=slot_name / %} + {% endfill %} + {% endfor %} + {% endcomponent %} +
+ """ + + template_str: types.django_html = """ + {% load component_tags %} + {% component "test_comp" %} + {% fill "header1" %} + CUSTOM HEADER + {% endfill %} + {% fill "main" %} + CUSTOM MAIN + {% endfill %} + {% endcomponent %} + """ + template = Template(template_str) + rendered = template.render(Context()) + + expected = """ +
+ +
Default header
+
CUSTOM MAIN
+
Default footer
+
+
+ """ + self.assertHTMLEqual(rendered, expected) + # See https://github.com/EmilStenstrom/django-components/issues/698 class NestedSlotsTests(BaseTestCase): @@ -1036,6 +1566,43 @@ class ScopedSlotTest(BaseTestCase): """ self.assertHTMLEqual(rendered, expected) + @parametrize_context_behavior(["django", "isolated"]) + def test_slot_data_and_default_on_default_slot(self): + @register("test") + class TestComponent(Component): + template: types.django_html = """ + {% load component_tags %} +
+ {% slot "slot_a" abc=abc var123=var123 %} Default text A {% endslot %} + {% slot "slot_b" abc=abc var123=var123 default %} Default text B {% endslot %} +
+ """ + + def get_context_data(self): + return { + "abc": "xyz", + "var123": 456, + } + + template: types.django_html = """ + {% load component_tags %} + {% component "test" %} + {% fill name="default" data="slot_data_in_fill" default="slot_var" %} + {{ slot_data_in_fill.abc }} + {{ slot_var }} + {{ slot_data_in_fill.var123 }} + {% endfill %} + {% endcomponent %} + """ + rendered = Template(template).render(Context()) + expected = """ +
+ Default text A + xyz Default text B 456 +
+ """ + self.assertHTMLEqual(rendered, expected) + @parametrize_context_behavior(["django", "isolated"]) def test_slot_data_raises_on_slot_data_and_slot_default_same_var(self): @register("test") @@ -1482,37 +2049,23 @@ class SlotFillTemplateSyntaxErrorTests(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_fill_with_no_parent_is_error(self): - with self.assertRaises(TemplateSyntaxError): + with self.assertRaisesMessage( + TemplateSyntaxError, + "FillNode.render() (AKA {% fill ... %} block) cannot be rendered outside of a Component context", + ): template_str: types.django_html = """ {% load component_tags %} {% fill "header" %}contents{% endfill %} """ Template(template_str).render(Context({})) - @parametrize_context_behavior(["django", "isolated"]) - def test_isolated_slot_is_error(self): - @register("broken_component") - class BrokenComponent(Component): - template: types.django_html = """ - {% load component_tags %} - {% include 'slotted_template.html' with context=None only %} - """ - - template_str: types.django_html = """ - {% load component_tags %} - {% component "broken_component" %} - {% fill "header" %}Custom header {% endfill %} - {% fill "main" %}Custom main{% endfill %} - {% fill "footer" %}Custom footer{% endfill %} - {% endcomponent %} - """ - - with self.assertRaises(KeyError): - Template(template_str).render(Context({})) - @parametrize_context_behavior(["django", "isolated"]) def test_non_unique_fill_names_is_error(self): - with self.assertRaises(TemplateSyntaxError): + with self.assertRaisesMessage( + TemplateSyntaxError, + "Multiple fill tags cannot target the same slot name in component 'test': " + "Detected duplicate fill tag name 'header'", + ): template_str: types.django_html = """ {% load component_tags %} {% component "test" %} @@ -1524,7 +2077,11 @@ class SlotFillTemplateSyntaxErrorTests(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_non_unique_fill_names_is_error_via_vars(self): - with self.assertRaises(TemplateSyntaxError): + with self.assertRaisesMessage( + TemplateSyntaxError, + "Multiple fill tags cannot target the same slot name in component 'test': " + "Detected duplicate fill tag name 'header'", + ): template_str: types.django_html = """ {% load component_tags %} {% with var1="header" var2="header" %} @@ -1647,3 +2204,86 @@ class SlotBehaviorTests(BaseTestCase): """, ) + + +class SlotInputTests(BaseTestCase): + @parametrize_context_behavior(["django", "isolated"]) + def test_slots_accessible_when_python_render(self): + slots: Dict = {} + + @register("test") + class SlottedComponent(Component): + template: types.django_html = """ + {% load component_tags %} +
{% slot "header" %}Default header{% endslot %}
+
{% slot "main" %}Default main header{% endslot %}
+
{% slot "footer" %}Default footer{% endslot %}
+ """ + + def get_context_data(self, input: Optional[int] = None) -> Dict[str, Any]: + nonlocal slots + slots = self.input.slots + return {} + + self.assertEqual(slots, {}) + + template_str: types.django_html = """ + {% load component_tags %} + {% component "test" input=1 %} + {% fill "header" data="data1" %} + data1_in_slot1: {{ data1|safe }} + {% endfill %} + {% fill "main" / %} + {% endcomponent %} + """ + template = Template(template_str) + template.render(Context()) + + self.assertListEqual( + list(slots.keys()), + ["header", "main"], + ) + self.assertTrue(callable(slots["header"])) + self.assertTrue(callable(slots["main"])) + self.assertTrue("footer" not in slots) + + @parametrize_context_behavior(["django", "isolated"]) + def test_slots_normalized_as_slot_instances(self): + slots: Dict[str, Slot] = {} + + @register("test") + class SlottedComponent(Component): + template: types.django_html = """ + {% load component_tags %} +
{% slot "header" %}Default header{% endslot %}
+
{% slot "main" %}Default main header{% endslot %}
+
{% slot "footer" %}Default footer{% endslot %}
+ """ + + def get_context_data(self, input: Optional[int] = None) -> Dict[str, Any]: + nonlocal slots + slots = self.input.slots + return {} + + self.assertEqual(slots, {}) + + header_slot = Slot(lambda *a, **kw: "HEADER_SLOT") + main_slot_str = "MAIN_SLOT" + footer_slot_fn = lambda *a, **kw: "FOOTER_SLOT" # noqa: E731 + + SlottedComponent.render( + slots={ + "header": header_slot, + "main": main_slot_str, + "footer": footer_slot_fn, + } + ) + + self.assertIsInstance(slots["header"], Slot) + self.assertEqual(slots["header"](Context(), None, None), "HEADER_SLOT") # type: ignore[arg-type] + + self.assertIsInstance(slots["main"], Slot) + self.assertEqual(slots["main"](Context(), None, None), "MAIN_SLOT") # type: ignore[arg-type] + + self.assertIsInstance(slots["footer"], Slot) + self.assertEqual(slots["footer"](Context(), None, None), "FOOTER_SLOT") # type: ignore[arg-type] diff --git a/tests/test_utils.py b/tests/test_utils.py index efa147bf..75849ce5 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,4 +1,4 @@ -from django_components.utils import is_str_wrapped_in_quotes +from django_components.util.misc import is_str_wrapped_in_quotes from .django_test_setup import setup_test_config from .testutils import BaseTestCase diff --git a/tests/testutils.py b/tests/testutils.py index 6251e92b..21731587 100644 --- a/tests/testutils.py +++ b/tests/testutils.py @@ -2,7 +2,7 @@ import contextlib import functools import sys from typing import Any, Dict, List, Optional, Tuple, Union -from unittest.mock import Mock +from unittest.mock import Mock, patch from django.template import Context, Node from django.template.loader import engines @@ -10,7 +10,7 @@ from django.template.response import TemplateResponse from django.test import SimpleTestCase, override_settings from django_components.app_settings import ContextBehavior -from django_components.autodiscover import autodiscover +from django_components.autodiscovery import autodiscover from django_components.component_registry import registry from django_components.middleware import ComponentDependencyMiddleware @@ -20,7 +20,13 @@ middleware = ComponentDependencyMiddleware(get_response=lambda _: response_stash class BaseTestCase(SimpleTestCase): - def tearDown(self) -> None: + def setUp(self): + super().setUp() + self._start_gen_id_patch() + + def tearDown(self): + self._stop_gen_id_patch() + super().tearDown() registry.clear() @@ -28,6 +34,22 @@ class BaseTestCase(SimpleTestCase): _create_template.cache_remove() # type: ignore[attr-defined] + # Mock the `generate` function used inside `gen_id` so it returns deterministic IDs + def _start_gen_id_patch(self): + # Random number so that the generated IDs are "hex-looking", e.g. a1bc3d + self._gen_id_count = 10599485 + + def mock_gen_id(*args, **kwargs): + self._gen_id_count += 1 + return hex(self._gen_id_count)[2:] + + self._gen_id_patch = patch("django_components.util.misc.generate", side_effect=mock_gen_id) + self._gen_id_patch.start() + + def _stop_gen_id_patch(self): + self._gen_id_patch.stop() + self._gen_id_count = 10599485 + request = Mock() mock_template = Mock() @@ -142,12 +164,16 @@ def parametrize_context_behavior(cases: List[ContextBehParam], settings: Optiona # Because of this, we need to clear the loader cache, and, on error, we need to # propagate the info on which test case failed. @functools.wraps(test_func) - def wrapper(*args, **kwargs): + def wrapper(self: BaseTestCase, *args, **kwargs): for case in cases: # Clear loader cache, see https://stackoverflow.com/a/77531127/9788634 for engine in engines.all(): engine.engine.template_loaders[0].reset() + # Reset gen_id + self._stop_gen_id_patch() + self._start_gen_id_patch() + case_has_data = not isinstance(case, str) if isinstance(case, str): @@ -169,9 +195,9 @@ def parametrize_context_behavior(cases: List[ContextBehParam], settings: Optiona # Call the test function with the fixture as an argument try: if case_has_data: - test_func(*args, context_behavior_data=fixture, **kwargs) + test_func(self, *args, context_behavior_data=fixture, **kwargs) else: - test_func(*args, **kwargs) + test_func(self, *args, **kwargs) except Exception as err: # Give a hint on which iteration the test failed raise RuntimeError( From d093bfb05212555e14c5cd46c94b0ba057cbeceb Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Mon, 25 Nov 2024 09:57:42 +0100 Subject: [PATCH 101/487] chore: bump v0.110 (#768) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 52bb24f4..65f09fa9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "django_components" -version = "0.102" +version = "0.110" requires-python = ">=3.8, <4.0" description = "A way to create simple reusable template components in Django." keywords = ["django", "components", "css", "js", "html"] From 42093bd8d86a303240d59ac16185f078cc781f8b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 18:01:20 +0000 Subject: [PATCH 102/487] build(deps-dev): bump selectolax from 0.3.21 to 0.3.26 Bumps [selectolax](https://github.com/rushter/selectolax) from 0.3.21 to 0.3.26. - [Release notes](https://github.com/rushter/selectolax/releases) - [Changelog](https://github.com/rushter/selectolax/blob/master/CHANGES.rst) - [Commits](https://github.com/rushter/selectolax/compare/v0.3.21...v0.3.26) --- updated-dependencies: - dependency-name: selectolax dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 18464dd5..a46f930d 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -91,7 +91,7 @@ pyyaml==6.0.1 # via pre-commit requests==2.32.3 # via -r requirements-dev.in -selectolax==0.3.21 +selectolax==0.3.26 # via -r requirements-dev.in sqlparse==0.5.0 # via django From cc3279923c000ff850559c4fe6a9d4c9f83f847e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 18:04:34 +0000 Subject: [PATCH 103/487] build(deps): bump mkdocs-redirects from 1.2.1 to 1.2.2 Bumps [mkdocs-redirects](https://github.com/mkdocs/mkdocs-redirects) from 1.2.1 to 1.2.2. - [Release notes](https://github.com/mkdocs/mkdocs-redirects/releases) - [Commits](https://github.com/mkdocs/mkdocs-redirects/compare/v1.2.1...v1.2.2) --- updated-dependencies: - dependency-name: mkdocs-redirects dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 6c28f4b4..9f761923 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -140,7 +140,7 @@ mkdocs-material-extensions==1.3.1 # via mkdocs-material mkdocs-minify-plugin==0.8.0 # via hatch.envs.docs -mkdocs-redirects==1.2.1 +mkdocs-redirects==1.2.2 # via hatch.envs.docs mkdocstrings==0.27.0 # via From f9c181fd4f9cb20e0b27a8b3676241bf809e5cce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 18:07:58 +0000 Subject: [PATCH 104/487] build(deps): bump mkdocs-include-markdown-plugin from 7.0.0 to 7.1.1 Bumps [mkdocs-include-markdown-plugin](https://github.com/mondeja/mkdocs-include-markdown-plugin) from 7.0.0 to 7.1.1. - [Release notes](https://github.com/mondeja/mkdocs-include-markdown-plugin/releases) - [Commits](https://github.com/mondeja/mkdocs-include-markdown-plugin/compare/v7.0.0...v7.1.1) --- updated-dependencies: - dependency-name: mkdocs-include-markdown-plugin dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 9f761923..0bc3b391 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -130,7 +130,7 @@ mkdocs-git-authors-plugin==0.9.2 # via hatch.envs.docs mkdocs-git-revision-date-localized-plugin==1.3.0 # via hatch.envs.docs -mkdocs-include-markdown-plugin==7.0.0 +mkdocs-include-markdown-plugin==7.1.1 # via hatch.envs.docs mkdocs-literate-nav==0.6.1 # via hatch.envs.docs From a2803920305bafc9767dfb439014558a9306df38 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 18:10:45 +0000 Subject: [PATCH 105/487] build(deps): bump django from 5.1.1 to 5.1.3 Bumps [django](https://github.com/django/django) from 5.1.1 to 5.1.3. - [Commits](https://github.com/django/django/compare/5.1.1...5.1.3) --- updated-dependencies: - dependency-name: django dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 0bc3b391..b06398fa 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -56,7 +56,7 @@ cssselect2==0.7.0 # via cairosvg defusedxml==0.7.1 # via cairosvg -django==5.1.2 +django==5.1.3 # via hatch.envs.docs ghp-import==2.1.0 # via mkdocs From 8b87d3d3d6ed170ec65548652a1ce98894eb0e1c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 18:14:08 +0000 Subject: [PATCH 106/487] build(deps): bump pymdown-extensions from 10.11.2 to 10.12 Bumps [pymdown-extensions](https://github.com/facelessuser/pymdown-extensions) from 10.11.2 to 10.12. - [Release notes](https://github.com/facelessuser/pymdown-extensions/releases) - [Commits](https://github.com/facelessuser/pymdown-extensions/compare/10.11.2...10.12) --- updated-dependencies: - dependency-name: pymdown-extensions dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index b06398fa..5ba7efcb 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -173,7 +173,7 @@ pycparser==2.22 # via cffi pygments==2.18.0 # via mkdocs-material -pymdown-extensions==10.11.2 +pymdown-extensions==10.12 # via # hatch.envs.docs # markdown-exec From a7972895f967d921fb2e4d3e8aec168edce23885 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 18:17:49 +0000 Subject: [PATCH 107/487] build(deps): bump playwright from 1.48.0 to 1.49.0 Bumps [playwright](https://github.com/microsoft/playwright-python) from 1.48.0 to 1.49.0. - [Release notes](https://github.com/microsoft/playwright-python/releases) - [Commits](https://github.com/microsoft/playwright-python/compare/v1.48.0...v1.49.0) --- updated-dependencies: - dependency-name: playwright dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-ci.txt | 2 +- requirements-dev.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements-ci.txt b/requirements-ci.txt index c2c1dce0..f3743fad 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -32,7 +32,7 @@ platformdirs==4.3.6 # via # tox # virtualenv -playwright==1.48.0 +playwright==1.49.0 # via -r requirements-ci.in pluggy==1.5.0 # via tox diff --git a/requirements-dev.txt b/requirements-dev.txt index a46f930d..bd1120c2 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -69,7 +69,7 @@ platformdirs==4.3.6 # black # tox # virtualenv -playwright==1.48.0 +playwright==1.49.0 # via -r requirements-dev.in pluggy==1.5.0 # via From e00e0433062c5960e813700961e2010e4104ec96 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Mon, 25 Nov 2024 19:38:20 +0000 Subject: [PATCH 108/487] chore: bump playwright --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index de14a0ca..d99ce07f 100644 --- a/tox.ini +++ b/tox.ini @@ -33,7 +33,7 @@ deps = # NOTE: Keep playwright is sync with the version in requirements-ci.txt # Othrwise we get error: # playwright._impl._errors.Error: BrowserType.launch: Executable doesn't exist at /home/runner/.cache/ms-playwright/chromium-1140/chrome-linux/chrome - playwright==1.48.0 + playwright==1.49.0 requests types-requests whitenoise @@ -52,7 +52,7 @@ commands = isort --check-only --diff src/django_components deps = pytest-coverage # NOTE: Keep playwright in sync with the version in requirements-ci.txt - playwright==1.48.0 + playwright==1.49.0 requests types-requests whitenoise From 4d5fecf3eeac913d482976275eed4d345b979c3f Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Mon, 25 Nov 2024 23:46:52 +0100 Subject: [PATCH 109/487] docs: Split README (#775) --- src/django_components/component.py | 10 + src/django_components/components/dynamic.py | 10 + src/docs/CODE_OF_CONDUCT.md | 1 - src/docs/README.md | 1 - src/docs/SUMMARY.md | 18 +- .../advanced/authoring_component_libraries.md | 255 +++++++ .../concepts/advanced/component_registry.md | 151 ++++ src/docs/concepts/advanced/hooks.md | 84 +++ src/docs/concepts/advanced/provide_inject.md | 135 ++++ .../concepts/advanced/rendering_js_css.md | 227 ++++++ src/docs/concepts/advanced/tag_formatter.md | 171 +++++ .../advanced/typing_and_validation.md | 178 +++++ .../fundamentals/access_component_input.md | 39 + .../concepts/fundamentals/autodiscovery.md | 68 ++ .../fundamentals/component_context_scope.md | 186 +++++ .../fundamentals/components_as_views.md | 153 ++++ .../fundamentals/components_in_python.md | 133 ++++ .../fundamentals/components_in_templates.md | 51 ++ .../defining_js_css_html_files.md | 203 ++++++ .../concepts/fundamentals/html_attributes.md | 378 ++++++++++ .../fundamentals/single_file_components.md | 37 + src/docs/concepts/fundamentals/slots.md | 670 ++++++++++++++++++ .../fundamentals/template_tag_syntax.md | 290 ++++++++ .../fundamentals/your_first_component.md | 88 +++ src/docs/guides/setup/dev_server_setup.md | 26 + .../guides/setup/logging_and_debugging.md | 34 + src/docs/guides/setup/syntax_highlight.md | 45 ++ src/docs/license.md | 3 - src/docs/overview/code_of_conduct.md | 6 + src/docs/overview/community.md | 16 + src/docs/overview/compatibility.md | 15 + src/docs/overview/development.md | 145 ++++ src/docs/overview/installation.md | 162 +++++ src/docs/overview/license.md | 6 + src/docs/overview/security_notes.md | 59 ++ src/docs/overview/welcome.md | 75 ++ 36 files changed, 4118 insertions(+), 11 deletions(-) delete mode 100644 src/docs/CODE_OF_CONDUCT.md delete mode 100644 src/docs/README.md create mode 100644 src/docs/concepts/advanced/authoring_component_libraries.md create mode 100644 src/docs/concepts/advanced/component_registry.md create mode 100644 src/docs/concepts/advanced/hooks.md create mode 100644 src/docs/concepts/advanced/provide_inject.md create mode 100644 src/docs/concepts/advanced/rendering_js_css.md create mode 100644 src/docs/concepts/advanced/tag_formatter.md create mode 100644 src/docs/concepts/advanced/typing_and_validation.md create mode 100644 src/docs/concepts/fundamentals/access_component_input.md create mode 100644 src/docs/concepts/fundamentals/autodiscovery.md create mode 100644 src/docs/concepts/fundamentals/component_context_scope.md create mode 100644 src/docs/concepts/fundamentals/components_as_views.md create mode 100644 src/docs/concepts/fundamentals/components_in_python.md create mode 100644 src/docs/concepts/fundamentals/components_in_templates.md create mode 100644 src/docs/concepts/fundamentals/defining_js_css_html_files.md create mode 100644 src/docs/concepts/fundamentals/html_attributes.md create mode 100644 src/docs/concepts/fundamentals/single_file_components.md create mode 100644 src/docs/concepts/fundamentals/slots.md create mode 100644 src/docs/concepts/fundamentals/template_tag_syntax.md create mode 100644 src/docs/concepts/fundamentals/your_first_component.md create mode 100644 src/docs/guides/setup/dev_server_setup.md create mode 100644 src/docs/guides/setup/logging_and_debugging.md create mode 100644 src/docs/guides/setup/syntax_highlight.md delete mode 100644 src/docs/license.md create mode 100644 src/docs/overview/code_of_conduct.md create mode 100644 src/docs/overview/community.md create mode 100644 src/docs/overview/compatibility.md create mode 100644 src/docs/overview/development.md create mode 100644 src/docs/overview/installation.md create mode 100644 src/docs/overview/license.md create mode 100644 src/docs/overview/security_notes.md create mode 100644 src/docs/overview/welcome.md diff --git a/src/django_components/component.py b/src/django_components/component.py index 46b5f52c..5741b756 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -139,6 +139,16 @@ class ComponentVars(NamedTuple): {% endif %} ``` + + This is equivalent to checking if a given key is among the slot fills: + + ```py + class MyTable(Component): + def get_context_data(self, *args, **kwargs): + return { + "my_slot_filled": "my_slot" in self.input.slots + } + ``` """ diff --git a/src/django_components/components/dynamic.py b/src/django_components/components/dynamic.py index b438676a..c8e2126c 100644 --- a/src/django_components/components/dynamic.py +++ b/src/django_components/components/dynamic.py @@ -35,6 +35,16 @@ class DynamicComponent(Component): {% endcomponent %} ``` + Or in case you use the `django_components.component_shorthand_formatter` tag formatter: + + ```django + {% dynamic is=table_comp data=table_data headers=table_headers %} + {% fill "pagination" %} + {% component "pagination" / %} + {% endfill %} + {% enddynamic %} + ``` + Python ```py from django_components import DynamicComponent diff --git a/src/docs/CODE_OF_CONDUCT.md b/src/docs/CODE_OF_CONDUCT.md deleted file mode 100644 index 01f2ea20..00000000 --- a/src/docs/CODE_OF_CONDUCT.md +++ /dev/null @@ -1 +0,0 @@ ---8<-- "CODE_OF_CONDUCT.md" diff --git a/src/docs/README.md b/src/docs/README.md deleted file mode 100644 index 612c7a5e..00000000 --- a/src/docs/README.md +++ /dev/null @@ -1 +0,0 @@ ---8<-- "README.md" diff --git a/src/docs/SUMMARY.md b/src/docs/SUMMARY.md index 18286a21..fa0b968e 100644 --- a/src/docs/SUMMARY.md +++ b/src/docs/SUMMARY.md @@ -1,6 +1,12 @@ -* [README](README.md) - * [Changelog](CHANGELOG.md) - * [Code of Conduct](CODE_OF_CONDUCT.md) - * [License](license.md) -* Reference - * [API Reference](reference/) \ No newline at end of file + +- [Get Started](overview/) +- Concepts + - [Fundamentals](concepts/fundamentals/) + - [Advanced](concepts/advanced/) +- Guides + - [Setup](guides/setup/) + - [Cookbook](guides/cookbook/) +- [API Documentation](reference/) +- [Changelog](changelog.md) + + diff --git a/src/docs/concepts/advanced/authoring_component_libraries.md b/src/docs/concepts/advanced/authoring_component_libraries.md new file mode 100644 index 00000000..f3423434 --- /dev/null +++ b/src/docs/concepts/advanced/authoring_component_libraries.md @@ -0,0 +1,255 @@ +--- +title: Authoring component libraries +weight: 7 +--- + +You can publish and share your components for others to use. Here are the steps to do so: + +## Writing component libraries + +1. Create a Django project with a similar structure: + + ```txt + project/ + |-- myapp/ + |-- __init__.py + |-- apps.py + |-- templates/ + |-- table/ + |-- table.py + |-- table.js + |-- table.css + |-- table.html + |-- menu.py <--- single-file component + |-- templatetags/ + |-- __init__.py + |-- mytags.py + ``` + +2. Create custom [`Library`](https://docs.djangoproject.com/en/5.1/howto/custom-template-tags/#how-to-create-custom-template-tags-and-filters) + and [`ComponentRegistry`](django_components.component_registry.ComponentRegistry) instances in `mytags.py` + + This will be the entrypoint for using the components inside Django templates. + + Remember that Django requires the [`Library`](https://docs.djangoproject.com/en/5.1/howto/custom-template-tags/#how-to-create-custom-template-tags-and-filters) + instance to be accessible under the `register` variable ([See Django docs](https://docs.djangoproject.com/en/dev/howto/custom-template-tags)): + + ```py + from django.template import Library + from django_components import ComponentRegistry, RegistrySettings + + register = library = django.template.Library() + comp_registry = ComponentRegistry( + library=library, + settings=RegistrySettings( + context_behavior="isolated", + tag_formatter="django_components.component_formatter", + ), + ) + ``` + + As you can see above, this is also the place where we configure how our components should behave, + using the [`settings`](django_components.component_registry.ComponentRegistry.settings) argument. + If omitted, default settings are used. + + For library authors, we recommend setting [`context_behavior`](django_components.app_settings.ComponentsSettings.context_behavior) + to [`"isolated"`](django_components.app_settings.ContextBehavior.ISOLATED), so that the state cannot leak into the components, + and so the components' behavior is configured solely through the inputs. This means that the components will be more predictable and easier to debug. + + Next, you can decide how will others use your components by setting the + [`tag_formatter`](django_components.app_settings.ComponentsSettings.tag_formatter) + options. + + If omitted or set to `"django_components.component_formatter"`, + your components will be used like this: + + ```django + {% component "table" items=items headers=headers %} + {% endcomponent %} + ``` + + Or you can use `"django_components.component_shorthand_formatter"` + to use components like so: + + ```django + {% table items=items headers=headers %} + {% endtable %} + ``` + + Or you can define a [custom TagFormatter](#tagformatter). + + Either way, these settings will be scoped only to your components. So, in the user code, + there may be components side-by-side that use different formatters: + + ```django + {% load mytags %} + + {# Component from your library "mytags", using the "shorthand" formatter #} + {% table items=items headers=header %} + {% endtable %} + + {# User-created components using the default settings #} + {% component "my_comp" title="Abc..." %} + {% endcomponent %} + ``` + +3. Write your components and register them with your instance of [`ComponentRegistry`](../../reference/api#ComponentRegistry) + + There's one difference when you are writing components that are to be shared, and that's + that the components must be explicitly registered with your instance of + [`ComponentRegistry`](../../reference/api#ComponentRegistry) from the previous step. + + For better user experience, you can also define the types for the args, kwargs, slots and data. + + It's also a good idea to have a common prefix for your components, so they can be easily distinguished from users' components. In the example below, we use the prefix `my_` / `My`. + + ```py + from typing import Dict, NotRequired, Optional, Tuple, TypedDict + + from django_components import Component, SlotFunc, register, types + + from myapp.templatetags.mytags import comp_registry + + # Define the types + class EmptyDict(TypedDict): + pass + + type MyMenuArgs = Tuple[int, str] + + class MyMenuSlots(TypedDict): + default: NotRequired[Optional[SlotFunc[EmptyDict]]] + + class MyMenuProps(TypedDict): + vertical: NotRequired[bool] + klass: NotRequired[str] + style: NotRequired[str] + + # Define the component + # NOTE: Don't forget to set the `registry`! + @register("my_menu", registry=comp_registry) + class MyMenu(Component[MyMenuArgs, MyMenuProps, MyMenuSlots, Any, Any, Any]): + def get_context_data( + self, + *args, + attrs: Optional[Dict] = None, + ): + return { + "attrs": attrs, + } + + template: types.django_html = """ + {# Load django_components template tags #} + {% load component_tags %} + +
+
+ {% slot "default" default / %} +
+
+ """ + ``` + +4. Import the components in `apps.py` + + Normally, users rely on [autodiscovery](../../concepts/autodiscovery) and [`COMPONENTS.dirs`](../../reference/settings#dirs) + to load the component files. + + Since you, as the library author, are not in control of the file system, it is recommended to load the components manually. + + We recommend doing this in the [`AppConfig.ready()`](https://docs.djangoproject.com/en/5.1/ref/applications/#django.apps.AppConfig.ready) + hook of your `apps.py`: + + ```py + from django.apps import AppConfig + + class MyAppConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "myapp" + + # This is the code that gets run when user adds myapp + # to Django's INSTALLED_APPS + def ready(self) -> None: + # Import the components that you want to make available + # inside the templates. + from myapp.templates import ( + menu, + table, + ) + ``` + + Note that you can also include any other startup logic within + [`AppConfig.ready()`](https://docs.djangoproject.com/en/5.1/ref/applications/#django.apps.AppConfig.ready). + +And that's it! The next step is to publish it. + +## Publishing component libraries + +Once you are ready to share your library, you need to build +a distribution and then publish it to PyPI. + +django_components uses the [`build`](https://build.pypa.io/en/stable/) utility to build a distribution: + +```bash +python -m build --sdist --wheel --outdir dist/ . +``` + +And to publish to PyPI, you can use [`twine`](https://docs.djangoproject.com/en/5.1/ref/applications/#django.apps.AppConfig.ready) +([See Python user guide](https://packaging.python.org/en/latest/tutorials/packaging-projects/#uploading-the-distribution-archives)) + +```bash +twine upload --repository pypi dist/* -u __token__ -p +``` + +Notes on publishing: + +- If you use components where the HTML / CSS / JS files are separate, you may need to define + [`MANIFEST.in`](https://setuptools.pypa.io/en/latest/userguide/miscellaneous.html) + to include those files with the distribution + ([see user guide](https://setuptools.pypa.io/en/latest/userguide/miscellaneous.html)). + +## Installing and using component libraries + +After the package has been published, all that remains is to install it in other django projects: + +1. Install the package: + + ```bash + pip install myapp django_components + ``` + +2. Add the package to `INSTALLED_APPS` + + ```py + INSTALLED_APPS = [ + ... + "django_components", + "myapp", + ] + ``` + +3. Optionally add the template tags to the [`builtins`](https://docs.djangoproject.com/en/5.1/topics/templates/#django.template.backends.django.DjangoTemplates), + so you don't have to call `{% load mytags %}` in every template: + + ```python + TEMPLATES = [ + { + ..., + 'OPTIONS': { + 'context_processors': [ + ... + ], + 'builtins': [ + 'myapp.templatetags.mytags', + ] + }, + }, + ] + ``` + +4. And, at last, you can use the components in your own project! + + ```django + {% my_menu title="Abc..." %} + Hello World! + {% endmy_menu %} + ``` diff --git a/src/docs/concepts/advanced/component_registry.md b/src/docs/concepts/advanced/component_registry.md new file mode 100644 index 00000000..da8c0c9c --- /dev/null +++ b/src/docs/concepts/advanced/component_registry.md @@ -0,0 +1,151 @@ +--- +title: Registering components +weight: 4 +--- + +In previous examples you could repeatedly see us using `@register()` to "register" +the components. In this section we dive deeper into what it actually means and how you can +manage (add or remove) components. + +As a reminder, we may have a component like this: + +```python +from django_components import Component, register + +@register("calendar") +class Calendar(Component): + template_name = "template.html" + + # This component takes one parameter, a date string to show in the template + def get_context_data(self, date): + return { + "date": date, + } +``` + +which we then render in the template as: + +```django +{% component "calendar" date="1970-01-01" %} +{% endcomponent %} +``` + +As you can see, `@register` links up the component class +with the `{% component %}` template tag. So when the template tag comes across +a component called `"calendar"`, it can look up it's class and instantiate it. + +## What is ComponentRegistry + +The `@register` decorator is a shortcut for working with the `ComponentRegistry`. + +`ComponentRegistry` manages which components can be used in the template tags. + +Each `ComponentRegistry` instance is associated with an instance +of Django's `Library`. And Libraries are inserted into Django template +using the `{% load %}` tags. + +The `@register` decorator accepts an optional kwarg `registry`, which specifies, the `ComponentRegistry` to register components into. +If omitted, the default `ComponentRegistry` instance defined in django_components is used. + +```py +my_registry = ComponentRegistry() + +@register(registry=my_registry) +class MyComponent(Component): + ... +``` + +The default `ComponentRegistry` is associated with the `Library` that +you load when you call `{% load component_tags %}` inside your template, or when you +add `django_components.templatetags.component_tags` to the template builtins. + +So when you register or unregister a component to/from a component registry, +then behind the scenes the registry automatically adds/removes the component's +template tags to/from the Library, so you can call the component from within the templates +such as `{% component "my_comp" %}`. + +## Working with ComponentRegistry + +The default `ComponentRegistry` instance can be imported as: + +```py +from django_components import registry +``` + +You can use the registry to manually add/remove/get components: + +```py +from django_components import registry + +# Register components +registry.register("button", ButtonComponent) +registry.register("card", CardComponent) + +# Get all or single +registry.all() # {"button": ButtonComponent, "card": CardComponent} +registry.get("card") # CardComponent + +# Unregister single component +registry.unregister("card") + +# Unregister all components +registry.clear() +``` + +## Registering components to custom ComponentRegistry + +If you are writing a component library to be shared with others, you may want to manage your own instance of `ComponentRegistry` +and register components onto a different `Library` instance than the default one. + +The `Library` instance can be set at instantiation of `ComponentRegistry`. If omitted, +then the default Library instance from django_components is used. + +```py +from django.template import Library +from django_components import ComponentRegistry + +my_library = Library(...) +my_registry = ComponentRegistry(library=my_library) +``` + +When you have defined your own `ComponentRegistry`, you can either register the components +with `my_registry.register()`, or pass the registry to the `@component.register()` decorator +via the `registry` kwarg: + +```py +from path.to.my.registry import my_registry + +@register("my_component", registry=my_registry) +class MyComponent(Component): + ... +``` + +NOTE: The Library instance can be accessed under `library` attribute of `ComponentRegistry`. + +## ComponentRegistry settings + +When you are creating an instance of `ComponentRegistry`, you can define the components' behavior within the template. + +The registry accepts these settings: +- `context_behavior` +- `tag_formatter` + +```py +from django.template import Library +from django_components import ComponentRegistry, RegistrySettings + +register = library = django.template.Library() +comp_registry = ComponentRegistry( + library=library, + settings=RegistrySettings( + context_behavior="isolated", + tag_formatter="django_components.component_formatter", + ), +) +``` + +These settings are [the same as the ones you can set for django_components](#available-settings). + +In fact, when you set `COMPONENT.tag_formatter` or `COMPONENT.context_behavior`, these are forwarded to the default `ComponentRegistry`. + +This makes it possible to have multiple registries with different settings in one projects, and makes sharing of component libraries possible. diff --git a/src/docs/concepts/advanced/hooks.md b/src/docs/concepts/advanced/hooks.md new file mode 100644 index 00000000..9e0e47d2 --- /dev/null +++ b/src/docs/concepts/advanced/hooks.md @@ -0,0 +1,84 @@ +--- +title: Lifecycle hooks +weight: 3 +--- + +_New in version 0.96_ + +Component hooks are functions that allow you to intercept the rendering process at specific positions. + +## Available hooks + +- `on_render_before` + + ```py + def on_render_before( + self: Component, + context: Context, + template: Template + ) -> None: + ``` + + Hook that runs just before the component's template is rendered. + + You can use this hook to access or modify the context or the template: + + ```py + def on_render_before(self, context, template) -> None: + # Insert value into the Context + context["from_on_before"] = ":)" + + # Append text into the Template + template.nodelist.append(TextNode("FROM_ON_BEFORE")) + ``` + +- `on_render_after` + + ```py + def on_render_after( + self: Component, + context: Context, + template: Template, + content: str + ) -> None | str | SafeString: + ``` + + Hook that runs just after the component's template was rendered. + It receives the rendered output as the last argument. + + You can use this hook to access the context or the template, but modifying + them won't have any effect. + + To override the content that gets rendered, you can return a string or SafeString from this hook: + + ```py + def on_render_after(self, context, template, content): + # Prepend text to the rendered content + return "Chocolate cookie recipe: " + content + ``` + +## Component hooks example + +You can use hooks together with [provide / inject](#how-to-use-provide--inject) to create components +that accept a list of items via a slot. + +In the example below, each `tab_item` component will be rendered on a separate tab page, but they are all defined in the default slot of the `tabs` component. + +[See here for how it was done](https://github.com/EmilStenstrom/django-components/discussions/540) + +```django +{% component "tabs" %} + {% component "tab_item" header="Tab 1" %} +

+ hello from tab 1 +

+ {% component "button" %} + Click me! + {% endcomponent %} + {% endcomponent %} + + {% component "tab_item" header="Tab 2" %} + Hello this is tab 2 + {% endcomponent %} +{% endcomponent %} +``` diff --git a/src/docs/concepts/advanced/provide_inject.md b/src/docs/concepts/advanced/provide_inject.md new file mode 100644 index 00000000..e6df9ed4 --- /dev/null +++ b/src/docs/concepts/advanced/provide_inject.md @@ -0,0 +1,135 @@ +--- +title: Prop drilling and provide / inject +weight: 2 +--- + +_New in version 0.80_: + +Django components supports the provide / inject or ContextProvider pattern with the combination of: + +1. `{% provide %}` tag +1. `inject()` method of the `Component` class + +## What is "prop drilling"? + +Prop drilling refers to a scenario in UI development where you need to pass data through many layers of a component tree to reach the nested components that actually need the data. + +Normally, you'd use props to send data from a parent component to its children. However, this straightforward method becomes cumbersome and inefficient if the data has to travel through many levels or if several components scattered at different depths all need the same piece of information. + +This results in a situation where the intermediate components, which don't need the data for their own functioning, end up having to manage and pass along these props. This clutters the component tree and makes the code verbose and harder to manage. + +A neat solution to avoid prop drilling is using the "provide and inject" technique. + +With provide / inject, a parent component acts like a data hub for all its descendants. This setup allows any component, no matter how deeply nested it is, to access the required data directly from this centralized provider without having to messily pass props down the chain. This approach significantly cleans up the code and makes it easier to maintain. + +This feature is inspired by Vue's [Provide / Inject](https://vuejs.org/guide/components/provide-inject) and React's [Context / useContext](https://react.dev/learn/passing-data-deeply-with-context). + +## How to use provide / inject + +As the name suggest, using provide / inject consists of 2 steps + +1. Providing data +2. Injecting provided data + +For examples of advanced uses of provide / inject, [see this discussion](https://github.com/EmilStenstrom/django-components/pull/506#issuecomment-2132102584). + +## Using `{% provide %}` tag + +First we use the `{% provide %}` tag to define the data we want to "provide" (make available). + +```django +{% provide "my_data" key="hi" another=123 %} + {% component "child" / %} <--- Can access "my_data" +{% endprovide %} + +{% component "child" / %} <--- Cannot access "my_data" +``` + +Notice that the `provide` tag REQUIRES a name as a first argument. This is the _key_ by which we can then access the data passed to this tag. + +`provide` tag name must resolve to a valid identifier (AKA a valid Python variable name). + +Once you've set the name, you define the data you want to "provide" by passing it as keyword arguments. This is similar to how you pass data to the `{% with %}` tag. + +> NOTE: Kwargs passed to `{% provide %}` are NOT added to the context. +> In the example below, the `{{ key }}` won't render anything: +> +> ```django +> {% provide "my_data" key="hi" another=123 %} +> {{ key }} +> {% endprovide %} +> ``` + +Similarly to [slots and fills](#dynamic-slots-and-fills), also provide's name argument can be set dynamically via a variable, a template expression, or a spread operator: + +```django +{% provide name=name ... %} + ... +{% provide %} + +``` + +## Using `inject()` method + +To "inject" (access) the data defined on the `provide` tag, you can use the `inject()` method inside of `get_context_data()`. + +For a component to be able to "inject" some data, the component (`{% component %}` tag) must be nested inside the `{% provide %}` tag. + +In the example from previous section, we've defined two kwargs: `key="hi" another=123`. That means that if we now inject `"my_data"`, we get an object with 2 attributes - `key` and `another`. + +```py +class ChildComponent(Component): + def get_context_data(self): + my_data = self.inject("my_data") + print(my_data.key) # hi + print(my_data.another) # 123 + return {} +``` + +First argument to `inject` is the _key_ (or _name_) of the provided data. This +must match the string that you used in the `provide` tag. If no provider +with given key is found, `inject` raises a `KeyError`. + +To avoid the error, you can pass a second argument to `inject` to which will act as a default value, similar to `dict.get(key, default)`: + +```py +class ChildComponent(Component): + def get_context_data(self): + my_data = self.inject("invalid_key", DEFAULT_DATA) + assert my_data == DEFAUKT_DATA + return {} +``` + +The instance returned from `inject()` is a subclass of `NamedTuple`, so the instance is immutable. This ensures that the data returned from `inject` will always +have all the keys that were passed to the `provide` tag. + +> NOTE: `inject()` works strictly only in `get_context_data`. If you try to call it from elsewhere, it will raise an error. + +## Full example + +```py +@register("child") +class ChildComponent(Component): + template = """ +
{{ my_data.key }}
+
{{ my_data.another }}
+ """ + + def get_context_data(self): + my_data = self.inject("my_data", "default") + return {"my_data": my_data} + +template_str = """ + {% load component_tags %} + {% provide "my_data" key="hi" another=123 %} + {% component "child" / %} + {% endprovide %} +""" +``` + +renders: + +```html +
hi
+
123
+``` diff --git a/src/docs/concepts/advanced/rendering_js_css.md b/src/docs/concepts/advanced/rendering_js_css.md new file mode 100644 index 00000000..364232dc --- /dev/null +++ b/src/docs/concepts/advanced/rendering_js_css.md @@ -0,0 +1,227 @@ +--- +title: Rendering JS / CSS +weight: 1 +--- + +### JS and CSS output locations + +If: + +1. Your components use JS and CSS via any of: + - [`Component.css`](#TODO) + - [`Component.js`](#TODO) + - [`Component.Media.css`](#TODO) + - [`Component.Media.js`](#TODO) +2. And you use the [`ComponentDependencyMiddleware`](#TODO) middleware + +Then, by default, the components' JS and CSS will be automatically inserted into the HTML: + +- CSS styles will be inserted at the end of the `` +- JS scripts will be inserted at the end of the `` + +If you want to place the dependencies elsewhere in the HTML, you can override +the locations by inserting following Django template tags: + +- [`{% component_js_dependencies %}`](#TODO) - Set new location(s) for JS scripts +- [`{% component_css_dependencies %}`](#TODO) - Set new location(s) for CSS styles + +So if you have a component with JS and CSS: + +```python +from django_components import Component, types + +class MyButton(Component): + template: types.django_html = """ + + """ + js: types.js = """ + for (const btnEl of document.querySelectorAll(".my-button")) { + btnEl.addEventListener("click", () => { + console.log("BUTTON CLICKED!"); + }); + } + """ + css: types.css """ + .my-button { + background: green; + } + """ + + class Media: + js = ["/extra/script.js"] + css = ["/extra/style.css"] +``` + +Then the JS from `MyButton.js` and `MyButton.Media.js` will be rendered at the default place, +or in [`{% component_js_dependencies %}`](#TODO). + +And the CSS from `MyButton.css` and `MyButton.Media.css` will be rendered at the default place, +or in [`{% component_css_dependencies %}`](#TODO). + +And if you don't specify `{% component_dependencies %}` tags, it is the equivalent of: + +```django + + + + MyPage + ... + {% component_css_dependencies %} + + +
+ ... +
+ {% component_js_dependencies %} + + +``` + +### Setting up the middleware + +[`ComponentDependencyMiddleware`](#TODO) is a Django [middleware](https://docs.djangoproject.com/en/5.1/topics/http/middleware/) +designed to manage and inject CSS / JS dependencies of rendered components dynamically. +It ensures that only the necessary stylesheets and scripts are loaded +in your HTML responses, based on the components used in your Django templates. + +To set it up, add the middleware to your [`MIDDLEWARE`](https://docs.djangoproject.com/en/5.1/ref/settings/#std-setting-MIDDLEWARE) +in `settings.py`: + +```python +MIDDLEWARE = [ + # ... other middleware classes ... + 'django_components.middleware.ComponentDependencyMiddleware' + # ... other middleware classes ... +] +``` + +### `render_dependencies` and rendering JS / CSS without the middleware + +For most scenarios, using the [`ComponentDependencyMiddleware`](#TODO) middleware will be just fine. + +However, this section is for you if you want to: + +- Render HTML that will NOT be sent as a server response +- Insert pre-rendered HTML into another component +- Render HTML fragments (partials) + +Every time there is an HTML string that has parts which were rendered using components, +and any of those components has JS / CSS, then this HTML string MUST be processed with [`render_dependencies()`](#TODO). + +It is actually [`render_dependencies()`](#TODO) that finds all used components in the HTML string, +and inserts the component's JS and CSS into `{% component_dependencies %}` tags, or at the default locations. + +#### Render JS / CSS without the middleware + +The truth is that the [`ComponentDependencyMiddleware`](#TODO) middleware just calls [`render_dependencies()`](#TODO), +passing in the HTML content. So if you render a template that contained [`{% component %}`](#TODO) tags, +you MUST pass the result through [`render_dependencies()`](#TODO). And the middleware is just one of the options. + +Here is how you can achieve the same, without the middleware, using [`render_dependencies()`](#TODO): + +```python +from django.template.base import Template +from django.template.context import Context +from django_component import render_dependencies + +template = Template(""" + {% load component_tags %} + + + + MyPage + + +
+ {% component "my_button" %} + Click me! + {% endcomponent %} +
+ + +""") + +rendered = template.render(Context()) +rendered = render_dependencies(rendered) +``` + +Same applies if you render a template using Django's [`django.shortcuts.render`](https://docs.djangoproject.com/en/5.1/topics/http/shortcuts/#render): + +```python +from django.shortcuts import render + +def my_view(request): + rendered = render(request, "pages/home.html") + rendered = render_dependencies(rendered) + return rendered +``` + +Alternatively, when you render HTML with [`Component.render()`](#TODO) +or [`Component.render_to_response()`](#TODO), +these, by default, call [`render_dependencies()`](#TODO) for you, so you don't have to: + +```python +from django_components import Component + +class MyButton(Component): + ... + +# No need to call `render_dependencies()` +rendered = MyButton.render() +``` + +#### Inserting pre-rendered HTML into another component + +In previous section we've shown that [`render_dependencies()`](#TODO) does NOT need to be called +when you render a component via [`Component.render()`](#TODO). + +API of django_components makes it possible to compose components in a "React-like" way, +where we pre-render a piece of HTML and then insert it into a larger structure. + +To do this, you must add [`render_dependencies=False`](#TODO) to the nested components: + +```python +card_actions = CardActions.render( + kwargs={"editable": editable}, + render_dependencies=False, +) + +card = Card.render( + slots={"actions": card_actions}, + render_dependencies=False, +) + +page = MyPage.render( + slots={"card": card}, +) +``` + +Why is `render_dependencies=False` required? + +This is a technical limitation of the current implementation. + +As mentioned earlier, each time we call [`Component.render()`](#TODO), +we also call [`render_dependencies()`](#TODO). + +However, there is a problem here - When we call [`render_dependencies()`](#TODO) +inside [`CardActions.render()`](#TODO), +we extract and REMOVE the info on components' JS and CSS from the HTML. But the template +of `CardActions` contains no `{% component_depedencies %}` tags, and nor `` nor `` HTML tags. +So the component's JS and CSS will NOT be inserted, and will be lost. + +To work around this, you must set [`render_dependencies=False`](#TODO) when rendering pieces of HTML +with [`Component.render()`](#TODO) and inserting them into larger structures. + +#### Summary + +1. Every time you render HTML that contained components, you have to call [`render_dependencies()`](#TODO) + on the rendered output. +2. There are several ways to call [`render_dependencies()`](#TODO): + - Using the [`ComponentDependencyMiddleware`](#TODO) middleware + - Rendering the HTML by calling [`Component.render()`](#TODO) with `render_dependencies=True` (default) + - Rendering the HTML by calling [`Component.render_to_response()`](#TODO) (always renders dependencies) + - Directly passing rendered HTML to [`render_dependencies()`](#TODO) +3. If you pre-render one component to pass it into another, the pre-rendered component must be rendered with + [`render_dependencies=False`](#TODO). diff --git a/src/docs/concepts/advanced/tag_formatter.md b/src/docs/concepts/advanced/tag_formatter.md new file mode 100644 index 00000000..79ca5893 --- /dev/null +++ b/src/docs/concepts/advanced/tag_formatter.md @@ -0,0 +1,171 @@ +--- +title: Tag formatters +weight: 6 +--- + +## Customizing component tags with TagFormatter + +_New in version 0.89_ + +By default, components are rendered using the pair of `{% component %}` / `{% endcomponent %}` template tags: + +```django +{% component "button" href="..." disabled %} +Click me! +{% endcomponent %} + +{# or #} + +{% component "button" href="..." disabled / %} +``` + +You can change this behaviour in the settings under the [`COMPONENTS.tag_formatter`](#tag-formatter-setting). + +For example, if you set the tag formatter to + +`django_components.component_shorthand_formatter` + +then the components' names will be used as the template tags: + +```django +{% button href="..." disabled %} + Click me! +{% endbutton %} + +{# or #} + +{% button href="..." disabled / %} +``` + +## Available TagFormatters + +django_components provides following predefined TagFormatters: + +- **`ComponentFormatter` (`django_components.component_formatter`)** + + Default + + Uses the `component` and `endcomponent` tags, and the component name is gives as the first positional argument. + + Example as block: + + ```django + {% component "button" href="..." %} + {% fill "content" %} + ... + {% endfill %} + {% endcomponent %} + ``` + + Example as inlined tag: + + ```django + {% component "button" href="..." / %} + ``` + +- **`ShorthandComponentFormatter` (`django_components.component_shorthand_formatter`)** + + Uses the component name as start tag, and `end` + as an end tag. + + Example as block: + + ```django + {% button href="..." %} + Click me! + {% endbutton %} + ``` + + Example as inlined tag: + + ```django + {% button href="..." / %} + ``` + +## Writing your own TagFormatter + +### Background + +First, let's discuss how TagFormatters work, and how components are rendered in django_components. + +When you render a component with `{% component %}` (or your own tag), the following happens: + +1. `component` must be registered as a Django's template tag +2. Django triggers django_components's tag handler for tag `component`. +3. The tag handler passes the tag contents for pre-processing to `TagFormatter.parse()`. + + So if you render this: + + ```django + {% component "button" href="..." disabled %} + {% endcomponent %} + ``` + + Then `TagFormatter.parse()` will receive a following input: + + ```py + ["component", '"button"', 'href="..."', 'disabled'] + ``` + +4. `TagFormatter` extracts the component name and the remaining input. + + So, given the above, `TagFormatter.parse()` returns the following: + + ```py + TagResult( + component_name="button", + tokens=['href="..."', 'disabled'] + ) + ``` + +5. The tag handler resumes, using the tokens returned from `TagFormatter`. + + So, continuing the example, at this point the tag handler practically behaves as if you rendered: + + ```django + {% component href="..." disabled %} + ``` + +6. Tag handler looks up the component `button`, and passes the args, kwargs, and slots to it. + +### TagFormatter + +`TagFormatter` handles following parts of the process above: + +- Generates start/end tags, given a component. This is what you then call from within your template as `{% component %}`. + +- When you `{% component %}`, tag formatter pre-processes the tag contents, so it can link back the custom template tag to the right component. + +To do so, subclass from `TagFormatterABC` and implement following method: + +- `start_tag` +- `end_tag` +- `parse` + +For example, this is the implementation of [`ShorthandComponentFormatter`](#available-tagformatters) + +```py +class ShorthandComponentFormatter(TagFormatterABC): + # Given a component name, generate the start template tag + def start_tag(self, name: str) -> str: + return name # e.g. 'button' + + # Given a component name, generate the start template tag + def end_tag(self, name: str) -> str: + return f"end{name}" # e.g. 'endbutton' + + # Given a tag, e.g. + # `{% button href="..." disabled %}` + # + # The parser receives: + # `['button', 'href="..."', 'disabled']` + def parse(self, tokens: List[str]) -> TagResult: + tokens = [*tokens] + name = tokens.pop(0) + return TagResult( + name, # e.g. 'button' + tokens # e.g. ['href="..."', 'disabled'] + ) +``` + +That's it! And once your `TagFormatter` is ready, don't forget to update the settings! diff --git a/src/docs/concepts/advanced/typing_and_validation.md b/src/docs/concepts/advanced/typing_and_validation.md new file mode 100644 index 00000000..63231650 --- /dev/null +++ b/src/docs/concepts/advanced/typing_and_validation.md @@ -0,0 +1,178 @@ +--- +title: Typing and validation +weight: 5 +--- + +## Adding type hints with Generics + +_New in version 0.92_ + +The `Component` class optionally accepts type parameters +that allow you to specify the types of args, kwargs, slots, and +data: + +```py +class Button(Component[Args, Kwargs, Slots, Data, JsData, CssData]): + ... +``` + +- `Args` - Must be a `Tuple` or `Any` +- `Kwargs` - Must be a `TypedDict` or `Any` +- `Data` - Must be a `TypedDict` or `Any` +- `Slots` - Must be a `TypedDict` or `Any` + +Here's a full example: + +```py +from typing import NotRequired, Tuple, TypedDict, SlotContent, SlotFunc + +# Positional inputs +Args = Tuple[int, str] + +# Kwargs inputs +class Kwargs(TypedDict): + variable: str + another: int + maybe_var: NotRequired[int] # May be ommited + +# Data returned from `get_context_data` +class Data(TypedDict): + variable: str + +# The data available to the `my_slot` scoped slot +class MySlotData(TypedDict): + value: int + +# Slots +class Slots(TypedDict): + # Use SlotFunc for slot functions. + # The generic specifies the `data` dictionary + my_slot: NotRequired[SlotFunc[MySlotData]] + # SlotContent == Union[str, SafeString] + another_slot: SlotContent + +class Button(Component[Args, Kwargs, Slots, Data, JsData, CssData]): + def get_context_data(self, variable, another): + return { + "variable": variable, + } +``` + +When you then call `Component.render` or `Component.render_to_response`, you will get type hints: + +```py +Button.render( + # Error: First arg must be `int`, got `float` + args=(1.25, "abc"), + # Error: Key "another" is missing + kwargs={ + "variable": "text", + }, +) +``` + +### Usage for Python <3.11 + +On Python 3.8-3.10, use `typing_extensions` + +```py +from typing_extensions import TypedDict, NotRequired +``` + +Additionally on Python 3.8-3.9, also import `annotations`: + +```py +from __future__ import annotations +``` + +Moreover, on 3.10 and less, you may not be able to use `NotRequired`, and instead you will need to mark either all keys are required, or all keys as optional, using TypeDict's `total` kwarg. + +[See PEP-655](https://peps.python.org/pep-0655) for more info. + +## Passing additional args or kwargs + +You may have a function that supports any number of args or kwargs: + +```py +def get_context_data(self, *args, **kwargs): + ... +``` + +This is not supported with the typed components. + +As a workaround: + +- For `*args`, set a positional argument that accepts a list of values: + + ```py + # Tuple of one member of list of strings + Args = Tuple[List[str]] + ``` + +- For `*kwargs`, set a keyword argument that accepts a dictionary of values: + + ```py + class Kwargs(TypedDict): + variable: str + another: int + # Pass any extra keys under `extra` + extra: Dict[str, any] + ``` + +## Handling no args or no kwargs + +To declare that a component accepts no Args, Kwargs, etc, you can use `EmptyTuple` and `EmptyDict` types: + +```py +from django_components import Component, EmptyDict, EmptyTuple + +Args = EmptyTuple +Kwargs = Data = Slots = EmptyDict + +class Button(Component[Args, Kwargs, Slots, Data, JsData, CssData]): + ... +``` + +## Runtime input validation with types + +_New in version 0.96_ + +> NOTE: Kwargs, slots, and data validation is supported only for Python >=3.11 + +In Python 3.11 and later, when you specify the component types, you will get also runtime validation of the inputs you pass to `Component.render` or `Component.render_to_response`. + +So, using the example from before, if you ignored the type errors and still ran the following code: + +```py +Button.render( + # Error: First arg must be `int`, got `float` + args=(1.25, "abc"), + # Error: Key "another" is missing + kwargs={ + "variable": "text", + }, +) +``` + +This would raise a `TypeError`: + +```txt +Component 'Button' expected positional argument at index 0 to be , got 1.25 of type +``` + +In case you need to skip these errors, you can either set the faulty member to `Any`, e.g.: + +```py +# Changed `int` to `Any` +Args = Tuple[Any, str] +``` + +Or you can replace `Args` with `Any` altogether, to skip the validation of args: + +```py +# Replaced `Args` with `Any` +class Button(Component[Any, Kwargs, Slots, Data, JsData, CssData]): + ... +``` + +Same applies to kwargs, data, and slots. diff --git a/src/docs/concepts/fundamentals/access_component_input.md b/src/docs/concepts/fundamentals/access_component_input.md new file mode 100644 index 00000000..672fb7f9 --- /dev/null +++ b/src/docs/concepts/fundamentals/access_component_input.md @@ -0,0 +1,39 @@ +--- +title: Accessing component inputs +weight: 5 +--- + +When you call `Component.render` or `Component.render_to_response`, the inputs to these methods can be accessed from within the instance under `self.input`. + +This means that you can use `self.input` inside: + +- `get_context_data` +- `get_template_name` +- `get_template` +- `on_render_before` +- `on_render_after` + +`self.input` is only defined during the execution of `Component.render`, and raises a `RuntimeError` when called outside of this context. + +`self.input` has the same fields as the input to `Component.render`: + +```python +class TestComponent(Component): + def get_context_data(self, var1, var2, variable, another, **attrs): + assert self.input.args == (123, "str") + assert self.input.kwargs == {"variable": "test", "another": 1} + assert self.input.slots == {"my_slot": "MY_SLOT"} + assert isinstance(self.input.context, Context) + + return { + "variable": variable, + } + +rendered = TestComponent.render( + kwargs={"variable": "test", "another": 1}, + args=(123, "str"), + slots={"my_slot": "MY_SLOT"}, +) +``` + +NOTE: The slots in `self.input.slots` are normalized to slot functions. diff --git a/src/docs/concepts/fundamentals/autodiscovery.md b/src/docs/concepts/fundamentals/autodiscovery.md new file mode 100644 index 00000000..deb06655 --- /dev/null +++ b/src/docs/concepts/fundamentals/autodiscovery.md @@ -0,0 +1,68 @@ +--- +title: Autodiscovery +weight: 11 +--- + +Every component that you want to use in the template with the [`{% component %}`](django_components.templateags.component_tags) +tag needs to be registered with the [`ComponentRegistry`](django_components.component_registry.ComponentRegistry). +Normally, we use the [`@register`](django_components.component_registry.register) decorator for that: + +```python +from django_components import Component, register + +@register("calendar") +class Calendar(Component): + ... +``` + +But for the component to be registered, the code needs to be executed - the file needs to be imported as a module. + +One way to do that is by importing all your components in `apps.py`: + +```python +from django.apps import AppConfig + +class MyAppConfig(AppConfig): + name = "my_app" + + def ready(self) -> None: + from components.card.card import Card + from components.list.list import List + from components.menu.menu import Menu + from components.button.button import Button + ... +``` + +However, there's a simpler way! + +By default, the Python files in the [`COMPONENTS.dirs`](django_components.app_settings.ComponentsSettings.dirs) directories (and app-level [`[app]/components/`](django_components.app_settings.ComponentsSettings.app_dirs)) are auto-imported in order to auto-register the components. + +Autodiscovery occurs when Django is loaded, during the [`AppConfig.ready`](https://docs.djangoproject.com/en/5.1/ref/applications/#django.apps.AppConfig.ready) +hook of the `apps.py` file. + +If you are using autodiscovery, keep a few points in mind: + +- Avoid defining any logic on the module-level inside the `components` dir, that you would not want to run anyway. +- Components inside the auto-imported files still need to be registered with [`@register`](django_components.component_registry.register)p +- Auto-imported component files must be valid Python modules, they must use suffix `.py`, and module name should follow [PEP-8](https://peps.python.org/pep-0008/#package-and-module-names). + +Autodiscovery can be disabled in the [settings](django_components.app_settings.ComponentsSettings.autodiscovery). + +### Manually trigger autodiscovery + +Autodiscovery can be also triggered manually, using the [`autodiscover`](django_components.autodiscovery.autodiscover) function. This is useful if you want to run autodiscovery at a custom point of the lifecycle: + +```python +from django_components import autodiscover + +autodiscover() +``` + +To get the same list of modules that [`autodiscover()`](../../../reference/api#django_components.autodiscover) would return, +but without importing them, use [`get_component_files()`](../../../reference/api#django_components.get_component_files): + +```python +from django_components import get_component_files + +modules = get_component_files(".py") +``` diff --git a/src/docs/concepts/fundamentals/component_context_scope.md b/src/docs/concepts/fundamentals/component_context_scope.md new file mode 100644 index 00000000..3287b108 --- /dev/null +++ b/src/docs/concepts/fundamentals/component_context_scope.md @@ -0,0 +1,186 @@ +--- +title: Component context and scope +weight: 6 +--- + +By default, context variables are passed down the template as in regular Django - deeper scopes can access the variables from the outer scopes. So if you have several nested forloops, then inside the deep-most loop you can access variables defined by all previous loops. + +With this in mind, the `{% component %}` tag behaves similarly to `{% include %}` tag - inside the component tag, you can access all variables that were defined outside of it. + +And just like with `{% include %}`, if you don't want a specific component template to have access to the parent context, add `only` to the `{% component %}` tag: + +```htmldjango +{% component "calendar" date="2015-06-19" only / %} +``` + +NOTE: `{% csrf_token %}` tags need access to the top-level context, and they will not function properly if they are rendered in a component that is called with the `only` modifier. + +If you find yourself using the `only` modifier often, you can set the [context_behavior](#context-behavior) option to `"isolated"`, which automatically applies the `only` modifier. This is useful if you want to make sure that components don't accidentally access the outer context. + +Components can also access the outer context in their context methods like `get_context_data` by accessing the property `self.outer_context`. + +## Example of Accessing Outer Context + +```django +
+ {% component "calender" / %} +
+``` + +Assuming that the rendering context has variables such as `date`, you can use `self.outer_context` to access them from within `get_context_data`. Here's how you might implement it: + +```python +class Calender(Component): + + ... + + def get_context_data(self): + outer_field = self.outer_context["date"] + return { + "date": outer_fields, + } +``` + +However, as a best practice, it’s recommended not to rely on accessing the outer context directly through `self.outer_context`. Instead, explicitly pass the variables to the component. For instance, continue passing the variables in the component tag as shown in the previous examples. + +## Context behavior + +django_components supports both Django and Vue-like behavior when it comes to passing data to and through +components. This can be configured in [context_behavior](../../../reference/settings#context_behavior). + +This has two modes: + +- `"django"` + + The default Django template behavior. + + Inside the [`{% fill %}`](../../../reference/template_tags#fill) tag, the context variables + you can access are a union of: + + - All the variables that were OUTSIDE the fill tag, including any\ + [`{% with %}`](https://docs.djangoproject.com/en/5.1/ref/templates/builtins/#with) tags. + - Any loops ([`{% for ... %}`](https://docs.djangoproject.com/en/5.1/ref/templates/builtins/#cycle)) + that the `{% fill %}` tag is part of. + - Data returned from [`Component.get_context_data()`](../../../reference/api#django_components.Component.get_context_data) + of the component that owns the fill tag. + +- `"isolated"` + + Similar behavior to [Vue](https://vuejs.org/guide/components/slots.html#render-scope) or React, + this is useful if you want to make sure that components don't accidentally access variables defined outside + of the component. + + Inside the [`{% fill %}`](../../../reference/template_tags#fill) tag, you can ONLY access variables from 2 places: + + - Any loops ([`{% for ... %}`](https://docs.djangoproject.com/en/5.1/ref/templates/builtins/#cycle)) + that the `{% fill %}` tag is part of. + - [`Component.get_context_data()`](../../../reference/api#django_components.Component.get_context_data) + of the component which defined the template (AKA the "root" component). + +!!! warning + + Notice that the component whose `get_context_data()` we use inside + [`{% fill %}`](../../../reference/template_tags#fill) + is NOT the same across the two modes! + + Consider this example: + + ```python + class Outer(Component): + template = \"\"\" +
+ {% component "inner" %} + {% fill "content" %} + {{ my_var }} + {% endfill %} + {% endcomponent %} +
+ \"\"\" + ``` + + - `"django"` - `my_var` has access to data from `get_context_data()` of both `Inner` and `Outer`. + If there are variables defined in both, then `Inner` overshadows `Outer`. + + - `"isolated"` - `my_var` has access to data from `get_context_data()` of ONLY `Outer`. + + +### Example "django" + +Given this template: + +```python +@register("root_comp") +class RootComp(Component): + template = """ + {% with cheese="feta" %} + {% component 'my_comp' %} + {{ my_var }} # my_var + {{ cheese }} # cheese + {% endcomponent %} + {% endwith %} + """ + + def get_context_data(self): + return { "my_var": 123 } +``` + +Then if [`get_context_data()`](../../../reference/api#django_components.Component.get_context_data) +of the component `"my_comp"` returns following data: + +```py +{ "my_var": 456 } +``` + +Then the template will be rendered as: + +```django +456 # my_var +feta # cheese +``` + +Because `"my_comp"` overshadows the outer variable `"my_var"`, +so `{{ my_var }}` equals `456`. + +And variable `"cheese"` equals `feta`, because the fill CAN access +all the data defined in the outer layers, like the `{% with %}` tag. + +### Example "isolated" + +Given this template: + +```python +class RootComp(Component): + template = """ + {% with cheese="feta" %} + {% component 'my_comp' %} + {{ my_var }} # my_var + {{ cheese }} # cheese + {% endcomponent %} + {% endwith %} + """ + + def get_context_data(self): + return { "my_var": 123 } +``` + +Then if [`get_context_data()`](../../../reference/api#django_components.Component.get_context_data) +of the component `"my_comp"` returns following data: + +```py +{ "my_var": 456 } +``` + +Then the template will be rendered as: + +```django +123 # my_var + # cheese +``` + +Because variables `"my_var"` and `"cheese"` are searched only inside `RootComponent.get_context_data()`. +But since `"cheese"` is not defined there, it's empty. + +!!! info + + Notice that the variables defined with the [`{% with %}`](https://docs.djangoproject.com/en/5.1/ref/templates/builtins/#with) + tag are ignored inside the [`{% fill %}`](../../../reference/template_tags#fill) tag with the `"isolated"` mode. diff --git a/src/docs/concepts/fundamentals/components_as_views.md b/src/docs/concepts/fundamentals/components_as_views.md new file mode 100644 index 00000000..0d94f0bf --- /dev/null +++ b/src/docs/concepts/fundamentals/components_as_views.md @@ -0,0 +1,153 @@ +--- +title: Components as views +weight: 12 +--- + +_New in version 0.34_ + +_Note: Since 0.92, Component no longer subclasses View. To configure the View class, set the nested `Component.View` class_ + +Components can now be used as views: + +- Components define the `Component.as_view()` class method that can be used the same as [`View.as_view()`](https://docs.djangoproject.com/en/5.1/ref/class-based-views/base/#django.views.generic.base.View.as_view). + +- By default, you can define GET, POST or other HTTP handlers directly on the Component, same as you do with [View](https://docs.djangoproject.com/en/5.1/ref/class-based-views/base/#view). For example, you can override `get` and `post` to handle GET and POST requests, respectively. + +- In addition, `Component` now has a [`render_to_response`](#inputs-of-render-and-render_to_response) method that renders the component template based on the provided context and slots' data and returns an `HttpResponse` object. + +## Component as view example + +Here's an example of a calendar component defined as a view: + +```python +# In a file called [project root]/components/calendar.py +from django_components import Component, ComponentView, register + +@register("calendar") +class Calendar(Component): + + template = """ +
+
+ {% slot "header" / %} +
+
+ Today's date is {{ date }} +
+
+ """ + + # Handle GET requests + def get(self, request, *args, **kwargs): + context = { + "date": request.GET.get("date", "2020-06-06"), + } + slots = { + "header": "Calendar header", + } + # Return HttpResponse with the rendered content + return self.render_to_response( + context=context, + slots=slots, + ) +``` + +Then, to use this component as a view, you should create a `urls.py` file in your components directory, and add a path to the component's view: + +```python +# In a file called [project root]/components/urls.py +from django.urls import path +from components.calendar.calendar import Calendar + +urlpatterns = [ + path("calendar/", Calendar.as_view()), +] +``` + +`Component.as_view()` is a shorthand for calling [`View.as_view()`](https://docs.djangoproject.com/en/5.1/ref/class-based-views/base/#django.views.generic.base.View.as_view) and passing the component +instance as one of the arguments. + +Remember to add `__init__.py` to your components directory, so that Django can find the `urls.py` file. + +Finally, include the component's urls in your project's `urls.py` file: + +```python +# In a file called [project root]/urls.py +from django.urls import include, path + +urlpatterns = [ + path("components/", include("components.urls")), +] +``` + +Note: Slots content are automatically escaped by default to prevent XSS attacks. To disable escaping, set `escape_slots_content=False` in the `render_to_response` method. If you do so, you should make sure that any content you pass to the slots is safe, especially if it comes from user input. + +If you're planning on passing an HTML string, check Django's use of [`format_html`](https://docs.djangoproject.com/en/5.0/ref/utils/#django.utils.html.format_html) and [`mark_safe`](https://docs.djangoproject.com/en/5.0/ref/utils/#django.utils.safestring.mark_safe). + +## Modifying the View class + +The View class that handles the requests is defined on `Component.View`. + +When you define a GET or POST handlers on the `Component` class, like so: + +```py +class MyComponent(Component): + def get(self, request, *args, **kwargs): + return self.render_to_response( + context={ + "date": request.GET.get("date", "2020-06-06"), + }, + ) + + def post(self, request, *args, **kwargs) -> HttpResponse: + variable = request.POST.get("variable") + return self.render_to_response( + kwargs={"variable": variable} + ) +``` + +Then the request is still handled by `Component.View.get()` or `Component.View.post()` +methods. However, by default, `Component.View.get()` points to `Component.get()`, and so on. + +```py +class ComponentView(View): + component: Component = None + ... + + def get(self, request, *args, **kwargs): + return self.component.get(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + return self.component.post(request, *args, **kwargs) + + ... +``` + +If you want to define your own `View` class, you need to: + +1. Set the class as `Component.View` +2. Subclass from `ComponentView`, so the View instance has access to the component instance. + +In the example below, we added extra logic into `View.setup()`. + +Note that the POST handler is still defined at the top. This is because `View` subclasses `ComponentView`, which defines the `post()` method that calls `Component.post()`. + +If you were to overwrite the `View.post()` method, then `Component.post()` would be ignored. + +```py +from django_components import Component, ComponentView + +class MyComponent(Component): + + def post(self, request, *args, **kwargs) -> HttpResponse: + variable = request.POST.get("variable") + return self.component.render_to_response( + kwargs={"variable": variable} + ) + + class View(ComponentView): + def setup(self, request, *args, **kwargs): + super(request, *args, **kwargs) + + do_something_extra(request, *args, **kwargs) +``` diff --git a/src/docs/concepts/fundamentals/components_in_python.md b/src/docs/concepts/fundamentals/components_in_python.md new file mode 100644 index 00000000..7177b8c4 --- /dev/null +++ b/src/docs/concepts/fundamentals/components_in_python.md @@ -0,0 +1,133 @@ +--- +title: Components in Python +weight: 4 +--- + +_New in version 0.81_ + +Components can be rendered outside of Django templates, calling them as regular functions ("React-style"). + +The component class defines `render` and `render_to_response` class methods. These methods accept positional args, kwargs, and slots, offering the same flexibility as the `{% component %}` tag: + +```py +class SimpleComponent(Component): + template = """ + {% load component_tags %} + hello: {{ hello }} + foo: {{ foo }} + kwargs: {{ kwargs|safe }} + slot_first: {% slot "first" required / %} + """ + + def get_context_data(self, arg1, arg2, **kwargs): + return { + "hello": arg1, + "foo": arg2, + "kwargs": kwargs, + } + +rendered = SimpleComponent.render( + args=["world", "bar"], + kwargs={"kw1": "test", "kw2": "ooo"}, + slots={"first": "FIRST_SLOT"}, + context={"from_context": 98}, +) +``` + +Renders: + +``` +hello: world +foo: bar +kwargs: {'kw1': 'test', 'kw2': 'ooo'} +slot_first: FIRST_SLOT +``` + +## Inputs of `render` and `render_to_response` + +Both `render` and `render_to_response` accept the same input: + +```py +Component.render( + context: Mapping | django.template.Context | None = None, + args: List[Any] | None = None, + kwargs: Dict[str, Any] | None = None, + slots: Dict[str, str | SafeString | SlotFunc] | None = None, + escape_slots_content: bool = True +) -> str: +``` + +- _`args`_ - Positional args for the component. This is the same as calling the component + as `{% component "my_comp" arg1 arg2 ... %}` + +- _`kwargs`_ - Keyword args for the component. This is the same as calling the component + as `{% component "my_comp" key1=val1 key2=val2 ... %}` + +- _`slots`_ - Component slot fills. This is the same as pasing `{% fill %}` tags to the component. + Accepts a dictionary of `{ slot_name: slot_content }` where `slot_content` can be a string + or [`SlotFunc`](#slotfunc). + +- _`escape_slots_content`_ - Whether the content from `slots` should be escaped. `True` by default to prevent XSS attacks. If you disable escaping, you should make sure that any content you pass to the slots is safe, especially if it comes from user input. + +- _`context`_ - A context (dictionary or Django's Context) within which the component + is rendered. The keys on the context can be accessed from within the template. + - NOTE: In "isolated" mode, context is NOT accessible, and data MUST be passed via + component's args and kwargs. + +### `SlotFunc` + +When rendering components with slots in `render` or `render_to_response`, you can pass either a string or a function. + +The function has following signature: + +```py +def render_func( + context: Context, + data: Dict[str, Any], + slot_ref: SlotRef, +) -> str | SafeString: + return nodelist.render(ctx) +``` + +- _`context`_ - Django's Context available to the Slot Node. +- _`data`_ - Data passed to the `{% slot %}` tag. See [Scoped Slots](#scoped-slots). +- _`slot_ref`_ - The default slot content. See [Accessing original content of slots](#accessing-original-content-of-slots). + - NOTE: The slot is lazily evaluated. To render the slot, convert it to string with `str(slot_ref)`. + +Example: + +```py +def footer_slot(ctx, data, slot_ref): + return f""" + SLOT_DATA: {data['abc']} + ORIGINAL: {slot_ref} + """ + +MyComponent.render_to_response( + slots={ + "footer": footer_slot, + }, +) +``` + +## Response class of `render_to_response` + +While `render` method returns a plain string, `render_to_response` wraps the rendered content in a "Response" class. By default, this is `django.http.HttpResponse`. + +If you want to use a different Response class in `render_to_response`, set the `Component.response_class` attribute: + +```py +class MyResponse(HttpResponse): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + # Configure response + self.headers = ... + self.status = ... + +class SimpleComponent(Component): + response_class = MyResponse + template: types.django_html = "HELLO" + +response = SimpleComponent.render_to_response() +assert isinstance(response, MyResponse) +``` diff --git a/src/docs/concepts/fundamentals/components_in_templates.md b/src/docs/concepts/fundamentals/components_in_templates.md new file mode 100644 index 00000000..39508833 --- /dev/null +++ b/src/docs/concepts/fundamentals/components_in_templates.md @@ -0,0 +1,51 @@ +--- +title: Components in templates +weight: 3 +--- + +First load the `component_tags` tag library, then use the `component_[js/css]_dependencies` and `component` tags to render the component to the page. + +```htmldjango +{% load component_tags %} + + + + My example calendar + {% component_css_dependencies %} + + + {% component "calendar" date="2015-06-19" %}{% endcomponent %} + {% component_js_dependencies %} + + +``` + +> NOTE: Instead of writing `{% endcomponent %}` at the end, you can use a self-closing tag: +> +> `{% component "calendar" date="2015-06-19" / %}` + +The output from the above template will be: + +```html + + + + My example calendar + + + +
+ Today's date is 2015-06-19 +
+ + + + +``` + +This makes it possible to organize your front-end around reusable components. Instead of relying on template tags and keeping your CSS and Javascript in the static directory. diff --git a/src/docs/concepts/fundamentals/defining_js_css_html_files.md b/src/docs/concepts/fundamentals/defining_js_css_html_files.md new file mode 100644 index 00000000..c9de0df6 --- /dev/null +++ b/src/docs/concepts/fundamentals/defining_js_css_html_files.md @@ -0,0 +1,203 @@ +--- +title: Defining HTML / JS / CSS files +weight: 10 +--- + +django_component's management of files builds on top of [Django's `Media` class](https://docs.djangoproject.com/en/5.0/topics/forms/media/). + +To be familiar with how Django handles static files, we recommend reading also: + +- [How to manage static files (e.g. images, JavaScript, CSS)](https://docs.djangoproject.com/en/5.0/howto/static-files/) + +## Defining file paths relative to component or static dirs + +As seen in the [getting started example](#create-your-first-component), to associate HTML/JS/CSS +files with a component, you set them as `template_name`, `Media.js` and `Media.css` respectively: + +```py +# In a file [project root]/components/calendar/calendar.py +from django_components import Component, register + +@register("calendar") +class Calendar(Component): + template_name = "template.html" + + class Media: + css = "style.css" + js = "script.js" +``` + +In the example above, the files are defined relative to the directory where `component.py` is. + +Alternatively, you can specify the file paths relative to the directories set in `COMPONENTS.dirs` or `COMPONENTS.app_dirs`. + +Assuming that `COMPONENTS.dirs` contains path `[project root]/components`, we can rewrite the example as: + +```py +# In a file [project root]/components/calendar/calendar.py +from django_components import Component, register + +@register("calendar") +class Calendar(Component): + template_name = "calendar/template.html" + + class Media: + css = "calendar/style.css" + js = "calendar/script.js" +``` + +NOTE: In case of conflict, the preference goes to resolving the files relative to the component's directory. + +## Defining multiple paths + +Each component can have only a single template. However, you can define as many JS or CSS files as you want using a list. + +```py +class MyComponent(Component): + class Media: + js = ["path/to/script1.js", "path/to/script2.js"] + css = ["path/to/style1.css", "path/to/style2.css"] +``` + +## Configuring CSS Media Types + +You can define which stylesheets will be associated with which +[CSS Media types](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_media_queries/Using_media_queries#targeting_media_types). You do so by defining CSS files as a dictionary. + +See the corresponding [Django Documentation](https://docs.djangoproject.com/en/5.0/topics/forms/media/#css). + +Again, you can set either a single file or a list of files per media type: + +```py +class MyComponent(Component): + class Media: + css = { + "all": "path/to/style1.css", + "print": "path/to/style2.css", + } +``` + +```py +class MyComponent(Component): + class Media: + css = { + "all": ["path/to/style1.css", "path/to/style2.css"], + "print": ["path/to/style3.css", "path/to/style4.css"], + } +``` + +NOTE: When you define CSS as a string or a list, the `all` media type is implied. + +## Supported types for file paths + +File paths can be any of: + +- `str` +- `bytes` +- `PathLike` (`__fspath__` method) +- `SafeData` (`__html__` method) +- `Callable` that returns any of the above, evaluated at class creation (`__new__`) + +```py +from pathlib import Path + +from django.utils.safestring import mark_safe + +class SimpleComponent(Component): + class Media: + css = [ + mark_safe(''), + Path("calendar/style1.css"), + "calendar/style2.css", + b"calendar/style3.css", + lambda: "calendar/style4.css", + ] + js = [ + mark_safe(''), + Path("calendar/script1.js"), + "calendar/script2.js", + b"calendar/script3.js", + lambda: "calendar/script4.js", + ] +``` + +## Path as objects + +In the example [above](#supported-types-for-file-paths), you could see that when we used `mark_safe` to mark a string as a `SafeString`, we had to define the full `' + ) + +@register("calendar") +class Calendar(Component): + template_name = "calendar/template.html" + + def get_context_data(self, date): + return { + "date": date, + } + + class Media: + css = "calendar/style.css" + js = [ + # ', + self.absolute_path(path) + ) + return tags + +@register("calendar") +class Calendar(Component): + template_name = "calendar/template.html" + + class Media: + css = "calendar/style.css" + js = "calendar/script.js" + + # Override the behavior of Media class + media_class = MyMedia +``` + +NOTE: The instance of the `Media` class (or it's subclass) is available under `Component.media` after the class creation (`__new__`). diff --git a/src/docs/concepts/fundamentals/html_attributes.md b/src/docs/concepts/fundamentals/html_attributes.md new file mode 100644 index 00000000..9dcc319f --- /dev/null +++ b/src/docs/concepts/fundamentals/html_attributes.md @@ -0,0 +1,378 @@ +--- +title: HTML attributes +weight: 9 +--- + +_New in version 0.74_: + +You can use the `html_attrs` tag to render HTML attributes, given a dictionary +of values. + +So if you have a template: + +```django +
+
+``` + +You can simplify it with `html_attrs` tag: + +```django +
+
+``` + +where `attrs` is: + +```py +attrs = { + "class": classes, + "data-id": my_id, +} +``` + +This feature is inspired by [`merge_attrs` tag of django-web-components](https://github.com/Xzya/django-web-components/tree/master?tab=readme-ov-file#default--merged-attributes) and +["fallthrough attributes" feature of Vue](https://vuejs.org/guide/components/attrs). + +## Removing atttributes + +Attributes that are set to `None` or `False` are NOT rendered. + +So given this input: + +```py +attrs = { + "class": "text-green", + "required": False, + "data-id": None, +} +``` + +And template: + +```django +
+
+``` + +Then this renders: + +```html +
+``` + +## Boolean attributes + +In HTML, boolean attributes are usually rendered with no value. Consider the example below where the first button is disabled and the second is not: + +```html + +``` + +HTML rendering with `html_attrs` tag or `attributes_to_string` works the same way, where `key=True` is rendered simply as `key`, and `key=False` is not render at all. + +So given this input: + +```py +attrs = { + "disabled": True, + "autofocus": False, +} +``` + +And template: + +```django +
+
+``` + +Then this renders: + +```html +
+``` + +## Default attributes + +Sometimes you may want to specify default values for attributes. You can pass a second argument (or kwarg `defaults`) to set the defaults. + +```django +
+ ... +
+``` + +In the example above, if `attrs` contains e.g. the `class` key, `html_attrs` will render: + +`class="{{ attrs.class }}"` + +Otherwise, `html_attrs` will render: + +`class="{{ defaults.class }}"` + +## Appending attributes + +For the `class` HTML attribute, it's common that we want to _join_ multiple values, +instead of overriding them. For example, if you're authoring a component, you may +want to ensure that the component will ALWAYS have a specific class. Yet, you may +want to allow users of your component to supply their own classes. + +We can achieve this by adding extra kwargs. These values +will be appended, instead of overwriting the previous value. + +So if we have a variable `attrs`: + +```py +attrs = { + "class": "my-class pa-4", +} +``` + +And on `html_attrs` tag, we set the key `class`: + +```django +
+
+``` + +Then these will be merged and rendered as: + +```html +
+``` + +To simplify merging of variables, you can supply the same key multiple times, and these will be all joined together: + +```django +{# my_var = "class-from-var text-red" #} +
+
+``` + +Renders: + +```html +
+``` + +## Rules for `html_attrs` + +1. Both `attrs` and `defaults` can be passed as positional args + + `{% html_attrs attrs defaults key=val %}` + + or as kwargs + + `{% html_attrs key=val defaults=defaults attrs=attrs %}` + +2. Both `attrs` and `defaults` are optional (can be omitted) + +3. Both `attrs` and `defaults` are dictionaries, and we can define them the same way [we define dictionaries for the `component` tag](#pass-dictonary-by-its-key-value-pairs). So either as `attrs=attrs` or `attrs:key=value`. + +4. All other kwargs are appended and can be repeated. + +## Examples for `html_attrs` + +Assuming that: + +```py +class_from_var = "from-var" + +attrs = { + "class": "from-attrs", + "type": "submit", +} + +defaults = { + "class": "from-defaults", + "role": "button", +} +``` + +Then: + +- Empty tag
+ `{% html_attr %}` + + renders (empty string):
+ ` ` + +- Only kwargs
+ `{% html_attr class="some-class" class=class_from_var data-id="123" %}` + + renders:
+ `class="some-class from-var" data-id="123"` + +- Only attrs
+ `{% html_attr attrs %}` + + renders:
+ `class="from-attrs" type="submit"` + +- Attrs as kwarg
+ `{% html_attr attrs=attrs %}` + + renders:
+ `class="from-attrs" type="submit"` + +- Only defaults (as kwarg)
+ `{% html_attr defaults=defaults %}` + + renders:
+ `class="from-defaults" role="button"` + +- Attrs using the `prefix:key=value` construct
+ `{% html_attr attrs:class="from-attrs" attrs:type="submit" %}` + + renders:
+ `class="from-attrs" type="submit"` + +- Defaults using the `prefix:key=value` construct
+ `{% html_attr defaults:class="from-defaults" %}` + + renders:
+ `class="from-defaults" role="button"` + +- All together (1) - attrs and defaults as positional args:
+ `{% html_attrs attrs defaults class="added_class" class=class_from_var data-id=123 %}` + + renders:
+ `class="from-attrs added_class from-var" type="submit" role="button" data-id=123` + +- All together (2) - attrs and defaults as kwargs args:
+ `{% html_attrs class="added_class" class=class_from_var data-id=123 attrs=attrs defaults=defaults %}` + + renders:
+ `class="from-attrs added_class from-var" type="submit" role="button" data-id=123` + +- All together (3) - mixed:
+ `{% html_attrs attrs defaults:class="default-class" class="added_class" class=class_from_var data-id=123 %}` + + renders:
+ `class="from-attrs added_class from-var" type="submit" data-id=123` + +## Full example for `html_attrs` + +```py +@register("my_comp") +class MyComp(Component): + template: t.django_html = """ +
+ Today's date is {{ date }} +
+ """ + + def get_context_data(self, date: Date, attrs: dict): + return { + "date": date, + "attrs": attrs, + "class_from_var": "extra-class" + } + +@register("parent") +class Parent(Component): + template: t.django_html = """ + {% component "my_comp" + date=date + attrs:class="pa-0 border-solid border-red" + attrs:data-json=json_data + attrs:@click="(e) => onClick(e, 'from_parent')" + / %} + """ + + def get_context_data(self, date: Date): + return { + "date": datetime.now(), + "json_data": json.dumps({"value": 456}) + } +``` + +Note: For readability, we've split the tags across multiple lines. + +Inside `MyComp`, we defined a default attribute + +`defaults:class="pa-4 text-red"` + +So if `attrs` includes key `class`, the default above will be ignored. + +`MyComp` also defines `class` key twice. It means that whether the `class` +attribute is taken from `attrs` or `defaults`, the two `class` values +will be appended to it. + +So by default, `MyComp` renders: + +```html +
...
+``` + +Next, let's consider what will be rendered when we call `MyComp` from `Parent` +component. + +`MyComp` accepts a `attrs` dictionary, that is passed to `html_attrs`, so the +contents of that dictionary are rendered as the HTML attributes. + +In `Parent`, we make use of passing dictionary key-value pairs as kwargs to define +individual attributes as if they were regular kwargs. + +So all kwargs that start with `attrs:` will be collected into an `attrs` dict. + +```django + attrs:class="pa-0 border-solid border-red" + attrs:data-json=json_data + attrs:@click="(e) => onClick(e, 'from_parent')" +``` + +And `get_context_data` of `MyComp` will receive `attrs` input with following keys: + +```py +attrs = { + "class": "pa-0 border-solid", + "data-json": '{"value": 456}', + "@click": "(e) => onClick(e, 'from_parent')", +} +``` + +`attrs["class"]` overrides the default value for `class`, whereas other keys +will be merged. + +So in the end `MyComp` will render: + +```html +
+ ... +
+``` + +## Rendering HTML attributes outside of templates + +If you need to use serialize HTML attributes outside of Django template and the `html_attrs` tag, you can use `attributes_to_string`: + +```py +from django_components.attributes import attributes_to_string + +attrs = { + "class": "my-class text-red pa-4", + "data-id": 123, + "required": True, + "disabled": False, + "ignored-attr": None, +} + +attributes_to_string(attrs) +# 'class="my-class text-red pa-4" data-id="123" required' +``` diff --git a/src/docs/concepts/fundamentals/single_file_components.md b/src/docs/concepts/fundamentals/single_file_components.md new file mode 100644 index 00000000..0327d09f --- /dev/null +++ b/src/docs/concepts/fundamentals/single_file_components.md @@ -0,0 +1,37 @@ +--- +title: Single-file components +weight: 2 +--- + +Components can also be defined in a single file, which is useful for small components. To do this, you can use the `template`, `js`, and `css` class attributes instead of the `template_name` and `Media`. For example, here's the calendar component from above, defined in a single file: + +```python title="[project root]/components/calendar.py" +# In a file called [project root]/components/calendar.py +from django_components import Component, register, types + +@register("calendar") +class Calendar(Component): + def get_context_data(self, date): + return { + "date": date, + } + + template: types.django_html = """ +
Today's date is {{ date }}
+ """ + + css: types.css = """ + .calendar-component { width: 200px; background: pink; } + .calendar-component span { font-weight: bold; } + """ + + js: types.js = """ + (function(){ + if (document.querySelector(".calendar-component")) { + document.querySelector(".calendar-component").onclick = function(){ alert("Clicked calendar!"); }; + } + })() + """ +``` + +This makes it easy to create small components without having to create a separate template, CSS, and JS file. diff --git a/src/docs/concepts/fundamentals/slots.md b/src/docs/concepts/fundamentals/slots.md new file mode 100644 index 00000000..557dd668 --- /dev/null +++ b/src/docs/concepts/fundamentals/slots.md @@ -0,0 +1,670 @@ +--- +title: Slots +weight: 8 +--- + +_New in version 0.26_: + +- The `slot` tag now serves only to declare new slots inside the component template. + - To override the content of a declared slot, use the newly introduced `fill` tag instead. +- Whereas unfilled slots used to raise a warning, filling a slot is now optional by default. + - To indicate that a slot must be filled, the new `required` option should be added at the end of the `slot` tag. + +--- + +Components support something called 'slots'. +When a component is used inside another template, slots allow the parent template to override specific parts of the child component by passing in different content. +This mechanism makes components more reusable and composable. +This behavior is similar to [slots in Vue](https://vuejs.org/guide/components/slots.html). + +In the example below we introduce two block tags that work hand in hand to make this work. These are... + +- `{% slot %}`/`{% endslot %}`: Declares a new slot in the component template. +- `{% fill %}`/`{% endfill %}`: (Used inside a `{% component %}` tag pair.) Fills a declared slot with the specified content. + +Let's update our calendar component to support more customization. We'll add `slot` tag pairs to its template, _template.html_. + +```htmldjango +
+
+ {% slot "header" %}Calendar header{% endslot %} +
+
+ {% slot "body" %}Today's date is {{ date }}{% endslot %} +
+
+``` + +When using the component, you specify which slots you want to fill and where you want to use the defaults from the template. It looks like this: + +```htmldjango +{% component "calendar" date="2020-06-06" %} + {% fill "body" %} + Can you believe it's already {{ date }}?? + {% endfill %} +{% endcomponent %} +``` + +Since the 'header' fill is unspecified, it's taken from the base template. If you put this in a template, and pass in `date=2020-06-06`, this is what gets rendered: + +```htmldjango +
+
+ Calendar header +
+
+ Can you believe it's already 2020-06-06?? +
+
+``` + +### Named slots + +As seen in the previouse section, you can use `{% fill slot_name %}` to insert content into a specific +slot. + +You can define fills for multiple slot simply by defining them all within the `{% component %} {% endcomponent %}` +tags: + +```htmldjango +{% component "calendar" date="2020-06-06" %} + {% fill "header" %} + Hi this is header! + {% endfill %} + {% fill "body" %} + Can you believe it's already {{ date }}?? + {% endfill %} +{% endcomponent %} +``` + +You can also use `{% for %}`, `{% with %}`, or other tags (even `{% include %}`) +to construct the `{% fill %}` tags, **as long as these other tags do not leave any text behind!** + +```django +{% component "table" %} + {% for slot_name in slots %} + {% fill name=slot_name %} + {{ slot_name }} + {% endfill %} + {% endfor %} + + {% with slot_name="abc" %} + {% fill name=slot_name %} + {{ slot_name }} + {% endfill %} + {% endwith %} +{% endcomponent %} +``` + +### Default slot + +_Added in version 0.28_ + +As you can see, component slots lets you write reusable containers that you fill in when you use a component. This makes for highly reusable components that can be used in different circumstances. + +It can become tedious to use `fill` tags everywhere, especially when you're using a component that declares only one slot. To make things easier, `slot` tags can be marked with an optional keyword: `default`. + +When added to the tag (as shown below), this option lets you pass filling content directly in the body of a `component` tag pair – without using a `fill` tag. Choose carefully, though: a component template may contain at most one slot that is marked as `default`. The `default` option can be combined with other slot options, e.g. `required`. + +Here's the same example as before, except with default slots and implicit filling. + +The template: + +```htmldjango +
+
+ {% slot "header" %}Calendar header{% endslot %} +
+
+ {% slot "body" default %}Today's date is {{ date }}{% endslot %} +
+
+``` + +Including the component (notice how the `fill` tag is omitted): + +```htmldjango +{% component "calendar" date="2020-06-06" %} + Can you believe it's already {{ date }}?? +{% endcomponent %} +``` + +The rendered result (exactly the same as before): + +```html +
+
Calendar header
+
Can you believe it's already 2020-06-06??
+
+``` + +You may be tempted to combine implicit fills with explicit `fill` tags. This will not work. The following component template will raise an error when rendered. + +```htmldjango +{# DON'T DO THIS #} +{% component "calendar" date="2020-06-06" %} + {% fill "header" %}Totally new header!{% endfill %} + Can you believe it's already {{ date }}?? +{% endcomponent %} +``` + +Instead, you can use a named fill with name `default` to target the default fill: + +```htmldjango +{# THIS WORKS #} +{% component "calendar" date="2020-06-06" %} + {% fill "header" %}Totally new header!{% endfill %} + {% fill "default" %} + Can you believe it's already {{ date }}?? + {% endfill %} +{% endcomponent %} +``` + +NOTE: If you doubly-fill a slot, that is, that both `{% fill "default" %}` and `{% fill "header" %}` +would point to the same slot, this will raise an error when rendered. + +#### Accessing default slot in Python + +Since the default slot is stored under the slot name `default`, you can access the default slot +like so: + +```py +class MyTable(Component): + def get_context_data(self, *args, **kwargs): + default_slot = self.input.slots["default"] + return { + "default_slot": default_slot, + } +``` + +### Render fill in multiple places + +_Added in version 0.70_ + +You can render the same content in multiple places by defining multiple slots with +identical names: + +```htmldjango +
+
+ {% slot "image" %}Image here{% endslot %} +
+
+ {% slot "image" %}Image here{% endslot %} +
+
+``` + +So if used like: + +```htmldjango +{% component "calendar" date="2020-06-06" %} + {% fill "image" %} + + {% endfill %} +{% endcomponent %} +``` + +This renders: + +```htmldjango +
+
+ +
+
+ +
+
+``` + +#### Default and required slots + +If you use a slot multiple times, you can still mark the slot as `default` or `required`. +For that, you must mark each slot individually, e.g.: + +```htmldjango +
+
+ {% slot "image" default required %}Image here{% endslot %} +
+
+ {% slot "image" default required %}Image here{% endslot %} +
+
+``` + +Which you can then use as regular default slot: + +```htmldjango +{% component "calendar" date="2020-06-06" %} + +{% endcomponent %} +``` + +Since each slot is tagged individually, you can have multiple slots +with the same name but different conditions. + +E.g. in this example, we have a component that renders a user avatar - a small circular image with a profile picture or name initials. + +If the component is given `image_src` or `name_initials` variables, +the `image` slot is optional. But if neither of those are provided, +you MUST fill the `image` slot. + +```htmldjango +
+ {% if image_src %} + {% slot "image" default %} + + {% endslot %} + {% elif name_initials %} + {% slot "image" default %} +
+ {{ name_initials }} +
+ {% endslot %} + {% else %} + {% slot "image" default required / %} + {% endif %} +
+``` + +### Accessing original content of slots + +_Added in version 0.26_ + +> NOTE: In version 0.77, the syntax was changed from +> +> ```django +> {% fill "my_slot" as "alias" %} {{ alias.default }} +> ``` +> +> to +> +> ```django +> {% fill "my_slot" default="slot_default" %} {{ slot_default }} +> ``` + +Sometimes you may want to keep the original slot, but only wrap or prepend/append content to it. To do so, you can access the default slot via the `default` kwarg. + +Similarly to the `data` attribute, you specify the variable name through which the default slot will be made available. + +For instance, let's say you're filling a slot called 'body'. To render the original slot, assign it to a variable using the `'default'` keyword. You then render this variable to insert the default content: + +```htmldjango +{% component "calendar" date="2020-06-06" %} + {% fill "body" default="body_default" %} + {{ body_default }}. Have a great day! + {% endfill %} +{% endcomponent %} +``` + +This produces: + +```htmldjango +
+
+ Calendar header +
+
+ Today's date is 2020-06-06. Have a great day! +
+
+``` + +To access the original content of a default slot, set the name to `default`: + +```htmldjango +{% component "calendar" date="2020-06-06" %} + {% fill "default" default="slot_default" %} + {{ slot_default }}. Have a great day! + {% endfill %} +{% endcomponent %} +``` + +### Conditional slots + +_Added in version 0.26._ + +> NOTE: In version 0.70, `{% if_filled %}` tags were replaced with `{{ component_vars.is_filled }}` variables. If your slot name contained special characters, see the section [Accessing `is_filled` of slot names with special characters](#accessing-is_filled-of-slot-names-with-special-characters). + +In certain circumstances, you may want the behavior of slot filling to depend on +whether or not a particular slot is filled. + +For example, suppose we have the following component template: + +```htmldjango +
+
+ {% slot "title" %}Title{% endslot %} +
+
+ {% slot "subtitle" %}{# Optional subtitle #}{% endslot %} +
+
+``` + +By default the slot named 'subtitle' is empty. Yet when the component is used without +explicit fills, the div containing the slot is still rendered, as shown below: + +```html +
+
Title
+
+
+``` + +This may not be what you want. What if instead the outer 'subtitle' div should only +be included when the inner slot is in fact filled? + +The answer is to use the `{{ component_vars.is_filled. }}` variable. You can use this together with Django's `{% if/elif/else/endif %}` tags to define a block whose contents will be rendered only if the component slot with +the corresponding 'name' is filled. + +This is what our example looks like with `component_vars.is_filled`. + +```htmldjango +
+
+ {% slot "title" %}Title{% endslot %} +
+ {% if component_vars.is_filled.subtitle %} +
+ {% slot "subtitle" %}{# Optional subtitle #}{% endslot %} +
+ {% endif %} +
+``` + +Here's our example with more complex branching. + +```htmldjango +
+
+ {% slot "title" %}Title{% endslot %} +
+ {% if component_vars.is_filled.subtitle %} +
+ {% slot "subtitle" %}{# Optional subtitle #}{% endslot %} +
+ {% elif component_vars.is_filled.title %} + ... + {% elif component_vars.is_filled. %} + ... + {% endif %} +
+``` + +Sometimes you're not interested in whether a slot is filled, but rather that it _isn't_. +To negate the meaning of `component_vars.is_filled`, simply treat it as boolean and negate it with `not`: + +```htmldjango +{% if not component_vars.is_filled.subtitle %} +
+ {% slot "subtitle" / %} +
+{% endif %} +``` + +#### Accessing `is_filled` of slot names with special characters + +To be able to access a slot name via `component_vars.is_filled`, the slot name needs to be composed of only alphanumeric characters and underscores (e.g. `this__isvalid_123`). + +However, you can still define slots with other special characters. In such case, the slot name in `component_vars.is_filled` is modified to replace all invalid characters into `_`. + +So a slot named `"my super-slot :)"` will be available as `component_vars.is_filled.my_super_slot___`. + +Same applies when you are accessing `is_filled` from within the Python, e.g.: + +```py +class MyTable(Component): + def on_render_before(self, context, template) -> None: + # ✅ Works + if self.is_filled["my_super_slot___"]: + # Do something + + # ❌ Does not work + if self.is_filled["my super-slot :)"]: + # Do something +``` + +### Conditional fills + +Similarly, you can use `{% if %}` and `{% for %}` when defining the `{% fill %}` tags, to conditionally fill the slots when using the componnet: + +In the example below, the `{% fill "footer" %}` fill is used only if the condition is true. If falsy, the fill is ignored, and so the `my_table` component will use its default content for the `footer` slot. + +```django_html +{% component "my_table" %} + {% if editable %} + {% fill "footer" %} + + {% endfill %} + {% endif %} +{% endcomponent %} +``` + +You can even combine `{% if %}` and `{% for %}`: + +```django_html +{% component "my_table" %} + {% for header in headers %} + {% if header != "hyperlink" %} + {# Generate fill name like `header.my_column` #} + {% fill name="header."|add:header" %} + {{ header }} + {% endfill %} + {% endif %} + {% endfor %} +{% endcomponent %} +``` + +### Scoped slots + +_Added in version 0.76_: + +Consider a component with slot(s). This component may do some processing on the inputs, and then use the processed variable in the slot's default template: + +```py +@register("my_comp") +class MyComp(Component): + template = """ +
+ {% slot "content" default %} + input: {{ input }} + {% endslot %} +
+ """ + + def get_context_data(self, input): + processed_input = do_something(input) + return {"input": processed_input} +``` + +You may want to design a component so that users of your component can still access the `input` variable, so they don't have to recompute it. + +This behavior is called "scoped slots". This is inspired by [Vue scoped slots](https://vuejs.org/guide/components/slots.html#scoped-slots) and [scoped slots of django-web-components](https://github.com/Xzya/django-web-components/tree/master?tab=readme-ov-file#scoped-slots). + +Using scoped slots consists of two steps: + +1. Passing data to `slot` tag +2. Accessing data in `fill` tag + +#### Passing data to slots + +To pass the data to the `slot` tag, simply pass them as keyword attributes (`key=value`): + +```py +@register("my_comp") +class MyComp(Component): + template = """ +
+ {% slot "content" default input=input %} + input: {{ input }} + {% endslot %} +
+ """ + + def get_context_data(self, input): + processed_input = do_something(input) + return { + "input": processed_input, + } +``` + +#### Accessing slot data in fill + +Next, we head over to where we define a fill for this slot. Here, to access the slot data +we set the `data` attribute to the name of the variable through which we want to access +the slot data. In the example below, we set it to `data`: + +```django +{% component "my_comp" %} + {% fill "content" data="slot_data" %} + {{ slot_data.input }} + {% endfill %} +{% endcomponent %} +``` + +To access slot data on a default slot, you have to explictly define the `{% fill %}` tags. + +So this works: + +```django +{% component "my_comp" %} + {% fill "content" data="slot_data" %} + {{ slot_data.input }} + {% endfill %} +{% endcomponent %} +``` + +While this does not: + +```django +{% component "my_comp" data="data" %} + {{ data.input }} +{% endcomponent %} +``` + +Note: You cannot set the `data` attribute and +[`default` attribute)](#accessing-original-content-of-slots) +to the same name. This raises an error: + +```django +{% component "my_comp" %} + {% fill "content" data="slot_var" default="slot_var" %} + {{ slot_var.input }} + {% endfill %} +{% endcomponent %} +``` + +#### Slot data of default slots + +To access data of a default slot, you can specify `{% fill name="default" %}`: + +```htmldjango +{% component "my_comp" %} + {% fill "default" data="slot_data" %} + {{ slot_data.input }} + {% endfill %} +{% endcomponent %} +``` + +### Dynamic slots and fills + +Until now, we were declaring slot and fill names statically, as a string literal, e.g. + +```django +{% slot "content" / %} +``` + +However, sometimes you may want to generate slots based on the given input. One example of this is [a table component like that of Vuetify](https://vuetifyjs.com/en/api/v-data-table/), which creates a header and an item slots for each user-defined column. + +In django_components you can achieve the same, simply by using a variable (or a [template expression](#use-template-tags-inside-component-inputs)) instead of a string literal: + +```django + + + {% for header in headers %} + + {% endfor %} + +
+ {% slot "header-{{ header.key }}" value=header.title %} + {{ header.title }} + {% endslot %} +
+``` + +When using the component, you can either set the fill explicitly: + +```django +{% component "table" headers=headers items=items %} + {% fill "header-name" data="data" %} + {{ data.value }} + {% endfill %} +{% endcomponent %} +``` + +Or also use a variable: + +```django +{% component "table" headers=headers items=items %} + {# Make only the active column bold #} + {% fill "header-{{ active_header_name }}" data="data" %} + {{ data.value }} + {% endfill %} +{% endcomponent %} +``` + +> NOTE: It's better to use static slot names whenever possible for clarity. The dynamic slot names should be reserved for advanced use only. + +Lastly, in rare cases, you can also pass the slot name via [the spread operator](#spread-operator). This is possible, because the slot name argument is actually a shortcut for a `name` keyword argument. + +So this: + +```django +{% slot "content" / %} +``` + +is the same as: + +```django +{% slot name="content" / %} +``` + +So it's possible to define a `name` key on a dictionary, and then spread that onto the slot tag: + +```django +{# slot_props = {"name": "content"} #} +{% slot ...slot_props / %} +``` + +### Pass through all the slots + +You can dynamically pass all slots to a child component. This is similar to +[passing all slots in Vue](https://vue-land.github.io/faq/forwarding-slots#passing-all-slots): + +```py +class MyTable(Component): + def get_context_data(self, *args, **kwargs): + return { + "slots": self.input.slots, + } + + template: """ +
+ {% component "child" %} + {% for slot_name in slots %} + {% fill name=slot_name data="data" %} + {% slot name=slot_name ...data / %} + {% endfill %} + {% endfor %} + {% endcomponent %} +
+ """ +``` diff --git a/src/docs/concepts/fundamentals/template_tag_syntax.md b/src/docs/concepts/fundamentals/template_tag_syntax.md new file mode 100644 index 00000000..9b173c58 --- /dev/null +++ b/src/docs/concepts/fundamentals/template_tag_syntax.md @@ -0,0 +1,290 @@ +--- +title: Template tag syntax +weight: 7 +--- + +All template tags in django_component, like `{% component %}` or `{% slot %}`, and so on, +support extra syntax that makes it possible to write components like in Vue or React (JSX). + +## Self-closing tags + +When you have a tag like `{% component %}` or `{% slot %}`, but it has no content, you can simply append a forward slash `/` at the end, instead of writing out the closing tags like `{% endcomponent %}` or `{% endslot %}`: + +So this: + +```django +{% component "button" %}{% endcomponent %} +``` + +becomes + +```django +{% component "button" / %} +``` + +## Special characters + +_New in version 0.71_: + +Keyword arguments can contain special characters `# @ . - _`, so keywords like +so are still valid: + +```django + + {% component "calendar" my-date="2015-06-19" @click.native=do_something #some_id=True / %} + +``` + +These can then be accessed inside `get_context_data` so: + +```py +@register("calendar") +class Calendar(Component): + # Since # . @ - are not valid identifiers, we have to + # use `**kwargs` so the method can accept these args. + def get_context_data(self, **kwargs): + return { + "date": kwargs["my-date"], + "id": kwargs["#some_id"], + "on_click": kwargs["@click.native"] + } +``` + +## Spread operator + +_New in version 0.93_: + +Instead of passing keyword arguments one-by-one: + +```django +{% component "calendar" title="How to abc" date="2015-06-19" author="John Wick" / %} +``` + +You can use a spread operator `...dict` to apply key-value pairs from a dictionary: + +```py +post_data = { + "title": "How to...", + "date": "2015-06-19", + "author": "John Wick", +} +``` + +```django +{% component "calendar" ...post_data / %} +``` + +This behaves similar to [JSX's spread operator](https://kevinyckim33.medium.com/jsx-spread-operator-component-props-meaning-3c9bcadd2493) +or [Vue's `v-bind`](https://vuejs.org/api/built-in-directives.html#v-bind). + +Spread operators are treated as keyword arguments, which means that: + +1. Spread operators must come after positional arguments. +2. You cannot use spread operators for [positional-only arguments](https://martinxpn.medium.com/positional-only-and-keyword-only-arguments-in-python-37-100-days-of-python-310c311657b0). + +Other than that, you can use spread operators multiple times, and even put keyword arguments in-between or after them: + +```django +{% component "calendar" ...post_data id=post.id ...extra / %} +``` + +In a case of conflicts, the values added later (right-most) overwrite previous values. + +## Use template tags inside component inputs + +_New in version 0.93_ + +When passing data around, sometimes you may need to do light transformations, like negating booleans or filtering lists. + +Normally, what you would have to do is to define ALL the variables +inside `get_context_data()`. But this can get messy if your components contain a lot of logic. + +```py +@register("calendar") +class Calendar(Component): + def get_context_data(self, id: str, editable: bool): + return { + "editable": editable, + "readonly": not editable, + "input_id": f"input-{id}", + "icon_id": f"icon-{id}", + ... + } +``` + +Instead, template tags in django_components (`{% component %}`, `{% slot %}`, `{% provide %}`, etc) allow you to treat literal string values as templates: + +```django +{% component 'blog_post' + "As positional arg {# yay #}" + title="{{ person.first_name }} {{ person.last_name }}" + id="{% random_int 10 20 %}" + readonly="{{ editable|not }}" + author="John Wick {# TODO: parametrize #}" +/ %} +``` + +In the example above: + +- Component `test` receives a positional argument with value `"As positional arg "`. The comment is omitted. +- Kwarg `title` is passed as a string, e.g. `John Doe` +- Kwarg `id` is passed as `int`, e.g. `15` +- Kwarg `readonly` is passed as `bool`, e.g. `False` +- Kwarg `author` is passed as a string, e.g. `John Wick ` (Comment omitted) + +This is inspired by [django-cotton](https://github.com/wrabit/django-cotton#template-expressions-in-attributes). + +### Passing data as string vs original values + +Sometimes you may want to use the template tags to transform +or generate the data that is then passed to the component. + +The data doesn't necessarily have to be strings. In the example above, the kwarg `id` was passed as an integer, NOT a string. + +Although the string literals for components inputs are treated as regular Django templates, there is one special case: + +When the string literal contains only a single template tag, with no extra text, then the value is passed as the original type instead of a string. + +Here, `page` is an integer: + +```django +{% component 'blog_post' page="{% random_int 10 20 %}" / %} +``` + +Here, `page` is a string: + +```django +{% component 'blog_post' page=" {% random_int 10 20 %} " / %} +``` + +And same applies to the `{{ }}` variable tags: + +Here, `items` is a list: + +```django +{% component 'cat_list' items="{{ cats|slice:':2' }}" / %} +``` + +Here, `items` is a string: + +```django +{% component 'cat_list' items="{{ cats|slice:':2' }} See more" / %} +``` + +### Evaluating Python expressions in template + +You can even go a step further and have a similar experience to Vue or React, +where you can evaluate arbitrary code expressions: + +```jsx + +``` + +Similar is possible with [`django-expr`](https://pypi.org/project/django-expr/), which adds an `expr` tag and filter that you can use to evaluate Python expressions from within the template: + +```django +{% component "my_form" + value="{% expr 'input_value if is_enabled else None' %}" +/ %} +``` + +> Note: Never use this feature to mix business logic and template logic. Business logic should still be in the view! + +## Pass dictonary by its key-value pairs + +_New in version 0.74_: + +Sometimes, a component may expect a dictionary as one of its inputs. + +Most commonly, this happens when a component accepts a dictionary +of HTML attributes (usually called `attrs`) to pass to the underlying template. + +In such cases, we may want to define some HTML attributes statically, and other dynamically. +But for that, we need to define this dictionary on Python side: + +```py +@register("my_comp") +class MyComp(Component): + template = """ + {% component "other" attrs=attrs / %} + """ + + def get_context_data(self, some_id: str): + attrs = { + "class": "pa-4 flex", + "data-some-id": some_id, + "@click.stop": "onClickHandler", + } + return {"attrs": attrs} +``` + +But as you can see in the case above, the event handler `@click.stop` and styling `pa-4 flex` +are disconnected from the template. If the component grew in size and we moved the HTML +to a separate file, we would have hard time reasoning about the component's template. + +Luckily, there's a better way. + +When we want to pass a dictionary to a component, we can define individual key-value pairs +as component kwargs, so we can keep all the relevant information in the template. For that, +we prefix the key with the name of the dict and `:`. So key `class` of input `attrs` becomes +`attrs:class`. And our example becomes: + +```py +@register("my_comp") +class MyComp(Component): + template = """ + {% component "other" + attrs:class="pa-4 flex" + attrs:data-some-id=some_id + attrs:@click.stop="onClickHandler" + / %} + """ + + def get_context_data(self, some_id: str): + return {"some_id": some_id} +``` + +Sweet! Now all the relevant HTML is inside the template, and we can move it to a separate file with confidence: + +```django +{% component "other" + attrs:class="pa-4 flex" + attrs:data-some-id=some_id + attrs:@click.stop="onClickHandler" +/ %} +``` + +> Note: It is NOT possible to define nested dictionaries, so +> `attrs:my_key:two=2` would be interpreted as: +> +> ```py +> {"attrs": {"my_key:two": 2}} +> ``` + +## Multiline 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` diff --git a/src/docs/concepts/fundamentals/your_first_component.md b/src/docs/concepts/fundamentals/your_first_component.md new file mode 100644 index 00000000..de6b7c7b --- /dev/null +++ b/src/docs/concepts/fundamentals/your_first_component.md @@ -0,0 +1,88 @@ +--- +title: Create your first component +weight: 1 +--- + +A component in django-components is the combination of four things: CSS, Javascript, a Django template, and some Python code to put them all together. + +``` + sampleproject/ + ├── calendarapp/ + ├── components/ 🆕 + │ └── calendar/ 🆕 + │ ├── calendar.py 🆕 + │ ├── script.js 🆕 + │ ├── style.css 🆕 + │ └── template.html 🆕 + ├── sampleproject/ + ├── manage.py + └── requirements.txt +``` + +Start by creating empty files in the structure above. + +First, you need a CSS file. Be sure to prefix all rules with a unique class so they don't clash with other rules. + +```css title="[project root]/components/calendar/style.css" +/* In a file called [project root]/components/calendar/style.css */ +.calendar-component { + width: 200px; + background: pink; +} +.calendar-component span { + font-weight: bold; +} +``` + +Then you need a javascript file that specifies how you interact with this component. You are free to use any javascript framework you want. A good way to make sure this component doesn't clash with other components is to define all code inside an anonymous function that calls itself. This makes all variables defined only be defined inside this component and not affect other components. + +```js title="[project root]/components/calendar/script.js" +/* In a file called [project root]/components/calendar/script.js */ +(function () { + if (document.querySelector(".calendar-component")) { + document.querySelector(".calendar-component").onclick = function () { + alert("Clicked calendar!"); + }; + } +})(); +``` + +Now you need a Django template for your component. Feel free to define more variables like `date` in this example. When creating an instance of this component we will send in the values for these variables. The template will be rendered with whatever template backend you've specified in your Django settings file. + +```htmldjango title="[project root]/components/calendar/calendar.html" +{# In a file called [project root]/components/calendar/template.html #} +
Today's date is {{ date }}
+``` + +Finally, we use django-components to tie this together. Start by creating a file called `calendar.py` in your component calendar directory. It will be auto-detected and loaded by the app. + +Inside this file we create a Component by inheriting from the Component class and specifying the context method. We also register the global component registry so that we easily can render it anywhere in our templates. + +```python title="[project root]/components/calendar/calendar.py" +# In a file called [project root]/components/calendar/calendar.py +from django_components import Component, register + +@register("calendar") +class Calendar(Component): + # Templates inside `[your apps]/components` dir and `[project root]/components` dir + # will be automatically found. + # + # `template_name` can be relative to dir where `calendar.py` is, or relative to COMPONENTS.dirs + template_name = "template.html" + # Or + def get_template_name(context): + return f"template-{context['name']}.html" + + # This component takes one parameter, a date string to show in the template + def get_context_data(self, date): + return { + "date": date, + } + + # Both `css` and `js` can be relative to dir where `calendar.py` is, or relative to COMPONENTS.dirs + class Media: + css = "style.css" + js = "script.js" +``` + +And voilá!! We've created our first component. diff --git a/src/docs/guides/setup/dev_server_setup.md b/src/docs/guides/setup/dev_server_setup.md new file mode 100644 index 00000000..09e6c9ff --- /dev/null +++ b/src/docs/guides/setup/dev_server_setup.md @@ -0,0 +1,26 @@ +--- +title: Running with development server +weight: 2 +--- + +### 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 in separate files 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 component 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_file_change`](#reload_on_file_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! diff --git a/src/docs/guides/setup/logging_and_debugging.md b/src/docs/guides/setup/logging_and_debugging.md new file mode 100644 index 00000000..431b2cf3 --- /dev/null +++ b/src/docs/guides/setup/logging_and_debugging.md @@ -0,0 +1,34 @@ +--- +weight: 3 +--- + +Django components supports [logging with Django](https://docs.djangoproject.com/en/5.0/howto/logging/#logging-how-to). +This can help with troubleshooting. + +To configure logging for Django components, set the `django_components` logger in +[`LOGGING`](https://docs.djangoproject.com/en/5.1/ref/settings/#std-setting-LOGGING) +in `settings.py` (below). + +Also see the [`settings.py` file in sampleproject](https://github.com/EmilStenstrom/django-components/blob/master/sampleproject/sampleproject/settings.py) for a real-life example. + +```py +import logging +import sys + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + "handlers": { + "console": { + 'class': 'logging.StreamHandler', + 'stream': sys.stdout, + }, + }, + "loggers": { + "django_components": { + "level": logging.DEBUG, + "handlers": ["console"], + }, + }, +} +``` diff --git a/src/docs/guides/setup/syntax_highlight.md b/src/docs/guides/setup/syntax_highlight.md new file mode 100644 index 00000000..259d8521 --- /dev/null +++ b/src/docs/guides/setup/syntax_highlight.md @@ -0,0 +1,45 @@ +--- +title: Syntax highlighting +--- + +## VSCode + +Note, in the above example, that the `t.django_html`, `t.css`, and `t.js` types are used to specify the type of the template, CSS, and JS files, respectively. This is not necessary, but if you're using VSCode with the [Python Inline Source Syntax Highlighting](https://marketplace.visualstudio.com/items?itemName=samwillis.python-inline-source) extension, it will give you syntax highlighting for the template, CSS, and JS. + +## Pycharm (or other Jetbrains IDEs) + +If you're a Pycharm user (or any other editor from Jetbrains), you can have coding assistance as well: + +```python +from django_components import Component, register + +@register("calendar") +class Calendar(Component): + def get_context_data(self, date): + return { + "date": date, + } + + # language=HTML + template= """ +
Today's date is {{ date }}
+ """ + + # language=CSS + css = """ + .calendar-component { width: 200px; background: pink; } + .calendar-component span { font-weight: bold; } + """ + + # language=JS + js = """ + (function(){ + if (document.querySelector(".calendar-component")) { + document.querySelector(".calendar-component").onclick = function(){ alert("Clicked calendar!"); }; + } + })() + """ +``` + +You don't need to use `types.django_html`, `types.css`, `types.js` since Pycharm uses [language injections](https://www.jetbrains.com/help/pycharm/using-language-injections.html). +You only need to write the comments `# language=` above the variables. diff --git a/src/docs/license.md b/src/docs/license.md deleted file mode 100644 index 0e8c6fca..00000000 --- a/src/docs/license.md +++ /dev/null @@ -1,3 +0,0 @@ -# License - ---8<-- "LICENSE" diff --git a/src/docs/overview/code_of_conduct.md b/src/docs/overview/code_of_conduct.md new file mode 100644 index 00000000..ee151f6e --- /dev/null +++ b/src/docs/overview/code_of_conduct.md @@ -0,0 +1,6 @@ +--- +title: Code of Conduct +weight: 8 +--- + +--8<-- "CODE_OF_CONDUCT.md" diff --git a/src/docs/overview/community.md b/src/docs/overview/community.md new file mode 100644 index 00000000..a1c361e1 --- /dev/null +++ b/src/docs/overview/community.md @@ -0,0 +1,16 @@ +--- +title: Community +weight: 5 +--- + +## Community questions + +The best place to ask questions is in our [Github Discussion board](https://github.com/EmilStenstrom/django-components/discussions). + +Please, before opening a new discussion, [check if similar discussion wasn't opened already](https://github.com/EmilStenstrom/django-components/discussions?discussions_q=). + +## Community examples + +One of our goals with `django-components` is to make it easy to share components between projects. If you have a set of components that you think would be useful to others, please open a pull request to add them to the list below. + +- [django-htmx-components](https://github.com/iwanalabs/django-htmx-components): A set of components for use with [htmx](https://htmx.org/). Try out the [live demo](https://dhc.iwanalabs.com/). diff --git a/src/docs/overview/compatibility.md b/src/docs/overview/compatibility.md new file mode 100644 index 00000000..6aea5f23 --- /dev/null +++ b/src/docs/overview/compatibility.md @@ -0,0 +1,15 @@ +--- +title: Compatibility +weight: 2 +--- +## Compatibility + +Django-components supports all supported combinations versions of [Django](https://docs.djangoproject.com/en/dev/faq/install/#what-python-version-can-i-use-with-django) and [Python](https://devguide.python.org/versions/#versions). + +| Python version | Django version | +| -------------- | -------------- | +| 3.8 | 4.2 | +| 3.9 | 4.2 | +| 3.10 | 4.2, 5.0 | +| 3.11 | 4.2, 5.0 | +| 3.12 | 4.2, 5.0 | diff --git a/src/docs/overview/development.md b/src/docs/overview/development.md new file mode 100644 index 00000000..85344750 --- /dev/null +++ b/src/docs/overview/development.md @@ -0,0 +1,145 @@ +--- +title: Development +weight: 7 +--- + +## Install locally and run the tests + +Start by forking the project by clicking the **Fork button** up in the right corner in the GitHub . This makes a copy of the repository in your own name. Now you can clone this repository locally and start adding features: + +```sh +git clone https://github.com//django-components.git +``` + +To quickly run the tests install the local dependencies by running: + +```sh +pip install -r requirements-dev.txt +``` + +Now you can run the tests to make sure everything works as expected: + +```sh +pytest +``` + +The library is also tested across many versions of Python and Django. To run tests that way: + +```sh +pyenv install -s 3.8 +pyenv install -s 3.9 +pyenv install -s 3.10 +pyenv install -s 3.11 +pyenv install -s 3.12 +pyenv local 3.8 3.9 3.10 3.11 3.12 +tox -p +``` + +## Running Playwright tests + +We use [Playwright](https://playwright.dev/python/docs/intro) for end-to-end tests. You will therefore need to install Playwright to be able to run these tests. + +Luckily, Playwright makes it very easy: + +```sh +pip install -r requirements-dev.txt +playwright install chromium --with-deps +``` + +After Playwright is ready, simply run the tests with `tox`: + +```sh +tox +``` + +## Developing against live Django app + +How do you check that your changes to django-components project will work in an actual Django project? + +Use the [sampleproject](https://github.com/EmilStenstrom/django-components/tree/master/sampleproject/) demo project to validate the changes: + +1. Navigate to [sampleproject](https://github.com/EmilStenstrom/django-components/tree/master/sampleproject/) directory: + + ```sh + cd sampleproject + ``` + +2. Install dependencies from the [requirements.txt](https://github.com/EmilStenstrom/django-components/blob/master/sampleproject/requirements.txt) file: + + ```sh + pip install -r requirements.txt + ``` + +3. Link to your local version of django-components: + + ```sh + pip install -e .. + ``` + + NOTE: The path (in this case `..`) must point to the directory that has the `setup.py` file. + +4. Start Django server + ```sh + python manage.py runserver + ``` + +Once the server is up, it should be available at . + +To display individual components, add them to the `urls.py`, like in the case of + +## Building JS code + +django_components uses a bit of JS code to: + +- Manage the loading of JS and CSS files used by the components +- Allow to pass data from Python to JS + +When you make changes to this JS code, you also need to compile it: + +1. Make sure you are inside `src/django_components_js`: + +```sh +cd src/django_components_js +``` + +2. Install the JS dependencies + +```sh +npm install +``` + +3. Compile the JS/TS code: + +```sh +python build.py +``` + +The script will combine all JS/TS code into a single `.js` file, minify it, +and copy it to `django_components/static/django_components/django_components.min.js`. + +## Packaging and publishing + +To package the library into a distribution that can be published to PyPI, run: + +```sh +# Install pypa/build +python -m pip install build --user +# Build a binary wheel and a source tarball +python -m build --sdist --wheel --outdir dist/ . +``` + +To publish the package to PyPI, use `twine` ([See Python user guide](https://packaging.python.org/en/latest/tutorials/packaging-projects/#uploading-the-distribution-archives)): + +```sh +twine upload --repository pypi dist/* -u __token__ -p +``` + +[See the full workflow here.](https://github.com/EmilStenstrom/django-components/discussions/557#discussioncomment-10179141) + +## Development guides + +Deep dive into how django_components' features are implemented. + +- [Slot rendering](../devguides/slot_rendering.md) +- [Slots and blocks](../devguides/slots_and_blocks.md) +- [JS and CSS dependency management](../devguides/dependency_mgmt.md) diff --git a/src/docs/overview/installation.md b/src/docs/overview/installation.md new file mode 100644 index 00000000..43c73339 --- /dev/null +++ b/src/docs/overview/installation.md @@ -0,0 +1,162 @@ +--- +title: Installation +weight: 3 +--- + +1. Install `django_components` into your environment: + + ```bash + pip install django_components + ``` + +2. Load `django_components` into Django by adding it into `INSTALLED_APPS` in settings.py: + + ```python + INSTALLED_APPS = [ + ..., + 'django_components', + ] + ``` + +3. `BASE_DIR` setting is required. Ensure that it is defined in settings.py: + + ```python + from pathlib import Path + + BASE_DIR = Path(__file__).resolve().parent.parent + ``` + +4. Add / modify [`COMPONENTS.dirs`](#dirs) and / or [`COMPONENTS.app_dirs`](#app_dirs) so django_components knows where to find component HTML, JS and CSS files: + + ```python + from django_components import ComponentsSettings + + COMPONENTS = ComponentsSettings( + dirs=[ + ..., + Path(BASE_DIR) / "components", + ], + ) + ``` + + If `COMPONENTS.dirs` is omitted, django-components will by default look for a top-level `/components` directory, + `{BASE_DIR}/components`. + + In addition to `COMPONENTS.dirs`, django_components will also load components from app-level directories, such as `my-app/components/`. + The directories within apps are configured with [`COMPONENTS.app_dirs`](#app_dirs), and the default is `[app]/components`. + + NOTE: The input to `COMPONENTS.dirs` is the same as for `STATICFILES_DIRS`, and the paths must be full paths. [See Django docs](https://docs.djangoproject.com/en/5.0/ref/settings/#staticfiles-dirs). + +5. Next, to make Django load component HTML files as Django templates, 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 + TEMPLATES = [ + { + ..., + 'OPTIONS': { + 'context_processors': [ + ... + ], + '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', + ] + )], + }, + }, + ] + ``` + +## Adding support for JS and CSS + +If you want to use JS or CSS with components, you will need to: + +1. Modify `STATICFILES_FINDERS` section of settings.py as follows to be able to serve + the component JS and CSS files as static files: + + ```python + STATICFILES_FINDERS = [ + # Default finders + "django.contrib.staticfiles.finders.FileSystemFinder", + "django.contrib.staticfiles.finders.AppDirectoriesFinder", + # Django components + "django_components.finders.ComponentsFileSystemFinder", + ] + ``` + +2. Add [`ComponentDependencyMiddleware`](#setting-up-componentdependencymiddleware) to `MIDDLEWARE` setting. + + The middleware searches the outgoing HTML for all components that were rendered + to generate the HTML, and adds the JS and CSS associated with those components. + + ```python + MIDDLEWARE = [ + ... + "django_components.middleware.ComponentDependencyMiddleware", + ] + ``` + + Read more in [Rendering JS/CSS dependencies](#rendering-jscss-dependencies). + +3. Add django-component's URL paths to your `urlpatterns`: + + ```python + from django.urls import include, path + + urlpatterns = [ + ... + path("", include("django_components.urls")), + ] + ``` + +4. _Optional._ If you want to change where the JS and CSS is rendered, use + [`{% component_js_dependencies %}`](../reference/template_tags.md#component_js_dependencies) + and [`{% component_css_dependencies %}`](../reference/template_tags.md#component_js_dependencies). + + By default, the JS ` - - - -``` - -This makes it possible to organize your front-end around reusable components. Instead of relying on template tags and keeping your CSS and Javascript in the static directory. diff --git a/src/docs/concepts/fundamentals/defining_js_css_html_files.md b/src/docs/concepts/fundamentals/defining_js_css_html_files.md deleted file mode 100644 index c9de0df6..00000000 --- a/src/docs/concepts/fundamentals/defining_js_css_html_files.md +++ /dev/null @@ -1,203 +0,0 @@ ---- -title: Defining HTML / JS / CSS files -weight: 10 ---- - -django_component's management of files builds on top of [Django's `Media` class](https://docs.djangoproject.com/en/5.0/topics/forms/media/). - -To be familiar with how Django handles static files, we recommend reading also: - -- [How to manage static files (e.g. images, JavaScript, CSS)](https://docs.djangoproject.com/en/5.0/howto/static-files/) - -## Defining file paths relative to component or static dirs - -As seen in the [getting started example](#create-your-first-component), to associate HTML/JS/CSS -files with a component, you set them as `template_name`, `Media.js` and `Media.css` respectively: - -```py -# In a file [project root]/components/calendar/calendar.py -from django_components import Component, register - -@register("calendar") -class Calendar(Component): - template_name = "template.html" - - class Media: - css = "style.css" - js = "script.js" -``` - -In the example above, the files are defined relative to the directory where `component.py` is. - -Alternatively, you can specify the file paths relative to the directories set in `COMPONENTS.dirs` or `COMPONENTS.app_dirs`. - -Assuming that `COMPONENTS.dirs` contains path `[project root]/components`, we can rewrite the example as: - -```py -# In a file [project root]/components/calendar/calendar.py -from django_components import Component, register - -@register("calendar") -class Calendar(Component): - template_name = "calendar/template.html" - - class Media: - css = "calendar/style.css" - js = "calendar/script.js" -``` - -NOTE: In case of conflict, the preference goes to resolving the files relative to the component's directory. - -## Defining multiple paths - -Each component can have only a single template. However, you can define as many JS or CSS files as you want using a list. - -```py -class MyComponent(Component): - class Media: - js = ["path/to/script1.js", "path/to/script2.js"] - css = ["path/to/style1.css", "path/to/style2.css"] -``` - -## Configuring CSS Media Types - -You can define which stylesheets will be associated with which -[CSS Media types](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_media_queries/Using_media_queries#targeting_media_types). You do so by defining CSS files as a dictionary. - -See the corresponding [Django Documentation](https://docs.djangoproject.com/en/5.0/topics/forms/media/#css). - -Again, you can set either a single file or a list of files per media type: - -```py -class MyComponent(Component): - class Media: - css = { - "all": "path/to/style1.css", - "print": "path/to/style2.css", - } -``` - -```py -class MyComponent(Component): - class Media: - css = { - "all": ["path/to/style1.css", "path/to/style2.css"], - "print": ["path/to/style3.css", "path/to/style4.css"], - } -``` - -NOTE: When you define CSS as a string or a list, the `all` media type is implied. - -## Supported types for file paths - -File paths can be any of: - -- `str` -- `bytes` -- `PathLike` (`__fspath__` method) -- `SafeData` (`__html__` method) -- `Callable` that returns any of the above, evaluated at class creation (`__new__`) - -```py -from pathlib import Path - -from django.utils.safestring import mark_safe - -class SimpleComponent(Component): - class Media: - css = [ - mark_safe(''), - Path("calendar/style1.css"), - "calendar/style2.css", - b"calendar/style3.css", - lambda: "calendar/style4.css", - ] - js = [ - mark_safe(''), - Path("calendar/script1.js"), - "calendar/script2.js", - b"calendar/script3.js", - lambda: "calendar/script4.js", - ] -``` - -## Path as objects - -In the example [above](#supported-types-for-file-paths), you could see that when we used `mark_safe` to mark a string as a `SafeString`, we had to define the full `' - ) - -@register("calendar") -class Calendar(Component): - template_name = "calendar/template.html" - - def get_context_data(self, date): - return { - "date": date, - } - - class Media: - css = "calendar/style.css" - js = [ - # ', - self.absolute_path(path) - ) - return tags - -@register("calendar") -class Calendar(Component): - template_name = "calendar/template.html" - - class Media: - css = "calendar/style.css" - js = "calendar/script.js" - - # Override the behavior of Media class - media_class = MyMedia -``` - -NOTE: The instance of the `Media` class (or it's subclass) is available under `Component.media` after the class creation (`__new__`). diff --git a/src/docs/concepts/fundamentals/html_attributes.md b/src/docs/concepts/fundamentals/html_attributes.md deleted file mode 100644 index 9dcc319f..00000000 --- a/src/docs/concepts/fundamentals/html_attributes.md +++ /dev/null @@ -1,378 +0,0 @@ ---- -title: HTML attributes -weight: 9 ---- - -_New in version 0.74_: - -You can use the `html_attrs` tag to render HTML attributes, given a dictionary -of values. - -So if you have a template: - -```django -
-
-``` - -You can simplify it with `html_attrs` tag: - -```django -
-
-``` - -where `attrs` is: - -```py -attrs = { - "class": classes, - "data-id": my_id, -} -``` - -This feature is inspired by [`merge_attrs` tag of django-web-components](https://github.com/Xzya/django-web-components/tree/master?tab=readme-ov-file#default--merged-attributes) and -["fallthrough attributes" feature of Vue](https://vuejs.org/guide/components/attrs). - -## Removing atttributes - -Attributes that are set to `None` or `False` are NOT rendered. - -So given this input: - -```py -attrs = { - "class": "text-green", - "required": False, - "data-id": None, -} -``` - -And template: - -```django -
-
-``` - -Then this renders: - -```html -
-``` - -## Boolean attributes - -In HTML, boolean attributes are usually rendered with no value. Consider the example below where the first button is disabled and the second is not: - -```html - -``` - -HTML rendering with `html_attrs` tag or `attributes_to_string` works the same way, where `key=True` is rendered simply as `key`, and `key=False` is not render at all. - -So given this input: - -```py -attrs = { - "disabled": True, - "autofocus": False, -} -``` - -And template: - -```django -
-
-``` - -Then this renders: - -```html -
-``` - -## Default attributes - -Sometimes you may want to specify default values for attributes. You can pass a second argument (or kwarg `defaults`) to set the defaults. - -```django -
- ... -
-``` - -In the example above, if `attrs` contains e.g. the `class` key, `html_attrs` will render: - -`class="{{ attrs.class }}"` - -Otherwise, `html_attrs` will render: - -`class="{{ defaults.class }}"` - -## Appending attributes - -For the `class` HTML attribute, it's common that we want to _join_ multiple values, -instead of overriding them. For example, if you're authoring a component, you may -want to ensure that the component will ALWAYS have a specific class. Yet, you may -want to allow users of your component to supply their own classes. - -We can achieve this by adding extra kwargs. These values -will be appended, instead of overwriting the previous value. - -So if we have a variable `attrs`: - -```py -attrs = { - "class": "my-class pa-4", -} -``` - -And on `html_attrs` tag, we set the key `class`: - -```django -
-
-``` - -Then these will be merged and rendered as: - -```html -
-``` - -To simplify merging of variables, you can supply the same key multiple times, and these will be all joined together: - -```django -{# my_var = "class-from-var text-red" #} -
-
-``` - -Renders: - -```html -
-``` - -## Rules for `html_attrs` - -1. Both `attrs` and `defaults` can be passed as positional args - - `{% html_attrs attrs defaults key=val %}` - - or as kwargs - - `{% html_attrs key=val defaults=defaults attrs=attrs %}` - -2. Both `attrs` and `defaults` are optional (can be omitted) - -3. Both `attrs` and `defaults` are dictionaries, and we can define them the same way [we define dictionaries for the `component` tag](#pass-dictonary-by-its-key-value-pairs). So either as `attrs=attrs` or `attrs:key=value`. - -4. All other kwargs are appended and can be repeated. - -## Examples for `html_attrs` - -Assuming that: - -```py -class_from_var = "from-var" - -attrs = { - "class": "from-attrs", - "type": "submit", -} - -defaults = { - "class": "from-defaults", - "role": "button", -} -``` - -Then: - -- Empty tag
- `{% html_attr %}` - - renders (empty string):
- ` ` - -- Only kwargs
- `{% html_attr class="some-class" class=class_from_var data-id="123" %}` - - renders:
- `class="some-class from-var" data-id="123"` - -- Only attrs
- `{% html_attr attrs %}` - - renders:
- `class="from-attrs" type="submit"` - -- Attrs as kwarg
- `{% html_attr attrs=attrs %}` - - renders:
- `class="from-attrs" type="submit"` - -- Only defaults (as kwarg)
- `{% html_attr defaults=defaults %}` - - renders:
- `class="from-defaults" role="button"` - -- Attrs using the `prefix:key=value` construct
- `{% html_attr attrs:class="from-attrs" attrs:type="submit" %}` - - renders:
- `class="from-attrs" type="submit"` - -- Defaults using the `prefix:key=value` construct
- `{% html_attr defaults:class="from-defaults" %}` - - renders:
- `class="from-defaults" role="button"` - -- All together (1) - attrs and defaults as positional args:
- `{% html_attrs attrs defaults class="added_class" class=class_from_var data-id=123 %}` - - renders:
- `class="from-attrs added_class from-var" type="submit" role="button" data-id=123` - -- All together (2) - attrs and defaults as kwargs args:
- `{% html_attrs class="added_class" class=class_from_var data-id=123 attrs=attrs defaults=defaults %}` - - renders:
- `class="from-attrs added_class from-var" type="submit" role="button" data-id=123` - -- All together (3) - mixed:
- `{% html_attrs attrs defaults:class="default-class" class="added_class" class=class_from_var data-id=123 %}` - - renders:
- `class="from-attrs added_class from-var" type="submit" data-id=123` - -## Full example for `html_attrs` - -```py -@register("my_comp") -class MyComp(Component): - template: t.django_html = """ -
- Today's date is {{ date }} -
- """ - - def get_context_data(self, date: Date, attrs: dict): - return { - "date": date, - "attrs": attrs, - "class_from_var": "extra-class" - } - -@register("parent") -class Parent(Component): - template: t.django_html = """ - {% component "my_comp" - date=date - attrs:class="pa-0 border-solid border-red" - attrs:data-json=json_data - attrs:@click="(e) => onClick(e, 'from_parent')" - / %} - """ - - def get_context_data(self, date: Date): - return { - "date": datetime.now(), - "json_data": json.dumps({"value": 456}) - } -``` - -Note: For readability, we've split the tags across multiple lines. - -Inside `MyComp`, we defined a default attribute - -`defaults:class="pa-4 text-red"` - -So if `attrs` includes key `class`, the default above will be ignored. - -`MyComp` also defines `class` key twice. It means that whether the `class` -attribute is taken from `attrs` or `defaults`, the two `class` values -will be appended to it. - -So by default, `MyComp` renders: - -```html -
...
-``` - -Next, let's consider what will be rendered when we call `MyComp` from `Parent` -component. - -`MyComp` accepts a `attrs` dictionary, that is passed to `html_attrs`, so the -contents of that dictionary are rendered as the HTML attributes. - -In `Parent`, we make use of passing dictionary key-value pairs as kwargs to define -individual attributes as if they were regular kwargs. - -So all kwargs that start with `attrs:` will be collected into an `attrs` dict. - -```django - attrs:class="pa-0 border-solid border-red" - attrs:data-json=json_data - attrs:@click="(e) => onClick(e, 'from_parent')" -``` - -And `get_context_data` of `MyComp` will receive `attrs` input with following keys: - -```py -attrs = { - "class": "pa-0 border-solid", - "data-json": '{"value": 456}', - "@click": "(e) => onClick(e, 'from_parent')", -} -``` - -`attrs["class"]` overrides the default value for `class`, whereas other keys -will be merged. - -So in the end `MyComp` will render: - -```html -
- ... -
-``` - -## Rendering HTML attributes outside of templates - -If you need to use serialize HTML attributes outside of Django template and the `html_attrs` tag, you can use `attributes_to_string`: - -```py -from django_components.attributes import attributes_to_string - -attrs = { - "class": "my-class text-red pa-4", - "data-id": 123, - "required": True, - "disabled": False, - "ignored-attr": None, -} - -attributes_to_string(attrs) -# 'class="my-class text-red pa-4" data-id="123" required' -``` diff --git a/src/docs/concepts/fundamentals/single_file_components.md b/src/docs/concepts/fundamentals/single_file_components.md deleted file mode 100644 index 0327d09f..00000000 --- a/src/docs/concepts/fundamentals/single_file_components.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -title: Single-file components -weight: 2 ---- - -Components can also be defined in a single file, which is useful for small components. To do this, you can use the `template`, `js`, and `css` class attributes instead of the `template_name` and `Media`. For example, here's the calendar component from above, defined in a single file: - -```python title="[project root]/components/calendar.py" -# In a file called [project root]/components/calendar.py -from django_components import Component, register, types - -@register("calendar") -class Calendar(Component): - def get_context_data(self, date): - return { - "date": date, - } - - template: types.django_html = """ -
Today's date is {{ date }}
- """ - - css: types.css = """ - .calendar-component { width: 200px; background: pink; } - .calendar-component span { font-weight: bold; } - """ - - js: types.js = """ - (function(){ - if (document.querySelector(".calendar-component")) { - document.querySelector(".calendar-component").onclick = function(){ alert("Clicked calendar!"); }; - } - })() - """ -``` - -This makes it easy to create small components without having to create a separate template, CSS, and JS file. diff --git a/src/docs/concepts/fundamentals/slots.md b/src/docs/concepts/fundamentals/slots.md deleted file mode 100644 index 05b6e12b..00000000 --- a/src/docs/concepts/fundamentals/slots.md +++ /dev/null @@ -1,670 +0,0 @@ ---- -title: Slots -weight: 8 ---- - -_New in version 0.26_: - -- The `slot` tag now serves only to declare new slots inside the component template. - - To override the content of a declared slot, use the newly introduced `fill` tag instead. -- Whereas unfilled slots used to raise a warning, filling a slot is now optional by default. - - To indicate that a slot must be filled, the new `required` option should be added at the end of the `slot` tag. - ---- - -Components support something called 'slots'. -When a component is used inside another template, slots allow the parent template to override specific parts of the child component by passing in different content. -This mechanism makes components more reusable and composable. -This behavior is similar to [slots in Vue](https://vuejs.org/guide/components/slots.html). - -In the example below we introduce two block tags that work hand in hand to make this work. These are... - -- `{% slot %}`/`{% endslot %}`: Declares a new slot in the component template. -- `{% fill %}`/`{% endfill %}`: (Used inside a `{% component %}` tag pair.) Fills a declared slot with the specified content. - -Let's update our calendar component to support more customization. We'll add `slot` tag pairs to its template, _template.html_. - -```htmldjango -
-
- {% slot "header" %}Calendar header{% endslot %} -
-
- {% slot "body" %}Today's date is {{ date }}{% endslot %} -
-
-``` - -When using the component, you specify which slots you want to fill and where you want to use the defaults from the template. It looks like this: - -```htmldjango -{% component "calendar" date="2020-06-06" %} - {% fill "body" %} - Can you believe it's already {{ date }}?? - {% endfill %} -{% endcomponent %} -``` - -Since the 'header' fill is unspecified, it's taken from the base template. If you put this in a template, and pass in `date=2020-06-06`, this is what gets rendered: - -```htmldjango -
-
- Calendar header -
-
- Can you believe it's already 2020-06-06?? -
-
-``` - -### Named slots - -As seen in the previouse section, you can use `{% fill slot_name %}` to insert content into a specific -slot. - -You can define fills for multiple slot simply by defining them all within the `{% component %} {% endcomponent %}` -tags: - -```htmldjango -{% component "calendar" date="2020-06-06" %} - {% fill "header" %} - Hi this is header! - {% endfill %} - {% fill "body" %} - Can you believe it's already {{ date }}?? - {% endfill %} -{% endcomponent %} -``` - -You can also use `{% for %}`, `{% with %}`, or other non-component tags (even `{% include %}`) -to construct the `{% fill %}` tags, **as long as these other tags do not leave any text behind!** - -```django -{% component "table" %} - {% for slot_name in slots %} - {% fill name=slot_name %} - {{ slot_name }} - {% endfill %} - {% endfor %} - - {% with slot_name="abc" %} - {% fill name=slot_name %} - {{ slot_name }} - {% endfill %} - {% endwith %} -{% endcomponent %} -``` - -### Default slot - -_Added in version 0.28_ - -As you can see, component slots lets you write reusable containers that you fill in when you use a component. This makes for highly reusable components that can be used in different circumstances. - -It can become tedious to use `fill` tags everywhere, especially when you're using a component that declares only one slot. To make things easier, `slot` tags can be marked with an optional keyword: `default`. - -When added to the tag (as shown below), this option lets you pass filling content directly in the body of a `component` tag pair – without using a `fill` tag. Choose carefully, though: a component template may contain at most one slot that is marked as `default`. The `default` option can be combined with other slot options, e.g. `required`. - -Here's the same example as before, except with default slots and implicit filling. - -The template: - -```htmldjango -
-
- {% slot "header" %}Calendar header{% endslot %} -
-
- {% slot "body" default %}Today's date is {{ date }}{% endslot %} -
-
-``` - -Including the component (notice how the `fill` tag is omitted): - -```htmldjango -{% component "calendar" date="2020-06-06" %} - Can you believe it's already {{ date }}?? -{% endcomponent %} -``` - -The rendered result (exactly the same as before): - -```html -
-
Calendar header
-
Can you believe it's already 2020-06-06??
-
-``` - -You may be tempted to combine implicit fills with explicit `fill` tags. This will not work. The following component template will raise an error when rendered. - -```htmldjango -{# DON'T DO THIS #} -{% component "calendar" date="2020-06-06" %} - {% fill "header" %}Totally new header!{% endfill %} - Can you believe it's already {{ date }}?? -{% endcomponent %} -``` - -Instead, you can use a named fill with name `default` to target the default fill: - -```htmldjango -{# THIS WORKS #} -{% component "calendar" date="2020-06-06" %} - {% fill "header" %}Totally new header!{% endfill %} - {% fill "default" %} - Can you believe it's already {{ date }}?? - {% endfill %} -{% endcomponent %} -``` - -NOTE: If you doubly-fill a slot, that is, that both `{% fill "default" %}` and `{% fill "header" %}` -would point to the same slot, this will raise an error when rendered. - -#### Accessing default slot in Python - -Since the default slot is stored under the slot name `default`, you can access the default slot -like so: - -```py -class MyTable(Component): - def get_context_data(self, *args, **kwargs): - default_slot = self.input.slots["default"] - return { - "default_slot": default_slot, - } -``` - -### Render fill in multiple places - -_Added in version 0.70_ - -You can render the same content in multiple places by defining multiple slots with -identical names: - -```htmldjango -
-
- {% slot "image" %}Image here{% endslot %} -
-
- {% slot "image" %}Image here{% endslot %} -
-
-``` - -So if used like: - -```htmldjango -{% component "calendar" date="2020-06-06" %} - {% fill "image" %} - - {% endfill %} -{% endcomponent %} -``` - -This renders: - -```htmldjango -
-
- -
-
- -
-
-``` - -#### Default and required slots - -If you use a slot multiple times, you can still mark the slot as `default` or `required`. -For that, you must mark each slot individually, e.g.: - -```htmldjango -
-
- {% slot "image" default required %}Image here{% endslot %} -
-
- {% slot "image" default required %}Image here{% endslot %} -
-
-``` - -Which you can then use as regular default slot: - -```htmldjango -{% component "calendar" date="2020-06-06" %} - -{% endcomponent %} -``` - -Since each slot is tagged individually, you can have multiple slots -with the same name but different conditions. - -E.g. in this example, we have a component that renders a user avatar - a small circular image with a profile picture or name initials. - -If the component is given `image_src` or `name_initials` variables, -the `image` slot is optional. But if neither of those are provided, -you MUST fill the `image` slot. - -```htmldjango -
- {% if image_src %} - {% slot "image" default %} - - {% endslot %} - {% elif name_initials %} - {% slot "image" default %} -
- {{ name_initials }} -
- {% endslot %} - {% else %} - {% slot "image" default required / %} - {% endif %} -
-``` - -### Accessing original content of slots - -_Added in version 0.26_ - -> NOTE: In version 0.77, the syntax was changed from -> -> ```django -> {% fill "my_slot" as "alias" %} {{ alias.default }} -> ``` -> -> to -> -> ```django -> {% fill "my_slot" default="slot_default" %} {{ slot_default }} -> ``` - -Sometimes you may want to keep the original slot, but only wrap or prepend/append content to it. To do so, you can access the default slot via the `default` kwarg. - -Similarly to the `data` attribute, you specify the variable name through which the default slot will be made available. - -For instance, let's say you're filling a slot called 'body'. To render the original slot, assign it to a variable using the `'default'` keyword. You then render this variable to insert the default content: - -```htmldjango -{% component "calendar" date="2020-06-06" %} - {% fill "body" default="body_default" %} - {{ body_default }}. Have a great day! - {% endfill %} -{% endcomponent %} -``` - -This produces: - -```htmldjango -
-
- Calendar header -
-
- Today's date is 2020-06-06. Have a great day! -
-
-``` - -To access the original content of a default slot, set the name to `default`: - -```htmldjango -{% component "calendar" date="2020-06-06" %} - {% fill "default" default="slot_default" %} - {{ slot_default }}. Have a great day! - {% endfill %} -{% endcomponent %} -``` - -### Conditional slots - -_Added in version 0.26._ - -> NOTE: In version 0.70, `{% if_filled %}` tags were replaced with `{{ component_vars.is_filled }}` variables. If your slot name contained special characters, see the section [Accessing `is_filled` of slot names with special characters](#accessing-is_filled-of-slot-names-with-special-characters). - -In certain circumstances, you may want the behavior of slot filling to depend on -whether or not a particular slot is filled. - -For example, suppose we have the following component template: - -```htmldjango -
-
- {% slot "title" %}Title{% endslot %} -
-
- {% slot "subtitle" %}{# Optional subtitle #}{% endslot %} -
-
-``` - -By default the slot named 'subtitle' is empty. Yet when the component is used without -explicit fills, the div containing the slot is still rendered, as shown below: - -```html -
-
Title
-
-
-``` - -This may not be what you want. What if instead the outer 'subtitle' div should only -be included when the inner slot is in fact filled? - -The answer is to use the `{{ component_vars.is_filled. }}` variable. You can use this together with Django's `{% if/elif/else/endif %}` tags to define a block whose contents will be rendered only if the component slot with -the corresponding 'name' is filled. - -This is what our example looks like with `component_vars.is_filled`. - -```htmldjango -
-
- {% slot "title" %}Title{% endslot %} -
- {% if component_vars.is_filled.subtitle %} -
- {% slot "subtitle" %}{# Optional subtitle #}{% endslot %} -
- {% endif %} -
-``` - -Here's our example with more complex branching. - -```htmldjango -
-
- {% slot "title" %}Title{% endslot %} -
- {% if component_vars.is_filled.subtitle %} -
- {% slot "subtitle" %}{# Optional subtitle #}{% endslot %} -
- {% elif component_vars.is_filled.title %} - ... - {% elif component_vars.is_filled. %} - ... - {% endif %} -
-``` - -Sometimes you're not interested in whether a slot is filled, but rather that it _isn't_. -To negate the meaning of `component_vars.is_filled`, simply treat it as boolean and negate it with `not`: - -```htmldjango -{% if not component_vars.is_filled.subtitle %} -
- {% slot "subtitle" / %} -
-{% endif %} -``` - -#### Accessing `is_filled` of slot names with special characters - -To be able to access a slot name via `component_vars.is_filled`, the slot name needs to be composed of only alphanumeric characters and underscores (e.g. `this__isvalid_123`). - -However, you can still define slots with other special characters. In such case, the slot name in `component_vars.is_filled` is modified to replace all invalid characters into `_`. - -So a slot named `"my super-slot :)"` will be available as `component_vars.is_filled.my_super_slot___`. - -Same applies when you are accessing `is_filled` from within the Python, e.g.: - -```py -class MyTable(Component): - def on_render_before(self, context, template) -> None: - # ✅ Works - if self.is_filled["my_super_slot___"]: - # Do something - - # ❌ Does not work - if self.is_filled["my super-slot :)"]: - # Do something -``` - -### Conditional fills - -Similarly, you can use `{% if %}` and `{% for %}` when defining the `{% fill %}` tags, to conditionally fill the slots when using the componnet: - -In the example below, the `{% fill "footer" %}` fill is used only if the condition is true. If falsy, the fill is ignored, and so the `my_table` component will use its default content for the `footer` slot. - -```django_html -{% component "my_table" %} - {% if editable %} - {% fill "footer" %} - - {% endfill %} - {% endif %} -{% endcomponent %} -``` - -You can even combine `{% if %}` and `{% for %}`: - -```django_html -{% component "my_table" %} - {% for header in headers %} - {% if header != "hyperlink" %} - {# Generate fill name like `header.my_column` #} - {% fill name="header."|add:header" %} - {{ header }} - {% endfill %} - {% endif %} - {% endfor %} -{% endcomponent %} -``` - -### Scoped slots - -_Added in version 0.76_: - -Consider a component with slot(s). This component may do some processing on the inputs, and then use the processed variable in the slot's default template: - -```py -@register("my_comp") -class MyComp(Component): - template = """ -
- {% slot "content" default %} - input: {{ input }} - {% endslot %} -
- """ - - def get_context_data(self, input): - processed_input = do_something(input) - return {"input": processed_input} -``` - -You may want to design a component so that users of your component can still access the `input` variable, so they don't have to recompute it. - -This behavior is called "scoped slots". This is inspired by [Vue scoped slots](https://vuejs.org/guide/components/slots.html#scoped-slots) and [scoped slots of django-web-components](https://github.com/Xzya/django-web-components/tree/master?tab=readme-ov-file#scoped-slots). - -Using scoped slots consists of two steps: - -1. Passing data to `slot` tag -2. Accessing data in `fill` tag - -#### Passing data to slots - -To pass the data to the `slot` tag, simply pass them as keyword attributes (`key=value`): - -```py -@register("my_comp") -class MyComp(Component): - template = """ -
- {% slot "content" default input=input %} - input: {{ input }} - {% endslot %} -
- """ - - def get_context_data(self, input): - processed_input = do_something(input) - return { - "input": processed_input, - } -``` - -#### Accessing slot data in fill - -Next, we head over to where we define a fill for this slot. Here, to access the slot data -we set the `data` attribute to the name of the variable through which we want to access -the slot data. In the example below, we set it to `data`: - -```django -{% component "my_comp" %} - {% fill "content" data="slot_data" %} - {{ slot_data.input }} - {% endfill %} -{% endcomponent %} -``` - -To access slot data on a default slot, you have to explictly define the `{% fill %}` tags. - -So this works: - -```django -{% component "my_comp" %} - {% fill "content" data="slot_data" %} - {{ slot_data.input }} - {% endfill %} -{% endcomponent %} -``` - -While this does not: - -```django -{% component "my_comp" data="data" %} - {{ data.input }} -{% endcomponent %} -``` - -Note: You cannot set the `data` attribute and -[`default` attribute)](#accessing-original-content-of-slots) -to the same name. This raises an error: - -```django -{% component "my_comp" %} - {% fill "content" data="slot_var" default="slot_var" %} - {{ slot_var.input }} - {% endfill %} -{% endcomponent %} -``` - -#### Slot data of default slots - -To access data of a default slot, you can specify `{% fill name="default" %}`: - -```htmldjango -{% component "my_comp" %} - {% fill "default" data="slot_data" %} - {{ slot_data.input }} - {% endfill %} -{% endcomponent %} -``` - -### Dynamic slots and fills - -Until now, we were declaring slot and fill names statically, as a string literal, e.g. - -```django -{% slot "content" / %} -``` - -However, sometimes you may want to generate slots based on the given input. One example of this is [a table component like that of Vuetify](https://vuetifyjs.com/en/api/v-data-table/), which creates a header and an item slots for each user-defined column. - -In django_components you can achieve the same, simply by using a variable (or a [template expression](#use-template-tags-inside-component-inputs)) instead of a string literal: - -```django - - - {% for header in headers %} - - {% endfor %} - -
- {% slot "header-{{ header.key }}" value=header.title %} - {{ header.title }} - {% endslot %} -
-``` - -When using the component, you can either set the fill explicitly: - -```django -{% component "table" headers=headers items=items %} - {% fill "header-name" data="data" %} - {{ data.value }} - {% endfill %} -{% endcomponent %} -``` - -Or also use a variable: - -```django -{% component "table" headers=headers items=items %} - {# Make only the active column bold #} - {% fill "header-{{ active_header_name }}" data="data" %} - {{ data.value }} - {% endfill %} -{% endcomponent %} -``` - -> NOTE: It's better to use static slot names whenever possible for clarity. The dynamic slot names should be reserved for advanced use only. - -Lastly, in rare cases, you can also pass the slot name via [the spread operator](#spread-operator). This is possible, because the slot name argument is actually a shortcut for a `name` keyword argument. - -So this: - -```django -{% slot "content" / %} -``` - -is the same as: - -```django -{% slot name="content" / %} -``` - -So it's possible to define a `name` key on a dictionary, and then spread that onto the slot tag: - -```django -{# slot_props = {"name": "content"} #} -{% slot ...slot_props / %} -``` - -### Pass through all the slots - -You can dynamically pass all slots to a child component. This is similar to -[passing all slots in Vue](https://vue-land.github.io/faq/forwarding-slots#passing-all-slots): - -```py -class MyTable(Component): - def get_context_data(self, *args, **kwargs): - return { - "slots": self.input.slots, - } - - template: """ -
- {% component "child" %} - {% for slot_name in slots %} - {% fill name=slot_name data="data" %} - {% slot name=slot_name ...data / %} - {% endfill %} - {% endfor %} - {% endcomponent %} -
- """ -``` diff --git a/src/docs/concepts/fundamentals/template_tag_syntax.md b/src/docs/concepts/fundamentals/template_tag_syntax.md deleted file mode 100644 index 9b173c58..00000000 --- a/src/docs/concepts/fundamentals/template_tag_syntax.md +++ /dev/null @@ -1,290 +0,0 @@ ---- -title: Template tag syntax -weight: 7 ---- - -All template tags in django_component, like `{% component %}` or `{% slot %}`, and so on, -support extra syntax that makes it possible to write components like in Vue or React (JSX). - -## Self-closing tags - -When you have a tag like `{% component %}` or `{% slot %}`, but it has no content, you can simply append a forward slash `/` at the end, instead of writing out the closing tags like `{% endcomponent %}` or `{% endslot %}`: - -So this: - -```django -{% component "button" %}{% endcomponent %} -``` - -becomes - -```django -{% component "button" / %} -``` - -## Special characters - -_New in version 0.71_: - -Keyword arguments can contain special characters `# @ . - _`, so keywords like -so are still valid: - -```django - - {% component "calendar" my-date="2015-06-19" @click.native=do_something #some_id=True / %} - -``` - -These can then be accessed inside `get_context_data` so: - -```py -@register("calendar") -class Calendar(Component): - # Since # . @ - are not valid identifiers, we have to - # use `**kwargs` so the method can accept these args. - def get_context_data(self, **kwargs): - return { - "date": kwargs["my-date"], - "id": kwargs["#some_id"], - "on_click": kwargs["@click.native"] - } -``` - -## Spread operator - -_New in version 0.93_: - -Instead of passing keyword arguments one-by-one: - -```django -{% component "calendar" title="How to abc" date="2015-06-19" author="John Wick" / %} -``` - -You can use a spread operator `...dict` to apply key-value pairs from a dictionary: - -```py -post_data = { - "title": "How to...", - "date": "2015-06-19", - "author": "John Wick", -} -``` - -```django -{% component "calendar" ...post_data / %} -``` - -This behaves similar to [JSX's spread operator](https://kevinyckim33.medium.com/jsx-spread-operator-component-props-meaning-3c9bcadd2493) -or [Vue's `v-bind`](https://vuejs.org/api/built-in-directives.html#v-bind). - -Spread operators are treated as keyword arguments, which means that: - -1. Spread operators must come after positional arguments. -2. You cannot use spread operators for [positional-only arguments](https://martinxpn.medium.com/positional-only-and-keyword-only-arguments-in-python-37-100-days-of-python-310c311657b0). - -Other than that, you can use spread operators multiple times, and even put keyword arguments in-between or after them: - -```django -{% component "calendar" ...post_data id=post.id ...extra / %} -``` - -In a case of conflicts, the values added later (right-most) overwrite previous values. - -## Use template tags inside component inputs - -_New in version 0.93_ - -When passing data around, sometimes you may need to do light transformations, like negating booleans or filtering lists. - -Normally, what you would have to do is to define ALL the variables -inside `get_context_data()`. But this can get messy if your components contain a lot of logic. - -```py -@register("calendar") -class Calendar(Component): - def get_context_data(self, id: str, editable: bool): - return { - "editable": editable, - "readonly": not editable, - "input_id": f"input-{id}", - "icon_id": f"icon-{id}", - ... - } -``` - -Instead, template tags in django_components (`{% component %}`, `{% slot %}`, `{% provide %}`, etc) allow you to treat literal string values as templates: - -```django -{% component 'blog_post' - "As positional arg {# yay #}" - title="{{ person.first_name }} {{ person.last_name }}" - id="{% random_int 10 20 %}" - readonly="{{ editable|not }}" - author="John Wick {# TODO: parametrize #}" -/ %} -``` - -In the example above: - -- Component `test` receives a positional argument with value `"As positional arg "`. The comment is omitted. -- Kwarg `title` is passed as a string, e.g. `John Doe` -- Kwarg `id` is passed as `int`, e.g. `15` -- Kwarg `readonly` is passed as `bool`, e.g. `False` -- Kwarg `author` is passed as a string, e.g. `John Wick ` (Comment omitted) - -This is inspired by [django-cotton](https://github.com/wrabit/django-cotton#template-expressions-in-attributes). - -### Passing data as string vs original values - -Sometimes you may want to use the template tags to transform -or generate the data that is then passed to the component. - -The data doesn't necessarily have to be strings. In the example above, the kwarg `id` was passed as an integer, NOT a string. - -Although the string literals for components inputs are treated as regular Django templates, there is one special case: - -When the string literal contains only a single template tag, with no extra text, then the value is passed as the original type instead of a string. - -Here, `page` is an integer: - -```django -{% component 'blog_post' page="{% random_int 10 20 %}" / %} -``` - -Here, `page` is a string: - -```django -{% component 'blog_post' page=" {% random_int 10 20 %} " / %} -``` - -And same applies to the `{{ }}` variable tags: - -Here, `items` is a list: - -```django -{% component 'cat_list' items="{{ cats|slice:':2' }}" / %} -``` - -Here, `items` is a string: - -```django -{% component 'cat_list' items="{{ cats|slice:':2' }} See more" / %} -``` - -### Evaluating Python expressions in template - -You can even go a step further and have a similar experience to Vue or React, -where you can evaluate arbitrary code expressions: - -```jsx - -``` - -Similar is possible with [`django-expr`](https://pypi.org/project/django-expr/), which adds an `expr` tag and filter that you can use to evaluate Python expressions from within the template: - -```django -{% component "my_form" - value="{% expr 'input_value if is_enabled else None' %}" -/ %} -``` - -> Note: Never use this feature to mix business logic and template logic. Business logic should still be in the view! - -## Pass dictonary by its key-value pairs - -_New in version 0.74_: - -Sometimes, a component may expect a dictionary as one of its inputs. - -Most commonly, this happens when a component accepts a dictionary -of HTML attributes (usually called `attrs`) to pass to the underlying template. - -In such cases, we may want to define some HTML attributes statically, and other dynamically. -But for that, we need to define this dictionary on Python side: - -```py -@register("my_comp") -class MyComp(Component): - template = """ - {% component "other" attrs=attrs / %} - """ - - def get_context_data(self, some_id: str): - attrs = { - "class": "pa-4 flex", - "data-some-id": some_id, - "@click.stop": "onClickHandler", - } - return {"attrs": attrs} -``` - -But as you can see in the case above, the event handler `@click.stop` and styling `pa-4 flex` -are disconnected from the template. If the component grew in size and we moved the HTML -to a separate file, we would have hard time reasoning about the component's template. - -Luckily, there's a better way. - -When we want to pass a dictionary to a component, we can define individual key-value pairs -as component kwargs, so we can keep all the relevant information in the template. For that, -we prefix the key with the name of the dict and `:`. So key `class` of input `attrs` becomes -`attrs:class`. And our example becomes: - -```py -@register("my_comp") -class MyComp(Component): - template = """ - {% component "other" - attrs:class="pa-4 flex" - attrs:data-some-id=some_id - attrs:@click.stop="onClickHandler" - / %} - """ - - def get_context_data(self, some_id: str): - return {"some_id": some_id} -``` - -Sweet! Now all the relevant HTML is inside the template, and we can move it to a separate file with confidence: - -```django -{% component "other" - attrs:class="pa-4 flex" - attrs:data-some-id=some_id - attrs:@click.stop="onClickHandler" -/ %} -``` - -> Note: It is NOT possible to define nested dictionaries, so -> `attrs:my_key:two=2` would be interpreted as: -> -> ```py -> {"attrs": {"my_key:two": 2}} -> ``` - -## Multiline 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` diff --git a/src/docs/concepts/fundamentals/your_first_component.md b/src/docs/concepts/fundamentals/your_first_component.md deleted file mode 100644 index de6b7c7b..00000000 --- a/src/docs/concepts/fundamentals/your_first_component.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -title: Create your first component -weight: 1 ---- - -A component in django-components is the combination of four things: CSS, Javascript, a Django template, and some Python code to put them all together. - -``` - sampleproject/ - ├── calendarapp/ - ├── components/ 🆕 - │ └── calendar/ 🆕 - │ ├── calendar.py 🆕 - │ ├── script.js 🆕 - │ ├── style.css 🆕 - │ └── template.html 🆕 - ├── sampleproject/ - ├── manage.py - └── requirements.txt -``` - -Start by creating empty files in the structure above. - -First, you need a CSS file. Be sure to prefix all rules with a unique class so they don't clash with other rules. - -```css title="[project root]/components/calendar/style.css" -/* In a file called [project root]/components/calendar/style.css */ -.calendar-component { - width: 200px; - background: pink; -} -.calendar-component span { - font-weight: bold; -} -``` - -Then you need a javascript file that specifies how you interact with this component. You are free to use any javascript framework you want. A good way to make sure this component doesn't clash with other components is to define all code inside an anonymous function that calls itself. This makes all variables defined only be defined inside this component and not affect other components. - -```js title="[project root]/components/calendar/script.js" -/* In a file called [project root]/components/calendar/script.js */ -(function () { - if (document.querySelector(".calendar-component")) { - document.querySelector(".calendar-component").onclick = function () { - alert("Clicked calendar!"); - }; - } -})(); -``` - -Now you need a Django template for your component. Feel free to define more variables like `date` in this example. When creating an instance of this component we will send in the values for these variables. The template will be rendered with whatever template backend you've specified in your Django settings file. - -```htmldjango title="[project root]/components/calendar/calendar.html" -{# In a file called [project root]/components/calendar/template.html #} -
Today's date is {{ date }}
-``` - -Finally, we use django-components to tie this together. Start by creating a file called `calendar.py` in your component calendar directory. It will be auto-detected and loaded by the app. - -Inside this file we create a Component by inheriting from the Component class and specifying the context method. We also register the global component registry so that we easily can render it anywhere in our templates. - -```python title="[project root]/components/calendar/calendar.py" -# In a file called [project root]/components/calendar/calendar.py -from django_components import Component, register - -@register("calendar") -class Calendar(Component): - # Templates inside `[your apps]/components` dir and `[project root]/components` dir - # will be automatically found. - # - # `template_name` can be relative to dir where `calendar.py` is, or relative to COMPONENTS.dirs - template_name = "template.html" - # Or - def get_template_name(context): - return f"template-{context['name']}.html" - - # This component takes one parameter, a date string to show in the template - def get_context_data(self, date): - return { - "date": date, - } - - # Both `css` and `js` can be relative to dir where `calendar.py` is, or relative to COMPONENTS.dirs - class Media: - css = "style.css" - js = "script.js" -``` - -And voilá!! We've created our first component. diff --git a/src/docs/guides/setup/dev_server_setup.md b/src/docs/guides/setup/dev_server_setup.md deleted file mode 100644 index 09e6c9ff..00000000 --- a/src/docs/guides/setup/dev_server_setup.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -title: Running with development server -weight: 2 ---- - -### 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 in separate files 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 component 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_file_change`](#reload_on_file_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! diff --git a/src/docs/guides/setup/logging_and_debugging.md b/src/docs/guides/setup/logging_and_debugging.md deleted file mode 100644 index 431b2cf3..00000000 --- a/src/docs/guides/setup/logging_and_debugging.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -weight: 3 ---- - -Django components supports [logging with Django](https://docs.djangoproject.com/en/5.0/howto/logging/#logging-how-to). -This can help with troubleshooting. - -To configure logging for Django components, set the `django_components` logger in -[`LOGGING`](https://docs.djangoproject.com/en/5.1/ref/settings/#std-setting-LOGGING) -in `settings.py` (below). - -Also see the [`settings.py` file in sampleproject](https://github.com/EmilStenstrom/django-components/blob/master/sampleproject/sampleproject/settings.py) for a real-life example. - -```py -import logging -import sys - -LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - "handlers": { - "console": { - 'class': 'logging.StreamHandler', - 'stream': sys.stdout, - }, - }, - "loggers": { - "django_components": { - "level": logging.DEBUG, - "handlers": ["console"], - }, - }, -} -``` diff --git a/src/docs/guides/setup/syntax_highlight.md b/src/docs/guides/setup/syntax_highlight.md deleted file mode 100644 index 259d8521..00000000 --- a/src/docs/guides/setup/syntax_highlight.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -title: Syntax highlighting ---- - -## VSCode - -Note, in the above example, that the `t.django_html`, `t.css`, and `t.js` types are used to specify the type of the template, CSS, and JS files, respectively. This is not necessary, but if you're using VSCode with the [Python Inline Source Syntax Highlighting](https://marketplace.visualstudio.com/items?itemName=samwillis.python-inline-source) extension, it will give you syntax highlighting for the template, CSS, and JS. - -## Pycharm (or other Jetbrains IDEs) - -If you're a Pycharm user (or any other editor from Jetbrains), you can have coding assistance as well: - -```python -from django_components import Component, register - -@register("calendar") -class Calendar(Component): - def get_context_data(self, date): - return { - "date": date, - } - - # language=HTML - template= """ -
Today's date is {{ date }}
- """ - - # language=CSS - css = """ - .calendar-component { width: 200px; background: pink; } - .calendar-component span { font-weight: bold; } - """ - - # language=JS - js = """ - (function(){ - if (document.querySelector(".calendar-component")) { - document.querySelector(".calendar-component").onclick = function(){ alert("Clicked calendar!"); }; - } - })() - """ -``` - -You don't need to use `types.django_html`, `types.css`, `types.js` since Pycharm uses [language injections](https://www.jetbrains.com/help/pycharm/using-language-injections.html). -You only need to write the comments `# language=` above the variables. diff --git a/src/docs/license.md b/src/docs/license.md new file mode 100644 index 00000000..0e8c6fca --- /dev/null +++ b/src/docs/license.md @@ -0,0 +1,3 @@ +# License + +--8<-- "LICENSE" diff --git a/src/docs/overview/code_of_conduct.md b/src/docs/overview/code_of_conduct.md deleted file mode 100644 index ee151f6e..00000000 --- a/src/docs/overview/code_of_conduct.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: Code of Conduct -weight: 8 ---- - ---8<-- "CODE_OF_CONDUCT.md" diff --git a/src/docs/overview/community.md b/src/docs/overview/community.md deleted file mode 100644 index a1c361e1..00000000 --- a/src/docs/overview/community.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -title: Community -weight: 5 ---- - -## Community questions - -The best place to ask questions is in our [Github Discussion board](https://github.com/EmilStenstrom/django-components/discussions). - -Please, before opening a new discussion, [check if similar discussion wasn't opened already](https://github.com/EmilStenstrom/django-components/discussions?discussions_q=). - -## Community examples - -One of our goals with `django-components` is to make it easy to share components between projects. If you have a set of components that you think would be useful to others, please open a pull request to add them to the list below. - -- [django-htmx-components](https://github.com/iwanalabs/django-htmx-components): A set of components for use with [htmx](https://htmx.org/). Try out the [live demo](https://dhc.iwanalabs.com/). diff --git a/src/docs/overview/compatibility.md b/src/docs/overview/compatibility.md deleted file mode 100644 index 6aea5f23..00000000 --- a/src/docs/overview/compatibility.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -title: Compatibility -weight: 2 ---- -## Compatibility - -Django-components supports all supported combinations versions of [Django](https://docs.djangoproject.com/en/dev/faq/install/#what-python-version-can-i-use-with-django) and [Python](https://devguide.python.org/versions/#versions). - -| Python version | Django version | -| -------------- | -------------- | -| 3.8 | 4.2 | -| 3.9 | 4.2 | -| 3.10 | 4.2, 5.0 | -| 3.11 | 4.2, 5.0 | -| 3.12 | 4.2, 5.0 | diff --git a/src/docs/overview/development.md b/src/docs/overview/development.md deleted file mode 100644 index 85344750..00000000 --- a/src/docs/overview/development.md +++ /dev/null @@ -1,145 +0,0 @@ ---- -title: Development -weight: 7 ---- - -## Install locally and run the tests - -Start by forking the project by clicking the **Fork button** up in the right corner in the GitHub . This makes a copy of the repository in your own name. Now you can clone this repository locally and start adding features: - -```sh -git clone https://github.com//django-components.git -``` - -To quickly run the tests install the local dependencies by running: - -```sh -pip install -r requirements-dev.txt -``` - -Now you can run the tests to make sure everything works as expected: - -```sh -pytest -``` - -The library is also tested across many versions of Python and Django. To run tests that way: - -```sh -pyenv install -s 3.8 -pyenv install -s 3.9 -pyenv install -s 3.10 -pyenv install -s 3.11 -pyenv install -s 3.12 -pyenv local 3.8 3.9 3.10 3.11 3.12 -tox -p -``` - -## Running Playwright tests - -We use [Playwright](https://playwright.dev/python/docs/intro) for end-to-end tests. You will therefore need to install Playwright to be able to run these tests. - -Luckily, Playwright makes it very easy: - -```sh -pip install -r requirements-dev.txt -playwright install chromium --with-deps -``` - -After Playwright is ready, simply run the tests with `tox`: - -```sh -tox -``` - -## Developing against live Django app - -How do you check that your changes to django-components project will work in an actual Django project? - -Use the [sampleproject](https://github.com/EmilStenstrom/django-components/tree/master/sampleproject/) demo project to validate the changes: - -1. Navigate to [sampleproject](https://github.com/EmilStenstrom/django-components/tree/master/sampleproject/) directory: - - ```sh - cd sampleproject - ``` - -2. Install dependencies from the [requirements.txt](https://github.com/EmilStenstrom/django-components/blob/master/sampleproject/requirements.txt) file: - - ```sh - pip install -r requirements.txt - ``` - -3. Link to your local version of django-components: - - ```sh - pip install -e .. - ``` - - NOTE: The path (in this case `..`) must point to the directory that has the `setup.py` file. - -4. Start Django server - ```sh - python manage.py runserver - ``` - -Once the server is up, it should be available at . - -To display individual components, add them to the `urls.py`, like in the case of - -## Building JS code - -django_components uses a bit of JS code to: - -- Manage the loading of JS and CSS files used by the components -- Allow to pass data from Python to JS - -When you make changes to this JS code, you also need to compile it: - -1. Make sure you are inside `src/django_components_js`: - -```sh -cd src/django_components_js -``` - -2. Install the JS dependencies - -```sh -npm install -``` - -3. Compile the JS/TS code: - -```sh -python build.py -``` - -The script will combine all JS/TS code into a single `.js` file, minify it, -and copy it to `django_components/static/django_components/django_components.min.js`. - -## Packaging and publishing - -To package the library into a distribution that can be published to PyPI, run: - -```sh -# Install pypa/build -python -m pip install build --user -# Build a binary wheel and a source tarball -python -m build --sdist --wheel --outdir dist/ . -``` - -To publish the package to PyPI, use `twine` ([See Python user guide](https://packaging.python.org/en/latest/tutorials/packaging-projects/#uploading-the-distribution-archives)): - -```sh -twine upload --repository pypi dist/* -u __token__ -p -``` - -[See the full workflow here.](https://github.com/EmilStenstrom/django-components/discussions/557#discussioncomment-10179141) - -## Development guides - -Deep dive into how django_components' features are implemented. - -- [Slot rendering](../devguides/slot_rendering.md) -- [Slots and blocks](../devguides/slots_and_blocks.md) -- [JS and CSS dependency management](../devguides/dependency_mgmt.md) diff --git a/src/docs/overview/installation.md b/src/docs/overview/installation.md deleted file mode 100644 index 43c73339..00000000 --- a/src/docs/overview/installation.md +++ /dev/null @@ -1,162 +0,0 @@ ---- -title: Installation -weight: 3 ---- - -1. Install `django_components` into your environment: - - ```bash - pip install django_components - ``` - -2. Load `django_components` into Django by adding it into `INSTALLED_APPS` in settings.py: - - ```python - INSTALLED_APPS = [ - ..., - 'django_components', - ] - ``` - -3. `BASE_DIR` setting is required. Ensure that it is defined in settings.py: - - ```python - from pathlib import Path - - BASE_DIR = Path(__file__).resolve().parent.parent - ``` - -4. Add / modify [`COMPONENTS.dirs`](#dirs) and / or [`COMPONENTS.app_dirs`](#app_dirs) so django_components knows where to find component HTML, JS and CSS files: - - ```python - from django_components import ComponentsSettings - - COMPONENTS = ComponentsSettings( - dirs=[ - ..., - Path(BASE_DIR) / "components", - ], - ) - ``` - - If `COMPONENTS.dirs` is omitted, django-components will by default look for a top-level `/components` directory, - `{BASE_DIR}/components`. - - In addition to `COMPONENTS.dirs`, django_components will also load components from app-level directories, such as `my-app/components/`. - The directories within apps are configured with [`COMPONENTS.app_dirs`](#app_dirs), and the default is `[app]/components`. - - NOTE: The input to `COMPONENTS.dirs` is the same as for `STATICFILES_DIRS`, and the paths must be full paths. [See Django docs](https://docs.djangoproject.com/en/5.0/ref/settings/#staticfiles-dirs). - -5. Next, to make Django load component HTML files as Django templates, 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 - TEMPLATES = [ - { - ..., - 'OPTIONS': { - 'context_processors': [ - ... - ], - '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', - ] - )], - }, - }, - ] - ``` - -## Adding support for JS and CSS - -If you want to use JS or CSS with components, you will need to: - -1. Modify `STATICFILES_FINDERS` section of settings.py as follows to be able to serve - the component JS and CSS files as static files: - - ```python - STATICFILES_FINDERS = [ - # Default finders - "django.contrib.staticfiles.finders.FileSystemFinder", - "django.contrib.staticfiles.finders.AppDirectoriesFinder", - # Django components - "django_components.finders.ComponentsFileSystemFinder", - ] - ``` - -2. Add [`ComponentDependencyMiddleware`](#setting-up-componentdependencymiddleware) to `MIDDLEWARE` setting. - - The middleware searches the outgoing HTML for all components that were rendered - to generate the HTML, and adds the JS and CSS associated with those components. - - ```python - MIDDLEWARE = [ - ... - "django_components.middleware.ComponentDependencyMiddleware", - ] - ``` - - Read more in [Rendering JS/CSS dependencies](#rendering-jscss-dependencies). - -3. Add django-component's URL paths to your `urlpatterns`: - - ```python - from django.urls import include, path - - urlpatterns = [ - ... - path("", include("django_components.urls")), - ] - ``` - -4. _Optional._ If you want to change where the JS and CSS is rendered, use - [`{% component_js_dependencies %}`](../reference/template_tags.md#component_js_dependencies) - and [`{% component_css_dependencies %}`](../reference/template_tags.md#component_js_dependencies). - - By default, the JS ` + + + +``` + +This makes it possible to organize your front-end around reusable components. Instead of relying on template tags and keeping your CSS and Javascript in the static directory. diff --git a/src/docs/concepts/fundamentals/defining_js_css_html_files.md b/src/docs/concepts/fundamentals/defining_js_css_html_files.md new file mode 100644 index 00000000..c9de0df6 --- /dev/null +++ b/src/docs/concepts/fundamentals/defining_js_css_html_files.md @@ -0,0 +1,203 @@ +--- +title: Defining HTML / JS / CSS files +weight: 10 +--- + +django_component's management of files builds on top of [Django's `Media` class](https://docs.djangoproject.com/en/5.0/topics/forms/media/). + +To be familiar with how Django handles static files, we recommend reading also: + +- [How to manage static files (e.g. images, JavaScript, CSS)](https://docs.djangoproject.com/en/5.0/howto/static-files/) + +## Defining file paths relative to component or static dirs + +As seen in the [getting started example](#create-your-first-component), to associate HTML/JS/CSS +files with a component, you set them as `template_name`, `Media.js` and `Media.css` respectively: + +```py +# In a file [project root]/components/calendar/calendar.py +from django_components import Component, register + +@register("calendar") +class Calendar(Component): + template_name = "template.html" + + class Media: + css = "style.css" + js = "script.js" +``` + +In the example above, the files are defined relative to the directory where `component.py` is. + +Alternatively, you can specify the file paths relative to the directories set in `COMPONENTS.dirs` or `COMPONENTS.app_dirs`. + +Assuming that `COMPONENTS.dirs` contains path `[project root]/components`, we can rewrite the example as: + +```py +# In a file [project root]/components/calendar/calendar.py +from django_components import Component, register + +@register("calendar") +class Calendar(Component): + template_name = "calendar/template.html" + + class Media: + css = "calendar/style.css" + js = "calendar/script.js" +``` + +NOTE: In case of conflict, the preference goes to resolving the files relative to the component's directory. + +## Defining multiple paths + +Each component can have only a single template. However, you can define as many JS or CSS files as you want using a list. + +```py +class MyComponent(Component): + class Media: + js = ["path/to/script1.js", "path/to/script2.js"] + css = ["path/to/style1.css", "path/to/style2.css"] +``` + +## Configuring CSS Media Types + +You can define which stylesheets will be associated with which +[CSS Media types](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_media_queries/Using_media_queries#targeting_media_types). You do so by defining CSS files as a dictionary. + +See the corresponding [Django Documentation](https://docs.djangoproject.com/en/5.0/topics/forms/media/#css). + +Again, you can set either a single file or a list of files per media type: + +```py +class MyComponent(Component): + class Media: + css = { + "all": "path/to/style1.css", + "print": "path/to/style2.css", + } +``` + +```py +class MyComponent(Component): + class Media: + css = { + "all": ["path/to/style1.css", "path/to/style2.css"], + "print": ["path/to/style3.css", "path/to/style4.css"], + } +``` + +NOTE: When you define CSS as a string or a list, the `all` media type is implied. + +## Supported types for file paths + +File paths can be any of: + +- `str` +- `bytes` +- `PathLike` (`__fspath__` method) +- `SafeData` (`__html__` method) +- `Callable` that returns any of the above, evaluated at class creation (`__new__`) + +```py +from pathlib import Path + +from django.utils.safestring import mark_safe + +class SimpleComponent(Component): + class Media: + css = [ + mark_safe(''), + Path("calendar/style1.css"), + "calendar/style2.css", + b"calendar/style3.css", + lambda: "calendar/style4.css", + ] + js = [ + mark_safe(''), + Path("calendar/script1.js"), + "calendar/script2.js", + b"calendar/script3.js", + lambda: "calendar/script4.js", + ] +``` + +## Path as objects + +In the example [above](#supported-types-for-file-paths), you could see that when we used `mark_safe` to mark a string as a `SafeString`, we had to define the full `' + ) + +@register("calendar") +class Calendar(Component): + template_name = "calendar/template.html" + + def get_context_data(self, date): + return { + "date": date, + } + + class Media: + css = "calendar/style.css" + js = [ + # ', + self.absolute_path(path) + ) + return tags + +@register("calendar") +class Calendar(Component): + template_name = "calendar/template.html" + + class Media: + css = "calendar/style.css" + js = "calendar/script.js" + + # Override the behavior of Media class + media_class = MyMedia +``` + +NOTE: The instance of the `Media` class (or it's subclass) is available under `Component.media` after the class creation (`__new__`). diff --git a/src/docs/concepts/fundamentals/html_attributes.md b/src/docs/concepts/fundamentals/html_attributes.md new file mode 100644 index 00000000..9dcc319f --- /dev/null +++ b/src/docs/concepts/fundamentals/html_attributes.md @@ -0,0 +1,378 @@ +--- +title: HTML attributes +weight: 9 +--- + +_New in version 0.74_: + +You can use the `html_attrs` tag to render HTML attributes, given a dictionary +of values. + +So if you have a template: + +```django +
+
+``` + +You can simplify it with `html_attrs` tag: + +```django +
+
+``` + +where `attrs` is: + +```py +attrs = { + "class": classes, + "data-id": my_id, +} +``` + +This feature is inspired by [`merge_attrs` tag of django-web-components](https://github.com/Xzya/django-web-components/tree/master?tab=readme-ov-file#default--merged-attributes) and +["fallthrough attributes" feature of Vue](https://vuejs.org/guide/components/attrs). + +## Removing atttributes + +Attributes that are set to `None` or `False` are NOT rendered. + +So given this input: + +```py +attrs = { + "class": "text-green", + "required": False, + "data-id": None, +} +``` + +And template: + +```django +
+
+``` + +Then this renders: + +```html +
+``` + +## Boolean attributes + +In HTML, boolean attributes are usually rendered with no value. Consider the example below where the first button is disabled and the second is not: + +```html + +``` + +HTML rendering with `html_attrs` tag or `attributes_to_string` works the same way, where `key=True` is rendered simply as `key`, and `key=False` is not render at all. + +So given this input: + +```py +attrs = { + "disabled": True, + "autofocus": False, +} +``` + +And template: + +```django +
+
+``` + +Then this renders: + +```html +
+``` + +## Default attributes + +Sometimes you may want to specify default values for attributes. You can pass a second argument (or kwarg `defaults`) to set the defaults. + +```django +
+ ... +
+``` + +In the example above, if `attrs` contains e.g. the `class` key, `html_attrs` will render: + +`class="{{ attrs.class }}"` + +Otherwise, `html_attrs` will render: + +`class="{{ defaults.class }}"` + +## Appending attributes + +For the `class` HTML attribute, it's common that we want to _join_ multiple values, +instead of overriding them. For example, if you're authoring a component, you may +want to ensure that the component will ALWAYS have a specific class. Yet, you may +want to allow users of your component to supply their own classes. + +We can achieve this by adding extra kwargs. These values +will be appended, instead of overwriting the previous value. + +So if we have a variable `attrs`: + +```py +attrs = { + "class": "my-class pa-4", +} +``` + +And on `html_attrs` tag, we set the key `class`: + +```django +
+
+``` + +Then these will be merged and rendered as: + +```html +
+``` + +To simplify merging of variables, you can supply the same key multiple times, and these will be all joined together: + +```django +{# my_var = "class-from-var text-red" #} +
+
+``` + +Renders: + +```html +
+``` + +## Rules for `html_attrs` + +1. Both `attrs` and `defaults` can be passed as positional args + + `{% html_attrs attrs defaults key=val %}` + + or as kwargs + + `{% html_attrs key=val defaults=defaults attrs=attrs %}` + +2. Both `attrs` and `defaults` are optional (can be omitted) + +3. Both `attrs` and `defaults` are dictionaries, and we can define them the same way [we define dictionaries for the `component` tag](#pass-dictonary-by-its-key-value-pairs). So either as `attrs=attrs` or `attrs:key=value`. + +4. All other kwargs are appended and can be repeated. + +## Examples for `html_attrs` + +Assuming that: + +```py +class_from_var = "from-var" + +attrs = { + "class": "from-attrs", + "type": "submit", +} + +defaults = { + "class": "from-defaults", + "role": "button", +} +``` + +Then: + +- Empty tag
+ `{% html_attr %}` + + renders (empty string):
+ ` ` + +- Only kwargs
+ `{% html_attr class="some-class" class=class_from_var data-id="123" %}` + + renders:
+ `class="some-class from-var" data-id="123"` + +- Only attrs
+ `{% html_attr attrs %}` + + renders:
+ `class="from-attrs" type="submit"` + +- Attrs as kwarg
+ `{% html_attr attrs=attrs %}` + + renders:
+ `class="from-attrs" type="submit"` + +- Only defaults (as kwarg)
+ `{% html_attr defaults=defaults %}` + + renders:
+ `class="from-defaults" role="button"` + +- Attrs using the `prefix:key=value` construct
+ `{% html_attr attrs:class="from-attrs" attrs:type="submit" %}` + + renders:
+ `class="from-attrs" type="submit"` + +- Defaults using the `prefix:key=value` construct
+ `{% html_attr defaults:class="from-defaults" %}` + + renders:
+ `class="from-defaults" role="button"` + +- All together (1) - attrs and defaults as positional args:
+ `{% html_attrs attrs defaults class="added_class" class=class_from_var data-id=123 %}` + + renders:
+ `class="from-attrs added_class from-var" type="submit" role="button" data-id=123` + +- All together (2) - attrs and defaults as kwargs args:
+ `{% html_attrs class="added_class" class=class_from_var data-id=123 attrs=attrs defaults=defaults %}` + + renders:
+ `class="from-attrs added_class from-var" type="submit" role="button" data-id=123` + +- All together (3) - mixed:
+ `{% html_attrs attrs defaults:class="default-class" class="added_class" class=class_from_var data-id=123 %}` + + renders:
+ `class="from-attrs added_class from-var" type="submit" data-id=123` + +## Full example for `html_attrs` + +```py +@register("my_comp") +class MyComp(Component): + template: t.django_html = """ +
+ Today's date is {{ date }} +
+ """ + + def get_context_data(self, date: Date, attrs: dict): + return { + "date": date, + "attrs": attrs, + "class_from_var": "extra-class" + } + +@register("parent") +class Parent(Component): + template: t.django_html = """ + {% component "my_comp" + date=date + attrs:class="pa-0 border-solid border-red" + attrs:data-json=json_data + attrs:@click="(e) => onClick(e, 'from_parent')" + / %} + """ + + def get_context_data(self, date: Date): + return { + "date": datetime.now(), + "json_data": json.dumps({"value": 456}) + } +``` + +Note: For readability, we've split the tags across multiple lines. + +Inside `MyComp`, we defined a default attribute + +`defaults:class="pa-4 text-red"` + +So if `attrs` includes key `class`, the default above will be ignored. + +`MyComp` also defines `class` key twice. It means that whether the `class` +attribute is taken from `attrs` or `defaults`, the two `class` values +will be appended to it. + +So by default, `MyComp` renders: + +```html +
...
+``` + +Next, let's consider what will be rendered when we call `MyComp` from `Parent` +component. + +`MyComp` accepts a `attrs` dictionary, that is passed to `html_attrs`, so the +contents of that dictionary are rendered as the HTML attributes. + +In `Parent`, we make use of passing dictionary key-value pairs as kwargs to define +individual attributes as if they were regular kwargs. + +So all kwargs that start with `attrs:` will be collected into an `attrs` dict. + +```django + attrs:class="pa-0 border-solid border-red" + attrs:data-json=json_data + attrs:@click="(e) => onClick(e, 'from_parent')" +``` + +And `get_context_data` of `MyComp` will receive `attrs` input with following keys: + +```py +attrs = { + "class": "pa-0 border-solid", + "data-json": '{"value": 456}', + "@click": "(e) => onClick(e, 'from_parent')", +} +``` + +`attrs["class"]` overrides the default value for `class`, whereas other keys +will be merged. + +So in the end `MyComp` will render: + +```html +
+ ... +
+``` + +## Rendering HTML attributes outside of templates + +If you need to use serialize HTML attributes outside of Django template and the `html_attrs` tag, you can use `attributes_to_string`: + +```py +from django_components.attributes import attributes_to_string + +attrs = { + "class": "my-class text-red pa-4", + "data-id": 123, + "required": True, + "disabled": False, + "ignored-attr": None, +} + +attributes_to_string(attrs) +# 'class="my-class text-red pa-4" data-id="123" required' +``` diff --git a/src/docs/concepts/fundamentals/single_file_components.md b/src/docs/concepts/fundamentals/single_file_components.md new file mode 100644 index 00000000..0327d09f --- /dev/null +++ b/src/docs/concepts/fundamentals/single_file_components.md @@ -0,0 +1,37 @@ +--- +title: Single-file components +weight: 2 +--- + +Components can also be defined in a single file, which is useful for small components. To do this, you can use the `template`, `js`, and `css` class attributes instead of the `template_name` and `Media`. For example, here's the calendar component from above, defined in a single file: + +```python title="[project root]/components/calendar.py" +# In a file called [project root]/components/calendar.py +from django_components import Component, register, types + +@register("calendar") +class Calendar(Component): + def get_context_data(self, date): + return { + "date": date, + } + + template: types.django_html = """ +
Today's date is {{ date }}
+ """ + + css: types.css = """ + .calendar-component { width: 200px; background: pink; } + .calendar-component span { font-weight: bold; } + """ + + js: types.js = """ + (function(){ + if (document.querySelector(".calendar-component")) { + document.querySelector(".calendar-component").onclick = function(){ alert("Clicked calendar!"); }; + } + })() + """ +``` + +This makes it easy to create small components without having to create a separate template, CSS, and JS file. diff --git a/src/docs/concepts/fundamentals/slots.md b/src/docs/concepts/fundamentals/slots.md new file mode 100644 index 00000000..05b6e12b --- /dev/null +++ b/src/docs/concepts/fundamentals/slots.md @@ -0,0 +1,670 @@ +--- +title: Slots +weight: 8 +--- + +_New in version 0.26_: + +- The `slot` tag now serves only to declare new slots inside the component template. + - To override the content of a declared slot, use the newly introduced `fill` tag instead. +- Whereas unfilled slots used to raise a warning, filling a slot is now optional by default. + - To indicate that a slot must be filled, the new `required` option should be added at the end of the `slot` tag. + +--- + +Components support something called 'slots'. +When a component is used inside another template, slots allow the parent template to override specific parts of the child component by passing in different content. +This mechanism makes components more reusable and composable. +This behavior is similar to [slots in Vue](https://vuejs.org/guide/components/slots.html). + +In the example below we introduce two block tags that work hand in hand to make this work. These are... + +- `{% slot %}`/`{% endslot %}`: Declares a new slot in the component template. +- `{% fill %}`/`{% endfill %}`: (Used inside a `{% component %}` tag pair.) Fills a declared slot with the specified content. + +Let's update our calendar component to support more customization. We'll add `slot` tag pairs to its template, _template.html_. + +```htmldjango +
+
+ {% slot "header" %}Calendar header{% endslot %} +
+
+ {% slot "body" %}Today's date is {{ date }}{% endslot %} +
+
+``` + +When using the component, you specify which slots you want to fill and where you want to use the defaults from the template. It looks like this: + +```htmldjango +{% component "calendar" date="2020-06-06" %} + {% fill "body" %} + Can you believe it's already {{ date }}?? + {% endfill %} +{% endcomponent %} +``` + +Since the 'header' fill is unspecified, it's taken from the base template. If you put this in a template, and pass in `date=2020-06-06`, this is what gets rendered: + +```htmldjango +
+
+ Calendar header +
+
+ Can you believe it's already 2020-06-06?? +
+
+``` + +### Named slots + +As seen in the previouse section, you can use `{% fill slot_name %}` to insert content into a specific +slot. + +You can define fills for multiple slot simply by defining them all within the `{% component %} {% endcomponent %}` +tags: + +```htmldjango +{% component "calendar" date="2020-06-06" %} + {% fill "header" %} + Hi this is header! + {% endfill %} + {% fill "body" %} + Can you believe it's already {{ date }}?? + {% endfill %} +{% endcomponent %} +``` + +You can also use `{% for %}`, `{% with %}`, or other non-component tags (even `{% include %}`) +to construct the `{% fill %}` tags, **as long as these other tags do not leave any text behind!** + +```django +{% component "table" %} + {% for slot_name in slots %} + {% fill name=slot_name %} + {{ slot_name }} + {% endfill %} + {% endfor %} + + {% with slot_name="abc" %} + {% fill name=slot_name %} + {{ slot_name }} + {% endfill %} + {% endwith %} +{% endcomponent %} +``` + +### Default slot + +_Added in version 0.28_ + +As you can see, component slots lets you write reusable containers that you fill in when you use a component. This makes for highly reusable components that can be used in different circumstances. + +It can become tedious to use `fill` tags everywhere, especially when you're using a component that declares only one slot. To make things easier, `slot` tags can be marked with an optional keyword: `default`. + +When added to the tag (as shown below), this option lets you pass filling content directly in the body of a `component` tag pair – without using a `fill` tag. Choose carefully, though: a component template may contain at most one slot that is marked as `default`. The `default` option can be combined with other slot options, e.g. `required`. + +Here's the same example as before, except with default slots and implicit filling. + +The template: + +```htmldjango +
+
+ {% slot "header" %}Calendar header{% endslot %} +
+
+ {% slot "body" default %}Today's date is {{ date }}{% endslot %} +
+
+``` + +Including the component (notice how the `fill` tag is omitted): + +```htmldjango +{% component "calendar" date="2020-06-06" %} + Can you believe it's already {{ date }}?? +{% endcomponent %} +``` + +The rendered result (exactly the same as before): + +```html +
+
Calendar header
+
Can you believe it's already 2020-06-06??
+
+``` + +You may be tempted to combine implicit fills with explicit `fill` tags. This will not work. The following component template will raise an error when rendered. + +```htmldjango +{# DON'T DO THIS #} +{% component "calendar" date="2020-06-06" %} + {% fill "header" %}Totally new header!{% endfill %} + Can you believe it's already {{ date }}?? +{% endcomponent %} +``` + +Instead, you can use a named fill with name `default` to target the default fill: + +```htmldjango +{# THIS WORKS #} +{% component "calendar" date="2020-06-06" %} + {% fill "header" %}Totally new header!{% endfill %} + {% fill "default" %} + Can you believe it's already {{ date }}?? + {% endfill %} +{% endcomponent %} +``` + +NOTE: If you doubly-fill a slot, that is, that both `{% fill "default" %}` and `{% fill "header" %}` +would point to the same slot, this will raise an error when rendered. + +#### Accessing default slot in Python + +Since the default slot is stored under the slot name `default`, you can access the default slot +like so: + +```py +class MyTable(Component): + def get_context_data(self, *args, **kwargs): + default_slot = self.input.slots["default"] + return { + "default_slot": default_slot, + } +``` + +### Render fill in multiple places + +_Added in version 0.70_ + +You can render the same content in multiple places by defining multiple slots with +identical names: + +```htmldjango +
+
+ {% slot "image" %}Image here{% endslot %} +
+
+ {% slot "image" %}Image here{% endslot %} +
+
+``` + +So if used like: + +```htmldjango +{% component "calendar" date="2020-06-06" %} + {% fill "image" %} + + {% endfill %} +{% endcomponent %} +``` + +This renders: + +```htmldjango +
+
+ +
+
+ +
+
+``` + +#### Default and required slots + +If you use a slot multiple times, you can still mark the slot as `default` or `required`. +For that, you must mark each slot individually, e.g.: + +```htmldjango +
+
+ {% slot "image" default required %}Image here{% endslot %} +
+
+ {% slot "image" default required %}Image here{% endslot %} +
+
+``` + +Which you can then use as regular default slot: + +```htmldjango +{% component "calendar" date="2020-06-06" %} + +{% endcomponent %} +``` + +Since each slot is tagged individually, you can have multiple slots +with the same name but different conditions. + +E.g. in this example, we have a component that renders a user avatar - a small circular image with a profile picture or name initials. + +If the component is given `image_src` or `name_initials` variables, +the `image` slot is optional. But if neither of those are provided, +you MUST fill the `image` slot. + +```htmldjango +
+ {% if image_src %} + {% slot "image" default %} + + {% endslot %} + {% elif name_initials %} + {% slot "image" default %} +
+ {{ name_initials }} +
+ {% endslot %} + {% else %} + {% slot "image" default required / %} + {% endif %} +
+``` + +### Accessing original content of slots + +_Added in version 0.26_ + +> NOTE: In version 0.77, the syntax was changed from +> +> ```django +> {% fill "my_slot" as "alias" %} {{ alias.default }} +> ``` +> +> to +> +> ```django +> {% fill "my_slot" default="slot_default" %} {{ slot_default }} +> ``` + +Sometimes you may want to keep the original slot, but only wrap or prepend/append content to it. To do so, you can access the default slot via the `default` kwarg. + +Similarly to the `data` attribute, you specify the variable name through which the default slot will be made available. + +For instance, let's say you're filling a slot called 'body'. To render the original slot, assign it to a variable using the `'default'` keyword. You then render this variable to insert the default content: + +```htmldjango +{% component "calendar" date="2020-06-06" %} + {% fill "body" default="body_default" %} + {{ body_default }}. Have a great day! + {% endfill %} +{% endcomponent %} +``` + +This produces: + +```htmldjango +
+
+ Calendar header +
+
+ Today's date is 2020-06-06. Have a great day! +
+
+``` + +To access the original content of a default slot, set the name to `default`: + +```htmldjango +{% component "calendar" date="2020-06-06" %} + {% fill "default" default="slot_default" %} + {{ slot_default }}. Have a great day! + {% endfill %} +{% endcomponent %} +``` + +### Conditional slots + +_Added in version 0.26._ + +> NOTE: In version 0.70, `{% if_filled %}` tags were replaced with `{{ component_vars.is_filled }}` variables. If your slot name contained special characters, see the section [Accessing `is_filled` of slot names with special characters](#accessing-is_filled-of-slot-names-with-special-characters). + +In certain circumstances, you may want the behavior of slot filling to depend on +whether or not a particular slot is filled. + +For example, suppose we have the following component template: + +```htmldjango +
+
+ {% slot "title" %}Title{% endslot %} +
+
+ {% slot "subtitle" %}{# Optional subtitle #}{% endslot %} +
+
+``` + +By default the slot named 'subtitle' is empty. Yet when the component is used without +explicit fills, the div containing the slot is still rendered, as shown below: + +```html +
+
Title
+
+
+``` + +This may not be what you want. What if instead the outer 'subtitle' div should only +be included when the inner slot is in fact filled? + +The answer is to use the `{{ component_vars.is_filled. }}` variable. You can use this together with Django's `{% if/elif/else/endif %}` tags to define a block whose contents will be rendered only if the component slot with +the corresponding 'name' is filled. + +This is what our example looks like with `component_vars.is_filled`. + +```htmldjango +
+
+ {% slot "title" %}Title{% endslot %} +
+ {% if component_vars.is_filled.subtitle %} +
+ {% slot "subtitle" %}{# Optional subtitle #}{% endslot %} +
+ {% endif %} +
+``` + +Here's our example with more complex branching. + +```htmldjango +
+
+ {% slot "title" %}Title{% endslot %} +
+ {% if component_vars.is_filled.subtitle %} +
+ {% slot "subtitle" %}{# Optional subtitle #}{% endslot %} +
+ {% elif component_vars.is_filled.title %} + ... + {% elif component_vars.is_filled. %} + ... + {% endif %} +
+``` + +Sometimes you're not interested in whether a slot is filled, but rather that it _isn't_. +To negate the meaning of `component_vars.is_filled`, simply treat it as boolean and negate it with `not`: + +```htmldjango +{% if not component_vars.is_filled.subtitle %} +
+ {% slot "subtitle" / %} +
+{% endif %} +``` + +#### Accessing `is_filled` of slot names with special characters + +To be able to access a slot name via `component_vars.is_filled`, the slot name needs to be composed of only alphanumeric characters and underscores (e.g. `this__isvalid_123`). + +However, you can still define slots with other special characters. In such case, the slot name in `component_vars.is_filled` is modified to replace all invalid characters into `_`. + +So a slot named `"my super-slot :)"` will be available as `component_vars.is_filled.my_super_slot___`. + +Same applies when you are accessing `is_filled` from within the Python, e.g.: + +```py +class MyTable(Component): + def on_render_before(self, context, template) -> None: + # ✅ Works + if self.is_filled["my_super_slot___"]: + # Do something + + # ❌ Does not work + if self.is_filled["my super-slot :)"]: + # Do something +``` + +### Conditional fills + +Similarly, you can use `{% if %}` and `{% for %}` when defining the `{% fill %}` tags, to conditionally fill the slots when using the componnet: + +In the example below, the `{% fill "footer" %}` fill is used only if the condition is true. If falsy, the fill is ignored, and so the `my_table` component will use its default content for the `footer` slot. + +```django_html +{% component "my_table" %} + {% if editable %} + {% fill "footer" %} + + {% endfill %} + {% endif %} +{% endcomponent %} +``` + +You can even combine `{% if %}` and `{% for %}`: + +```django_html +{% component "my_table" %} + {% for header in headers %} + {% if header != "hyperlink" %} + {# Generate fill name like `header.my_column` #} + {% fill name="header."|add:header" %} + {{ header }} + {% endfill %} + {% endif %} + {% endfor %} +{% endcomponent %} +``` + +### Scoped slots + +_Added in version 0.76_: + +Consider a component with slot(s). This component may do some processing on the inputs, and then use the processed variable in the slot's default template: + +```py +@register("my_comp") +class MyComp(Component): + template = """ +
+ {% slot "content" default %} + input: {{ input }} + {% endslot %} +
+ """ + + def get_context_data(self, input): + processed_input = do_something(input) + return {"input": processed_input} +``` + +You may want to design a component so that users of your component can still access the `input` variable, so they don't have to recompute it. + +This behavior is called "scoped slots". This is inspired by [Vue scoped slots](https://vuejs.org/guide/components/slots.html#scoped-slots) and [scoped slots of django-web-components](https://github.com/Xzya/django-web-components/tree/master?tab=readme-ov-file#scoped-slots). + +Using scoped slots consists of two steps: + +1. Passing data to `slot` tag +2. Accessing data in `fill` tag + +#### Passing data to slots + +To pass the data to the `slot` tag, simply pass them as keyword attributes (`key=value`): + +```py +@register("my_comp") +class MyComp(Component): + template = """ +
+ {% slot "content" default input=input %} + input: {{ input }} + {% endslot %} +
+ """ + + def get_context_data(self, input): + processed_input = do_something(input) + return { + "input": processed_input, + } +``` + +#### Accessing slot data in fill + +Next, we head over to where we define a fill for this slot. Here, to access the slot data +we set the `data` attribute to the name of the variable through which we want to access +the slot data. In the example below, we set it to `data`: + +```django +{% component "my_comp" %} + {% fill "content" data="slot_data" %} + {{ slot_data.input }} + {% endfill %} +{% endcomponent %} +``` + +To access slot data on a default slot, you have to explictly define the `{% fill %}` tags. + +So this works: + +```django +{% component "my_comp" %} + {% fill "content" data="slot_data" %} + {{ slot_data.input }} + {% endfill %} +{% endcomponent %} +``` + +While this does not: + +```django +{% component "my_comp" data="data" %} + {{ data.input }} +{% endcomponent %} +``` + +Note: You cannot set the `data` attribute and +[`default` attribute)](#accessing-original-content-of-slots) +to the same name. This raises an error: + +```django +{% component "my_comp" %} + {% fill "content" data="slot_var" default="slot_var" %} + {{ slot_var.input }} + {% endfill %} +{% endcomponent %} +``` + +#### Slot data of default slots + +To access data of a default slot, you can specify `{% fill name="default" %}`: + +```htmldjango +{% component "my_comp" %} + {% fill "default" data="slot_data" %} + {{ slot_data.input }} + {% endfill %} +{% endcomponent %} +``` + +### Dynamic slots and fills + +Until now, we were declaring slot and fill names statically, as a string literal, e.g. + +```django +{% slot "content" / %} +``` + +However, sometimes you may want to generate slots based on the given input. One example of this is [a table component like that of Vuetify](https://vuetifyjs.com/en/api/v-data-table/), which creates a header and an item slots for each user-defined column. + +In django_components you can achieve the same, simply by using a variable (or a [template expression](#use-template-tags-inside-component-inputs)) instead of a string literal: + +```django + + + {% for header in headers %} + + {% endfor %} + +
+ {% slot "header-{{ header.key }}" value=header.title %} + {{ header.title }} + {% endslot %} +
+``` + +When using the component, you can either set the fill explicitly: + +```django +{% component "table" headers=headers items=items %} + {% fill "header-name" data="data" %} + {{ data.value }} + {% endfill %} +{% endcomponent %} +``` + +Or also use a variable: + +```django +{% component "table" headers=headers items=items %} + {# Make only the active column bold #} + {% fill "header-{{ active_header_name }}" data="data" %} + {{ data.value }} + {% endfill %} +{% endcomponent %} +``` + +> NOTE: It's better to use static slot names whenever possible for clarity. The dynamic slot names should be reserved for advanced use only. + +Lastly, in rare cases, you can also pass the slot name via [the spread operator](#spread-operator). This is possible, because the slot name argument is actually a shortcut for a `name` keyword argument. + +So this: + +```django +{% slot "content" / %} +``` + +is the same as: + +```django +{% slot name="content" / %} +``` + +So it's possible to define a `name` key on a dictionary, and then spread that onto the slot tag: + +```django +{# slot_props = {"name": "content"} #} +{% slot ...slot_props / %} +``` + +### Pass through all the slots + +You can dynamically pass all slots to a child component. This is similar to +[passing all slots in Vue](https://vue-land.github.io/faq/forwarding-slots#passing-all-slots): + +```py +class MyTable(Component): + def get_context_data(self, *args, **kwargs): + return { + "slots": self.input.slots, + } + + template: """ +
+ {% component "child" %} + {% for slot_name in slots %} + {% fill name=slot_name data="data" %} + {% slot name=slot_name ...data / %} + {% endfill %} + {% endfor %} + {% endcomponent %} +
+ """ +``` diff --git a/src/docs/concepts/fundamentals/template_tag_syntax.md b/src/docs/concepts/fundamentals/template_tag_syntax.md new file mode 100644 index 00000000..9b173c58 --- /dev/null +++ b/src/docs/concepts/fundamentals/template_tag_syntax.md @@ -0,0 +1,290 @@ +--- +title: Template tag syntax +weight: 7 +--- + +All template tags in django_component, like `{% component %}` or `{% slot %}`, and so on, +support extra syntax that makes it possible to write components like in Vue or React (JSX). + +## Self-closing tags + +When you have a tag like `{% component %}` or `{% slot %}`, but it has no content, you can simply append a forward slash `/` at the end, instead of writing out the closing tags like `{% endcomponent %}` or `{% endslot %}`: + +So this: + +```django +{% component "button" %}{% endcomponent %} +``` + +becomes + +```django +{% component "button" / %} +``` + +## Special characters + +_New in version 0.71_: + +Keyword arguments can contain special characters `# @ . - _`, so keywords like +so are still valid: + +```django + + {% component "calendar" my-date="2015-06-19" @click.native=do_something #some_id=True / %} + +``` + +These can then be accessed inside `get_context_data` so: + +```py +@register("calendar") +class Calendar(Component): + # Since # . @ - are not valid identifiers, we have to + # use `**kwargs` so the method can accept these args. + def get_context_data(self, **kwargs): + return { + "date": kwargs["my-date"], + "id": kwargs["#some_id"], + "on_click": kwargs["@click.native"] + } +``` + +## Spread operator + +_New in version 0.93_: + +Instead of passing keyword arguments one-by-one: + +```django +{% component "calendar" title="How to abc" date="2015-06-19" author="John Wick" / %} +``` + +You can use a spread operator `...dict` to apply key-value pairs from a dictionary: + +```py +post_data = { + "title": "How to...", + "date": "2015-06-19", + "author": "John Wick", +} +``` + +```django +{% component "calendar" ...post_data / %} +``` + +This behaves similar to [JSX's spread operator](https://kevinyckim33.medium.com/jsx-spread-operator-component-props-meaning-3c9bcadd2493) +or [Vue's `v-bind`](https://vuejs.org/api/built-in-directives.html#v-bind). + +Spread operators are treated as keyword arguments, which means that: + +1. Spread operators must come after positional arguments. +2. You cannot use spread operators for [positional-only arguments](https://martinxpn.medium.com/positional-only-and-keyword-only-arguments-in-python-37-100-days-of-python-310c311657b0). + +Other than that, you can use spread operators multiple times, and even put keyword arguments in-between or after them: + +```django +{% component "calendar" ...post_data id=post.id ...extra / %} +``` + +In a case of conflicts, the values added later (right-most) overwrite previous values. + +## Use template tags inside component inputs + +_New in version 0.93_ + +When passing data around, sometimes you may need to do light transformations, like negating booleans or filtering lists. + +Normally, what you would have to do is to define ALL the variables +inside `get_context_data()`. But this can get messy if your components contain a lot of logic. + +```py +@register("calendar") +class Calendar(Component): + def get_context_data(self, id: str, editable: bool): + return { + "editable": editable, + "readonly": not editable, + "input_id": f"input-{id}", + "icon_id": f"icon-{id}", + ... + } +``` + +Instead, template tags in django_components (`{% component %}`, `{% slot %}`, `{% provide %}`, etc) allow you to treat literal string values as templates: + +```django +{% component 'blog_post' + "As positional arg {# yay #}" + title="{{ person.first_name }} {{ person.last_name }}" + id="{% random_int 10 20 %}" + readonly="{{ editable|not }}" + author="John Wick {# TODO: parametrize #}" +/ %} +``` + +In the example above: + +- Component `test` receives a positional argument with value `"As positional arg "`. The comment is omitted. +- Kwarg `title` is passed as a string, e.g. `John Doe` +- Kwarg `id` is passed as `int`, e.g. `15` +- Kwarg `readonly` is passed as `bool`, e.g. `False` +- Kwarg `author` is passed as a string, e.g. `John Wick ` (Comment omitted) + +This is inspired by [django-cotton](https://github.com/wrabit/django-cotton#template-expressions-in-attributes). + +### Passing data as string vs original values + +Sometimes you may want to use the template tags to transform +or generate the data that is then passed to the component. + +The data doesn't necessarily have to be strings. In the example above, the kwarg `id` was passed as an integer, NOT a string. + +Although the string literals for components inputs are treated as regular Django templates, there is one special case: + +When the string literal contains only a single template tag, with no extra text, then the value is passed as the original type instead of a string. + +Here, `page` is an integer: + +```django +{% component 'blog_post' page="{% random_int 10 20 %}" / %} +``` + +Here, `page` is a string: + +```django +{% component 'blog_post' page=" {% random_int 10 20 %} " / %} +``` + +And same applies to the `{{ }}` variable tags: + +Here, `items` is a list: + +```django +{% component 'cat_list' items="{{ cats|slice:':2' }}" / %} +``` + +Here, `items` is a string: + +```django +{% component 'cat_list' items="{{ cats|slice:':2' }} See more" / %} +``` + +### Evaluating Python expressions in template + +You can even go a step further and have a similar experience to Vue or React, +where you can evaluate arbitrary code expressions: + +```jsx + +``` + +Similar is possible with [`django-expr`](https://pypi.org/project/django-expr/), which adds an `expr` tag and filter that you can use to evaluate Python expressions from within the template: + +```django +{% component "my_form" + value="{% expr 'input_value if is_enabled else None' %}" +/ %} +``` + +> Note: Never use this feature to mix business logic and template logic. Business logic should still be in the view! + +## Pass dictonary by its key-value pairs + +_New in version 0.74_: + +Sometimes, a component may expect a dictionary as one of its inputs. + +Most commonly, this happens when a component accepts a dictionary +of HTML attributes (usually called `attrs`) to pass to the underlying template. + +In such cases, we may want to define some HTML attributes statically, and other dynamically. +But for that, we need to define this dictionary on Python side: + +```py +@register("my_comp") +class MyComp(Component): + template = """ + {% component "other" attrs=attrs / %} + """ + + def get_context_data(self, some_id: str): + attrs = { + "class": "pa-4 flex", + "data-some-id": some_id, + "@click.stop": "onClickHandler", + } + return {"attrs": attrs} +``` + +But as you can see in the case above, the event handler `@click.stop` and styling `pa-4 flex` +are disconnected from the template. If the component grew in size and we moved the HTML +to a separate file, we would have hard time reasoning about the component's template. + +Luckily, there's a better way. + +When we want to pass a dictionary to a component, we can define individual key-value pairs +as component kwargs, so we can keep all the relevant information in the template. For that, +we prefix the key with the name of the dict and `:`. So key `class` of input `attrs` becomes +`attrs:class`. And our example becomes: + +```py +@register("my_comp") +class MyComp(Component): + template = """ + {% component "other" + attrs:class="pa-4 flex" + attrs:data-some-id=some_id + attrs:@click.stop="onClickHandler" + / %} + """ + + def get_context_data(self, some_id: str): + return {"some_id": some_id} +``` + +Sweet! Now all the relevant HTML is inside the template, and we can move it to a separate file with confidence: + +```django +{% component "other" + attrs:class="pa-4 flex" + attrs:data-some-id=some_id + attrs:@click.stop="onClickHandler" +/ %} +``` + +> Note: It is NOT possible to define nested dictionaries, so +> `attrs:my_key:two=2` would be interpreted as: +> +> ```py +> {"attrs": {"my_key:two": 2}} +> ``` + +## Multiline 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` diff --git a/src/docs/concepts/fundamentals/your_first_component.md b/src/docs/concepts/fundamentals/your_first_component.md new file mode 100644 index 00000000..de6b7c7b --- /dev/null +++ b/src/docs/concepts/fundamentals/your_first_component.md @@ -0,0 +1,88 @@ +--- +title: Create your first component +weight: 1 +--- + +A component in django-components is the combination of four things: CSS, Javascript, a Django template, and some Python code to put them all together. + +``` + sampleproject/ + ├── calendarapp/ + ├── components/ 🆕 + │ └── calendar/ 🆕 + │ ├── calendar.py 🆕 + │ ├── script.js 🆕 + │ ├── style.css 🆕 + │ └── template.html 🆕 + ├── sampleproject/ + ├── manage.py + └── requirements.txt +``` + +Start by creating empty files in the structure above. + +First, you need a CSS file. Be sure to prefix all rules with a unique class so they don't clash with other rules. + +```css title="[project root]/components/calendar/style.css" +/* In a file called [project root]/components/calendar/style.css */ +.calendar-component { + width: 200px; + background: pink; +} +.calendar-component span { + font-weight: bold; +} +``` + +Then you need a javascript file that specifies how you interact with this component. You are free to use any javascript framework you want. A good way to make sure this component doesn't clash with other components is to define all code inside an anonymous function that calls itself. This makes all variables defined only be defined inside this component and not affect other components. + +```js title="[project root]/components/calendar/script.js" +/* In a file called [project root]/components/calendar/script.js */ +(function () { + if (document.querySelector(".calendar-component")) { + document.querySelector(".calendar-component").onclick = function () { + alert("Clicked calendar!"); + }; + } +})(); +``` + +Now you need a Django template for your component. Feel free to define more variables like `date` in this example. When creating an instance of this component we will send in the values for these variables. The template will be rendered with whatever template backend you've specified in your Django settings file. + +```htmldjango title="[project root]/components/calendar/calendar.html" +{# In a file called [project root]/components/calendar/template.html #} +
Today's date is {{ date }}
+``` + +Finally, we use django-components to tie this together. Start by creating a file called `calendar.py` in your component calendar directory. It will be auto-detected and loaded by the app. + +Inside this file we create a Component by inheriting from the Component class and specifying the context method. We also register the global component registry so that we easily can render it anywhere in our templates. + +```python title="[project root]/components/calendar/calendar.py" +# In a file called [project root]/components/calendar/calendar.py +from django_components import Component, register + +@register("calendar") +class Calendar(Component): + # Templates inside `[your apps]/components` dir and `[project root]/components` dir + # will be automatically found. + # + # `template_name` can be relative to dir where `calendar.py` is, or relative to COMPONENTS.dirs + template_name = "template.html" + # Or + def get_template_name(context): + return f"template-{context['name']}.html" + + # This component takes one parameter, a date string to show in the template + def get_context_data(self, date): + return { + "date": date, + } + + # Both `css` and `js` can be relative to dir where `calendar.py` is, or relative to COMPONENTS.dirs + class Media: + css = "style.css" + js = "script.js" +``` + +And voilá!! We've created our first component. diff --git a/src/docs/css/style.css b/src/docs/css/style.css new file mode 100644 index 00000000..d776a117 --- /dev/null +++ b/src/docs/css/style.css @@ -0,0 +1,16 @@ +h2, +h3, +h4, +h5, +h6 { + font-weight: bold !important; +} + +.md-typeset h2 { + border-top: 1px solid var(--md-typeset-color); + padding-top: 40px; +} + +.md-nav__item--section { + margin-top: 32px; +} diff --git a/src/docs/guides/setup/dev_server_setup.md b/src/docs/guides/setup/dev_server_setup.md new file mode 100644 index 00000000..09e6c9ff --- /dev/null +++ b/src/docs/guides/setup/dev_server_setup.md @@ -0,0 +1,26 @@ +--- +title: Running with development server +weight: 2 +--- + +### 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 in separate files 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 component 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_file_change`](#reload_on_file_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! diff --git a/src/docs/guides/setup/logging_and_debugging.md b/src/docs/guides/setup/logging_and_debugging.md new file mode 100644 index 00000000..431b2cf3 --- /dev/null +++ b/src/docs/guides/setup/logging_and_debugging.md @@ -0,0 +1,34 @@ +--- +weight: 3 +--- + +Django components supports [logging with Django](https://docs.djangoproject.com/en/5.0/howto/logging/#logging-how-to). +This can help with troubleshooting. + +To configure logging for Django components, set the `django_components` logger in +[`LOGGING`](https://docs.djangoproject.com/en/5.1/ref/settings/#std-setting-LOGGING) +in `settings.py` (below). + +Also see the [`settings.py` file in sampleproject](https://github.com/EmilStenstrom/django-components/blob/master/sampleproject/sampleproject/settings.py) for a real-life example. + +```py +import logging +import sys + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + "handlers": { + "console": { + 'class': 'logging.StreamHandler', + 'stream': sys.stdout, + }, + }, + "loggers": { + "django_components": { + "level": logging.DEBUG, + "handlers": ["console"], + }, + }, +} +``` diff --git a/src/docs/guides/setup/syntax_highlight.md b/src/docs/guides/setup/syntax_highlight.md new file mode 100644 index 00000000..259d8521 --- /dev/null +++ b/src/docs/guides/setup/syntax_highlight.md @@ -0,0 +1,45 @@ +--- +title: Syntax highlighting +--- + +## VSCode + +Note, in the above example, that the `t.django_html`, `t.css`, and `t.js` types are used to specify the type of the template, CSS, and JS files, respectively. This is not necessary, but if you're using VSCode with the [Python Inline Source Syntax Highlighting](https://marketplace.visualstudio.com/items?itemName=samwillis.python-inline-source) extension, it will give you syntax highlighting for the template, CSS, and JS. + +## Pycharm (or other Jetbrains IDEs) + +If you're a Pycharm user (or any other editor from Jetbrains), you can have coding assistance as well: + +```python +from django_components import Component, register + +@register("calendar") +class Calendar(Component): + def get_context_data(self, date): + return { + "date": date, + } + + # language=HTML + template= """ +
Today's date is {{ date }}
+ """ + + # language=CSS + css = """ + .calendar-component { width: 200px; background: pink; } + .calendar-component span { font-weight: bold; } + """ + + # language=JS + js = """ + (function(){ + if (document.querySelector(".calendar-component")) { + document.querySelector(".calendar-component").onclick = function(){ alert("Clicked calendar!"); }; + } + })() + """ +``` + +You don't need to use `types.django_html`, `types.css`, `types.js` since Pycharm uses [language injections](https://www.jetbrains.com/help/pycharm/using-language-injections.html). +You only need to write the comments `# language=` above the variables. diff --git a/src/docs/license.md b/src/docs/license.md deleted file mode 100644 index 0e8c6fca..00000000 --- a/src/docs/license.md +++ /dev/null @@ -1,3 +0,0 @@ -# License - ---8<-- "LICENSE" diff --git a/src/docs/overview/code_of_conduct.md b/src/docs/overview/code_of_conduct.md new file mode 100644 index 00000000..ee151f6e --- /dev/null +++ b/src/docs/overview/code_of_conduct.md @@ -0,0 +1,6 @@ +--- +title: Code of Conduct +weight: 8 +--- + +--8<-- "CODE_OF_CONDUCT.md" diff --git a/src/docs/overview/community.md b/src/docs/overview/community.md new file mode 100644 index 00000000..a1c361e1 --- /dev/null +++ b/src/docs/overview/community.md @@ -0,0 +1,16 @@ +--- +title: Community +weight: 5 +--- + +## Community questions + +The best place to ask questions is in our [Github Discussion board](https://github.com/EmilStenstrom/django-components/discussions). + +Please, before opening a new discussion, [check if similar discussion wasn't opened already](https://github.com/EmilStenstrom/django-components/discussions?discussions_q=). + +## Community examples + +One of our goals with `django-components` is to make it easy to share components between projects. If you have a set of components that you think would be useful to others, please open a pull request to add them to the list below. + +- [django-htmx-components](https://github.com/iwanalabs/django-htmx-components): A set of components for use with [htmx](https://htmx.org/). Try out the [live demo](https://dhc.iwanalabs.com/). diff --git a/src/docs/overview/compatibility.md b/src/docs/overview/compatibility.md new file mode 100644 index 00000000..6aea5f23 --- /dev/null +++ b/src/docs/overview/compatibility.md @@ -0,0 +1,15 @@ +--- +title: Compatibility +weight: 2 +--- +## Compatibility + +Django-components supports all supported combinations versions of [Django](https://docs.djangoproject.com/en/dev/faq/install/#what-python-version-can-i-use-with-django) and [Python](https://devguide.python.org/versions/#versions). + +| Python version | Django version | +| -------------- | -------------- | +| 3.8 | 4.2 | +| 3.9 | 4.2 | +| 3.10 | 4.2, 5.0 | +| 3.11 | 4.2, 5.0 | +| 3.12 | 4.2, 5.0 | diff --git a/src/docs/overview/contributing.md b/src/docs/overview/contributing.md new file mode 100644 index 00000000..b8d04d56 --- /dev/null +++ b/src/docs/overview/contributing.md @@ -0,0 +1,27 @@ +--- +title: Contributing +weight: 6 +--- + +### Bug reports + +If you find a bug, please open an issue with detailed description of what happened. + +### Bug fixes + +If you found a fix for a bug or typo, go ahead and open a PR with a fix. We'll help +you out with the rest! + +### Feature requests + +For feature requests or suggestions, please open either a discussion or an issue. + +## Getting involved + +django_components is still under active development, and there's much to build, +so come aboard! + +## Sponsoring + +Another way you can get involved is by [donating](https://github.com/sponsors/EmilStenstrom) +to the development of django_components. diff --git a/src/docs/overview/development.md b/src/docs/overview/development.md new file mode 100644 index 00000000..85344750 --- /dev/null +++ b/src/docs/overview/development.md @@ -0,0 +1,145 @@ +--- +title: Development +weight: 7 +--- + +## Install locally and run the tests + +Start by forking the project by clicking the **Fork button** up in the right corner in the GitHub . This makes a copy of the repository in your own name. Now you can clone this repository locally and start adding features: + +```sh +git clone https://github.com//django-components.git +``` + +To quickly run the tests install the local dependencies by running: + +```sh +pip install -r requirements-dev.txt +``` + +Now you can run the tests to make sure everything works as expected: + +```sh +pytest +``` + +The library is also tested across many versions of Python and Django. To run tests that way: + +```sh +pyenv install -s 3.8 +pyenv install -s 3.9 +pyenv install -s 3.10 +pyenv install -s 3.11 +pyenv install -s 3.12 +pyenv local 3.8 3.9 3.10 3.11 3.12 +tox -p +``` + +## Running Playwright tests + +We use [Playwright](https://playwright.dev/python/docs/intro) for end-to-end tests. You will therefore need to install Playwright to be able to run these tests. + +Luckily, Playwright makes it very easy: + +```sh +pip install -r requirements-dev.txt +playwright install chromium --with-deps +``` + +After Playwright is ready, simply run the tests with `tox`: + +```sh +tox +``` + +## Developing against live Django app + +How do you check that your changes to django-components project will work in an actual Django project? + +Use the [sampleproject](https://github.com/EmilStenstrom/django-components/tree/master/sampleproject/) demo project to validate the changes: + +1. Navigate to [sampleproject](https://github.com/EmilStenstrom/django-components/tree/master/sampleproject/) directory: + + ```sh + cd sampleproject + ``` + +2. Install dependencies from the [requirements.txt](https://github.com/EmilStenstrom/django-components/blob/master/sampleproject/requirements.txt) file: + + ```sh + pip install -r requirements.txt + ``` + +3. Link to your local version of django-components: + + ```sh + pip install -e .. + ``` + + NOTE: The path (in this case `..`) must point to the directory that has the `setup.py` file. + +4. Start Django server + ```sh + python manage.py runserver + ``` + +Once the server is up, it should be available at . + +To display individual components, add them to the `urls.py`, like in the case of + +## Building JS code + +django_components uses a bit of JS code to: + +- Manage the loading of JS and CSS files used by the components +- Allow to pass data from Python to JS + +When you make changes to this JS code, you also need to compile it: + +1. Make sure you are inside `src/django_components_js`: + +```sh +cd src/django_components_js +``` + +2. Install the JS dependencies + +```sh +npm install +``` + +3. Compile the JS/TS code: + +```sh +python build.py +``` + +The script will combine all JS/TS code into a single `.js` file, minify it, +and copy it to `django_components/static/django_components/django_components.min.js`. + +## Packaging and publishing + +To package the library into a distribution that can be published to PyPI, run: + +```sh +# Install pypa/build +python -m pip install build --user +# Build a binary wheel and a source tarball +python -m build --sdist --wheel --outdir dist/ . +``` + +To publish the package to PyPI, use `twine` ([See Python user guide](https://packaging.python.org/en/latest/tutorials/packaging-projects/#uploading-the-distribution-archives)): + +```sh +twine upload --repository pypi dist/* -u __token__ -p +``` + +[See the full workflow here.](https://github.com/EmilStenstrom/django-components/discussions/557#discussioncomment-10179141) + +## Development guides + +Deep dive into how django_components' features are implemented. + +- [Slot rendering](../devguides/slot_rendering.md) +- [Slots and blocks](../devguides/slots_and_blocks.md) +- [JS and CSS dependency management](../devguides/dependency_mgmt.md) diff --git a/src/docs/overview/installation.md b/src/docs/overview/installation.md new file mode 100644 index 00000000..43c73339 --- /dev/null +++ b/src/docs/overview/installation.md @@ -0,0 +1,162 @@ +--- +title: Installation +weight: 3 +--- + +1. Install `django_components` into your environment: + + ```bash + pip install django_components + ``` + +2. Load `django_components` into Django by adding it into `INSTALLED_APPS` in settings.py: + + ```python + INSTALLED_APPS = [ + ..., + 'django_components', + ] + ``` + +3. `BASE_DIR` setting is required. Ensure that it is defined in settings.py: + + ```python + from pathlib import Path + + BASE_DIR = Path(__file__).resolve().parent.parent + ``` + +4. Add / modify [`COMPONENTS.dirs`](#dirs) and / or [`COMPONENTS.app_dirs`](#app_dirs) so django_components knows where to find component HTML, JS and CSS files: + + ```python + from django_components import ComponentsSettings + + COMPONENTS = ComponentsSettings( + dirs=[ + ..., + Path(BASE_DIR) / "components", + ], + ) + ``` + + If `COMPONENTS.dirs` is omitted, django-components will by default look for a top-level `/components` directory, + `{BASE_DIR}/components`. + + In addition to `COMPONENTS.dirs`, django_components will also load components from app-level directories, such as `my-app/components/`. + The directories within apps are configured with [`COMPONENTS.app_dirs`](#app_dirs), and the default is `[app]/components`. + + NOTE: The input to `COMPONENTS.dirs` is the same as for `STATICFILES_DIRS`, and the paths must be full paths. [See Django docs](https://docs.djangoproject.com/en/5.0/ref/settings/#staticfiles-dirs). + +5. Next, to make Django load component HTML files as Django templates, 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 + TEMPLATES = [ + { + ..., + 'OPTIONS': { + 'context_processors': [ + ... + ], + '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', + ] + )], + }, + }, + ] + ``` + +## Adding support for JS and CSS + +If you want to use JS or CSS with components, you will need to: + +1. Modify `STATICFILES_FINDERS` section of settings.py as follows to be able to serve + the component JS and CSS files as static files: + + ```python + STATICFILES_FINDERS = [ + # Default finders + "django.contrib.staticfiles.finders.FileSystemFinder", + "django.contrib.staticfiles.finders.AppDirectoriesFinder", + # Django components + "django_components.finders.ComponentsFileSystemFinder", + ] + ``` + +2. Add [`ComponentDependencyMiddleware`](#setting-up-componentdependencymiddleware) to `MIDDLEWARE` setting. + + The middleware searches the outgoing HTML for all components that were rendered + to generate the HTML, and adds the JS and CSS associated with those components. + + ```python + MIDDLEWARE = [ + ... + "django_components.middleware.ComponentDependencyMiddleware", + ] + ``` + + Read more in [Rendering JS/CSS dependencies](#rendering-jscss-dependencies). + +3. Add django-component's URL paths to your `urlpatterns`: + + ```python + from django.urls import include, path + + urlpatterns = [ + ... + path("", include("django_components.urls")), + ] + ``` + +4. _Optional._ If you want to change where the JS and CSS is rendered, use + [`{% component_js_dependencies %}`](../reference/template_tags.md#component_js_dependencies) + and [`{% component_css_dependencies %}`](../reference/template_tags.md#component_js_dependencies). + + By default, the JS ` - - - -``` - -This makes it possible to organize your front-end around reusable components. Instead of relying on template tags and keeping your CSS and Javascript in the static directory. - -## Use components outside of templates - -_New in version 0.81_ - -Components can be rendered outside of Django templates, calling them as regular functions ("React-style"). - -The component class defines `render` and `render_to_response` class methods. These methods accept positional args, kwargs, and slots, offering the same flexibility as the `{% component %}` tag: - -```py -class SimpleComponent(Component): - template = """ - {% load component_tags %} - hello: {{ hello }} - foo: {{ foo }} - kwargs: {{ kwargs|safe }} - slot_first: {% slot "first" required / %} - """ - - def get_context_data(self, arg1, arg2, **kwargs): - return { - "hello": arg1, - "foo": arg2, - "kwargs": kwargs, - } - -rendered = SimpleComponent.render( - args=["world", "bar"], - kwargs={"kw1": "test", "kw2": "ooo"}, - slots={"first": "FIRST_SLOT"}, - context={"from_context": 98}, -) -``` - -Renders: - -``` -hello: world -foo: bar -kwargs: {'kw1': 'test', 'kw2': 'ooo'} -slot_first: FIRST_SLOT -``` - -### Inputs of `render` and `render_to_response` - -Both `render` and `render_to_response` accept the same input: - -```py -Component.render( - context: Mapping | django.template.Context | None = None, - args: List[Any] | None = None, - kwargs: Dict[str, Any] | None = None, - slots: Dict[str, str | SafeString | SlotFunc] | None = None, - escape_slots_content: bool = True -) -> str: -``` - -- _`args`_ - Positional args for the component. This is the same as calling the component - as `{% component "my_comp" arg1 arg2 ... %}` - -- _`kwargs`_ - Keyword args for the component. This is the same as calling the component - as `{% component "my_comp" key1=val1 key2=val2 ... %}` - -- _`slots`_ - Component slot fills. This is the same as pasing `{% fill %}` tags to the component. - Accepts a dictionary of `{ slot_name: slot_content }` where `slot_content` can be a string - or [`SlotFunc`](#slotfunc). - -- _`escape_slots_content`_ - Whether the content from `slots` should be escaped. `True` by default to prevent XSS attacks. If you disable escaping, you should make sure that any content you pass to the slots is safe, especially if it comes from user input. - -- _`context`_ - A context (dictionary or Django's Context) within which the component - is rendered. The keys on the context can be accessed from within the template. - - NOTE: In "isolated" mode, context is NOT accessible, and data MUST be passed via - component's args and kwargs. - -#### `SlotFunc` - -When rendering components with slots in `render` or `render_to_response`, you can pass either a string or a function. - -The function has following signature: - -```py -def render_func( - context: Context, - data: Dict[str, Any], - slot_ref: SlotRef, -) -> str | SafeString: - return nodelist.render(ctx) -``` - -- _`context`_ - Django's Context available to the Slot Node. -- _`data`_ - Data passed to the `{% slot %}` tag. See [Scoped Slots](#scoped-slots). -- _`slot_ref`_ - The default slot content. See [Accessing original content of slots](#accessing-original-content-of-slots). - - NOTE: The slot is lazily evaluated. To render the slot, convert it to string with `str(slot_ref)`. - -Example: - -```py -def footer_slot(ctx, data, slot_ref): - return f""" - SLOT_DATA: {data['abc']} - ORIGINAL: {slot_ref} - """ - -MyComponent.render_to_response( - slots={ - "footer": footer_slot, - }, -) -``` - -### Response class of `render_to_response` - -While `render` method returns a plain string, `render_to_response` wraps the rendered content in a "Response" class. By default, this is `django.http.HttpResponse`. - -If you want to use a different Response class in `render_to_response`, set the `Component.response_class` attribute: - -```py -class MyResponse(HttpResponse): - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - # Configure response - self.headers = ... - self.status = ... - -class SimpleComponent(Component): - response_class = MyResponse - template: types.django_html = "HELLO" - -response = SimpleComponent.render_to_response() -assert isinstance(response, MyResponse) -``` - -## Use components as views - -_New in version 0.34_ - -_Note: Since 0.92, Component no longer subclasses View. To configure the View class, set the nested `Component.View` class_ - -Components can now be used as views: -- Components define the `Component.as_view()` class method that can be used the same as [`View.as_view()`](https://docs.djangoproject.com/en/5.1/ref/class-based-views/base/#django.views.generic.base.View.as_view). - -- By default, you can define GET, POST or other HTTP handlers directly on the Component, same as you do with [View](https://docs.djangoproject.com/en/5.1/ref/class-based-views/base/#view). For example, you can override `get` and `post` to handle GET and POST requests, respectively. - -- In addition, `Component` now has a [`render_to_response`](#inputs-of-render-and-render_to_response) method that renders the component template based on the provided context and slots' data and returns an `HttpResponse` object. - -### Component as view example - -Here's an example of a calendar component defined as a view: - -```python -# In a file called [project root]/components/calendar.py -from django_components import Component, ComponentView, register - -@register("calendar") -class Calendar(Component): - - template = """ -
-
- {% slot "header" / %} -
-
- Today's date is {{ date }} -
-
- """ - - # Handle GET requests - def get(self, request, *args, **kwargs): - context = { - "date": request.GET.get("date", "2020-06-06"), - } - slots = { - "header": "Calendar header", - } - # Return HttpResponse with the rendered content - return self.render_to_response( - context=context, - slots=slots, - ) -``` - -Then, to use this component as a view, you should create a `urls.py` file in your components directory, and add a path to the component's view: - -```python -# In a file called [project root]/components/urls.py -from django.urls import path -from components.calendar.calendar import Calendar - -urlpatterns = [ - path("calendar/", Calendar.as_view()), -] -``` - -`Component.as_view()` is a shorthand for calling [`View.as_view()`](https://docs.djangoproject.com/en/5.1/ref/class-based-views/base/#django.views.generic.base.View.as_view) and passing the component -instance as one of the arguments. - -Remember to add `__init__.py` to your components directory, so that Django can find the `urls.py` file. - -Finally, include the component's urls in your project's `urls.py` file: - -```python -# In a file called [project root]/urls.py -from django.urls import include, path - -urlpatterns = [ - path("components/", include("components.urls")), -] -``` - -Note: Slots content are automatically escaped by default to prevent XSS attacks. To disable escaping, set `escape_slots_content=False` in the `render_to_response` method. If you do so, you should make sure that any content you pass to the slots is safe, especially if it comes from user input. - -If you're planning on passing an HTML string, check Django's use of [`format_html`](https://docs.djangoproject.com/en/5.0/ref/utils/#django.utils.html.format_html) and [`mark_safe`](https://docs.djangoproject.com/en/5.0/ref/utils/#django.utils.safestring.mark_safe). - -### Modifying the View class - -The View class that handles the requests is defined on `Component.View`. - -When you define a GET or POST handlers on the `Component` class, like so: - -```py -class MyComponent(Component): - def get(self, request, *args, **kwargs): - return self.render_to_response( - context={ - "date": request.GET.get("date", "2020-06-06"), - }, - ) - - def post(self, request, *args, **kwargs) -> HttpResponse: - variable = request.POST.get("variable") - return self.render_to_response( - kwargs={"variable": variable} - ) -``` - -Then the request is still handled by `Component.View.get()` or `Component.View.post()` -methods. However, by default, `Component.View.get()` points to `Component.get()`, and so on. - -```py -class ComponentView(View): - component: Component = None - ... - - def get(self, request, *args, **kwargs): - return self.component.get(request, *args, **kwargs) - - def post(self, request, *args, **kwargs): - return self.component.post(request, *args, **kwargs) - - ... -``` - -If you want to define your own `View` class, you need to: -1. Set the class as `Component.View` -2. Subclass from `ComponentView`, so the View instance has access to the component instance. - -In the example below, we added extra logic into `View.setup()`. - -Note that the POST handler is still defined at the top. This is because `View` subclasses `ComponentView`, which defines the `post()` method that calls `Component.post()`. - -If you were to overwrite the `View.post()` method, then `Component.post()` would be ignored. - -```py -from django_components import Component, ComponentView - -class MyComponent(Component): - - def post(self, request, *args, **kwargs) -> HttpResponse: - variable = request.POST.get("variable") - return self.component.render_to_response( - kwargs={"variable": variable} - ) - - class View(ComponentView): - def setup(self, request, *args, **kwargs): - super(request, *args, **kwargs) - - do_something_extra(request, *args, **kwargs) -``` - -## Typing and validating components - -### Adding type hints with Generics - -_New in version 0.92_ - -The `Component` class optionally accepts type parameters -that allow you to specify the types of args, kwargs, slots, and -data: - -```py -class Button(Component[Args, Kwargs, Slots, Data, JsData, CssData]): - ... -``` - -- `Args` - Must be a `Tuple` or `Any` -- `Kwargs` - Must be a `TypedDict` or `Any` -- `Data` - Must be a `TypedDict` or `Any` -- `Slots` - Must be a `TypedDict` or `Any` - -Here's a full example: - -```py -from typing import NotRequired, Tuple, TypedDict, SlotContent, SlotFunc - -# Positional inputs -Args = Tuple[int, str] - -# Kwargs inputs -class Kwargs(TypedDict): - variable: str - another: int - maybe_var: NotRequired[int] # May be ommited - -# Data returned from `get_context_data` -class Data(TypedDict): - variable: str - -# The data available to the `my_slot` scoped slot -class MySlotData(TypedDict): - value: int - -# Slots -class Slots(TypedDict): - # Use SlotFunc for slot functions. - # The generic specifies the `data` dictionary - my_slot: NotRequired[SlotFunc[MySlotData]] - # SlotContent == Union[str, SafeString] - another_slot: SlotContent - -class Button(Component[Args, Kwargs, Slots, Data, JsData, CssData]): - def get_context_data(self, variable, another): - return { - "variable": variable, - } -``` - -When you then call `Component.render` or `Component.render_to_response`, you will get type hints: - -```py -Button.render( - # Error: First arg must be `int`, got `float` - args=(1.25, "abc"), - # Error: Key "another" is missing - kwargs={ - "variable": "text", - }, -) -``` - -#### Usage for Python <3.11 - -On Python 3.8-3.10, use `typing_extensions` - -```py -from typing_extensions import TypedDict, NotRequired -``` - -Additionally on Python 3.8-3.9, also import `annotations`: - -```py -from __future__ import annotations -``` - -Moreover, on 3.10 and less, you may not be able to use `NotRequired`, and instead you will need to mark either all keys are required, or all keys as optional, using TypeDict's `total` kwarg. - -[See PEP-655](https://peps.python.org/pep-0655) for more info. - - -### Passing additional args or kwargs - -You may have a function that supports any number of args or kwargs: - -```py -def get_context_data(self, *args, **kwargs): - ... -``` - -This is not supported with the typed components. - -As a workaround: -- For `*args`, set a positional argument that accepts a list of values: - - ```py - # Tuple of one member of list of strings - Args = Tuple[List[str]] - ``` - -- For `*kwargs`, set a keyword argument that accepts a dictionary of values: - - ```py - class Kwargs(TypedDict): - variable: str - another: int - # Pass any extra keys under `extra` - extra: Dict[str, any] - ``` - -### Handling no args or no kwargs - -To declare that a component accepts no Args, Kwargs, etc, you can use `EmptyTuple` and `EmptyDict` types: - -```py -from django_components import Component, EmptyDict, EmptyTuple - -Args = EmptyTuple -Kwargs = Data = Slots = EmptyDict - -class Button(Component[Args, Kwargs, Slots, Data, JsData, CssData]): - ... -``` - -### Runtime input validation with types - -_New in version 0.96_ - -> NOTE: Kwargs, slots, and data validation is supported only for Python >=3.11 - -In Python 3.11 and later, when you specify the component types, you will get also runtime validation of the inputs you pass to `Component.render` or `Component.render_to_response`. - -So, using the example from before, if you ignored the type errors and still ran the following code: - -```py -Button.render( - # Error: First arg must be `int`, got `float` - args=(1.25, "abc"), - # Error: Key "another" is missing - kwargs={ - "variable": "text", - }, -) -``` - -This would raise a `TypeError`: - -```txt -Component 'Button' expected positional argument at index 0 to be , got 1.25 of type -``` - -In case you need to skip these errors, you can either set the faulty member to `Any`, e.g.: - -```py -# Changed `int` to `Any` -Args = Tuple[Any, str] -``` - -Or you can replace `Args` with `Any` altogether, to skip the validation of args: - -```py -# Replaced `Args` with `Any` -class Button(Component[Any, Kwargs, Slots, Data, JsData, CssData]): - ... -``` - -Same applies to kwargs, data, and slots. - -## Pre-defined components - -### Dynamic components - -If you are writing something like a form component, you may design it such that users -give you the component names, and your component renders it. - -While you can handle this with a series of if / else statements, this is not an extensible solution. - -Instead, you can use **dynamic components**. Dynamic components are used in place of normal components. - -```django -{% load component_tags %} -{% component "dynamic" is=component_name title="Cat Museum" %} - {% fill "content" %} - HELLO_FROM_SLOT_1 - {% endfill %} - {% fill "sidebar" %} - HELLO_FROM_SLOT_2 - {% endfill %} -{% endcomponent %} -``` - -or in case you use the `django_components.component_shorthand_formatter` tag formatter: - -```django -{% dynamic is=component_name title="Cat Museu" %} - {% fill "content" %} - HELLO_FROM_SLOT_1 - {% endfill %} - {% fill "sidebar" %} - HELLO_FROM_SLOT_2 - {% endfill %} -{% enddynamic %} -``` - - -These behave same way as regular components. You pass it the same args, kwargs, and slots as you would -to the component that you want to render. - -The only exception is that also you supply 1-2 additional inputs: -- `is` - Required - The component name or a component class to render -- `registry` - Optional - The `ComponentRegistry` that will be searched if `is` is a component name. If omitted, ALL registries are searched. - -By default, the dynamic component is registered under the name `"dynamic"`. In case of a conflict, you can change the name used for the dynamic components by defining the [`COMPONENTS.dynamic_component_name` setting](#dynamic_component_name). - -If you need to use the dynamic components in Python, you can also import it from `django_components`: -```py -from django_components import DynamicComponent - -comp = SimpleTableComp if is_readonly else TableComp - -output = DynamicComponent.render( - kwargs={ - "is": comp, - # Other kwargs... - }, - # args: [...], - # slots: {...}, -) -``` - -## Registering components - -In previous examples you could repeatedly see us using `@register()` to "register" -the components. In this section we dive deeper into what it actually means and how you can -manage (add or remove) components. - -As a reminder, we may have a component like this: - -```python -from django_components import Component, register - -@register("calendar") -class Calendar(Component): - template_name = "template.html" - - # This component takes one parameter, a date string to show in the template - def get_context_data(self, date): - return { - "date": date, - } -``` - -which we then render in the template as: - -```django -{% component "calendar" date="1970-01-01" %} -{% endcomponent %} -``` - -As you can see, `@register` links up the component class -with the `{% component %}` template tag. So when the template tag comes across -a component called `"calendar"`, it can look up it's class and instantiate it. - -### What is ComponentRegistry - -The `@register` decorator is a shortcut for working with the `ComponentRegistry`. - -`ComponentRegistry` manages which components can be used in the template tags. - -Each `ComponentRegistry` instance is associated with an instance -of Django's `Library`. And Libraries are inserted into Django template -using the `{% load %}` tags. - -The `@register` decorator accepts an optional kwarg `registry`, which specifies, the `ComponentRegistry` to register components into. -If omitted, the default `ComponentRegistry` instance defined in django_components is used. - -```py -my_registry = ComponentRegistry() - -@register(registry=my_registry) -class MyComponent(Component): - ... -``` - -The default `ComponentRegistry` is associated with the `Library` that -you load when you call `{% load component_tags %}` inside your template, or when you -add `django_components.templatetags.component_tags` to the template builtins. - -So when you register or unregister a component to/from a component registry, -then behind the scenes the registry automatically adds/removes the component's -template tags to/from the Library, so you can call the component from within the templates -such as `{% component "my_comp" %}`. - -### Working with ComponentRegistry - -The default `ComponentRegistry` instance can be imported as: - -```py -from django_components import registry -``` - -You can use the registry to manually add/remove/get components: - -```py -from django_components import registry - -# Register components -registry.register("button", ButtonComponent) -registry.register("card", CardComponent) - -# Get all or single -registry.all() # {"button": ButtonComponent, "card": CardComponent} -registry.get("card") # CardComponent - -# Unregister single component -registry.unregister("card") - -# Unregister all components -registry.clear() -``` - -### Registering components to custom ComponentRegistry - -If you are writing a component library to be shared with others, you may want to manage your own instance of `ComponentRegistry` -and register components onto a different `Library` instance than the default one. - -The `Library` instance can be set at instantiation of `ComponentRegistry`. If omitted, -then the default Library instance from django_components is used. - -```py -from django.template import Library -from django_components import ComponentRegistry - -my_library = Library(...) -my_registry = ComponentRegistry(library=my_library) -``` - -When you have defined your own `ComponentRegistry`, you can either register the components -with `my_registry.register()`, or pass the registry to the `@component.register()` decorator -via the `registry` kwarg: - -```py -from path.to.my.registry import my_registry - -@register("my_component", registry=my_registry) -class MyComponent(Component): - ... -``` - -NOTE: The Library instance can be accessed under `library` attribute of `ComponentRegistry`. - -### ComponentRegistry settings - -When you are creating an instance of `ComponentRegistry`, you can define the components' behavior within the template. - -The registry accepts these settings: -- `context_behavior` -- `tag_formatter` - -```py -from django.template import Library -from django_components import ComponentRegistry, RegistrySettings - -register = library = django.template.Library() -comp_registry = ComponentRegistry( - library=library, - settings=RegistrySettings( - context_behavior="isolated", - tag_formatter="django_components.component_formatter", - ), -) -``` - -These settings are [the same as the ones you can set for django_components](#available-settings). - -In fact, when you set `COMPONENT.tag_formatter` or `COMPONENT.context_behavior`, these are forwarded to the default `ComponentRegistry`. - -This makes it possible to have multiple registries with different settings in one projects, and makes sharing of component libraries possible. - -## Autodiscovery - -Every component that you want to use in the template with the `{% component %}` tag needs to be registered with the ComponentRegistry. Normally, we use the `@register` decorator for that: - -```py -from django_components import Component, register - -@register("calendar") -class Calendar(Component): - ... -``` - -But for the component to be registered, the code needs to be executed - the file needs to be imported as a module. - -One way to do that is by importing all your components in `apps.py`: - -```py -from django.apps import AppConfig - -class MyAppConfig(AppConfig): - name = "my_app" - - def ready(self) -> None: - from components.card.card import Card - from components.list.list import List - from components.menu.menu import Menu - from components.button.button import Button - ... -``` - -However, there's a simpler way! - -By default, the Python files in the `COMPONENTS.dirs` directories (or app-level `[app]/components/`) are auto-imported in order to auto-register the components. - -Autodiscovery occurs when Django is loaded, during the `ready` hook of the `apps.py` file. - -If you are using autodiscovery, keep a few points in mind: - -- Avoid defining any logic on the module-level inside the `components` dir, that you would not want to run anyway. -- Components inside the auto-imported files still need to be registered with `@register()` -- Auto-imported component files must be valid Python modules, they must use suffix `.py`, and module name should follow [PEP-8](https://peps.python.org/pep-0008/#package-and-module-names). - -Autodiscovery can be disabled in the [settings](#autodiscover---toggle-autodiscovery). - -### Manually trigger autodiscovery - -Autodiscovery can be also triggered manually as a function call. This is useful if you want to run autodiscovery at a custom point of the lifecycle: - -```py -from django_components import autodiscover - -autodiscover() -``` - -## Using slots in templates - -_New in version 0.26_: - -- The `slot` tag now serves only to declare new slots inside the component template. - - To override the content of a declared slot, use the newly introduced `fill` tag instead. -- Whereas unfilled slots used to raise a warning, filling a slot is now optional by default. - - To indicate that a slot must be filled, the new `required` option should be added at the end of the `slot` tag. - ---- - -Components support something called 'slots'. -When a component is used inside another template, slots allow the parent template to override specific parts of the child component by passing in different content. -This mechanism makes components more reusable and composable. -This behavior is similar to [slots in Vue](https://vuejs.org/guide/components/slots.html). - -In the example below we introduce two block tags that work hand in hand to make this work. These are... - -- `{% slot %}`/`{% endslot %}`: Declares a new slot in the component template. -- `{% fill %}`/`{% endfill %}`: (Used inside a `{% component %}` tag pair.) Fills a declared slot with the specified content. - -Let's update our calendar component to support more customization. We'll add `slot` tag pairs to its template, _template.html_. - -```htmldjango -
-
- {% slot "header" %}Calendar header{% endslot %} -
-
- {% slot "body" %}Today's date is {{ date }}{% endslot %} -
-
-``` - -When using the component, you specify which slots you want to fill and where you want to use the defaults from the template. It looks like this: - -```htmldjango -{% component "calendar" date="2020-06-06" %} - {% fill "body" %} - Can you believe it's already {{ date }}?? - {% endfill %} -{% endcomponent %} -``` - -Since the 'header' fill is unspecified, it's taken from the base template. If you put this in a template, and pass in `date=2020-06-06`, this is what gets rendered: - -```htmldjango -
-
- Calendar header -
-
- Can you believe it's already 2020-06-06?? -
-
-``` - -### Named slots - -As seen in the previouse section, you can use `{% fill slot_name %}` to insert content into a specific -slot. - -You can define fills for multiple slot simply by defining them all within the `{% component %} {% endcomponent %}` -tags: - -```htmldjango -{% component "calendar" date="2020-06-06" %} - {% fill "header" %} - Hi this is header! - {% endfill %} - {% fill "body" %} - Can you believe it's already {{ date }}?? - {% endfill %} -{% endcomponent %} -``` - -You can also use `{% for %}`, `{% with %}`, or other non-component tags (even `{% include %}`) -to construct the `{% fill %}` tags, **as long as these other tags do not leave any text behind!** - -```django -{% component "table" %} - {% for slot_name in slots %} - {% fill name=slot_name %} - {{ slot_name }} - {% endfill %} - {% endfor %} - - {% with slot_name="abc" %} - {% fill name=slot_name %} - {{ slot_name }} - {% endfill %} - {% endwith %} -{% endcomponent %} -``` - -### Default slot - -_Added in version 0.28_ - -As you can see, component slots lets you write reusable containers that you fill in when you use a component. This makes for highly reusable components that can be used in different circumstances. - -It can become tedious to use `fill` tags everywhere, especially when you're using a component that declares only one slot. To make things easier, `slot` tags can be marked with an optional keyword: `default`. - -When added to the tag (as shown below), this option lets you pass filling content directly in the body of a `component` tag pair – without using a `fill` tag. Choose carefully, though: a component template may contain at most one slot that is marked as `default`. The `default` option can be combined with other slot options, e.g. `required`. - -Here's the same example as before, except with default slots and implicit filling. - -The template: - -```htmldjango -
-
- {% slot "header" %}Calendar header{% endslot %} -
-
- {% slot "body" default %}Today's date is {{ date }}{% endslot %} -
-
-``` - -Including the component (notice how the `fill` tag is omitted): - -```htmldjango -{% component "calendar" date="2020-06-06" %} - Can you believe it's already {{ date }}?? -{% endcomponent %} -``` - -The rendered result (exactly the same as before): - -```html -
-
Calendar header
-
Can you believe it's already 2020-06-06??
-
-``` - -You may be tempted to combine implicit fills with explicit `fill` tags. This will not work. The following component template will raise an error when rendered. - -```htmldjango -{# DON'T DO THIS #} -{% component "calendar" date="2020-06-06" %} - {% fill "header" %}Totally new header!{% endfill %} - Can you believe it's already {{ date }}?? -{% endcomponent %} -``` - -Instead, you can use a named fill with name `default` to target the default fill: - -```htmldjango -{# THIS WORKS #} -{% component "calendar" date="2020-06-06" %} - {% fill "header" %}Totally new header!{% endfill %} - {% fill "default" %} - Can you believe it's already {{ date }}?? - {% endfill %} -{% endcomponent %} -``` - -NOTE: If you doubly-fill a slot, that is, that both `{% fill "default" %}` and `{% fill "header" %}` -would point to the same slot, this will raise an error when rendered. - -#### Accessing default slot in Python - -Since the default slot is stored under the slot name `default`, you can access the default slot -like so: - -```py -class MyTable(Component): - def get_context_data(self, *args, **kwargs): - default_slot = self.input.slots["default"] - return { - "default_slot": default_slot, - } -``` - -### Render fill in multiple places - -_Added in version 0.70_ - -You can render the same content in multiple places by defining multiple slots with -identical names: - -```htmldjango -
-
- {% slot "image" %}Image here{% endslot %} -
-
- {% slot "image" %}Image here{% endslot %} -
-
-``` - -So if used like: - -```htmldjango -{% component "calendar" date="2020-06-06" %} - {% fill "image" %} - - {% endfill %} -{% endcomponent %} -``` - -This renders: - -```htmldjango -
-
- -
-
- -
-
-``` - -#### Default and required slots - -If you use a slot multiple times, you can still mark the slot as `default` or `required`. -For that, you must mark each slot individually, e.g.: - -```htmldjango -
-
- {% slot "image" default required %}Image here{% endslot %} -
-
- {% slot "image" default required %}Image here{% endslot %} -
-
-``` - -Which you can then use as regular default slot: - -```htmldjango -{% component "calendar" date="2020-06-06" %} - -{% endcomponent %} -``` - -Since each slot is tagged individually, you can have multiple slots -with the same name but different conditions. - -E.g. in this example, we have a component that renders a user avatar -- a small circular image with a profile picture of name initials. - -If the component is given `image_src` or `name_initials` variables, -the `image` slot is optional. But if neither of those are provided, -you MUST fill the `image` slot. - -```htmldjango -
- {% if image_src %} - {% slot "image" default %} - - {% endslot %} - {% elif name_initials %} - {% slot "image" default %} -
- {{ name_initials }} -
- {% endslot %} - {% else %} - {% slot "image" default required / %} - {% endif %} -
-``` - -### Accessing original content of slots - -_Added in version 0.26_ - -> NOTE: In version 0.77, the syntax was changed from -> -> ```django -> {% fill "my_slot" as "alias" %} {{ alias.default }} -> ``` -> -> to -> -> ```django -> {% fill "my_slot" default="slot_default" %} {{ slot_default }} -> ``` - -Sometimes you may want to keep the original slot, but only wrap or prepend/append content to it. To do so, you can access the default slot via the `default` kwarg. - -Similarly to the `data` attribute, you specify the variable name through which the default slot will be made available. - -For instance, let's say you're filling a slot called 'body'. To render the original slot, assign it to a variable using the `'default'` keyword. You then render this variable to insert the default content: - -```htmldjango -{% component "calendar" date="2020-06-06" %} - {% fill "body" default="body_default" %} - {{ body_default }}. Have a great day! - {% endfill %} -{% endcomponent %} -``` - -This produces: - -```htmldjango -
-
- Calendar header -
-
- Today's date is 2020-06-06. Have a great day! -
-
-``` - -To access the original content of a default slot, set the name to `default`: - -```htmldjango -{% component "calendar" date="2020-06-06" %} - {% fill "default" default="slot_default" %} - {{ slot_default }}. Have a great day! - {% endfill %} -{% endcomponent %} -``` - -### Conditional slots - -_Added in version 0.26._ - -> NOTE: In version 0.70, `{% if_filled %}` tags were replaced with `{{ component_vars.is_filled }}` variables. If your slot name contained special characters, see the section [Accessing `is_filled` of slot names with special characters](#accessing-is_filled-of-slot-names-with-special-characters). - -In certain circumstances, you may want the behavior of slot filling to depend on -whether or not a particular slot is filled. - -For example, suppose we have the following component template: - -```htmldjango -
-
- {% slot "title" %}Title{% endslot %} -
-
- {% slot "subtitle" %}{# Optional subtitle #}{% endslot %} -
-
-``` - -By default the slot named 'subtitle' is empty. Yet when the component is used without -explicit fills, the div containing the slot is still rendered, as shown below: - -```html -
-
Title
-
-
-``` - -This may not be what you want. What if instead the outer 'subtitle' div should only -be included when the inner slot is in fact filled? - -The answer is to use the `{{ component_vars.is_filled. }}` variable. You can use this together with Django's `{% if/elif/else/endif %}` tags to define a block whose contents will be rendered only if the component slot with -the corresponding 'name' is filled. - -This is what our example looks like with `component_vars.is_filled`. - -```htmldjango -
-
- {% slot "title" %}Title{% endslot %} -
- {% if component_vars.is_filled.subtitle %} -
- {% slot "subtitle" %}{# Optional subtitle #}{% endslot %} -
- {% endif %} -
-``` - -Here's our example with more complex branching. - -```htmldjango -
-
- {% slot "title" %}Title{% endslot %} -
- {% if component_vars.is_filled.subtitle %} -
- {% slot "subtitle" %}{# Optional subtitle #}{% endslot %} -
- {% elif component_vars.is_filled.title %} - ... - {% elif component_vars.is_filled. %} - ... - {% endif %} -
-``` - -Sometimes you're not interested in whether a slot is filled, but rather that it _isn't_. -To negate the meaning of `component_vars.is_filled`, simply treat it as boolean and negate it with `not`: - -```htmldjango -{% if not component_vars.is_filled.subtitle %} -
- {% slot "subtitle" / %} -
-{% endif %} -``` - -#### Accessing `is_filled` of slot names with special characters - -To be able to access a slot name via `component_vars.is_filled`, the slot name needs to be composed of only alphanumeric characters and underscores (e.g. `this__isvalid_123`). - -However, you can still define slots with other special characters. In such case, the slot name in `component_vars.is_filled` is modified to replace all invalid characters into `_`. - -So a slot named `"my super-slot :)"` will be available as `component_vars.is_filled.my_super_slot___`. - -Same applies when you are accessing `is_filled` from within the Python, e.g.: - -```py -class MyTable(Component): - def on_render_before(self, context, template) -> None: - # ✅ Works - if self.is_filled["my_super_slot___"]: - # Do something - - # ❌ Does not work - if self.is_filled["my super-slot :)"]: - # Do something -``` - -### Scoped slots - -_Added in version 0.76_: - -Consider a component with slot(s). This component may do some processing on the inputs, and then use the processed variable in the slot's default template: - -```py -@register("my_comp") -class MyComp(Component): - template = """ -
- {% slot "content" default %} - input: {{ input }} - {% endslot %} -
- """ - - def get_context_data(self, input): - processed_input = do_something(input) - return {"input": processed_input} -``` - -You may want to design a component so that users of your component can still access the `input` variable, so they don't have to recompute it. - -This behavior is called "scoped slots". This is inspired by [Vue scoped slots](https://vuejs.org/guide/components/slots.html#scoped-slots) and [scoped slots of django-web-components](https://github.com/Xzya/django-web-components/tree/master?tab=readme-ov-file#scoped-slots). - -Using scoped slots consists of two steps: - -1. Passing data to `slot` tag -2. Accessing data in `fill` tag - -#### Passing data to slots - -To pass the data to the `slot` tag, simply pass them as keyword attributes (`key=value`): - -```py -@register("my_comp") -class MyComp(Component): - template = """ -
- {% slot "content" default input=input %} - input: {{ input }} - {% endslot %} -
- """ - - def get_context_data(self, input): - processed_input = do_something(input) - return { - "input": processed_input, - } -``` - -#### Accessing slot data in fill - -Next, we head over to where we define a fill for this slot. Here, to access the slot data -we set the `data` attribute to the name of the variable through which we want to access -the slot data. In the example below, we set it to `data`: - -```django -{% component "my_comp" %} - {% fill "content" data="slot_data" %} - {{ slot_data.input }} - {% endfill %} -{% endcomponent %} -``` - -To access slot data on a default slot, you have to explictly define the `{% fill %}` tags. - -So this works: - -```django -{% component "my_comp" %} - {% fill "default" data="slot_data" %} - {{ slot_data.input }} - {% endfill %} -{% endcomponent %} -``` - -While this does not: - -```django -{% component "my_comp" data="data" %} - {{ data.input }} -{% endcomponent %} -``` - -Note: You cannot set the `data` attribute and -[`default` attribute)](#accessing-original-content-of-slots) -to the same name. This raises an error: - -```django -{% component "my_comp" %} - {% fill "content" data="slot_var" default="slot_var" %} - {{ slot_var.input }} - {% endfill %} -{% endcomponent %} -``` - -### Dynamic slots and fills - -Until now, we were declaring slot and fill names statically, as a string literal, e.g. - -```django -{% slot "content" / %} -``` - -However, sometimes you may want to generate slots based on the given input. One example of this is [a table component like that of Vuetify](https://vuetifyjs.com/en/api/v-data-table/), which creates a header and an item slots for each user-defined column. - -In django_components you can achieve the same, simply by using a variable (or a [template expression](#use-template-tags-inside-component-inputs)) instead of a string literal: - -```django - - - {% for header in headers %} - - {% endfor %} - -
- {% slot "header-{{ header.key }}" value=header.title %} - {{ header.title }} - {% endslot %} -
-``` - -When using the component, you can either set the fill explicitly: - -```django -{% component "table" headers=headers items=items %} - {% fill "header-name" data="data" %} - {{ data.value }} - {% endfill %} -{% endcomponent %} -``` - -Or also use a variable: - -```django -{% component "table" headers=headers items=items %} - {# Make only the active column bold #} - {% fill "header-{{ active_header_name }}" data="data" %} - {{ data.value }} - {% endfill %} -{% endcomponent %} -``` - -> NOTE: It's better to use static slot names whenever possible for clarity. The dynamic slot names should be reserved for advanced use only. - -Lastly, in rare cases, you can also pass the slot name via [the spread operator](#spread-operator). This is possible, because the slot name argument is actually a shortcut for a `name` keyword argument. - -So this: - -```django -{% slot "content" / %} -``` - -is the same as: - -```django -{% slot name="content" / %} -``` - -So it's possible to define a `name` key on a dictionary, and then spread that onto the slot tag: - -```django -{# slot_props = {"name": "content"} #} -{% slot ...slot_props / %} -``` - -### Pass through all the slots - -You can dynamically pass all slots to a child component. This is similar to -[passing all slots in Vue](https://vue-land.github.io/faq/forwarding-slots#passing-all-slots): - -```py -class MyTable(Component): - def get_context_data(self, *args, **kwargs): - return { - "slots": self.input.slots, - } - - template: """ -
- {% component "child" %} - {% for slot_name in slots %} - {% fill name=slot_name data="data" %} - {% slot name=slot_name ...data / %} - {% endfill %} - {% endfor %} - {% endcomponent %} -
- """ -``` - -## Accessing data passed to the component - -When you call `Component.render` or `Component.render_to_response`, the inputs to these methods can be accessed from within the instance under `self.input`. - -This means that you can use `self.input` inside: -- `get_context_data` -- `get_template_name` -- `get_template` -- `on_render_before` -- `on_render_after` - -`self.input` is only defined during the execution of `Component.render`, and raises a `RuntimeError` when called outside of this context. - -`self.input` has the same fields as the input to `Component.render`: - -```py -class TestComponent(Component): - def get_context_data(self, var1, var2, variable, another, **attrs): - assert self.input.args == (123, "str") - assert self.input.kwargs == {"variable": "test", "another": 1} - assert self.input.slots == {"my_slot": ...} - assert isinstance(self.input.context, Context) - - return { - "variable": variable, - } - -rendered = TestComponent.render( - kwargs={"variable": "test", "another": 1}, - args=(123, "str"), - slots={"my_slot": "MY_SLOT"}, -) -``` - -NOTE: The slots in `self.input.slots` are normalized to slot functions. - -## Rendering HTML attributes - -_New in version 0.74_: - -You can use the `html_attrs` tag to render HTML attributes, given a dictionary -of values. - -So if you have a template: - -```django -
-
-``` - -You can simplify it with `html_attrs` tag: - -```django -
-
-``` - -where `attrs` is: - -```py -attrs = { - "class": classes, - "data-id": my_id, -} -``` - -This feature is inspired by [`merge_attrs` tag of django-web-components](https://github.com/Xzya/django-web-components/tree/master?tab=readme-ov-file#default--merged-attributes) and -["fallthrough attributes" feature of Vue](https://vuejs.org/guide/components/attrs). - -### Removing atttributes - -Attributes that are set to `None` or `False` are NOT rendered. - -So given this input: - -```py -attrs = { - "class": "text-green", - "required": False, - "data-id": None, -} -``` - -And template: - -```django -
-
-``` - -Then this renders: - -```html -
-``` - -### Boolean attributes - -In HTML, boolean attributes are usually rendered with no value. Consider the example below where the first button is disabled and the second is not: - -```html - -``` - -HTML rendering with `html_attrs` tag or `attributes_to_string` works the same way, where `key=True` is rendered simply as `key`, and `key=False` is not render at all. - -So given this input: - -```py -attrs = { - "disabled": True, - "autofocus": False, -} -``` - -And template: - -```django -
-
-``` - -Then this renders: - -```html -
-``` - -### Default attributes - -Sometimes you may want to specify default values for attributes. You can pass a second argument (or kwarg `defaults`) to set the defaults. - -```django -
- ... -
-``` - -In the example above, if `attrs` contains e.g. the `class` key, `html_attrs` will render: - -`class="{{ attrs.class }}"` - -Otherwise, `html_attrs` will render: - -`class="{{ defaults.class }}"` - -### Appending attributes - -For the `class` HTML attribute, it's common that we want to _join_ multiple values, -instead of overriding them. For example, if you're authoring a component, you may -want to ensure that the component will ALWAYS have a specific class. Yet, you may -want to allow users of your component to supply their own classes. - -We can achieve this by adding extra kwargs. These values -will be appended, instead of overwriting the previous value. - -So if we have a variable `attrs`: - -```py -attrs = { - "class": "my-class pa-4", -} -``` - -And on `html_attrs` tag, we set the key `class`: - -```django -
-
-``` - -Then these will be merged and rendered as: - -```html -
-``` - -To simplify merging of variables, you can supply the same key multiple times, and these will be all joined together: - -```django -{# my_var = "class-from-var text-red" #} -
-
-``` - -Renders: - -```html -
-``` - -### Rules for `html_attrs` - -1. Both `attrs` and `defaults` can be passed as positional args - - `{% html_attrs attrs defaults key=val %}` - - or as kwargs - - `{% html_attrs key=val defaults=defaults attrs=attrs %}` - -2. Both `attrs` and `defaults` are optional (can be omitted) - -3. Both `attrs` and `defaults` are dictionaries, and we can define them the same way [we define dictionaries for the `component` tag](#pass-dictonary-by-its-key-value-pairs). So either as `attrs=attrs` or `attrs:key=value`. - -4. All other kwargs are appended and can be repeated. - -### Examples for `html_attrs` - -Assuming that: - -```py -class_from_var = "from-var" - -attrs = { - "class": "from-attrs", - "type": "submit", -} - -defaults = { - "class": "from-defaults", - "role": "button", -} -``` - -Then: - -- Empty tag
- `{% html_attr %}` - - renders (empty string):
- ` ` - -- Only kwargs
- `{% html_attr class="some-class" class=class_from_var data-id="123" %}` - - renders:
- `class="some-class from-var" data-id="123"` - -- Only attrs
- `{% html_attr attrs %}` - - renders:
- `class="from-attrs" type="submit"` - -- Attrs as kwarg
- `{% html_attr attrs=attrs %}` - - renders:
- `class="from-attrs" type="submit"` - -- Only defaults (as kwarg)
- `{% html_attr defaults=defaults %}` - - renders:
- `class="from-defaults" role="button"` - -- Attrs using the `prefix:key=value` construct
- `{% html_attr attrs:class="from-attrs" attrs:type="submit" %}` - - renders:
- `class="from-attrs" type="submit"` - -- Defaults using the `prefix:key=value` construct
- `{% html_attr defaults:class="from-defaults" %}` - - renders:
- `class="from-defaults" role="button"` - -- All together (1) - attrs and defaults as positional args:
- `{% html_attrs attrs defaults class="added_class" class=class_from_var data-id=123 %}` - - renders:
- `class="from-attrs added_class from-var" type="submit" role="button" data-id=123` - -- All together (2) - attrs and defaults as kwargs args:
- `{% html_attrs class="added_class" class=class_from_var data-id=123 attrs=attrs defaults=defaults %}` - - renders:
- `class="from-attrs added_class from-var" type="submit" role="button" data-id=123` - -- All together (3) - mixed:
- `{% html_attrs attrs defaults:class="default-class" class="added_class" class=class_from_var data-id=123 %}` - - renders:
- `class="from-attrs added_class from-var" type="submit" data-id=123` - -### Full example for `html_attrs` - -```py -@register("my_comp") -class MyComp(Component): - template: t.django_html = """ -
- Today's date is {{ date }} -
- """ - - def get_context_data(self, date: Date, attrs: dict): - return { - "date": date, - "attrs": attrs, - "class_from_var": "extra-class" - } - -@register("parent") -class Parent(Component): - template: t.django_html = """ - {% component "my_comp" - date=date - attrs:class="pa-0 border-solid border-red" - attrs:data-json=json_data - attrs:@click="(e) => onClick(e, 'from_parent')" - / %} - """ - - def get_context_data(self, date: Date): - return { - "date": datetime.now(), - "json_data": json.dumps({"value": 456}) - } -``` - -Note: For readability, we've split the tags across multiple lines. - -Inside `MyComp`, we defined a default attribute - -`defaults:class="pa-4 text-red"` - -So if `attrs` includes key `class`, the default above will be ignored. - -`MyComp` also defines `class` key twice. It means that whether the `class` -attribute is taken from `attrs` or `defaults`, the two `class` values -will be appended to it. - -So by default, `MyComp` renders: - -```html -
...
-``` - -Next, let's consider what will be rendered when we call `MyComp` from `Parent` -component. - -`MyComp` accepts a `attrs` dictionary, that is passed to `html_attrs`, so the -contents of that dictionary are rendered as the HTML attributes. - -In `Parent`, we make use of passing dictionary key-value pairs as kwargs to define -individual attributes as if they were regular kwargs. - -So all kwargs that start with `attrs:` will be collected into an `attrs` dict. - -```django - attrs:class="pa-0 border-solid border-red" - attrs:data-json=json_data - attrs:@click="(e) => onClick(e, 'from_parent')" -``` - -And `get_context_data` of `MyComp` will receive `attrs` input with following keys: - -```py -attrs = { - "class": "pa-0 border-solid", - "data-json": '{"value": 456}', - "@click": "(e) => onClick(e, 'from_parent')", -} -``` - -`attrs["class"]` overrides the default value for `class`, whereas other keys -will be merged. - -So in the end `MyComp` will render: - -```html -
- ... -
-``` - -### Rendering HTML attributes outside of templates - -If you need to use serialize HTML attributes outside of Django template and the `html_attrs` tag, you can use `attributes_to_string`: - -```py -from django_components.attributes import attributes_to_string - -attrs = { - "class": "my-class text-red pa-4", - "data-id": 123, - "required": True, - "disabled": False, - "ignored-attr": None, -} - -attributes_to_string(attrs) -# 'class="my-class text-red pa-4" data-id="123" required' -``` - -## Template tag syntax - -All template tags in django_component, like `{% component %}` or `{% slot %}`, and so on, -support extra syntax that makes it possible to write components like in Vue or React (JSX). - -### Self-closing tags - -When you have a tag like `{% component %}` or `{% slot %}`, but it has no content, you can simply append a forward slash `/` at the end, instead of writing out the closing tags like `{% endcomponent %}` or `{% endslot %}`: - -So this: - -```django -{% component "button" %}{% endcomponent %} -``` - -becomes - -```django -{% component "button" / %} -``` - -### Special characters - -_New in version 0.71_: - -Keyword arguments can contain special characters `# @ . - _`, so keywords like -so are still valid: - -```django - - {% component "calendar" my-date="2015-06-19" @click.native=do_something #some_id=True / %} - -``` - -These can then be accessed inside `get_context_data` so: - -```py -@register("calendar") -class Calendar(Component): - # Since # . @ - are not valid identifiers, we have to - # use `**kwargs` so the method can accept these args. - def get_context_data(self, **kwargs): - return { - "date": kwargs["my-date"], - "id": kwargs["#some_id"], - "on_click": kwargs["@click.native"] - } -``` - -### Spread operator - -_New in version 0.93_: - -Instead of passing keyword arguments one-by-one: - -```django -{% component "calendar" title="How to abc" date="2015-06-19" author="John Wick" / %} -``` - -You can use a spread operator `...dict` to apply key-value pairs from a dictionary: - -```py -post_data = { - "title": "How to...", - "date": "2015-06-19", - "author": "John Wick", -} -``` - -```django -{% component "calendar" ...post_data / %} -``` - -This behaves similar to [JSX's spread operator](https://kevinyckim33.medium.com/jsx-spread-operator-component-props-meaning-3c9bcadd2493) -or [Vue's `v-bind`](https://vuejs.org/api/built-in-directives.html#v-bind). - -Spread operators are treated as keyword arguments, which means that: -1. Spread operators must come after positional arguments. -2. You cannot use spread operators for [positional-only arguments](https://martinxpn.medium.com/positional-only-and-keyword-only-arguments-in-python-37-100-days-of-python-310c311657b0). - -Other than that, you can use spread operators multiple times, and even put keyword arguments in-between or after them: - -```django -{% component "calendar" ...post_data id=post.id ...extra / %} -``` - -In a case of conflicts, the values added later (right-most) overwrite previous values. - -### Use template tags inside component inputs - -_New in version 0.93_ - -When passing data around, sometimes you may need to do light transformations, like negating booleans or filtering lists. - -Normally, what you would have to do is to define ALL the variables -inside `get_context_data()`. But this can get messy if your components contain a lot of logic. - -```py -@register("calendar") -class Calendar(Component): - def get_context_data(self, id: str, editable: bool): - return { - "editable": editable, - "readonly": not editable, - "input_id": f"input-{id}", - "icon_id": f"icon-{id}", - ... - } -``` - -Instead, template tags in django_components (`{% component %}`, `{% slot %}`, `{% provide %}`, etc) allow you to treat literal string values as templates: - -```django -{% component 'blog_post' - "As positional arg {# yay #}" - title="{{ person.first_name }} {{ person.last_name }}" - id="{% random_int 10 20 %}" - readonly="{{ editable|not }}" - author="John Wick {# TODO: parametrize #}" -/ %} -``` - -In the example above: -- Component `test` receives a positional argument with value `"As positional arg "`. The comment is omitted. -- Kwarg `title` is passed as a string, e.g. `John Doe` -- Kwarg `id` is passed as `int`, e.g. `15` -- Kwarg `readonly` is passed as `bool`, e.g. `False` -- Kwarg `author` is passed as a string, e.g. `John Wick ` (Comment omitted) - -This is inspired by [django-cotton](https://github.com/wrabit/django-cotton#template-expressions-in-attributes). - -#### Passing data as string vs original values - -Sometimes you may want to use the template tags to transform -or generate the data that is then passed to the component. - -The data doesn't necessarily have to be strings. In the example above, the kwarg `id` was passed as an integer, NOT a string. - -Although the string literals for components inputs are treated as regular Django templates, there is one special case: - -When the string literal contains only a single template tag, with no extra text, then the value is passed as the original type instead of a string. - -Here, `page` is an integer: - -```django -{% component 'blog_post' page="{% random_int 10 20 %}" / %} -``` - -Here, `page` is a string: - -```django -{% component 'blog_post' page=" {% random_int 10 20 %} " / %} -``` - -And same applies to the `{{ }}` variable tags: - -Here, `items` is a list: - -```django -{% component 'cat_list' items="{{ cats|slice:':2' }}" / %} -``` - -Here, `items` is a string: - -```django -{% component 'cat_list' items="{{ cats|slice:':2' }} See more" / %} -``` - -#### Evaluating Python expressions in template - -You can even go a step further and have a similar experience to Vue or React, -where you can evaluate arbitrary code expressions: - -```jsx - -``` - -Similar is possible with [`django-expr`](https://pypi.org/project/django-expr/), which adds an `expr` tag and filter that you can use to evaluate Python expressions from within the template: - -```django -{% component "my_form" - value="{% expr 'input_value if is_enabled else None' %}" -/ %} -``` - -> Note: Never use this feature to mix business logic and template logic. Business logic should still be in the view! - -### Pass dictonary by its key-value pairs - -_New in version 0.74_: - -Sometimes, a component may expect a dictionary as one of its inputs. - -Most commonly, this happens when a component accepts a dictionary -of HTML attributes (usually called `attrs`) to pass to the underlying template. - -In such cases, we may want to define some HTML attributes statically, and other dynamically. -But for that, we need to define this dictionary on Python side: - -```py -@register("my_comp") -class MyComp(Component): - template = """ - {% component "other" attrs=attrs / %} - """ - - def get_context_data(self, some_id: str): - attrs = { - "class": "pa-4 flex", - "data-some-id": some_id, - "@click.stop": "onClickHandler", - } - return {"attrs": attrs} -``` - -But as you can see in the case above, the event handler `@click.stop` and styling `pa-4 flex` -are disconnected from the template. If the component grew in size and we moved the HTML -to a separate file, we would have hard time reasoning about the component's template. - -Luckily, there's a better way. - -When we want to pass a dictionary to a component, we can define individual key-value pairs -as component kwargs, so we can keep all the relevant information in the template. For that, -we prefix the key with the name of the dict and `:`. So key `class` of input `attrs` becomes -`attrs:class`. And our example becomes: - -```py -@register("my_comp") -class MyComp(Component): - template = """ - {% component "other" - attrs:class="pa-4 flex" - attrs:data-some-id=some_id - attrs:@click.stop="onClickHandler" - / %} - """ - - def get_context_data(self, some_id: str): - return {"some_id": some_id} -``` - -Sweet! Now all the relevant HTML is inside the template, and we can move it to a separate file with confidence: - -```django -{% component "other" - attrs:class="pa-4 flex" - attrs:data-some-id=some_id - attrs:@click.stop="onClickHandler" -/ %} -``` - -> Note: It is NOT possible to define nested dictionaries, so -> `attrs:my_key:two=2` would be interpreted as: -> -> ```py -> {"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_: - -Django components supports dependency injection with the combination of: - -1. `{% provide %}` tag -1. `inject()` method of the `Component` class - -### What is "dependency injection" and "prop drilling"? - -Prop drilling refers to a scenario in UI development where you need to pass data through many layers of a component tree to reach the nested components that actually need the data. - -Normally, you'd use props to send data from a parent component to its children. However, this straightforward method becomes cumbersome and inefficient if the data has to travel through many levels or if several components scattered at different depths all need the same piece of information. - -This results in a situation where the intermediate components, which don't need the data for their own functioning, end up having to manage and pass along these props. This clutters the component tree and makes the code verbose and harder to manage. - -A neat solution to avoid prop drilling is using the "provide and inject" technique, AKA dependency injection. - -With dependency injection, a parent component acts like a data hub for all its descendants. This setup allows any component, no matter how deeply nested it is, to access the required data directly from this centralized provider without having to messily pass props down the chain. This approach significantly cleans up the code and makes it easier to maintain. - -This feature is inspired by Vue's [Provide / Inject](https://vuejs.org/guide/components/provide-inject) and React's [Context / useContext](https://react.dev/learn/passing-data-deeply-with-context). - -### How to use provide / inject - -As the name suggest, using provide / inject consists of 2 steps - -1. Providing data -2. Injecting provided data - -For examples of advanced uses of provide / inject, [see this discussion](https://github.com/EmilStenstrom/django-components/pull/506#issuecomment-2132102584). - -### Using `{% provide %}` tag - -First we use the `{% provide %}` tag to define the data we want to "provide" (make available). - -```django -{% provide "my_data" key="hi" another=123 %} - {% component "child" / %} <--- Can access "my_data" -{% endprovide %} - -{% component "child" / %} <--- Cannot access "my_data" -``` - -Notice that the `provide` tag REQUIRES a name as a first argument. This is the _key_ by which we can then access the data passed to this tag. - -`provide` tag name must resolve to a valid identifier (AKA a valid Python variable name). - -Once you've set the name, you define the data you want to "provide" by passing it as keyword arguments. This is similar to how you pass data to the `{% with %}` tag. - -> NOTE: Kwargs passed to `{% provide %}` are NOT added to the context. -> In the example below, the `{{ key }}` won't render anything: -> -> ```django -> {% provide "my_data" key="hi" another=123 %} -> {{ key }} -> {% endprovide %} -> ``` - -Similarly to [slots and fills](#dynamic-slots-and-fills), also provide's name argument can be set dynamically via a variable, a template expression, or a spread operator: - -```django -{% provide name=name ... %} - ... -{% provide %} - -``` - -### Using `inject()` method - -To "inject" (access) the data defined on the `provide` tag, you can use the `inject()` method inside of `get_context_data()`. - -For a component to be able to "inject" some data, the component (`{% component %}` tag) must be nested inside the `{% provide %}` tag. - -In the example from previous section, we've defined two kwargs: `key="hi" another=123`. That means that if we now inject `"my_data"`, we get an object with 2 attributes - `key` and `another`. - -```py -class ChildComponent(Component): - def get_context_data(self): - my_data = self.inject("my_data") - print(my_data.key) # hi - print(my_data.another) # 123 - return {} -``` - -First argument to `inject` is the _key_ (or _name_) of the provided data. This -must match the string that you used in the `provide` tag. If no provider -with given key is found, `inject` raises a `KeyError`. - -To avoid the error, you can pass a second argument to `inject` to which will act as a default value, similar to `dict.get(key, default)`: - -```py -class ChildComponent(Component): - def get_context_data(self): - my_data = self.inject("invalid_key", DEFAULT_DATA) - assert my_data == DEFAUKT_DATA - return {} -``` - -The instance returned from `inject()` is a subclass of `NamedTuple`, so the instance is immutable. This ensures that the data returned from `inject` will always -have all the keys that were passed to the `provide` tag. - -> NOTE: `inject()` works strictly only in `get_context_data`. If you try to call it from elsewhere, it will raise an error. - -### Full example - -```py -@register("child") -class ChildComponent(Component): - template = """ -
{{ my_data.key }}
-
{{ my_data.another }}
- """ - - def get_context_data(self): - my_data = self.inject("my_data", "default") - return {"my_data": my_data} - -template_str = """ - {% load component_tags %} - {% provide "my_data" key="hi" another=123 %} - {% component "child" / %} - {% endprovide %} -""" -``` - -renders: - -```html -
hi
-
123
-``` - -## Component hooks - -_New in version 0.96_ - -Component hooks are functions that allow you to intercept the rendering process at specific positions. - -### Available hooks - -- `on_render_before` - - ```py - def on_render_before( - self: Component, - context: Context, - template: Template - ) -> None: - ``` - - Hook that runs just before the component's template is rendered. - - You can use this hook to access or modify the context or the template: - - ```py - def on_render_before(self, context, template) -> None: - # Insert value into the Context - context["from_on_before"] = ":)" - - # Append text into the Template - template.nodelist.append(TextNode("FROM_ON_BEFORE")) - ``` - -- `on_render_after` - - ```py - def on_render_after( - self: Component, - context: Context, - template: Template, - content: str - ) -> None | str | SafeString: - ``` - - Hook that runs just after the component's template was rendered. - It receives the rendered output as the last argument. - - You can use this hook to access the context or the template, but modifying - them won't have any effect. - - To override the content that gets rendered, you can return a string or SafeString from this hook: - - ```py - def on_render_after(self, context, template, content): - # Prepend text to the rendered content - return "Chocolate cookie recipe: " + content - ``` - -### Component hooks example - -You can use hooks together with [provide / inject](#how-to-use-provide--inject) to create components -that accept a list of items via a slot. - -In the example below, each `tab_item` component will be rendered on a separate tab page, but they are all defined in the default slot of the `tabs` component. - -[See here for how it was done](https://github.com/EmilStenstrom/django-components/discussions/540) - -```django -{% component "tabs" %} - {% component "tab_item" header="Tab 1" %} -

- hello from tab 1 -

- {% component "button" %} - Click me! - {% endcomponent %} - {% endcomponent %} - - {% component "tab_item" header="Tab 2" %} - Hello this is tab 2 - {% endcomponent %} -{% endcomponent %} -``` - -## Component context and scope - -By default, context variables are passed down the template as in regular Django - deeper scopes can access the variables from the outer scopes. So if you have several nested forloops, then inside the deep-most loop you can access variables defined by all previous loops. - -With this in mind, the `{% component %}` tag behaves similarly to `{% include %}` tag - inside the component tag, you can access all variables that were defined outside of it. - -And just like with `{% include %}`, if you don't want a specific component template to have access to the parent context, add `only` to the `{% component %}` tag: - -```htmldjango -{% component "calendar" date="2015-06-19" only / %} -``` - -NOTE: `{% csrf_token %}` tags need access to the top-level context, and they will not function properly if they are rendered in a component that is called with the `only` modifier. - -If you find yourself using the `only` modifier often, you can set the [context_behavior](#context-behavior) option to `"isolated"`, which automatically applies the `only` modifier. This is useful if you want to make sure that components don't accidentally access the outer context. - -Components can also access the outer context in their context methods like `get_context_data` by accessing the property `self.outer_context`. - -### Example of Accessing Outer Context - -```django -
- {% component "calender" / %} -
-``` - -Assuming that the rendering context has variables such as `date`, you can use `self.outer_context` to access them from within `get_context_data`. Here's how you might implement it: - -```python -class Calender(Component): - - ... - - def get_context_data(self): - outer_field = self.outer_context["date"] - return { - "date": outer_fields, - } -``` - -However, as a best practice, it’s recommended not to rely on accessing the outer context directly through `self.outer_context`. Instead, explicitly pass the variables to the component. For instance, continue passing the variables in the component tag as shown in the previous examples. - -## Pre-defined template variables - -Here is a list of all variables that are automatically available from within the component's template and `on_render_before` / `on_render_after` hooks. - -- `component_vars.is_filled` - - _New in version 0.70_ - - Dictonary describing which slots are filled (`True`) or are not (`False`). - - Example: - - ```django - {% if component_vars.is_filled.my_slot %} - {% slot "my_slot" / %} - {% endif %} - ``` - - This is equivalent to checking if a given key is among the slot fills: - - ```py - class MyTable(Component): - def get_context_data(self, *args, **kwargs): - return { - "my_slot_filled": "my_slot" in self.input.slots - } - ``` - -## Customizing component tags with TagFormatter - -_New in version 0.89_ - -By default, components are rendered using the pair of `{% component %}` / `{% endcomponent %}` template tags: - -```django -{% component "button" href="..." disabled %} -Click me! -{% endcomponent %} - -{# or #} - -{% component "button" href="..." disabled / %} -``` - -You can change this behaviour in the settings under the [`COMPONENTS.tag_formatter`](#tag-formatter-setting). - -For example, if you set the tag formatter to `django_components.component_shorthand_formatter`, the components will use their name as the template tags: - -```django -{% button href="..." disabled %} - Click me! -{% endbutton %} - -{# or #} - -{% button href="..." disabled / %} -``` - -### Available TagFormatters - -django_components provides following predefined TagFormatters: - -- **`ComponentFormatter` (`django_components.component_formatter`)** - - Default - - Uses the `component` and `endcomponent` tags, and the component name is gives as the first positional argument. - - Example as block: - ```django - {% component "button" href="..." %} - {% fill "content" %} - ... - {% endfill %} - {% endcomponent %} - ``` - - Example as inlined tag: - ```django - {% component "button" href="..." / %} - ``` - -- **`ShorthandComponentFormatter` (`django_components.component_shorthand_formatter`)** - - Uses the component name as start tag, and `end` - as an end tag. - - Example as block: - ```django - {% button href="..." %} - Click me! - {% endbutton %} - ``` - - Example as inlined tag: - ```django - {% button href="..." / %} - ``` - -### Writing your own TagFormatter - -#### Background - -First, let's discuss how TagFormatters work, and how components are rendered in django_components. - -When you render a component with `{% component %}` (or your own tag), the following happens: -1. `component` must be registered as a Django's template tag -2. Django triggers django_components's tag handler for tag `component`. -3. The tag handler passes the tag contents for pre-processing to `TagFormatter.parse()`. - - So if you render this: - ```django - {% component "button" href="..." disabled %} - {% endcomponent %} - ``` - - Then `TagFormatter.parse()` will receive a following input: - ```py - ["component", '"button"', 'href="..."', 'disabled'] - ``` -4. `TagFormatter` extracts the component name and the remaining input. - - So, given the above, `TagFormatter.parse()` returns the following: - ```py - TagResult( - component_name="button", - tokens=['href="..."', 'disabled'] - ) - ``` -5. The tag handler resumes, using the tokens returned from `TagFormatter`. - - So, continuing the example, at this point the tag handler practically behaves as if you rendered: - ```django - {% component href="..." disabled %} - ``` -6. Tag handler looks up the component `button`, and passes the args, kwargs, and slots to it. - -#### TagFormatter - -`TagFormatter` handles following parts of the process above: -- Generates start/end tags, given a component. This is what you then call from within your template as `{% component %}`. - -- When you `{% component %}`, tag formatter pre-processes the tag contents, so it can link back the custom template tag to the right component. - -To do so, subclass from `TagFormatterABC` and implement following method: -- `start_tag` -- `end_tag` -- `parse` - -For example, this is the implementation of [`ShorthandComponentFormatter`](#available-tagformatters) - -```py -class ShorthandComponentFormatter(TagFormatterABC): - # Given a component name, generate the start template tag - def start_tag(self, name: str) -> str: - return name # e.g. 'button' - - # Given a component name, generate the start template tag - def end_tag(self, name: str) -> str: - return f"end{name}" # e.g. 'endbutton' - - # Given a tag, e.g. - # `{% button href="..." disabled %}` - # - # The parser receives: - # `['button', 'href="..."', 'disabled']` - def parse(self, tokens: List[str]) -> TagResult: - tokens = [*tokens] - name = tokens.pop(0) - return TagResult( - name, # e.g. 'button' - tokens # e.g. ['href="..."', 'disabled'] - ) -``` - -That's it! And once your `TagFormatter` is ready, don't forget to update the settings! - -## Defining HTML/JS/CSS files - -django_component's management of files builds on top of [Django's `Media` class](https://docs.djangoproject.com/en/5.0/topics/forms/media/). - -To be familiar with how Django handles static files, we recommend reading also: - -- [How to manage static files (e.g. images, JavaScript, CSS)](https://docs.djangoproject.com/en/5.0/howto/static-files/) - -### Defining file paths relative to component or static dirs - -As seen in the [getting started example](#create-your-first-component), to associate HTML/JS/CSS -files with a component, you set them as `template_name`, `Media.js` and `Media.css` respectively: - -```py -# In a file [project root]/components/calendar/calendar.py -from django_components import Component, register - -@register("calendar") -class Calendar(Component): - template_name = "template.html" - - class Media: - css = "style.css" - js = "script.js" -``` - -In the example above, the files are defined relative to the directory where `component.py` is. - -Alternatively, you can specify the file paths relative to the directories set in `COMPONENTS.dirs` or `COMPONENTS.app_dirs`. - -Assuming that `COMPONENTS.dirs` contains path `[project root]/components`, we can rewrite the example as: - -```py -# In a file [project root]/components/calendar/calendar.py -from django_components import Component, register - -@register("calendar") -class Calendar(Component): - template_name = "calendar/template.html" - - class Media: - css = "calendar/style.css" - js = "calendar/script.js" -``` - -NOTE: In case of conflict, the preference goes to resolving the files relative to the component's directory. - -### Defining multiple paths - -Each component can have only a single template. However, you can define as many JS or CSS files as you want using a list. - -```py -class MyComponent(Component): - class Media: - js = ["path/to/script1.js", "path/to/script2.js"] - css = ["path/to/style1.css", "path/to/style2.css"] -``` - -### Configuring CSS Media Types - -You can define which stylesheets will be associated with which -[CSS Media types](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_media_queries/Using_media_queries#targeting_media_types). You do so by defining CSS files as a dictionary. - -See the corresponding [Django Documentation](https://docs.djangoproject.com/en/5.0/topics/forms/media/#css). - -Again, you can set either a single file or a list of files per media type: - -```py -class MyComponent(Component): - class Media: - css = { - "all": "path/to/style1.css", - "print": "path/to/style2.css", - } -``` - -```py -class MyComponent(Component): - class Media: - css = { - "all": ["path/to/style1.css", "path/to/style2.css"], - "print": ["path/to/style3.css", "path/to/style4.css"], - } -``` - -NOTE: When you define CSS as a string or a list, the `all` media type is implied. - -### Supported types for file paths - -File paths can be any of: - -- `str` -- `bytes` -- `PathLike` (`__fspath__` method) -- `SafeData` (`__html__` method) -- `Callable` that returns any of the above, evaluated at class creation (`__new__`) - -```py -from pathlib import Path - -from django.utils.safestring import mark_safe - -class SimpleComponent(Component): - class Media: - css = [ - mark_safe(''), - Path("calendar/style1.css"), - "calendar/style2.css", - b"calendar/style3.css", - lambda: "calendar/style4.css", - ] - js = [ - mark_safe(''), - Path("calendar/script1.js"), - "calendar/script2.js", - b"calendar/script3.js", - lambda: "calendar/script4.js", - ] -``` - -### Path as objects - -In the example [above](#supported-types-for-file-paths), you could see that when we used `mark_safe` to mark a string as a `SafeString`, we had to define the full `' - ) - -@register("calendar") -class Calendar(Component): - template_name = "calendar/template.html" - - def get_context_data(self, date): - return { - "date": date, - } - - class Media: - css = "calendar/style.css" - js = [ - # ', - self.absolute_path(path) - ) - return tags - -@register("calendar") -class Calendar(Component): - template_name = "calendar/template.html" - - class Media: - css = "calendar/style.css" - js = "calendar/script.js" - - # Override the behavior of Media class - media_class = MyMedia -``` - -NOTE: The instance of the `Media` class (or it's subclass) is available under `Component.media` after the class creation (`__new__`). - -## Rendering JS/CSS dependencies - -If: -1. Your components use JS and CSS, whether inlined via `Component.js/css` or via `Component.Media.js/css`, -2. And you use the `ComponentDependencyMiddleware` middleware - -Then, by default, the components' JS and CSS will be automatically inserted into the HTML: -- CSS styles will be inserted at the end of the `` -- JS scripts will be inserted at the end of the `` - -If you want to place the dependencies elsewhere, you can override that with following Django template tags: - -- `{% component_js_dependencies %}` - Renders only JS -- `{% component_css_dependencies %}` - Renders only CSS - -So if you have a component with JS and CSS: - -```py -from django_components import Component, types - -class MyButton(Component): - template: types.django_html = """ - - """ - js: types.js = """ - for (const btnEl of document.querySelectorAll(".my-button")) { - btnEl.addEventListener("click", () => { - console.log("BUTTON CLICKED!"); - }); - } - """ - css: types.css """ - .my-button { - background: green; - } - """ - - class Media: - js = ["/extra/script.js"] - css = ["/extra/style.css"] -``` - -Then the inlined JS and the scripts in `Media.js` will be rendered at the default place, -or in `{% component_js_dependencies %}`. - -And the inlined CSS and the styles in `Media.css` will be rendered at the default place, -or in `{% component_css_dependencies %}`. - -And if you don't specify `{% component_dependencies %}` tags, it is the equivalent of: - -```django - - - - MyPage - ... - {% component_css_dependencies %} - - -
- ... -
- {% component_js_dependencies %} - - -``` - -### Setting Up `ComponentDependencyMiddleware` - -`ComponentDependencyMiddleware` is a Django middleware designed to manage and inject CSS/JS dependencies for rendered components dynamically. It ensures that only the necessary stylesheets and scripts are loaded in your HTML responses, based on the components used in your Django templates. - -To set it up, add the middleware to your `MIDDLEWARE` in settings.py: - -```python -MIDDLEWARE = [ - # ... other middleware classes ... - 'django_components.middleware.ComponentDependencyMiddleware' - # ... other middleware classes ... -] -``` - -### `render_dependencies` and deep-dive into rendering JS / CSS without the middleware - -For most scenarios, using the `ComponentDependencyMiddleware` middleware will be just fine. - -However, this section is for you if you want to: -- Render HTML that will NOT be sent as a server response -- Insert pre-rendered HTML into another component -- Render HTML fragments (partials) - -Every time there is an HTML string that has parts which were rendered using components, -and any of those components has JS / CSS, then this HTML string MUST be processed with `render_dependencies`. - -It is actually `render_dependencies` that finds all used components in the HTML string, -and inserts the component's JS and CSS into `{% component_dependencies %}` tags, or at the default locations. - -#### Render JS / CSS without the middleware - -The `ComponentDependencyMiddleware` middleware just calls `render_dependencies`, passing in the HTML -content. So if you rendered a template that contained `{% components %}` tags, instead of the middleware, -you MUST pass the result through `render_dependencies`: - -```py -from django.template.base import Template -from django.template.context import Context -from django_component import render_dependencies - -template = Template(""" - {% load component_tags %} - - - - MyPage - - -
- {% component "my_button" %} - Click me! - {% endcomponent %} -
- - -""") - -rendered = template.render(Context({})) -rendered = render_dependencies(rendered) -``` - -Same applies if you render a template using Django's [`django.shortcuts.render`](https://docs.djangoproject.com/en/5.1/topics/http/shortcuts/#render): - -```py -from django.shortcuts import render - -def my_view(request): - rendered = render(request, "pages/home.html") - rendered = render_dependencies(rendered) - return rendered -``` - -Alternatively, when you render HTML with `Component.render()` or `Component.render_to_response()`, -these automatically call `render_dependencies()` for you, so you don't have to: - -```py -from django_components import Component - -class MyButton(Component): - ... - -# No need to call `render_dependencies()` -rendered = MyButton.render() -``` - -#### Inserting pre-rendered HTML into another component - -In previous section we've shown that `render_dependencies()` does NOT need to be called -when you render a component via `Component.render()`. - -API of django-components makes it possible to compose components in a "React-like" way, -where we pre-render a piece of HTML and then insert it into a larger structure. - -To do this, you must add `render_dependencies=False` to the nested components: - -```py -card_actions = CardActions.render( - kwargs={"editable": editable}, - render_dependencies=False, -) - -card = Card.render( - slots={"actions": card_actions}, - render_dependencies=False, -) - -page = MyPage.render( - slots={"card": card}, -) -``` - -Why is `render_dependencies=False` required? - -As mentioned earlier, each time we call `Component.render()`, we also call `render_dependencies()`. - -However, there is a problem here - When we call `render_dependencies()` inside `CardActions.render()`, -we extract the info on components' JS and CSS from the HTML. But the template of `CardActions` -contains no `{% component_depedencies %}` tags, and nor `` nor `` HTML tags. -So the component's JS and CSS will NOT be inserted, and will be lost. - -To work around this, you must set `render_dependencies=False` when rendering pieces of HTML with `Component.render()` -and inserting them into larger structures. - -## Available settings - -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" - "dirs": [BASE_DIR / "components"], # Root-level "components" dirs, e.g. `/path/to/proj/components/` - "app_dirs": ["components"], # App-level "components" dirs, e.g. `[app]/components/` - "dynamic_component_name": "dynamic", - "libraries": [], # ["mysite.components.forms", ...] - "multiline_tags": True, - "reload_on_template_change": False, - "static_files_allowed": [ - ".css", - ".js", - # Images - ".apng", ".png", ".avif", ".gif", ".jpg", - ".jpeg", ".jfif", ".pjpeg", ".pjp", ".svg", - ".webp", ".bmp", ".ico", ".cur", ".tif", ".tiff", - # Fonts - ".eot", ".ttf", ".woff", ".otf", ".svg", - ], - "static_files_forbidden": [ - ".html", ".django", ".dj", ".tpl", - # Python files - ".py", ".pyc", - ], - "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. - -```python -COMPONENTS = { - "libraries": [ - "mysite.components.forms", - "mysite.components.buttons", - "mysite.components.cards", - ], -} -``` - -Where `mysite/components/forms.py` may look like this: - -```py -@register("form_simple") -class FormSimple(Component): - template = """ -
- ... -
- """ - -@register("form_other") -class FormOther(Component): - template = """ -
- ... -
- """ -``` - -In the rare cases when you need to manually trigger the import of libraries, you can use the `import_libraries` function: - -```py -from django_components import import_libraries - -import_libraries() -``` - -### `autodiscover` - Toggle autodiscovery - -If you specify all the component locations with the setting above and have a lot of apps, you can (very) slightly speed things up by disabling autodiscovery. - -```python -COMPONENTS = { - "autodiscover": False, -} -``` - -### `dirs` - -Specify the directories that contain your components. - -Directories must be full paths, same as with STATICFILES_DIRS. - -These locations are searched during autodiscovery, or when you define HTML, JS, or CSS as -a separate file. - -```py -COMPONENTS = { - "dirs": [BASE_DIR / "components"], -} -``` - -### `app_dirs` - -Specify the app-level directories that contain your components. - -Directories must be relative to app, e.g.: - -```py -COMPONENTS = { - "app_dirs": ["my_comps"], # To search for [app]/my_comps -} -``` - -These locations are searched during autodiscovery, or when you define HTML, JS, or CSS as -a separate file. - -Each app will be searched for these directories. - -Set to empty list to disable app-level components: - -```py -COMPONENTS = { - "app_dirs": [], -} -``` - -### `dynamic_component_name` - -By default, the dynamic component is registered under the name `"dynamic"`. In case of a conflict, use this setting to change the name used for the dynamic components. - -```python -COMPONENTS = { - "dynamic_component_name": "new_dynamic", -} -``` - -### `multiline_tags` - Enable/Disable multiline support - -If `True`, template tags can span multiple lines. Default: `True` - -```python -COMPONENTS = { - "multiline_tags": True, -} -``` - -### `static_files_allowed` - -A list of regex patterns (as strings) that define which files within `COMPONENTS.dirs` and `COMPONENTS.app_dirs` -are treated as static files. - -If a file is matched against any of the patterns, it's considered a static file. Such files are collected -when running `collectstatic`, and can be accessed under the static file endpoint. - -You can also pass in compiled regexes (`re.Pattern`) for more advanced patterns. - -By default, JS, CSS, and common image and font file formats are considered static files: - -```python -COMPONENTS = { - "static_files_allowed": [ - "css", - "js", - # Images - ".apng", ".png", - ".avif", - ".gif", - ".jpg", ".jpeg", ".jfif", ".pjpeg", ".pjp", # JPEG - ".svg", - ".webp", ".bmp", - ".ico", ".cur", # ICO - ".tif", ".tiff", - # Fonts - ".eot", ".ttf", ".woff", ".otf", ".svg", - ], -} -``` - -### `static_files_forbidden` - -A list of suffixes that define which files within `COMPONENTS.dirs` and `COMPONENTS.app_dirs` -will NEVER be treated as static files. - -If a file is matched against any of the patterns, it will never be considered a static file, even if the file matches -a pattern in [`COMPONENTS.static_files_allowed`](#static_files_allowed). - -Use this setting together with `COMPONENTS.static_files_allowed` for a fine control over what files will be exposed. - -You can also pass in compiled regexes (`re.Pattern`) for more advanced patterns. - -By default, any HTML and Python are considered NOT static files: - -```python -COMPONENTS = { - "static_files_forbidden": [ - ".html", ".django", ".dj", ".tpl", ".py", ".pyc", - ], -} -``` - -### `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. - -By default the cache holds 128 component templates in memory, which should be enough for most sites. But if you have a lot of components, or if you are using the `template` method of a component to render lots of dynamic templates, you can increase this number. To remove the cache limit altogether and cache everything, set template_cache_size to `None`. - -```python -COMPONENTS = { - "template_cache_size": 256, -} -``` - -If you want add templates to the cache yourself, you can use `cached_template()`: - -```py -from django_components import cached_template - -cached_template("Variable: {{ variable }}") - -# You can optionally specify Template class, and other Template inputs: -class MyTemplate(Template): - pass - -cached_template( - "Variable: {{ variable }}", - template_cls=MyTemplate, - name=... - origin=... - engine=... -) -``` - -### `context_behavior` - Make components isolated (or not) - -> NOTE: `context_behavior` and `slot_context_behavior` options were merged in v0.70. -> -> If you are migrating from BEFORE v0.67, set `context_behavior` to `"django"`. From v0.67 to v0.78 (incl) the default value was `"isolated"`. -> -> For v0.79 and later, the default is again `"django"`. See the rationale for change [here](https://github.com/EmilStenstrom/django-components/issues/498). - -You can configure what variables are available inside the `{% fill %}` tags. See [Component context and scope](#component-context-and-scope). - -This has two modes: - -- `"django"` - Default - The default Django template behavior. - - Inside the `{% fill %}` tag, the context variables you can access are a union of: - - - All the variables that were OUTSIDE the fill tag, including any loops or with tag - - Data returned from `get_context_data()` of the component that wraps the fill tag. - -- `"isolated"` - Similar behavior to [Vue](https://vuejs.org/guide/components/slots.html#render-scope) or React, this is useful if you want to make sure that components don't accidentally access variables defined outside of the component. - - Inside the `{% fill %}` tag, you can ONLY access variables from 2 places: - - - `get_context_data()` of the component which defined the template (AKA the "root" component) - - Any loops (`{% for ... %}`) that the `{% fill %}` tag is part of. - -```python -COMPONENTS = { - "context_behavior": "isolated", -} -``` - -#### Example "django" - -Given this template: - -```py -class RootComp(Component): - template = """ - {% with cheese="feta" %} - {% component 'my_comp' %} - {{ my_var }} # my_var - {{ cheese }} # cheese - {% endcomponent %} - {% endwith %} - """ - def get_context_data(self): - return { "my_var": 123 } -``` - -Then if `get_context_data()` of the component `"my_comp"` returns following data: - -```py -{ "my_var": 456 } -``` - -Then the template will be rendered as: - -```django -456 # my_var -feta # cheese -``` - -Because `"my_comp"` overshadows the variable `"my_var"`, -so `{{ my_var }}` equals `456`. - -And variable `"cheese"` equals `feta`, because the fill CAN access -all the data defined in the outer layers, like the `{% with %}` tag. - -#### Example "isolated" - -Given this template: - -```py -class RootComp(Component): - template = """ - {% with cheese="feta" %} - {% component 'my_comp' %} - {{ my_var }} # my_var - {{ cheese }} # cheese - {% endcomponent %} - {% endwith %} - """ - def get_context_data(self): - return { "my_var": 123 } -``` - -Then if `get_context_data()` of the component `"my_comp"` returns following data: - -```py -{ "my_var": 456 } -``` - -Then the template will be rendered as: - -```django -123 # my_var - # cheese -``` - -Because variables `"my_var"` and `"cheese"` are searched only inside `RootComponent.get_context_data()`. -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). - -Can be set either as direct reference, or as an import string; - -```py -COMPONENTS = { - "tag_formatter": "django_components.component_formatter" -} -``` - -Or - -```py -from django_components import component_formatter - -COMPONENTS = { - "tag_formatter": component_formatter -} -``` - -## 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. - -To configure logging for Django components, set the `django_components` logger in `LOGGING` in `settings.py` (below). - -Also see the [`settings.py` file in sampleproject](https://github.com/EmilStenstrom/django-components/blob/master/sampleproject/sampleproject/settings.py) for a real-life example. - -```py -import logging -import sys - -LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - "handlers": { - "console": { - 'class': 'logging.StreamHandler', - 'stream': sys.stdout, - }, - }, - "loggers": { - "django_components": { - "level": logging.DEBUG, - "handlers": ["console"], - }, - }, -} -``` - -## Management Command - -You can use the built-in management command `startcomponent` to create a django component. The command accepts the following arguments and options: - -- `name`: The name of the component to create. This is a required argument. - -- `--path`: The path to the components directory. This is an optional argument. If not provided, the command will use the `BASE_DIR` setting from your Django settings. - -- `--js`: The name of the JavaScript file. This is an optional argument. The default value is `script.js`. - -- `--css`: The name of the CSS file. This is an optional argument. The default value is `style.css`. - -- `--template`: The name of the template file. This is an optional argument. The default value is `template.html`. - -- `--force`: This option allows you to overwrite existing files if they exist. This is an optional argument. - -- `--verbose`: This option allows the command to print additional information during component creation. This is an optional argument. - -- `--dry-run`: This option allows you to simulate component creation without actually creating any files. This is an optional argument. The default value is `False`. - -### Management Command Usage - -To use the command, run the following command in your terminal: - -```bash -python manage.py startcomponent --path --js --css --template --force --verbose --dry-run -``` - -Replace ``, ``, ``, ``, and `` with your desired values. - -### Management Command Examples - -Here are some examples of how you can use the command: - -### Creating a Component with Default Settings - -To create a component with the default settings, you only need to provide the name of the component: - -```bash -python manage.py startcomponent my_component -``` - -This will create a new component named `my_component` in the `components` directory of your Django project. The JavaScript, CSS, and template files will be named `script.js`, `style.css`, and `template.html`, respectively. - -### Creating a Component with Custom Settings - -You can also create a component with custom settings by providing additional arguments: - -```bash -python manage.py startcomponent new_component --path my_components --js my_script.js --css my_style.css --template my_template.html -``` - -This will create a new component named `new_component` in the `my_components` directory. The JavaScript, CSS, and template files will be named `my_script.js`, `my_style.css`, and `my_template.html`, respectively. - -### Overwriting an Existing Component - -If you want to overwrite an existing component, you can use the `--force` option: - -```bash -python manage.py startcomponent my_component --force -``` - -This will overwrite the existing `my_component` if it exists. - -### Simulating Component Creation - -If you want to simulate the creation of a component without actually creating any files, you can use the `--dry-run` option: - -```bash -python manage.py startcomponent my_component --dry-run -``` - -This will simulate the creation of `my_component` without creating any files. - -## Writing and sharing component libraries - -You can publish and share your components for others to use. Here are the steps to do so: - -### Writing component libraries - -1. Create a Django project with the following structure: - - ```txt - project/ - |-- myapp/ - |-- __init__.py - |-- apps.py - |-- templates/ - |-- table/ - |-- table.py - |-- table.js - |-- table.css - |-- table.html - |-- menu.py <--- single-file component - |-- templatetags/ - |-- __init__.py - |-- mytags.py - ``` - -2. Create custom `Library` and `ComponentRegistry` instances in `mytags.py` - - This will be the entrypoint for using the components inside Django templates. - - Remember that Django requires the `Library` instance to be accessible under the `register` variable ([See Django docs](https://docs.djangoproject.com/en/dev/howto/custom-template-tags)): - - ```py - from django.template import Library - from django_components import ComponentRegistry, RegistrySettings - - register = library = django.template.Library() - comp_registry = ComponentRegistry( - library=library, - settings=RegistrySettings( - context_behavior="isolated", - tag_formatter="django_components.component_formatter", - ), - ) - ``` - - As you can see above, this is also the place where we configure how our components should behave, using the `settings` argument. If omitted, default settings are used. - - For library authors, we recommend setting `context_behavior` to `"isolated"`, so that the state cannot leak into the components, and so the components' behavior is configured solely through the inputs. This means that the components will be more predictable and easier to debug. - - Next, you can decide how will others use your components by settingt the `tag_formatter` options. - - If omitted or set to `"django_components.component_formatter"`, - your components will be used like this: - - ```django - {% component "table" items=items headers=headers %} - {% endcomponent %} - ``` - - Or you can use `"django_components.component_shorthand_formatter"` - to use components like so: - - ```django - {% table items=items headers=headers %} - {% endtable %} - ``` - - Or you can define a [custom TagFormatter](#tagformatter). - - Either way, these settings will be scoped only to your components. So, in the user code, there may be components side-by-side that use different formatters: - - ```django - {% load mytags %} - - {# Component from your library "mytags", using the "shorthand" formatter #} - {% table items=items headers=header %} - {% endtable %} - - {# User-created components using the default settings #} - {% component "my_comp" title="Abc..." %} - {% endcomponent %} - ``` - -3. Write your components and register them with your instance of `ComponentRegistry` - - There's one difference when you are writing components that are to be shared, and that's that the components must be explicitly registered with your instance of `ComponentRegistry` from the previous step. - - For better user experience, you can also define the types for the args, kwargs, slots and data. - - It's also a good idea to have a common prefix for your components, so they can be easily distinguished from users' components. In the example below, we use the prefix `my_` / `My`. - - ```py - from typing import Dict, NotRequired, Optional, Tuple, TypedDict - - from django_components import Component, SlotFunc, register, types - - from myapp.templatetags.mytags import comp_registry - - # Define the types - class EmptyDict(TypedDict): - pass - - type MyMenuArgs = Tuple[int, str] - - class MyMenuSlots(TypedDict): - default: NotRequired[Optional[SlotFunc[EmptyDict]]] - - class MyMenuProps(TypedDict): - vertical: NotRequired[bool] - klass: NotRequired[str] - style: NotRequired[str] - - # Define the component - # NOTE: Don't forget to set the `registry`! - @register("my_menu", registry=comp_registry) - class MyMenu(Component[MyMenuArgs, MyMenuProps, MyMenuSlots, Any, Any, Any]): - def get_context_data( - self, - *args, - attrs: Optional[Dict] = None, - ): - return { - "attrs": attrs, - } - - template: types.django_html = """ - {# Load django_components template tags #} - {% load component_tags %} - -
-
- {% slot "default" default / %} -
-
- """ - ``` - -4. Import the components in `apps.py` - - Normally, users rely on [autodiscovery](#autodiscovery) and `COMPONENTS.dirs` to load the component files. - - Since you, as the library author, are not in control of the file system, it is recommended to load the components manually. - - We recommend doing this in the `AppConfig.ready()` hook of your `apps.py`: - - ```py - from django.apps import AppConfig - - class MyAppConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "myapp" - - # This is the code that gets run when user adds myapp - # to Django's INSTALLED_APPS - def ready(self) -> None: - # Import the components that you want to make available - # inside the templates. - from myapp.templates import ( - menu, - table, - ) - ``` - - Note that you can also include any other startup logic within `AppConfig.ready()`. - -And that's it! The next step is to publish it. - -### Publishing component libraries - -Once you are ready to share your library, you need to build -a distribution and then publish it to PyPI. - -django_components uses the [`build`](https://build.pypa.io/en/stable/) utility to build a distribution: - -```bash -python -m build --sdist --wheel --outdir dist/ . -``` - -And to publish to PyPI, you can use `twine` ([See Python user guide](https://packaging.python.org/en/latest/tutorials/packaging-projects/#uploading-the-distribution-archives)) - -```bash -twine upload --repository pypi dist/* -u __token__ -p -``` - -Notes on publishing: -- The user of the package NEEDS to have installed and configured `django_components`. -- If you use components where the HTML / CSS / JS files are separate, you may need to define `MANIFEST.in` to include those files with the distribution ([see user guide](https://setuptools.pypa.io/en/latest/userguide/miscellaneous.html)). - -### Installing and using component libraries - -After the package has been published, all that remains is to install it in other django projects: - -1. Install the package: - - ```bash - pip install myapp - ``` - -2. Add the package to `INSTALLED_APPS` - - ```py - INSTALLED_APPS = [ - ... - "myapp", - ] - ``` - -3. Optionally add the template tags to the `builtins`, so you don't have to call `{% load mytags %}` in every template: - - ```py - TEMPLATES = [ - { - ..., - 'OPTIONS': { - 'context_processors': [ - ... - ], - 'builtins': [ - 'myapp.templatetags.mytags', - ] - }, - }, - ] - ``` - -4. And, at last, you can use the components in your own project! - - ```django - {% my_menu title="Abc..." %} - Hello World! - {% endmy_menu %} - ``` - ## Community examples One of our goals with `django-components` is to make it easy to share components between projects. If you have a set of components that you think would be useful to others, please open a pull request to add them to the list below. @@ -4120,140 +72,6 @@ One of our goals with `django-components` is to make it easy to share components ## Contributing and development -### Install locally and run the tests +Get involved or sponsor this project - [See here](https://emilstenstrom.github.io/django-components/dev/overview/contributing/) -Start by forking the project by clicking the **Fork button** up in the right corner in the GitHub . This makes a copy of the repository in your own name. Now you can clone this repository locally and start adding features: - -```sh -git clone https://github.com//django-components.git -``` - -To quickly run the tests install the local dependencies by running: - -```sh -pip install -r requirements-dev.txt -``` - -Now you can run the tests to make sure everything works as expected: - -```sh -pytest -``` - -The library is also tested across many versions of Python and Django. To run tests that way: - -```sh -pyenv install -s 3.8 -pyenv install -s 3.9 -pyenv install -s 3.10 -pyenv install -s 3.11 -pyenv install -s 3.12 -pyenv local 3.8 3.9 3.10 3.11 3.12 -tox -p -``` - -### Running Playwright tests - -We use [Playwright](https://playwright.dev/python/docs/intro) for end-to-end tests. You will therefore need to install Playwright to be able to run these tests. - -Luckily, Playwright makes it very easy: - -```sh -pip install -r requirements-dev.txt -playwright install chromium --with-deps -``` - -After Playwright is ready, simply run the tests with `tox`: -```sh -tox -``` - -### Developing against live Django app - -How do you check that your changes to django-components project will work in an actual Django project? - -Use the [sampleproject](https://github.com/EmilStenstrom/django-components/tree/master/sampleproject/) demo project to validate the changes: - -1. Navigate to [sampleproject](https://github.com/EmilStenstrom/django-components/tree/master/sampleproject/) directory: - - ```sh - cd sampleproject - ``` - -2. Install dependencies from the [requirements.txt](https://github.com/EmilStenstrom/django-components/blob/master/sampleproject/requirements.txt) file: - - ```sh - pip install -r requirements.txt - ``` - -3. Link to your local version of django-components: - - ```sh - pip install -e .. - ``` - - NOTE: The path (in this case `..`) must point to the directory that has the `setup.py` file. - -4. Start Django server - ```sh - python manage.py runserver - ``` - -Once the server is up, it should be available at . - -To display individual components, add them to the `urls.py`, like in the case of - -### Building JS code - -django_components uses a bit of JS code to: -- Manage the loading of JS and CSS files used by the components -- Allow to pass data from Python to JS - -When you make changes to this JS code, you also need to compile it: - -1. Make sure you are inside `src/django_components_js`: - -```sh -cd src/django_components_js -``` - -2. Install the JS dependencies - -```sh -npm install -``` - -3. Compile the JS/TS code: - -```sh -python build.py -``` - -The script will combine all JS/TS code into a single `.js` file, minify it, -and copy it to `django_components/static/django_components/django_components.min.js`. - -### Packaging and publishing - -To package the library into a distribution that can be published to PyPI, run: - -```sh -# Install pypa/build -python -m pip install build --user -# Build a binary wheel and a source tarball -python -m build --sdist --wheel --outdir dist/ . -``` - -To publish the package to PyPI, use `twine` ([See Python user guide](https://packaging.python.org/en/latest/tutorials/packaging-projects/#uploading-the-distribution-archives)): -```sh -twine upload --repository pypi dist/* -u __token__ -p -``` - -[See the full workflow here.](https://github.com/EmilStenstrom/django-components/discussions/557#discussioncomment-10179141) - -### Development guides - -Deep dive into how django_components' features are implemented. - -- [Slot rendering](https://github.com/EmilStenstrom/django-components/blob/master/docs/devguides/slot_rendering.md) -- [Slots and blocks](https://github.com/EmilStenstrom/django-components/blob/master/docs/devguides/slots_and_blocks.md) -- [JS and CSS dependency management](https://github.com/EmilStenstrom/django-components/blob/master/docs/devguides/dependency_mgmt.md) +Running django-components locally for development - [See here](https://emilstenstrom.github.io/django-components/dev/overview/development/) diff --git a/docs/overview/welcome.md b/docs/overview/welcome.md index 85465a8c..3d6ab46e 100644 --- a/docs/overview/welcome.md +++ b/docs/overview/welcome.md @@ -73,3 +73,9 @@ to see the latest features and fixes. One of our goals with `django-components` is to make it easy to share components between projects. If you have a set of components that you think would be useful to others, please open a pull request to add them to the list below. - [django-htmx-components](https://github.com/iwanalabs/django-htmx-components): A set of components for use with [htmx](https://htmx.org/). Try out the [live demo](https://dhc.iwanalabs.com/). + +## Contributing and development + +Get involved or sponsor this project - [See here](https://emilstenstrom.github.io/django-components/dev/overview/contributing/) + +Running django-components locally for development - [See here](https://emilstenstrom.github.io/django-components/dev/overview/development/) From be27c1c94d44e778fb189b877089a58209db00d1 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Fri, 6 Dec 2024 08:22:27 +0100 Subject: [PATCH 133/487] fix: Fix broken JS execution order (#821) * fix: fix broken js exec order * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * refactor: remove stale comment --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- docs/devguides/dependency_mgmt.md | 8 +- src/django_components/dependencies.py | 177 ++++++----- .../django_components.min.js | 2 +- src/django_components_js/README.md | 4 +- src/django_components_js/src/manager.ts | 157 +++++++--- .../testserver/components/__init__.py | 62 ++++ .../testserver/static/alpine_test.js | 5 + .../testserver/static/check_script_order.js | 16 + tests/e2e/testserver/testserver/urls.py | 24 +- tests/e2e/testserver/testserver/views.py | 161 ++++++++++ tests/test_component_media.py | 155 ++-------- tests/test_dependencies.py | 4 +- tests/test_dependency_manager.py | 29 +- tests/test_dependency_rendering.py | 279 +++++++++++------- tests/test_dependency_rendering_e2e.py | 129 ++++++++ 15 files changed, 833 insertions(+), 379 deletions(-) create mode 100644 tests/e2e/testserver/testserver/static/alpine_test.js create mode 100644 tests/e2e/testserver/testserver/static/check_script_order.js diff --git a/docs/devguides/dependency_mgmt.md b/docs/devguides/dependency_mgmt.md index 0246b52e..d63e391d 100644 --- a/docs/devguides/dependency_mgmt.md +++ b/docs/devguides/dependency_mgmt.md @@ -189,15 +189,15 @@ This is how we achieve that: ```js // Load JS or CSS script if not loaded already - Components.loadScript("js", '" + + elif script_type == "css": + return f"" + + return script + + +def get_script_url( + script_type: ScriptType, + comp_cls: Type["Component"], +) -> str: comp_cls_hash = _hash_comp_cls(comp_cls) - if type == "url": - # NOTE: To make sure that Media object won't modify the URLs, we need to - # resolve the full path (`/abc/def/etc`), not just the file name. - script = reverse( - CACHE_ENDPOINT_NAME, - kwargs={ - "comp_cls_hash": comp_cls_hash, - "script_type": script_type, - }, - ) - else: - cache_key = _gen_cache_key(comp_cls_hash, script_type) - script = comp_media_cache.get(cache_key) - - if script_type == "js": - script = mark_safe(f"") - elif script_type == "css": - script = mark_safe(f"") - return script + return reverse( + CACHE_ENDPOINT_NAME, + kwargs={ + "comp_cls_hash": comp_cls_hash, + "script_type": script_type, + }, + ) def _gen_exec_script( @@ -658,9 +695,9 @@ def _gen_exec_script( # Generate JS expression like so: # ```js # Promise.all([ - # Components.manager.loadScript("js", ''), - # Components.manager.loadScript("js", ''), - # Components.manager.loadScript("css", ''), + # Components.manager.loadJs(''), + # Components.manager.loadJs(''), + # Components.manager.loadCss(''), # ]); # ``` # @@ -672,13 +709,9 @@ def _gen_exec_script( # Components.manager.markScriptLoaded("js", "/abc/def3.js"), # ``` # - # NOTE: It would be better to pass only the URL itself for `loadScript`, instead of a whole tag. - # But because we allow users to specify the Media class, and thus users can - # configure how the `` or `" + # NOTE: The exec script MUST be executed SYNC, so we MUST NOT put `type="module"`, + # `async`, nor `defer` on it. + # See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#notes + exec_script = f"" return exec_script diff --git a/src/django_components/static/django_components/django_components.min.js b/src/django_components/static/django_components/django_components.min.js index 402af70c..876e51ab 100644 --- a/src/django_components/static/django_components/django_components.min.js +++ b/src/django_components/static/django_components/django_components.min.js @@ -1 +1 @@ -(()=>{var y=Array.isArray,p=t=>typeof t=="function",M=t=>t!==null&&typeof t=="object",h=t=>(M(t)||p(t))&&p(t.then)&&p(t.catch);function S(t,a){try{return a?t.apply(null,a):t()}catch(r){f(r)}}function m(t,a){if(p(t)){let r=S(t,a);return r&&h(r)&&r.catch(i=>{f(i)}),[r]}if(y(t)){let r=[];for(let i=0;i{let t=new Set,a=new Set,r={},i={},C=e=>{let n=new DOMParser().parseFromString(e,"text/html").querySelector("script");if(!n)throw Error("[Components] Failed to extract '); + * Components.loadJs(''); + * ``` + * + * ```js + * Components.loadCss(''); * ``` * * ```js @@ -84,68 +88,104 @@ export const createComponentsManager = () => { // one to the other. // Might be related to https://security.stackexchange.com/a/240362/302733 // See https://stackoverflow.com/questions/13121948 - const cloneNode = (srcNode: HTMLElement) => { - const targetNode = document.createElement(srcNode.tagName); + const cloneNode = (srcNode: T): T => { + const targetNode = document.createElement(srcNode.tagName) as T; + targetNode.innerHTML = srcNode.innerHTML; for (const attr of srcNode.attributes) { targetNode.setAttributeNode(attr.cloneNode() as Attr); } return targetNode; }; - const loadScript = (type: ScriptType, tag: string) => { - if (type === 'js') { - const srcScriptNode = parseScriptTag(tag); + const loadJs = (tag: string) => { + const srcScriptNode = parseScriptTag(tag); - // Use `.getAttribute()` instead of `.src` so we get the value as is, - // without the host name prepended if URL is just a path. - const src = srcScriptNode.getAttribute('src'); - if (!src || loadedJs.has(src)) return; + // Use `.getAttribute()` instead of `.src` so we get the value as is, + // without the host name prepended if URL is just a path. + const src = srcScriptNode.getAttribute('src'); + if (!src || isScriptLoaded('js', src)) return; - loadedJs.add(src); + markScriptLoaded('js', src); - const targetScriptNode = cloneNode(srcScriptNode); + const targetScriptNode = cloneNode(srcScriptNode); - // In case of JS scripts, we return a Promise that resolves when the script is loaded - // See https://stackoverflow.com/a/57267538/9788634 - return new Promise((resolve, reject) => { - targetScriptNode.onload = () => { - resolve(); - }; + const isAsync = ( + // NOTE: `async` and `defer` are boolean attributes, so their value can be + // an empty string, hence the `!= null` check. + // Read more on https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script + srcScriptNode.getAttribute('async') != null + || srcScriptNode.getAttribute('defer') != null + || srcScriptNode.getAttribute('type') === 'module' + ); - // Insert the script at the end of to follow convention - globalThis.document.body.append(targetScriptNode); - }); - } else if (type === 'css') { - const linkNode = parseLinkTag(tag); - // NOTE: Use `.getAttribute()` instead of `.href` so we get the value as is, - // without the host name prepended if URL is just a path. - const href = linkNode.getAttribute('href'); - if (!href || loadedCss.has(href)) return; + // Setting this to `false` ensures that the loading and execution of the script is "blocking", + // meaning that the next script in line will wait until this one is done. + // See https://stackoverflow.com/a/21550322/9788634 + targetScriptNode.async = isAsync; - // Insert at the end of to follow convention - const targetLinkNode = cloneNode(linkNode); - globalThis.document.head.append(targetLinkNode); - loadedCss.add(href); + // In case of JS scripts, we return a Promise that resolves when the script is loaded + // See https://stackoverflow.com/a/57267538/9788634 + const promise = new Promise((resolve, reject) => { + targetScriptNode.onload = () => { + resolve(); + }; - // For CSS, we return a dummy Promise, since we don't need to wait for anything - return Promise.resolve(); - } else { - throw Error( - `[Components] loadScript received invalid script type '${type}'. Must be one of 'js', 'css'` - ); - } + // Insert at the end of `` to follow convention + // + // NOTE: Because we are inserting the script into the DOM from within JS, + // the order of execution of the inserted scripts behaves a bit different: + // - The ` + + + {% component 'alpine_test_in_media' / %} + {% component_js_dependencies %} + + + """ + template = Template(template_str) + rendered_raw = template.render(Context({})) + rendered = render_dependencies(rendered_raw) + return HttpResponse(rendered) + + +def alpine_in_body_view(request): + template_str: types.django_html = """ + {% load component_tags %} + + + + {% component_css_dependencies %} + + + {% component 'alpine_test_in_media' / %} + {% component_js_dependencies %} + + + + """ + template = Template(template_str) + rendered_raw = template.render(Context({})) + rendered = render_dependencies(rendered_raw) + return HttpResponse(rendered) + + +# Same as before, but Alpine component defined in Component.js +def alpine_in_body_view_2(request): + template_str: types.django_html = """ + {% load component_tags %} + + + + {% component_css_dependencies %} + + + {% component 'alpine_test_in_js' / %} + {% component_js_dependencies %} + + + + """ + template = Template(template_str) + rendered_raw = template.render(Context({})) + rendered = render_dependencies(rendered_raw) + return HttpResponse(rendered) + + +def alpine_in_body_vars_not_available_before_view(request): + template_str: types.django_html = """ + {% load component_tags %} + + + + {% component_css_dependencies %} + + + {% component 'alpine_test_in_js' / %} + {# Alpine loaded BEFORE components JS #} + + {% component_js_dependencies %} + + + """ + template = Template(template_str) + rendered_raw = template.render(Context({})) + rendered = render_dependencies(rendered_raw) + return HttpResponse(rendered) diff --git a/tests/test_component_media.py b/tests/test_component_media.py index 3813eda8..8121ece4 100644 --- a/tests/test_component_media.py +++ b/tests/test_component_media.py @@ -125,11 +125,7 @@ class ComponentMediaTests(BaseTestCase): self.assertInHTML('', rendered) self.assertInHTML('', rendered) - # Command to load the JS from Media.js - self.assertIn( - "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script.js&quot;&gt;&lt;/script&gt;\\`)", - rendered, - ) + self.assertInHTML('', rendered) def test_css_js_as_string(self): class SimpleComponent(Component): @@ -146,12 +142,7 @@ class ComponentMediaTests(BaseTestCase): rendered = SimpleComponent.render() self.assertInHTML('', rendered) - - # Command to load the JS from Media.js - self.assertIn( - "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script.js&quot;&gt;&lt;/script&gt;\\`)", - rendered, - ) + self.assertInHTML('', rendered) def test_css_as_dict(self): class SimpleComponent(Component): @@ -175,11 +166,7 @@ class ComponentMediaTests(BaseTestCase): self.assertInHTML('', rendered) self.assertInHTML('', rendered) - # Command to load the JS from Media.js - self.assertIn( - "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script.js&quot;&gt;&lt;/script&gt;\\`)", - rendered, - ) + self.assertInHTML('', rendered) def test_media_custom_render_js(self): class MyMedia(Media): @@ -204,15 +191,8 @@ class ComponentMediaTests(BaseTestCase): rendered = SimpleComponent.render() - # Command to load the JS from Media.js - self.assertIn( - "Components.unescapeJs(\\`&lt;script defer src=&quot;path/to/script.js&quot;&gt;&lt;/script&gt;\\`)", - rendered, - ) - self.assertIn( - "Components.unescapeJs(\\`&lt;script defer src=&quot;path/to/script2.js&quot;&gt;&lt;/script&gt;\\`)", - rendered, - ) + self.assertIn('', rendered) + self.assertIn('', rendered) def test_media_custom_render_css(self): class MyMedia(Media): @@ -314,23 +294,10 @@ class MediaPathAsObjectTests(BaseTestCase): self.assertInHTML('', rendered) self.assertInHTML('', rendered) - # Command to load the JS from Media.js - self.assertIn( - "Components.unescapeJs(\\`&lt;script js_tag src=&quot;path/to/script.js&quot; type=&quot;module&quot;&gt;&lt;/script&gt;\\`)", - rendered, - ) - self.assertIn( - "Components.unescapeJs(\\`&lt;script hi src=&quot;path/to/script2.js&quot;&gt;&lt;/script&gt;\\`)", - rendered, - ) - self.assertIn( - "Components.unescapeJs(\\`&lt;script type=&quot;module&quot; src=&quot;path/to/script3.js&quot;&gt;&lt;/script&gt;\\`)", - rendered, - ) - self.assertIn( - "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script4.js&quot;&gt;&lt;/script&gt;\\`)", - rendered, - ) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) def test_pathlike(self): """ @@ -376,19 +343,9 @@ class MediaPathAsObjectTests(BaseTestCase): self.assertInHTML('', rendered) self.assertInHTML('', rendered) - # Command to load the JS from Media.js - self.assertIn( - "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script.js&quot;&gt;&lt;/script&gt;\\`)", - rendered, - ) - self.assertIn( - "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script2.js&quot;&gt;&lt;/script&gt;\\`)", - rendered, - ) - self.assertIn( - "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script3.js&quot;&gt;&lt;/script&gt;\\`)", - rendered, - ) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) def test_str(self): """ @@ -429,15 +386,8 @@ class MediaPathAsObjectTests(BaseTestCase): self.assertInHTML('', rendered) self.assertInHTML('', rendered) - # Command to load the JS from Media.js - self.assertIn( - "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script.js&quot;&gt;&lt;/script&gt;\\`)", - rendered, - ) - self.assertIn( - "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script2.js&quot;&gt;&lt;/script&gt;\\`)", - rendered, - ) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) def test_bytes(self): """ @@ -478,15 +428,8 @@ class MediaPathAsObjectTests(BaseTestCase): self.assertInHTML('', rendered) self.assertInHTML('', rendered) - # Command to load the JS from Media.js - self.assertIn( - "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script.js&quot;&gt;&lt;/script&gt;\\`)", - rendered, - ) - self.assertIn( - "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script2.js&quot;&gt;&lt;/script&gt;\\`)", - rendered, - ) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) def test_function(self): class SimpleComponent(Component): @@ -517,23 +460,10 @@ class MediaPathAsObjectTests(BaseTestCase): self.assertInHTML('', rendered) self.assertInHTML('', rendered) - # Command to load the JS from Media.js - self.assertIn( - "Components.unescapeJs(\\`&lt;script hi src=&quot;calendar/script.js&quot;&gt;&lt;/script&gt;\\`)", - rendered, - ) - self.assertIn( - "Components.unescapeJs(\\`&lt;script src=&quot;calendar/script1.js&quot;&gt;&lt;/script&gt;\\`)", - rendered, - ) - self.assertIn( - "Components.unescapeJs(\\`&lt;script src=&quot;calendar/script2.js&quot;&gt;&lt;/script&gt;\\`)", - rendered, - ) - self.assertIn( - "Components.unescapeJs(\\`&lt;script src=&quot;calendar/script3.js&quot;&gt;&lt;/script&gt;\\`)", - rendered, - ) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) @override_settings(STATIC_URL="static/") def test_works_with_static(self): @@ -570,23 +500,10 @@ class MediaPathAsObjectTests(BaseTestCase): self.assertInHTML('', rendered) self.assertInHTML('', rendered) - # Command to load the JS from Media.js - self.assertIn( - "Components.unescapeJs(\\`&lt;script hi src=&quot;/static/calendar/script.js&quot;&gt;&lt;/script&gt;\\`)", - rendered, - ) - self.assertIn( - "Components.unescapeJs(\\`&lt;script src=&quot;/static/calendar/script1.js&quot;&gt;&lt;/script&gt;\\`)", - rendered, - ) - self.assertIn( - "Components.unescapeJs(\\`&lt;script src=&quot;/static/calendar/script2.js&quot;&gt;&lt;/script&gt;\\`)", - rendered, - ) - self.assertIn( - "Components.unescapeJs(\\`&lt;script src=&quot;/static/calendar/script3.js&quot;&gt;&lt;/script&gt;\\`)", - rendered, - ) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) class MediaStaticfilesTests(BaseTestCase): @@ -634,11 +551,7 @@ class MediaStaticfilesTests(BaseTestCase): # be searched as specified above (e.g. `calendar/script.js`) inside `static_root` dir. self.assertInHTML('', rendered) - # Command to load the JS from Media.js - self.assertIn( - "Components.unescapeJs(\\`&lt;script defer src=&quot;/static/calendar/script.js&quot;&gt;&lt;/script&gt;\\`)", - rendered, - ) + self.assertInHTML('', rendered) # For context see https://github.com/EmilStenstrom/django-components/issues/522 @override_settings( @@ -698,11 +611,7 @@ class MediaStaticfilesTests(BaseTestCase): '', rendered ) - # Command to load the JS from Media.js - self.assertIn( - "Components.unescapeJs(\\`&lt;script defer src=&quot;/static/calendar/script.e1815e23e0ec.js&quot;&gt;&lt;/script&gt;\\`)", - rendered, - ) + self.assertInHTML('', rendered) class MediaRelativePathTests(BaseTestCase): @@ -790,11 +699,7 @@ class MediaRelativePathTests(BaseTestCase): rendered, ) - # Command to load the JS from Media.js - self.assertIn( - "Components.unescapeJs(\\`&lt;link href=&quot;relative_file/relative_file.css&quot; media=&quot;all&quot; rel=&quot;stylesheet&quot;&gt;\\`)", - rendered, - ) + self.assertInHTML('', rendered) # Settings required for autodiscover to work @override_settings( @@ -863,8 +768,4 @@ class MediaRelativePathTests(BaseTestCase): self.assertInHTML('', rendered) self.assertInHTML('', rendered) - # Command to load the JS from Media.js - self.assertIn( - "Components.unescapeJs(\\`&lt;script type=&quot;module&quot; src=&quot;relative_file_pathobj.js&quot;&gt;&lt;/script&gt;\\`)", - rendered, - ) + self.assertInHTML('', rendered) diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py index 83c8a1b2..2f490f67 100644 --- a/tests/test_dependencies.py +++ b/tests/test_dependencies.py @@ -208,7 +208,7 @@ class RenderDependenciesTests(BaseTestCase): rendered_raw = Template(template_str).render(Context({})) rendered = render_dependencies(rendered_raw) - self.assertEqual(rendered.count(""); + manager.loadJs(""); const bodyAfterFirstLoad = document.body.innerHTML; // Does not add it the second time - manager.loadScript('js', ""); + manager.loadJs(""); const bodyAfterSecondLoad = document.body.innerHTML; // Adds different script - manager.loadScript('js', ""); + manager.loadJs(""); const bodyAfterThirdLoad = document.body.innerHTML; const headAfterThirdLoad = document.head.innerHTML; @@ -127,15 +136,15 @@ class LoadScriptTests(_BaseDepManagerTestCase): const bodyBeforeFirstLoad = document.body.innerHTML; // Adds a script the first time - manager.loadScript('css', ""); + manager.loadCss(""); const headAfterFirstLoad = document.head.innerHTML; // Does not add it the second time - manager.loadScript('css', ""); + manager.loadCss(""); const headAfterSecondLoad = document.head.innerHTML; // Adds different script - manager.loadScript('css', ""); + manager.loadCss(""); const headAfterThirdLoad = document.head.innerHTML; const bodyAfterThirdLoad = document.body.innerHTML; @@ -172,10 +181,10 @@ class LoadScriptTests(_BaseDepManagerTestCase): manager.markScriptLoaded('css', '/one/two'); manager.markScriptLoaded('js', '/one/three'); - manager.loadScript('css', ""); + manager.loadCss(""); const headAfterFirstLoad = document.head.innerHTML; - manager.loadScript('js', ""); + manager.loadJs(""); const bodyAfterSecondLoad = document.body.innerHTML; return { diff --git a/tests/test_dependency_rendering.py b/tests/test_dependency_rendering.py index 660b4d77..694080b9 100644 --- a/tests/test_dependency_rendering.py +++ b/tests/test_dependency_rendering.py @@ -127,10 +127,19 @@ class DependencyRenderingTests(BaseTestCase): self.assertEqual(rendered.count("'), 1) # Media.css self.assertEqual(rendered.count("'), 1) # Media.css self.assertEqual(rendered.count("'), 1) # Media.css - self.assertEqual(rendered.count(''), 1) - - self.assertEqual(rendered.count("const loadedJsScripts = [];"), 1) - self.assertEqual( - rendered.count("const loadedCssScripts = ["style.css", "style2.css"];"), 1 + # Media.css + self.assertInHTML( + """ + + + """, + rendered, + count=1, ) - # JS ORDER - "script.js", "script2.js" - self.assertEqual( - rendered.count( - r"const toLoadJsScripts = [Components.unescapeJs(\`&lt;script src=&quot;script.js&quot;&gt;&lt;/script&gt;\`), Components.unescapeJs(\`&lt;script src=&quot;script2.js&quot;&gt;&lt;/script&gt;\`)];" - ), - 1, + # Media.js + self.assertInHTML( + """ + + + """, + rendered, + count=1, ) - # CSS ORDER - "style.css", "style2.css" - self.assertEqual( - rendered.count( - r"const toLoadCssScripts = [Components.unescapeJs(\`&lt;link href=&quot;style.css&quot; media=&quot;all&quot; rel=&quot;stylesheet&quot;&gt;\`), Components.unescapeJs(\`&lt;link href=&quot;style2.css&quot; media=&quot;all&quot; rel=&quot;stylesheet&quot;&gt;\`)];" - ), - 1, - ) + # We expect to find this: + # ```js + # Components.manager._loadComponentScripts({ + # loadedCssUrls: ["style.css", "style2.css"], + # loadedJsUrls: ["script.js", "script2.js"], + # toLoadCssTags: [], + # toLoadJsTags: [], + # }); + # ``` + self.assertEqual(rendered.count("loadedCssUrls: ["style.css", "style2.css"],"), 1) + self.assertEqual(rendered.count("loadedJsUrls: ["script.js", "script2.js""), 1) + self.assertEqual(rendered.count("toLoadCssTags: [],"), 1) + self.assertEqual(rendered.count("toLoadJsTags: [],"), 1) def test_no_dependencies_with_multiple_unused_components(self): registry.register(name="inner", component=SimpleComponent) @@ -361,14 +386,23 @@ class DependencyRenderingTests(BaseTestCase): # Dependency manager script self.assertInHTML('', rendered, count=1) - self.assertEqual(rendered.count("', rendered, count=1) - self.assertEqual(rendered.count(".xyz { color: red; }", rendered, count=1) - self.assertInHTML("", rendered, count=1) + self.assertInHTML( + """ + + + """, + rendered, + count=1, + ) # Components' Media.css - # NOTE: Each of these should be present only ONCE! - self.assertInHTML('', rendered, count=1) - self.assertInHTML('', rendered, count=1) - self.assertInHTML('', rendered, count=1) - - self.assertEqual( - rendered.count( - "const loadedJsScripts = ["/components/cache/OtherComponent_6329ae.js/", "/components/cache/SimpleComponentNested_f02d32.js/"];" - ), - 1, - ) - self.assertEqual( - rendered.count( - "const loadedCssScripts = ["/components/cache/OtherComponent_6329ae.css/", "/components/cache/SimpleComponentNested_f02d32.css/", "style.css", "style2.css", "xyz1.css"];" - ), - 1, - ) - - # JS ORDER: - # - "script2.js" (from SimpleComponentNested) - # - "script.js" (from SimpleComponent inside SimpleComponentNested) - # - "xyz1.js" (from OtherComponent inserted into SimpleComponentNested) - self.assertEqual( - rendered.count( - r"const toLoadJsScripts = [Components.unescapeJs(\`&lt;script src=&quot;script2.js&quot;&gt;&lt;/script&gt;\`), Components.unescapeJs(\`&lt;script src=&quot;script.js&quot;&gt;&lt;/script&gt;\`), Components.unescapeJs(\`&lt;script src=&quot;xyz1.js&quot;&gt;&lt;/script&gt;\`)];" - ), - 1, - ) - - # CSS ORDER: + # Order: # - "style.css", "style2.css" (from SimpleComponentNested) # - "style.css" (from SimpleComponent inside SimpleComponentNested) # - "xyz1.css" (from OtherComponent inserted into SimpleComponentNested) + self.assertInHTML( + """ + + + + """, + rendered, + count=1, + ) + + # Components' Media.js followed by inlined JS + # Order: + # - "script2.js" (from SimpleComponentNested) + # - "script.js" (from SimpleComponent inside SimpleComponentNested) + # - "xyz1.js" (from OtherComponent inserted into SimpleComponentNested) + self.assertInHTML( + """ + + + + + + """, + rendered, + count=1, + ) + + # We expect to find this: + # ```js + # Components.manager._loadComponentScripts({ + # loadedCssUrls: ["/components/cache/OtherComponent_6329ae.css/", "/components/cache/SimpleComponentNested_f02d32.css/", "style.css", "style2.css", "xyz1.css"], + # loadedJsUrls: ["/components/cache/OtherComponent_6329ae.js/", "/components/cache/SimpleComponentNested_f02d32.js/", "script.js", "script2.js", "xyz1.js"], + # toLoadCssTags: [], + # toLoadJsTags: [], + # }); + # ``` self.assertEqual( rendered.count( - r"const toLoadCssScripts = [Components.unescapeJs(\`&lt;link href=&quot;style.css&quot; media=&quot;all&quot; rel=&quot;stylesheet&quot;&gt;\`), Components.unescapeJs(\`&lt;link href=&quot;style2.css&quot; media=&quot;all&quot; rel=&quot;stylesheet&quot;&gt;\`), Components.unescapeJs(\`&lt;link href=&quot;xyz1.css&quot; media=&quot;all&quot; rel=&quot;stylesheet&quot;&gt;\`)];" + "loadedJsUrls: ["/components/cache/OtherComponent_6329ae.js/", "/components/cache/SimpleComponentNested_f02d32.js/", "script.js", "script2.js", "xyz1.js"]," ), 1, ) + self.assertEqual( + rendered.count( + "loadedCssUrls: ["/components/cache/OtherComponent_6329ae.css/", "/components/cache/SimpleComponentNested_f02d32.css/", "style.css", "style2.css", "xyz1.css"]," + ), + 1, + ) + self.assertEqual(rendered.count("toLoadJsTags: [],"), 1) + self.assertEqual(rendered.count("toLoadCssTags: [],"), 1) def test_multiple_components_all_placeholders_removed(self): registry.register(name="inner", component=SimpleComponent) diff --git a/tests/test_dependency_rendering_e2e.py b/tests/test_dependency_rendering_e2e.py index a81c6fd4..075633a9 100644 --- a/tests/test_dependency_rendering_e2e.py +++ b/tests/test_dependency_rendering_e2e.py @@ -215,3 +215,132 @@ class E2eDependencyRenderingTests(BaseTestCase): self.assertEqual("rgb(255, 0, 0)", data["myStyle2Color"]) # AKA 'color: red' await page.close() + + @with_playwright + async def test_js_executed_in_order__js(self): + single_comp_url = TEST_SERVER_URL + "/js-order/js" + + page: Page = await self.browser.new_page() + await page.goto(single_comp_url) + + test_js: types.js = """() => { + // NOTE: This variable should be defined by `check_script_order` component, + // and it should contain all other variables defined by the previous components + return checkVars; + }""" + + data = await page.evaluate(test_js) + + # Check components' inlined JS got loaded + self.assertEqual(data["testSimpleComponent"], "kapowww!") + self.assertEqual(data["testSimpleComponentNested"], "bongo!") + self.assertEqual(data["testOtherComponent"], "wowzee!") + + # Check JS from Media.js got loaded + self.assertEqual(data["testMsg"], {"hello": "world"}) + self.assertEqual(data["testMsg2"], {"hello2": "world2"}) + + await page.close() + + @with_playwright + async def test_js_executed_in_order__media(self): + single_comp_url = TEST_SERVER_URL + "/js-order/media" + + page: Page = await self.browser.new_page() + await page.goto(single_comp_url) + + test_js: types.js = """() => { + // NOTE: This variable should be defined by `check_script_order` component, + // and it should contain all other variables defined by the previous components + return checkVars; + }""" + + data = await page.evaluate(test_js) + + # Check components' inlined JS got loaded + # NOTE: The Media JS are loaded BEFORE the components' JS, so they should be empty + self.assertEqual(data["testSimpleComponent"], None) + self.assertEqual(data["testSimpleComponentNested"], None) + self.assertEqual(data["testOtherComponent"], None) + + # Check JS from Media.js + self.assertEqual(data["testMsg"], {"hello": "world"}) + self.assertEqual(data["testMsg2"], {"hello2": "world2"}) + + await page.close() + + # In this case the component whose JS is accessing data from other components + # is used in the template before the other components. So the JS should + # not be able to access the data from the other components. + @with_playwright + async def test_js_executed_in_order__invalid(self): + single_comp_url = TEST_SERVER_URL + "/js-order/invalid" + + page: Page = await self.browser.new_page() + await page.goto(single_comp_url) + + test_js: types.js = """() => { + // checkVars was defined BEFORE other components, so it should be empty! + return checkVars; + }""" + + data = await page.evaluate(test_js) + + # Check components' inlined JS got loaded + self.assertEqual(data["testSimpleComponent"], None) + self.assertEqual(data["testSimpleComponentNested"], None) + self.assertEqual(data["testOtherComponent"], None) + + # Check JS from Media.js got loaded + self.assertEqual(data["testMsg"], None) + self.assertEqual(data["testMsg2"], None) + + await page.close() + + @with_playwright + async def test_alpine__head(self): + single_comp_url = TEST_SERVER_URL + "/alpine/head" + + page: Page = await self.browser.new_page() + await page.goto(single_comp_url) + + component_text = await page.locator('[x-data="alpine_test"]').text_content() + self.assertHTMLEqual(component_text.strip(), "ALPINE_TEST: 123") + + await page.close() + + @with_playwright + async def test_alpine__body(self): + single_comp_url = TEST_SERVER_URL + "/alpine/body" + + page: Page = await self.browser.new_page() + await page.goto(single_comp_url) + + component_text = await page.locator('[x-data="alpine_test"]').text_content() + self.assertHTMLEqual(component_text.strip(), "ALPINE_TEST: 123") + + await page.close() + + @with_playwright + async def test_alpine__body2(self): + single_comp_url = TEST_SERVER_URL + "/alpine/body2" + + page: Page = await self.browser.new_page() + await page.goto(single_comp_url) + + component_text = await page.locator('[x-data="alpine_test"]').text_content() + self.assertHTMLEqual(component_text.strip(), "ALPINE_TEST: 123") + + await page.close() + + @with_playwright + async def test_alpine__invalid(self): + single_comp_url = TEST_SERVER_URL + "/alpine/invalid" + + page: Page = await self.browser.new_page() + await page.goto(single_comp_url) + + component_text = await page.locator('[x-data="alpine_test"]').text_content() + self.assertHTMLEqual(component_text.strip(), "ALPINE_TEST:") + + await page.close() From 468a593a472c35379fe7567918e8a534b2d53748 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Fri, 6 Dec 2024 08:48:49 +0100 Subject: [PATCH 134/487] chore: bump v0.116 (#822) * chore: bump v0.116 * docs: update changelog --- CHANGELOG.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc716955..44e29f8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,50 @@ # Release notes +## v0.116 + +#### Fix + +- Fix the order of execution of JS scripts: + - Scripts in `Component.Media.js` are executed in the order they are defined + - Scripts in `Component.js` are executed AFTER `Media.js` scripts + +- Fix compatibility with AlpineJS + - Scripts in `Component.Media.js` are now again inserted as ` + + + {% component 'my_alpine_component' / %} + {% component_js_dependencies %} + + + ``` + + Option 2 - AlpineJS loaded in `` AFTER `{% component_js_depenencies %}`: + ```html + + + {% component_css_dependencies %} + + + {% component 'my_alpine_component' / %} + {% component_js_dependencies %} + + + + + ``` + ## v0.115 #### Fix diff --git a/pyproject.toml b/pyproject.toml index a4929e34..9862e913 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "django_components" -version = "0.115" +version = "0.116" requires-python = ">=3.8, <4.0" description = "A way to create simple reusable template components in Django." keywords = ["django", "components", "css", "js", "html"] From bd700908ed9e3f3277184eded3a66227ba4d579e Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Fri, 6 Dec 2024 17:57:33 +0100 Subject: [PATCH 135/487] refactor: configure top-level redirect for docs (#824) --- mkdocs.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mkdocs.yml b/mkdocs.yml index f2ebd1a4..fe7a95f2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -152,6 +152,8 @@ plugins: version_selector: true - redirects: redirect_maps: + # 'old.md': 'new.md' + 'README.md': 'overview/welcome.md' - minify: minify_html: true - mkdocstrings: @@ -192,3 +194,4 @@ plugins: extensions: - docs/scripts/extensions.py:RuntimeBasesExtension - docs/scripts/extensions.py:SourceCodeExtension + # - docs/scripts/extensions.py:ComponentSyntaxesExtension From 835f8a9664dc5f7bb2bd0514e2d3678f98531826 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Dec 2024 18:32:54 +0000 Subject: [PATCH 136/487] build(deps): bump django from 5.1.3 to 5.1.4 Bumps [django](https://github.com/django/django) from 5.1.3 to 5.1.4. - [Commits](https://github.com/django/django/compare/5.1.3...5.1.4) --- updated-dependencies: - dependency-name: django dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 1327c79e..1dae779a 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -60,7 +60,7 @@ cssselect2==0.7.0 # via cairosvg defusedxml==0.7.1 # via cairosvg -django==5.1.3 +django==5.1.4 # via hatch.envs.docs ghp-import==2.1.0 # via mkdocs From 1cd545b986cdf6768c0f8cb21c5714d296f5c492 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Sun, 8 Dec 2024 08:42:48 +0100 Subject: [PATCH 137/487] refactor: replace selectolax with beautifulsoup (#823) * refactor: replace selectolax with beautifulsoup * refactor: add tests for html parser impl * refactor: add missing import * refactor: fix tests * refactor: fix linter issues * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- requirements-dev.in | 2 +- requirements-dev.txt | 37 ++-- requirements-docs.txt | 8 +- src/django_components/dependencies.py | 98 ++++++---- src/django_components/util/html.py | 178 ++++++++--------- src/django_components/util/misc.py | 7 + tests/test_dependencies.py | 146 +++++++++++++- tests/test_html.py | 264 ++++++-------------------- 9 files changed, 391 insertions(+), 351 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9862e913..419749e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ classifiers = [ ] dependencies = [ 'Django>=4.2', - 'selectolax>=0.3.24', + 'beautifulsoup4>=4.12', ] license = {text = "MIT"} diff --git a/requirements-dev.in b/requirements-dev.in index 6238e9f1..ee361540 100644 --- a/requirements-dev.in +++ b/requirements-dev.in @@ -11,4 +11,4 @@ playwright requests types-requests whitenoise -selectolax \ No newline at end of file +beautifulsoup4 \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index e079566f..e7e4fca6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,7 +6,9 @@ # asgiref==3.8.1 # via django -black==24.8.0 +beautifulsoup4==4.12.3 + # via -r requirements-dev.in +black==24.10.0 # via -r requirements-dev.in cachetools==5.5.0 # via tox @@ -16,15 +18,15 @@ cfgv==3.4.0 # via pre-commit chardet==5.2.0 # via tox -charset-normalizer==3.3.2 +charset-normalizer==3.4.0 # via requests click==8.1.7 # via black colorama==0.4.6 # via tox -distlib==0.3.8 +distlib==0.3.9 # via virtualenv -django==5.1.1 +django==5.1.4 # via -r requirements-dev.in filelock==3.16.1 # via @@ -38,7 +40,7 @@ flake8-pyproject==1.2.3 # via -r requirements-dev.in greenlet==3.1.1 # via playwright -identify==2.5.33 +identify==2.6.3 # via pre-commit idna==3.10 # via requests @@ -54,9 +56,9 @@ mypy-extensions==1.0.0 # via # black # mypy -nodeenv==1.8.0 +nodeenv==1.9.1 # via pre-commit -packaging==24.1 +packaging==24.2 # via # black # pyproject-api @@ -69,7 +71,7 @@ platformdirs==4.3.6 # black # tox # virtualenv -playwright==1.48.0 +playwright==1.49.0 # via -r requirements-dev.in pluggy==1.5.0 # via @@ -77,7 +79,7 @@ pluggy==1.5.0 # tox pre-commit==4.0.1 # via -r requirements-dev.in -pycodestyle==2.12.0 +pycodestyle==2.12.1 # via flake8 pyee==12.0.0 # via playwright @@ -87,19 +89,19 @@ pyproject-api==1.8.0 # via tox pytest==8.3.4 # via -r requirements-dev.in -pyyaml==6.0.1 +pyyaml==6.0.2 # via pre-commit requests==2.32.3 # via -r requirements-dev.in -selectolax==0.3.26 - # via -r requirements-dev.in -sqlparse==0.5.0 +soupsieve==2.6 + # via beautifulsoup4 +sqlparse==0.5.2 # via django tox==4.23.2 # via -r requirements-dev.in types-requests==2.32.0.20241016 # via -r requirements-dev.in -typing-extensions==4.10.0 +typing-extensions==4.12.2 # via # mypy # pyee @@ -107,12 +109,9 @@ urllib3==2.2.3 # via # requests # types-requests -virtualenv==20.26.6 +virtualenv==20.28.0 # via # pre-commit # tox -whitenoise==6.7.0 +whitenoise==6.8.2 # via -r requirements-dev.in - -# The following packages are considered to be unsafe in a requirements file: -# setuptools diff --git a/requirements-docs.txt b/requirements-docs.txt index 1dae779a..1c05cfbe 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -20,7 +20,7 @@ # - pymdown-extensions # - black # - django>=4.2 -# - selectolax>=0.3.24 +# - beautifulsoup4>=4.12 # asgiref==3.8.1 @@ -29,6 +29,8 @@ babel==2.16.0 # via # mkdocs-git-revision-date-localized-plugin # mkdocs-material +beautifulsoup4==4.12.3 + # via hatch.envs.docs black==24.10.0 # via hatch.envs.docs bracex==2.5.post1 @@ -207,12 +209,12 @@ regex==2024.11.6 # via mkdocs-material requests==2.32.3 # via mkdocs-material -selectolax==0.3.26 - # via hatch.envs.docs six==1.16.0 # via python-dateutil smmap==5.0.1 # via gitdb +soupsieve==2.6 + # via beautifulsoup4 sqlparse==0.5.2 # via django tinycss2==1.4.0 diff --git a/src/django_components/dependencies.py b/src/django_components/dependencies.py index 5585482b..2e0cc42b 100644 --- a/src/django_components/dependencies.py +++ b/src/django_components/dependencies.py @@ -33,11 +33,10 @@ from django.templatetags.static import static from django.urls import path, reverse from django.utils.decorators import sync_and_async_middleware from django.utils.safestring import SafeString, mark_safe -from selectolax.lexbor import LexborHTMLParser import django_components.types as types -from django_components.util.html import parse_document_or_nodes, parse_multiroot_html, parse_node -from django_components.util.misc import escape_js_string_literal, get_import_path +from django_components.util.html import SoupNode +from django_components.util.misc import _escape_js, get_import_path if TYPE_CHECKING: from django_components.component import Component @@ -362,26 +361,14 @@ def render_dependencies(content: TContent, type: RenderType = "document") -> TCo # then try to insert the JS scripts at the end of and CSS sheets at the end # of if type == "document" and (not did_find_js_placeholder or not did_find_css_placeholder): - tree = parse_document_or_nodes(content_.decode()) + maybe_transformed = _insert_js_css_to_default_locations( + content_.decode(), + css_content=None if did_find_css_placeholder else css_dependencies.decode(), + js_content=None if did_find_js_placeholder else js_dependencies.decode(), + ) - if isinstance(tree, LexborHTMLParser): - did_modify_html = False - - if not did_find_css_placeholder and tree.head: - css_elems = parse_multiroot_html(css_dependencies.decode()) - for css_elem in css_elems: - tree.head.insert_child(css_elem) # type: ignore # TODO: Update to selectolax 0.3.25 - did_modify_html = True - - if not did_find_js_placeholder and tree.body: - js_elems = parse_multiroot_html(js_dependencies.decode()) - for js_elem in js_elems: - tree.body.insert_child(js_elem) # type: ignore # TODO: Update to selectolax 0.3.25 - did_modify_html = True - - transformed = cast(str, tree.html) - if did_modify_html: - content_ = transformed.encode() + if maybe_transformed is not None: + content_ = maybe_transformed.encode() # Return the same type as we were given output = content_.decode() if isinstance(content, str) else content_ @@ -567,15 +554,15 @@ def _postprocess_media_tags( tags_by_url: Dict[str, str] = {} for tag in tags: - node = parse_node(tag) + node = SoupNode.from_fragment(tag.strip())[0] # - - - -``` - -This makes it possible to organize your front-end around reusable components. Instead of relying on template tags and keeping your CSS and Javascript in the static directory. diff --git a/docs/concepts/fundamentals/defining_js_css_html_files.md b/docs/concepts/fundamentals/defining_js_css_html_files.md index c9de0df6..70cc8720 100644 --- a/docs/concepts/fundamentals/defining_js_css_html_files.md +++ b/docs/concepts/fundamentals/defining_js_css_html_files.md @@ -1,6 +1,6 @@ --- title: Defining HTML / JS / CSS files -weight: 10 +weight: 8 --- django_component's management of files builds on top of [Django's `Media` class](https://docs.djangoproject.com/en/5.0/topics/forms/media/). diff --git a/docs/concepts/fundamentals/html_attributes.md b/docs/concepts/fundamentals/html_attributes.md index 9dcc319f..64e04b23 100644 --- a/docs/concepts/fundamentals/html_attributes.md +++ b/docs/concepts/fundamentals/html_attributes.md @@ -1,6 +1,6 @@ --- title: HTML attributes -weight: 9 +weight: 7 --- _New in version 0.74_: diff --git a/docs/concepts/fundamentals/single_file_components.md b/docs/concepts/fundamentals/single_file_components.md index 0327d09f..e4ab18c3 100644 --- a/docs/concepts/fundamentals/single_file_components.md +++ b/docs/concepts/fundamentals/single_file_components.md @@ -1,6 +1,6 @@ --- title: Single-file components -weight: 2 +weight: 1 --- Components can also be defined in a single file, which is useful for small components. To do this, you can use the `template`, `js`, and `css` class attributes instead of the `template_name` and `Media`. For example, here's the calendar component from above, defined in a single file: diff --git a/docs/concepts/fundamentals/slots.md b/docs/concepts/fundamentals/slots.md index 05b6e12b..f1ba610a 100644 --- a/docs/concepts/fundamentals/slots.md +++ b/docs/concepts/fundamentals/slots.md @@ -1,6 +1,6 @@ --- title: Slots -weight: 8 +weight: 6 --- _New in version 0.26_: diff --git a/docs/concepts/fundamentals/template_tag_syntax.md b/docs/concepts/fundamentals/template_tag_syntax.md index 9b173c58..6d4b1949 100644 --- a/docs/concepts/fundamentals/template_tag_syntax.md +++ b/docs/concepts/fundamentals/template_tag_syntax.md @@ -1,6 +1,6 @@ --- title: Template tag syntax -weight: 7 +weight: 5 --- All template tags in django_component, like `{% component %}` or `{% slot %}`, and so on, diff --git a/docs/concepts/fundamentals/your_first_component.md b/docs/concepts/fundamentals/your_first_component.md deleted file mode 100644 index de6b7c7b..00000000 --- a/docs/concepts/fundamentals/your_first_component.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -title: Create your first component -weight: 1 ---- - -A component in django-components is the combination of four things: CSS, Javascript, a Django template, and some Python code to put them all together. - -``` - sampleproject/ - ├── calendarapp/ - ├── components/ 🆕 - │ └── calendar/ 🆕 - │ ├── calendar.py 🆕 - │ ├── script.js 🆕 - │ ├── style.css 🆕 - │ └── template.html 🆕 - ├── sampleproject/ - ├── manage.py - └── requirements.txt -``` - -Start by creating empty files in the structure above. - -First, you need a CSS file. Be sure to prefix all rules with a unique class so they don't clash with other rules. - -```css title="[project root]/components/calendar/style.css" -/* In a file called [project root]/components/calendar/style.css */ -.calendar-component { - width: 200px; - background: pink; -} -.calendar-component span { - font-weight: bold; -} -``` - -Then you need a javascript file that specifies how you interact with this component. You are free to use any javascript framework you want. A good way to make sure this component doesn't clash with other components is to define all code inside an anonymous function that calls itself. This makes all variables defined only be defined inside this component and not affect other components. - -```js title="[project root]/components/calendar/script.js" -/* In a file called [project root]/components/calendar/script.js */ -(function () { - if (document.querySelector(".calendar-component")) { - document.querySelector(".calendar-component").onclick = function () { - alert("Clicked calendar!"); - }; - } -})(); -``` - -Now you need a Django template for your component. Feel free to define more variables like `date` in this example. When creating an instance of this component we will send in the values for these variables. The template will be rendered with whatever template backend you've specified in your Django settings file. - -```htmldjango title="[project root]/components/calendar/calendar.html" -{# In a file called [project root]/components/calendar/template.html #} -
Today's date is {{ date }}
-``` - -Finally, we use django-components to tie this together. Start by creating a file called `calendar.py` in your component calendar directory. It will be auto-detected and loaded by the app. - -Inside this file we create a Component by inheriting from the Component class and specifying the context method. We also register the global component registry so that we easily can render it anywhere in our templates. - -```python title="[project root]/components/calendar/calendar.py" -# In a file called [project root]/components/calendar/calendar.py -from django_components import Component, register - -@register("calendar") -class Calendar(Component): - # Templates inside `[your apps]/components` dir and `[project root]/components` dir - # will be automatically found. - # - # `template_name` can be relative to dir where `calendar.py` is, or relative to COMPONENTS.dirs - template_name = "template.html" - # Or - def get_template_name(context): - return f"template-{context['name']}.html" - - # This component takes one parameter, a date string to show in the template - def get_context_data(self, date): - return { - "date": date, - } - - # Both `css` and `js` can be relative to dir where `calendar.py` is, or relative to COMPONENTS.dirs - class Media: - css = "style.css" - js = "script.js" -``` - -And voilá!! We've created our first component. diff --git a/docs/concepts/getting_started/adding_js_and_css.md b/docs/concepts/getting_started/adding_js_and_css.md new file mode 100644 index 00000000..caed4f05 --- /dev/null +++ b/docs/concepts/getting_started/adding_js_and_css.md @@ -0,0 +1,240 @@ +--- +title: Adding JS and CSS +weight: 2 +--- + +Next we will add CSS and JavaScript to our template. + +!!! info + + In django-components, using JS and CSS is as simple as defining them on the Component class. + You don't have to insert the ` + + +``` + +#### Rules of JS execution + +1. **JS is executed in the order in which the components are found in the HTML** + + By default, the JS is inserted as a **synchronous** script (``) + + So if you define multiple components on the same page, their JS will be + executed in the order in which the components are found in the HTML. + + So if we have a template like so: + + ```htmldjango + + + ... + + + {% component "calendar" / %} + {% component "table" / %} + + + ``` + + Then the JS file of the component `calendar` will be executed first, and the JS file + of component `table` will be executed second. + +2. **JS will be executed only once, even if there is multiple instances of the same component** + + In this case, the JS of `calendar` will STILL execute first (because it was found first), + and will STILL execute only once, even though it's present twice: + + ```htmldjango + + + ... + + + {% component "calendar" / %} + {% component "table" / %} + {% component "calendar" / %} + + + ``` + + +### 4. Link JS and CSS to a component + +Finally, we return to our Python component in `calendar.py` to tie this together. + +To link JS and CSS defined in other files, use the `Media` nested class +([Learn more about using Media](../fundamentals/defining_js_css_html_files.md)). + +```python title="[project root]/components/calendar/calendar.py" +from django_components import Component + +class Calendar(Component): + template_name = "calendar.html" + + class Media: # <--- new + js = "calendar.js" + css = "calendar.css" + + def get_context_data(self): + return { + "date": "1970-01-01", + } +``` + +!!! info + + The `Media` nested class is shaped based on [Django's Media class](https://docs.djangoproject.com/en/5.1/topics/forms/media/). + + As such, django-components allows multiple formats to define the nested Media class: + + ```py + # Single files + class Media: + js = "calendar.js" + css = "calendar.css" + + # Lists of files + class Media: + js = ["calendar.js", "calendar2.js"] + css = ["calendar.css", "calendar2.css"] + + # Dictionary of media types for CSS + class Media: + js = ["calendar.js", "calendar2.js"] + css = { + "all": ["calendar.css", "calendar2.css"], + } + ``` + + If you define a list of JS files, they will be executed one-by-one, left-to-right. + +!!! note + + Same as with the template file, the file paths for the JS and CSS files can be either: + + 1. Relative to the Python component file (as seen above), + 2. Relative to any of the component directories as defined by + [`COMPONENTS.dirs`](../../reference/settings.md#django_components.app_settings.ComponentsSettings.dirs) + and/or [`COMPONENTS.app_dirs`](../../reference/settings.md#django_components.app_settings.ComponentsSettings.app_dirs) + (e.g. `[your apps]/components` dir and `[project root]/components`) + + +And that's it! If you were to embed this component in an HTML, django-components will +automatically embed the associated JS and CSS. + +Now that we have a fully-defined component, [next let's use it in a Django template ➡️](./components_in_templates.md). diff --git a/docs/concepts/getting_started/adding_slots.md b/docs/concepts/getting_started/adding_slots.md new file mode 100644 index 00000000..31001fb8 --- /dev/null +++ b/docs/concepts/getting_started/adding_slots.md @@ -0,0 +1,295 @@ +--- +title: Adding slots +weight: 5 +--- + +Our calendar component's looking great! But we just got a new assignment from +our colleague - The calendar date needs to be shown on 3 different pages: + +1. On one page, it needs to be shown as is +2. On the second, the date needs to be **bold** +3. On the third, the date needs to be in *italics* + +As a reminder, this is what the component's template looks like: + +```htmldjango +
+ Today's date is {{ date }} +
+``` + +There's many ways we could approach this: + +- Expose the date in a slot +- Style `.calendar > span` differently on different pages +- Pass a variable to the component that decides how the date is rendered +- Create a new component + +First two options are more flexible, because the custom styling is not baked into a component's +implementation. And for the sake of demonstration, we'll solve this challenge with slots. + +### 1. What are slots + +Components support something called [Slots](../fundamentals/slots.md). + +When a component is used inside another template, slots allow the parent template +to override specific parts of the child component by passing in different content. + +This mechanism makes components more reusable and composable. + +This behavior is similar to [slots in Vue](https://vuejs.org/guide/components/slots.html). + +In the example below we introduce two tags that work hand in hand to make this work. These are... + +- `{% slot %}`/`{% endslot %}`: Declares a new slot in the component template. +- `{% fill %}`/`{% endfill %}`: (Used inside a [`{% component %}`](../../reference/template_tags.md#component) + tag pair.) Fills a declared slot with the specified content. + +### 2. Add a slot tag + +Let's update our calendar component to support more customization. We'll add +[`{% slot %}`](../../reference/template_tags.md#slot) tag to the template: + +```htmldjango +
+ Today's date is + {% slot "date" default %} {# <--- new #} + {{ date }} + {% endslot %} +
+``` + +Notice that: + +1. We named the slot `date` - so we can fill this slot by using `{% fill "date" %}` + +2. We also made it the [default slot](../fundamentals/slots.md#default-slot). + +3. We placed our original implementation inside the [`{% slot %}`](../../reference/template_tags.md#slot) + tag - this is what will be rendered when the slot is NOT overriden. + +### 3. Add fill tag + +Now we can use [`{% fill %}`](../../reference/template_tags.md#fill) tags inside the +[`{% component %}`](../../reference/template_tags.md#component) tags to override the `date` slot +to generate the bold and italics variants: + +```htmldjango +{# Default #} +{% component "calendar" date="2024-12-13" / %} + +{# Bold #} +{% component "calendar" date="2024-12-13" %} + 2024-12-13 +{% endcomponent %} + +{# Italics #} +{% component "calendar" date="2024-12-13" %} + 2024-12-13 +{% endcomponent %} +``` + +Which will render as: + +```html + +
+ Today's date is 2024-12-13 +
+ + +
+ Today's date is 2024-12-13 +
+ + +
+ Today's date is 2024-12-13 +
+``` + +!!! info + + Since we used the `default` flag on `{% slot "date" %}` inside our calendar component, + we can target the `date` component in multiple ways: + + 1. Explicitly by it's name + ```htmldjango + {% component "calendar" date="2024-12-13" %} + {% fill "date" %} + 2024-12-13 + {% endfill %} + {% endcomponent %} + ``` + + 2. Implicitly as the [default slot](../fundamentals/slots.md#default-slot) (Omitting the + [`{% fill %}`](../../reference/template_tags.md#fill) tag) + ```htmldjango + {% component "calendar" date="2024-12-13" %} + 2024-12-13 + {% endcomponent %} + ``` + + 3. Explicitly as the [default slot](../fundamentals/slots.md#default-slot) (Setting fill name to `default`) + ```htmldjango + {% component "calendar" date="2024-12-13" %} + {% fill "default" %} + 2024-12-13 + {% endfill %} + {% endcomponent %} + ``` + +### 5. Wait, there's a bug + +There is a mistake in our code! `2024-12-13` is Friday, so that's fine. But if we updated +the to `2024-12-14`, which is Saturday, our template from previous step would render this: + +```html + +
+ Today's date is 2024-12-16 +
+ + +
+ Today's date is 2024-12-14 +
+ + +
+ Today's date is 2024-12-14 +
+``` + +The first instance rendered `2024-12-16`, while the rest rendered `2024-12-14`! + +Why? Remember that in the [`get_context_data()`](../../../reference/api#django_components.Component.get_context_data) +method of our Calendar component, we pre-process the date. If the date falls on Saturday or Sunday, we shift it to next Monday: + +```python title="[project root]/components/calendar/calendar.py" +from datetime import date + +from django_components import Component, register + +# If date is Sat or Sun, shift it to next Mon, so the date is always workweek. +def to_workweek_date(d: date): + ... + +@register("calendar") +class Calendar(Component): + template_name = "calendar.html" + ... + def get_context_data(self, date: date, extra_class: str | None = None): + workweek_date = to_workweek_date(date) + return { + "date": workweek_date, + "extra_class": extra_class, + } +``` + +And the issue is that in our template, we used the `date` value that we used *as input*, +which is NOT the same as the `date` variable used inside Calendar's template. + +### 5. Adding data to slots + +We want to use the same `date` variable that's used inside Calendar's template. + +Luckily, django-components allows passing data to the slot, also known as [Scoped slots](../fundamentals/slots.md#scoped-slots). + +This consists of two steps: + +1. Pass the `date` variable to the [`{% slot %}`](../../reference/template_tags.md#slot) tag +2. Access the `date` variable in the [`{% fill %}`](../../reference/template_tags.md#fill) + tag by using the special `data` kwarg + +Let's update the Calendar's template: + +```htmldjango +
+ Today's date is + {% slot "date" default date=date %} {# <--- changed #} + {{ date }} + {% endslot %} +
+``` + +!!! info + + The [`{% slot %}`](../../reference/template_tags.md#slot) tag has one special kwarg, `name`. When you write + + ```htmldjango + {% slot "date" / %} + ``` + + It's the same as: + + ```htmldjango + {% slot name="date" / %} + ``` + + Other than the `name` kwarg, you can pass any extra kwargs to the [`{% slot %}`](../../reference/template_tags.md#slot) tag, + and these will be exposed as the slot's data. + + ```htmldjango + {% slot name="date" kwarg1=123 kwarg2="text" kwarg3=my_var / %} + ``` + +### 6. Accessing slot data in fills + +Now, on the [`{% fill %}`](../../reference/template_tags.md#fill) tags, we can use the `data` kwarg to specify the variable under which +the slot data will be available. + +The variable from the `data` kwarg contains all the extra kwargs passed to the [`{% slot %}`](../../reference/template_tags.md#slot) tag. + +So if we set `data="slot_data"`, then we can access the date variable under `slot_data.date`: + +```htmldjango +{# Default #} +{% component "calendar" date="2024-12-13" / %} + +{# Bold #} +{% component "calendar" date="2024-12-13" %} + {% fill "date" data="slot_data" %} + {{ slot_data.date }} + {% endfill %} +{% endcomponent %} + +{# Italics #} +{% component "calendar" date="2024-12-13" %} + {% fill "date" data="slot_data" %} + {{ slot_data.date }} + {% endfill %} +{% endcomponent %} +``` + +By using the `date` variable from the slot, we'll render the correct date +each time: + +```html + +
+ Today's date is 2024-12-16 +
+ + +
+ Today's date is 2024-12-16 +
+ + +
+ Today's date is 2024-12-16 +
+``` + +!!! info + + **When to use slots vs variables?** + + Generally, slots are more flexible - you can access the slot data, even the original slot content. + Thus, slots behave more like functions that render content based on their context. + + On the other hand, variables are static - the variable you pass to a component is what will be used. + + Moreover, slots are treated as part of the template - for example the CSS scoping (work in progress) + is applied to the slot content too. diff --git a/docs/concepts/getting_started/components_in_templates.md b/docs/concepts/getting_started/components_in_templates.md new file mode 100644 index 00000000..4d322c9e --- /dev/null +++ b/docs/concepts/getting_started/components_in_templates.md @@ -0,0 +1,196 @@ +--- +title: Components in templates +weight: 3 +--- + +By the end of this section, we want to be able to use our components in Django templates like so: + +```htmldjango +{% load component_tags %} + + + + My example calendar + + + {% component "calendar" / %} + + +``` + +### 1. Register component + +First, however, we need to register our component class with [`ComponentRegistry`](../../../reference/api#django_components.ComponentRegistry). + +To register a component with a [`ComponentRegistry`](../../../reference/api#django_components.ComponentRegistry), +we will use the [`@register`](../../../reference/api#django_components.register) +decorator, and give it a name under which the component will be accessible from within the template: + +```python title="[project root]/components/calendar/calendar.py" +from django_components import Component, register # <--- new + +@register("calendar") # <--- new +class Calendar(Component): + template_name = "calendar.html" + + class Media: + js = "calendar.js" + css = "calendar.css" + + def get_context_data(self): + return { + "date": "1970-01-01", + } +``` + +This will register the component to the default registry. Default registry is loaded into the template +by calling `{% load component_tags %}` inside the template. + +!!! info + + Why do we have to register components? + + We want to use our component as a template tag (`{% ... %}`) in Django template. + + In Django, template tags are managed by the `Library` instances. Whenever you include `{% load xxx %}` + in your template, you are loading a `Library` instance into your template. + + [`ComponentRegistry`](../../../reference/api#django_components.ComponentRegistry) acts like a router + and connects the registered components with the associated `Library`. + + That way, when you include `{% load component_tags %}` in your template, you are able to "call" components + like `{% component "calendar" / %}`. + + `ComponentRegistries` also make it possible to group and share components as standalone packages. + [Learn more here](../advanced/authoring_component_libraries.md). + +!!! note + + You can create custom [`ComponentRegistry`](../../../reference/api#django_components.ComponentRegistry) + instances, which will use different `Library` instances. + In that case you will have to load different libraries depending on which components you want to use: + + Example 1 - Using component defined in the default registry + ```htmldjango + {% load component_tags %} +
+ {% component "calendar" / %} +
+ ``` + + Example 2 - Using component defined in a custom registry + ```htmldjango + {% load my_custom_tags %} +
+ {% my_component "table" / %} +
+ ``` + + Note that, because the tag name `component` is use by the default ComponentRegistry, + the custom registry was configured to use the tag `my_component` instead. [Read more here](../advanced/component_registry.md) + +### 2. Load and use the component in template + +The component is now registered under the name `calendar`. All that remains to do is to load +and render the component inside a template: + +```htmldjango +{% load component_tags %} {# Load the default registry #} + + + + My example calendar + + + {% component "calendar" / %} {# Render the component #} + + +``` + +!!! info + + Component tags should end with `/` if they do not contain any [Slot fills](../fundamentals/slots.md). + But you can also use `{% endcomponent %}` instead: + + ```htmldjango + {% component "calendar" %}{% endcomponent %} + ``` + +We defined the Calendar's template as + +```htmldjango +
+ Today's date is {{ date }} +
+``` + +and the variable `date` as `"1970-01-01"`. + +Thus, the final output will look something like this: + +```htmldjango + + + + My example calendar + + + +
+ Today's date is 1970-01-01 +
+ + + +``` + +This makes it possible to organize your front-end around reusable components, instead of relying on template tags +and keeping your CSS and Javascript in the static directory. + +!!! info + + Remember that you can use + [`{% component_js_dependencies %}`](../../reference/template_tags.md#component_js_dependencies) + and [`{% component_css_dependencies %}`](../../reference/template_tags.md#component_css_dependencies) + to change where the ` + + +
OLD
+ + + + {% component_js_dependencies %} + + + """ +``` + +### 2. Define fragment HTML + +```py title="[root]/components/demo.py" +class Frag(Component): + def get(self, request): + return self.render_to_response( + # IMPORTANT: Don't forget `type="fragment"` + type="fragment", + ) + + template = """ +
+ 123 + +
+ """ + + js = """ + document.querySelector('#frag-text').textContent = 'xxx'; + """ + + css = """ + .frag { + background: blue; + } + """ +``` + +### 3. Create view and URLs + +```py title="[app]/urls.py" +from django.urls import path + +from components.demo import MyPage, Frag + +urlpatterns = [ + path("mypage/", MyPage.as_view()) + path("mypage/frag", Frag.as_view()), +] +``` + +## Example - AlpineJS + +### 1. Define document HTML + +```py title="[root]/components/demo.py" +from django_components import Component, types + +# HTML into which a fragment will be loaded using AlpineJS +class MyPage(Component): + def get(self, request): + return self.render_to_response() + + template = """ + {% load component_tags %} + + + + {% component_css_dependencies %} + + + +
OLD
+ + + + {% component_js_dependencies %} + + + """ +``` + +### 2. Define fragment HTML + +```py title="[root]/components/demo.py" +class Frag(Component): + def get(self, request): + # IMPORTANT: Don't forget `type="fragment"` + return self.render_to_response( + type="fragment", + ) + + # NOTE: We wrap the actual fragment in a template tag with x-if="false" to prevent it + # from being rendered until we have registered the component with AlpineJS. + template = """ + + """ + + js = """ + Alpine.data('frag', () => ({ + fragVal: 'xxx', + })); + + // Now that the component has been defined in AlpineJS, we can "activate" + // all instances where we use the `x-data="frag"` directive. + document.querySelectorAll('[data-name="frag"]').forEach((el) => { + el.setAttribute('x-if', 'true'); + }); + """ + + css = """ + .frag { + background: blue; + } + """ +``` + +### 3. Create view and URLs + +```py title="[app]/urls.py" +from django.urls import path + +from components.demo import MyPage, Frag + +urlpatterns = [ + path("mypage/", MyPage.as_view()) + path("mypage/frag", Frag.as_view()), +] +``` + +## Example - Vanilla JS + +### 1. Define document HTML + +```py title="[root]/components/demo.py" +from django_components import Component, types + +# HTML into which a fragment will be loaded using JS +class MyPage(Component): + def get(self, request): + return self.render_to_response() + + template = """ + {% load component_tags %} + + + + {% component_css_dependencies %} + + +
OLD
+ + + + + {% component_js_dependencies %} + + + """ +``` + +### 2. Define fragment HTML + +```py title="[root]/components/demo.py" +class Frag(Component): + def get(self, request): + return self.render_to_response( + # IMPORTANT: Don't forget `type="fragment"` + type="fragment", + ) + + template = """ +
+ 123 + +
+ """ + + js = """ + document.querySelector('#frag-text').textContent = 'xxx'; + """ + + css = """ + .frag { + background: blue; + } + """ +``` + +### 3. Create view and URLs + +```py title="[app]/urls.py" +from django.urls import path + +from components.demo import MyPage, Frag + +urlpatterns = [ + path("mypage/", MyPage.as_view()) + path("mypage/frag", Frag.as_view()), +] +``` diff --git a/docs/concepts/advanced/provide_inject.md b/docs/concepts/advanced/provide_inject.md index e6df9ed4..0ba9b1c1 100644 --- a/docs/concepts/advanced/provide_inject.md +++ b/docs/concepts/advanced/provide_inject.md @@ -1,6 +1,6 @@ --- title: Prop drilling and provide / inject -weight: 2 +weight: 3 --- _New in version 0.80_: diff --git a/docs/concepts/advanced/tag_formatter.md b/docs/concepts/advanced/tag_formatter.md index 79ca5893..a5af24d6 100644 --- a/docs/concepts/advanced/tag_formatter.md +++ b/docs/concepts/advanced/tag_formatter.md @@ -1,6 +1,6 @@ --- title: Tag formatters -weight: 6 +weight: 7 --- ## Customizing component tags with TagFormatter diff --git a/docs/concepts/advanced/typing_and_validation.md b/docs/concepts/advanced/typing_and_validation.md index 63231650..e6c0842b 100644 --- a/docs/concepts/advanced/typing_and_validation.md +++ b/docs/concepts/advanced/typing_and_validation.md @@ -1,6 +1,6 @@ --- title: Typing and validation -weight: 5 +weight: 6 --- ## Adding type hints with Generics diff --git a/sampleproject/components/fragment.py b/sampleproject/components/fragment.py new file mode 100644 index 00000000..649f0368 --- /dev/null +++ b/sampleproject/components/fragment.py @@ -0,0 +1,159 @@ +from django_components import Component, types + + +# HTML into which a fragment will be loaded using vanilla JS +class FragmentBaseJs(Component): + def get(self, request): + return self.render_to_response() + + template: types.django_html = """ + {% load component_tags %} + + + + {% component_css_dependencies %} + + +
OLD
+ + + + + {% component_js_dependencies %} + + + """ + + +# HTML into which a fragment will be loaded using AlpineJs +class FragmentBaseAlpine(Component): + def get(self, request): + return self.render_to_response() + + template: types.django_html = """ + {% load component_tags %} + + + + {% component_css_dependencies %} + + + +
OLD
+ + + + {% component_js_dependencies %} + + + """ + + +# HTML into which a fragment will be loaded using HTMX +class FragmentBaseHtmx(Component): + def get(self, request): + return self.render_to_response() + + template: types.django_html = """ + {% load component_tags %} + + + + {% component_css_dependencies %} + + + +
OLD
+ + + + {% component_js_dependencies %} + + + """ + + +# Fragment where the JS and CSS are defined on the Component +class FragJs(Component): + def get(self, request): + return self.render_to_response(type="fragment") + + template: types.django_html = """ +
+ 123 + +
+ """ + + js: types.js = """ + document.querySelector('#frag-text').textContent = 'xxx'; + """ + + css: types.css = """ + .frag { + background: blue; + } + """ + + +# Fragment that defines an AlpineJS component +class FragAlpine(Component): + def get(self, request): + return self.render_to_response(type="fragment") + + # NOTE: We wrap the actual fragment in a template tag with x-if="false" to prevent it + # from being rendered until we have registered the component with AlpineJS. + template: types.django_html = """ + + """ + + js: types.js = """ + Alpine.data('frag', () => ({ + fragVal: 'xxx', + })); + + // Now that the component has been defined in AlpineJS, we can "activate" all instances + // where we use the `x-data="frag"` directive. + document.querySelectorAll('[data-name="frag"]').forEach((el) => { + el.setAttribute('x-if', 'true'); + }); + """ + + css: types.css = """ + .frag { + background: blue; + } + """ diff --git a/sampleproject/components/urls.py b/sampleproject/components/urls.py index bd6c2ee4..1d54820a 100644 --- a/sampleproject/components/urls.py +++ b/sampleproject/components/urls.py @@ -1,4 +1,5 @@ from components.calendar.calendar import Calendar, CalendarRelative +from components.fragment import FragAlpine, FragJs, FragmentBaseAlpine, FragmentBaseHtmx, FragmentBaseJs from components.greeting import Greeting from components.nested.calendar.calendar import CalendarNested from django.urls import path @@ -8,4 +9,9 @@ urlpatterns = [ path("calendar/", Calendar.as_view(), name="calendar"), path("calendar-relative/", CalendarRelative.as_view(), name="calendar-relative"), path("calendar-nested/", CalendarNested.as_view(), name="calendar-nested"), + path("fragment/base/alpine", FragmentBaseAlpine.as_view()), + path("fragment/base/htmx", FragmentBaseHtmx.as_view()), + path("fragment/base/js", FragmentBaseJs.as_view()), + path("fragment/frag/alpine", FragAlpine.as_view()), + path("fragment/frag/js", FragJs.as_view()), ] diff --git a/src/django_components/dependencies.py b/src/django_components/dependencies.py index 2e0cc42b..4710c478 100644 --- a/src/django_components/dependencies.py +++ b/src/django_components/dependencies.py @@ -1,12 +1,12 @@ """All code related to management of component dependencies (JS and CSS scripts)""" +import base64 import json import re import sys from abc import ABC, abstractmethod from functools import lru_cache from hashlib import md5 -from textwrap import dedent from typing import ( TYPE_CHECKING, Callable, @@ -34,9 +34,8 @@ from django.urls import path, reverse from django.utils.decorators import sync_and_async_middleware from django.utils.safestring import SafeString, mark_safe -import django_components.types as types from django_components.util.html import SoupNode -from django_components.util.misc import _escape_js, get_import_path +from django_components.util.misc import get_import_path if TYPE_CHECKING: from django_components.component import Component @@ -325,6 +324,9 @@ def render_dependencies(content: TContent, type: RenderType = "document") -> TCo return HttpResponse(processed_html) ``` """ + if type not in ("document", "fragment"): + raise ValueError(f"Invalid type '{type}'") + is_safestring = isinstance(content, SafeString) if isinstance(content, str): @@ -335,18 +337,24 @@ def render_dependencies(content: TContent, type: RenderType = "document") -> TCo content_, js_dependencies, css_dependencies = _process_dep_declarations(content_, type) # Replace the placeholders with the actual content + # If type == `document`, we insert the JS and CSS directly into the HTML, + # where the placeholders were. + # If type == `fragment`, we let the client-side manager load the JS and CSS, + # and remove the placeholders. did_find_js_placeholder = False did_find_css_placeholder = False + css_replacement = css_dependencies if type == "document" else b"" + js_replacement = js_dependencies if type == "document" else b"" def on_replace_match(match: "re.Match[bytes]") -> bytes: nonlocal did_find_css_placeholder nonlocal did_find_js_placeholder if match[0] == CSS_PLACEHOLDER_BYTES: - replacement = css_dependencies + replacement = css_replacement did_find_css_placeholder = True elif match[0] == JS_PLACEHOLDER_BYTES: - replacement = js_dependencies + replacement = js_replacement did_find_js_placeholder = True else: raise RuntimeError( @@ -370,6 +378,10 @@ def render_dependencies(content: TContent, type: RenderType = "document") -> TCo if maybe_transformed is not None: content_ = maybe_transformed.encode() + # In case of a fragment, we only append the JS (actually JSON) to trigger the call of dependency-manager + if type == "fragment": + content_ += js_dependencies + # Return the same type as we were given output = content_.decode() if isinstance(content, str) else content_ output = mark_safe(output) if is_safestring else output @@ -505,7 +517,8 @@ def _process_dep_declarations(content: bytes, type: RenderType) -> Tuple[bytes, # Core scripts without which the rest wouldn't work core_script_tags = Media( - js=[static("django_components/django_components.min.js")], + # NOTE: When rendering a document, the initial JS is inserted directly into the HTML + js=[static("django_components/django_components.min.js")] if type == "document" else [], ).render_js() final_script_tags = "".join( @@ -514,7 +527,7 @@ def _process_dep_declarations(content: bytes, type: RenderType) -> Tuple[bytes, *[tag for tag in core_script_tags], # Make calls to the JS dependency manager # Loads JS from `Media.js` and `Component.js` if fragment - exec_script, + *([exec_script] if exec_script else []), # JS from `Media.js` # NOTE: When rendering a document, the initial JS is inserted directly into the HTML # so the scripts are executed at proper order. In the dependency manager, we only mark those @@ -620,7 +633,7 @@ def _prepare_tags_and_urls( to_load_js_urls.append(get_script_url("js", comp_cls)) if _is_nonempty_str(comp_cls.css): - loaded_css_urls.append(get_script_url("css", comp_cls)) + to_load_css_urls.append(get_script_url("css", comp_cls)) return ( to_load_js_urls, @@ -650,9 +663,20 @@ def _get_script_tag( script = get_script_content(script_type, comp_cls) if script_type == "js": - return f"" + if "' end tag. " + "This is not allowed, as it would break the HTML." + ) + return f"" elif script_type == "css": + if "' end tag. " + "This is not allowed, as it would break the HTML." + ) + return f"" return script @@ -678,51 +702,33 @@ def _gen_exec_script( to_load_css_tags: List[str], loaded_js_urls: List[str], loaded_css_urls: List[str], -) -> str: - # Generate JS expression like so: - # ```js - # Promise.all([ - # Components.manager.loadJs(''), - # Components.manager.loadJs(''), - # Components.manager.loadCss(''), - # ]); - # ``` +) -> Optional[str]: + if not to_load_js_tags and not to_load_css_tags and not loaded_css_urls and not loaded_js_urls: + return None + + def map_to_base64(lst: List[str]) -> List[str]: + return [base64.b64encode(tag.encode()).decode() for tag in lst] + + # Generate JSON that will tell the JS dependency manager which JS and CSS to load # - # or - # - # ```js - # Components.manager.markScriptLoaded("css", "/abc/def1.css"), - # Components.manager.markScriptLoaded("css", "/abc/def2.css"), - # Components.manager.markScriptLoaded("js", "/abc/def3.js"), - # ``` - # - # NOTE: It would be better to pass only the URL itself for `loadJs/loadCss`, instead of a whole tag. + # NOTE: It would be simpler to pass only the URL itself for `loadJs/loadCss`, instead of a whole tag. # But because we allow users to specify the Media class, and thus users can # configure how the `` or `` tags in the content + exec_script_data = { + "loadedCssUrls": map_to_base64(loaded_css_urls), + "loadedJsUrls": map_to_base64(loaded_js_urls), + "toLoadCssTags": map_to_base64(to_load_css_tags), + "toLoadJsTags": map_to_base64(to_load_js_tags), + } - # Make JS array whose items are interpreted as JS statements (e.g. functions) - def js_arr(lst: List) -> str: - return "[" + ", ".join(lst) + "]" - - # NOTE: Wrap the body in self-executing function to avoid polluting the global scope. - exec_script: types.js = f""" - (() => {{ - Components.manager._loadComponentScripts({{ - loadedCssUrls: {json.dumps(loaded_css_urls)}, - loadedJsUrls: {json.dumps(loaded_js_urls)}, - toLoadCssTags: {js_arr(escaped_to_load_css_tags)}, - toLoadJsTags: {js_arr(escaped_to_load_js_tags)}, - }}); - document.currentScript.remove(); - }})(); - """ - - # NOTE: The exec script MUST be executed SYNC, so we MUST NOT put `type="module"`, - # `async`, nor `defer` on it. - # See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#notes - exec_script = f"" + # NOTE: This data is embedded into the HTML as JSON. It is the responsibility of + # the client-side code to detect that this script was inserted, and to load the + # corresponding assets + # See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#embedding_data_in_html + exec_script = json.dumps(exec_script_data) + exec_script = f'' return exec_script @@ -807,8 +813,8 @@ def cached_script_view( urlpatterns = [ - # E.g. `/components/cache/table.js/` - path("cache/./", cached_script_view, name=CACHE_ENDPOINT_NAME), + # E.g. `/components/cache/table.js` + path("cache/.", cached_script_view, name=CACHE_ENDPOINT_NAME), ] diff --git a/src/django_components/static/django_components/django_components.min.js b/src/django_components/static/django_components/django_components.min.js index 876e51ab..2b944672 100644 --- a/src/django_components/static/django_components/django_components.min.js +++ b/src/django_components/static/django_components/django_components.min.js @@ -1 +1 @@ -(()=>{var x=Array.isArray,l=n=>typeof n=="function",w=n=>n!==null&&typeof n=="object",E=n=>(w(n)||l(n))&&l(n.then)&&l(n.catch);function j(n,a){try{return a?n.apply(null,a):n()}catch(r){S(r)}}function g(n,a){if(l(n)){let r=j(n,a);return r&&E(r)&&r.catch(i=>{S(i)}),[r]}if(x(n)){let r=[];for(let i=0;i{let n=new Set,a=new Set,r={},i={},b=t=>{let e=new DOMParser().parseFromString(t,"text/html").querySelector("script");if(!e)throw Error("[Components] Failed to extract + + {% component_js_dependencies %} + + + """ + template = Template(template_str) + + frag = request.GET["frag"] + rendered_raw = template.render( + Context( + { + "frag": frag, + } + ) + ) + rendered = render_dependencies(rendered_raw) + return HttpResponse(rendered) + + +# HTML into which a fragment will be loaded using AlpineJS +def fragment_base_alpine_view(request): + template_str: types.django_html = """ + {% load component_tags %} + + + + {% component_css_dependencies %} + + + + {% component 'inner' variable='foo' / %} + +
OLD
+ + + + {% component_js_dependencies %} + + + """ + template = Template(template_str) + + frag = request.GET["frag"] + rendered_raw = template.render(Context({"frag": frag})) + rendered = render_dependencies(rendered_raw) + return HttpResponse(rendered) + + +# HTML into which a fragment will be loaded using HTMX +def fragment_base_htmx_view(request): + template_str: types.django_html = """ + {% load component_tags %} + + + + {% component_css_dependencies %} + + + + {% component 'inner' variable='foo' / %} + +
OLD
+ + + + {% component_js_dependencies %} + + + """ + template = Template(template_str) + + frag = request.GET["frag"] + rendered_raw = template.render(Context({"frag": frag})) + rendered = render_dependencies(rendered_raw) + return HttpResponse(rendered) + + +def fragment_view(request): + fragment_type = request.GET["frag"] + if fragment_type == "comp": + return FragComp.render_to_response(type="fragment") + elif fragment_type == "media": + return FragMedia.render_to_response(type="fragment") + else: + raise ValueError("Invalid fragment type") + + def alpine_in_head_view(request): template_str: types.django_html = """ {% load component_tags %} diff --git a/tests/test_component_media.py b/tests/test_component_media.py index 8121ece4..23a35b1a 100644 --- a/tests/test_component_media.py +++ b/tests/test_component_media.py @@ -49,7 +49,7 @@ class InlineComponentTest(BaseTestCase): rendered, ) self.assertInHTML( - "", + "", rendered, ) @@ -106,7 +106,7 @@ class ComponentMediaTests(BaseTestCase): self.assertEqual(rendered.count("', rendered, count=1) self.assertInHTML("", rendered, count=1) # Inlined CSS - self.assertInHTML( - "", rendered, count=1 - ) # Inlined JS + self.assertInHTML('', rendered, count=1) # Inlined JS self.assertInHTML('', rendered, count=1) # Media.css @@ -90,9 +88,7 @@ class RenderDependenciesTests(BaseTestCase): self.assertInHTML('', rendered, count=1) self.assertInHTML("", rendered, count=1) # Inlined CSS - self.assertInHTML( - "", rendered, count=1 - ) # Inlined JS + self.assertInHTML('', rendered, count=1) # Inlined JS self.assertInHTML('', rendered, count=1) # Media.css self.assertEqual(rendered.count("', rendered, count=1) self.assertInHTML("", rendered, count=1) # Inlined CSS - self.assertInHTML( - "", rendered, count=1 - ) # Inlined JS + self.assertInHTML('', rendered, count=1) # Inlined JS self.assertInHTML('', rendered, count=1) # Media.css self.assertEqual(rendered.count("', rendered_raw, count=0) # Media.css self.assertInHTML( - "", + '', rendered_raw, count=0, ) # Inlined JS @@ -184,9 +178,7 @@ class RenderDependenciesTests(BaseTestCase): self.assertInHTML('', rendered, count=1) self.assertInHTML("", rendered, count=1) # Inlined CSS - self.assertInHTML( - "", rendered, count=1 - ) # Inlined JS + self.assertInHTML('', rendered, count=1) # Inlined JS self.assertEqual(rendered.count(''), 1) # Media.css self.assertEqual(rendered.count("eval(Components.unescapeJs(`console.log("xyz");`))""", + '', rendered_body, count=1, ) @@ -286,7 +278,7 @@ class RenderDependenciesTests(BaseTestCase): count=1, ) self.assertInHTML( - """""", + '', rendered_head, count=1, ) @@ -401,6 +393,11 @@ class RenderDependenciesTests(BaseTestCase): rendered_raw = Template(template_str).render(Context({"formset": [1]})) rendered = render_dependencies(rendered_raw, type="fragment") + # Base64 encodings: + # `PGxpbmsgaHJlZj0ic3R5bGUuY3NzIiBtZWRpYT0iYWxsIiByZWw9InN0eWxlc2hlZXQiPg==` -> `` # noqa: E501 + # `PGxpbmsgaHJlZj0iL2NvbXBvbmVudHMvY2FjaGUvU2ltcGxlQ29tcG9uZW50XzMxMTA5Ny5jc3MiIG1lZGlhPSJhbGwiIHJlbD0ic3R5bGVzaGVldCI+` -> `` # noqa: E501 + # `PHNjcmlwdCBzcmM9InNjcmlwdC5qcyI+PC9zY3JpcHQ+` -> `` + # `PHNjcmlwdCBzcmM9Ii9jb21wb25lbnRzL2NhY2hlL1NpbXBsZUNvbXBvbmVudF8zMTEwOTcuanMiPjwvc2NyaXB0Pg==` -> `` # noqa: E501 expected = """ @@ -423,10 +420,49 @@ class RenderDependenciesTests(BaseTestCase):
- """ + + """ # noqa: E501 self.assertHTMLEqual(expected, rendered) + def test_raises_if_script_end_tag_inside_component_js(self): + class ComponentWithScript(SimpleComponent): + js: types.js = """ + console.log(""); + """ + + registry.register(name="test", component=ComponentWithScript) + + with self.assertRaisesMessage( + RuntimeError, + "Content of `Component.js` for component 'ComponentWithScript' contains '' end tag.", + ): + ComponentWithScript.render(kwargs={"variable": "foo"}) + + def test_raises_if_script_end_tag_inside_component_css(self): + class ComponentWithScript(SimpleComponent): + css: types.css = """ + /* */ + .xyz { + color: red; + } + """ + + registry.register(name="test", component=ComponentWithScript) + + with self.assertRaisesMessage( + RuntimeError, + "Content of `Component.css` for component 'ComponentWithScript' contains '' end tag.", + ): + ComponentWithScript.render(kwargs={"variable": "foo"}) + class MiddlewareTests(BaseTestCase): def test_middleware_response_without_content_type(self): @@ -462,9 +498,7 @@ class MiddlewareTests(BaseTestCase): self.assertInHTML('', rendered, count=1) # Inlined JS - self.assertInHTML( - "", rendered, count=1 - ) + self.assertInHTML('', rendered, count=1) # Inlined CSS self.assertInHTML("", rendered, count=1) # Media.css diff --git a/tests/test_dependency_manager.py b/tests/test_dependency_manager.py index 880270bf..21980718 100644 --- a/tests/test_dependency_manager.py +++ b/tests/test_dependency_manager.py @@ -69,7 +69,6 @@ class DependencyManagerTests(_BaseDepManagerTestCase): "loadJs", "loadCss", "markScriptLoaded", - "_loadComponentScripts", ], ) diff --git a/tests/test_dependency_rendering.py b/tests/test_dependency_rendering.py index 694080b9..9875d8b1 100644 --- a/tests/test_dependency_rendering.py +++ b/tests/test_dependency_rendering.py @@ -123,23 +123,14 @@ class DependencyRenderingTests(BaseTestCase): # Dependency manager script self.assertInHTML('', rendered, count=1) - self.assertEqual(rendered.count("', rendered, count=1) - self.assertEqual(rendered.count(" + {"loadedCssUrls": ["c3R5bGUuY3Nz"], + "loadedJsUrls": ["c2NyaXB0Lmpz"], + "toLoadCssTags": [], + "toLoadJsTags": []} + + """, + rendered, + count=1, + ) def test_single_component_with_dash_or_slash_in_name(self): registry.register(name="te-s/t", component=SimpleComponent) @@ -238,19 +221,20 @@ class DependencyRenderingTests(BaseTestCase): self.assertEqual(rendered.count(" + {"loadedCssUrls": ["c3R5bGUuY3Nz"], + "loadedJsUrls": ["c2NyaXB0Lmpz"], + "toLoadCssTags": [], + "toLoadJsTags": []} + + """, + rendered, + count=1, + ) def test_single_component_placeholder_removed(self): registry.register(name="test", component=SimpleComponent) @@ -303,19 +287,20 @@ class DependencyRenderingTests(BaseTestCase): self.assertEqual(rendered.count(" + {"loadedCssUrls": ["c3R5bGUuY3Nz"], + "loadedJsUrls": ["c2NyaXB0Lmpz"], + "toLoadCssTags": [], + "toLoadJsTags": []} + + """, + rendered, + count=1, + ) def test_all_dependencies_are_rendered_for_component_with_multiple_dependencies( self, @@ -357,19 +342,23 @@ class DependencyRenderingTests(BaseTestCase): count=1, ) - # We expect to find this: - # ```js - # Components.manager._loadComponentScripts({ - # loadedCssUrls: ["style.css", "style2.css"], - # loadedJsUrls: ["script.js", "script2.js"], - # toLoadCssTags: [], - # toLoadJsTags: [], - # }); - # ``` - self.assertEqual(rendered.count("loadedCssUrls: ["style.css", "style2.css"],"), 1) - self.assertEqual(rendered.count("loadedJsUrls: ["script.js", "script2.js""), 1) - self.assertEqual(rendered.count("toLoadCssTags: [],"), 1) - self.assertEqual(rendered.count("toLoadJsTags: [],"), 1) + # Base64 encoding: + # `c3R5bGUuY3Nz` -> `style.css` + # `c3R5bGUyLmNzcw==` -> `style2.css` + # `c2NyaXB0Lmpz` -> `script.js` + # `c2NyaXB0Mi5qcw==` -> `script2.js` + self.assertInHTML( + """ + + """, + rendered, + count=1, + ) def test_no_dependencies_with_multiple_unused_components(self): registry.register(name="inner", component=SimpleComponent) @@ -386,23 +375,14 @@ class DependencyRenderingTests(BaseTestCase): # Dependency manager script self.assertInHTML('', rendered, count=1) - self.assertEqual(rendered.count(" - - + + """, rendered, count=1, ) - # We expect to find this: - # ```js - # Components.manager._loadComponentScripts({ - # loadedCssUrls: ["/components/cache/OtherComponent_6329ae.css/", "/components/cache/SimpleComponentNested_f02d32.css/", "style.css", "style2.css", "xyz1.css"], - # loadedJsUrls: ["/components/cache/OtherComponent_6329ae.js/", "/components/cache/SimpleComponentNested_f02d32.js/", "script.js", "script2.js", "xyz1.js"], - # toLoadCssTags: [], - # toLoadJsTags: [], - # }); - # ``` - self.assertEqual( - rendered.count( - "loadedJsUrls: ["/components/cache/OtherComponent_6329ae.js/", "/components/cache/SimpleComponentNested_f02d32.js/", "script.js", "script2.js", "xyz1.js"]," - ), - 1, + # Base64 encoding: + # `c3R5bGUuY3Nz` -> `style.css` + # `c3R5bGUyLmNzcw==` -> `style2.css` + # `eHl6MS5jc3M=` -> `xyz1.css` + # `L2NvbXBvbmVudHMvY2FjaGUvT3RoZXJDb21wb25lbnRfNjMyOWFlLmNzcw==` -> `/components/cache/OtherComponent_6329ae.css` + # `L2NvbXBvbmVudHMvY2FjaGUvU2ltcGxlQ29tcG9uZW50TmVzdGVkX2YwMmQzMi5jc3M=` -> `/components/cache/SimpleComponentNested_f02d32.css` + # `L2NvbXBvbmVudHMvY2FjaGUvT3RoZXJDb21wb25lbnRfNjMyOWFlLmpz` -> `/components/cache/OtherComponent_6329ae.js` + # `L2NvbXBvbmVudHMvY2FjaGUvU2ltcGxlQ29tcG9uZW50TmVzdGVkX2YwMmQzMi5qcw==` -> `/components/cache/SimpleComponentNested_f02d32.js` + # `c2NyaXB0Lmpz` -> `script.js` + # `c2NyaXB0Mi5qcw==` -> `script2.js` + # `eHl6MS5qcw==` -> `xyz1.js` + self.assertInHTML( + """ + + """, + rendered, + count=1, ) - self.assertEqual( - rendered.count( - "loadedCssUrls: ["/components/cache/OtherComponent_6329ae.css/", "/components/cache/SimpleComponentNested_f02d32.css/", "style.css", "style2.css", "xyz1.css"]," - ), - 1, - ) - self.assertEqual(rendered.count("toLoadJsTags: [],"), 1) - self.assertEqual(rendered.count("toLoadCssTags: [],"), 1) def test_multiple_components_all_placeholders_removed(self): registry.register(name="inner", component=SimpleComponent) diff --git a/tests/test_dependency_rendering_e2e.py b/tests/test_dependency_rendering_e2e.py index 075633a9..d016a7c1 100644 --- a/tests/test_dependency_rendering_e2e.py +++ b/tests/test_dependency_rendering_e2e.py @@ -297,6 +297,214 @@ class E2eDependencyRenderingTests(BaseTestCase): await page.close() + # Fragment where JS and CSS is defined on Component class + @with_playwright + async def test_fragment_comp(self): + page: Page = await self.browser.new_page() + await page.goto(f"{TEST_SERVER_URL}/fragment/base/js?frag=comp") + + test_before_js: types.js = """() => { + const targetEl = document.querySelector("#target"); + const targetHtml = targetEl ? targetEl.outerHTML : null; + const fragEl = document.querySelector(".frag"); + const fragHtml = fragEl ? fragEl.outerHTML : null; + + return { targetHtml, fragHtml }; + }""" + + data_before = await page.evaluate(test_before_js) + + self.assertEqual(data_before["targetHtml"], '
OLD
') + self.assertEqual(data_before["fragHtml"], None) + + # Clicking button should load and insert the fragment + await page.locator("button").click() + + # Wait until both JS and CSS are loaded + await page.locator(".frag").wait_for(state="visible") + await page.wait_for_function( + "() => document.head.innerHTML.includes(' { + const targetEl = document.querySelector("#target"); + const targetHtml = targetEl ? targetEl.outerHTML : null; + const fragEl = document.querySelector(".frag"); + const fragHtml = fragEl ? fragEl.outerHTML : null; + + // Get the stylings defined via CSS + const fragBg = fragEl ? globalThis.getComputedStyle(fragEl).getPropertyValue('background') : null; + + return { targetHtml, fragHtml, fragBg }; + }""" + + data = await page.evaluate(test_js) + + self.assertEqual(data["targetHtml"], None) + self.assertHTMLEqual('
123 xxx
', data["fragHtml"]) + self.assertIn("rgb(0, 0, 255)", data["fragBg"]) # AKA 'background: blue' + + await page.close() + + # Fragment where JS and CSS is defined on Media class + @with_playwright + async def test_fragment_media(self): + page: Page = await self.browser.new_page() + await page.goto(f"{TEST_SERVER_URL}/fragment/base/js?frag=media") + + test_before_js: types.js = """() => { + const targetEl = document.querySelector("#target"); + const targetHtml = targetEl ? targetEl.outerHTML : null; + const fragEl = document.querySelector(".frag"); + const fragHtml = fragEl ? fragEl.outerHTML : null; + + return { targetHtml, fragHtml }; + }""" + + data_before = await page.evaluate(test_before_js) + + self.assertEqual(data_before["targetHtml"], '
OLD
') + self.assertEqual(data_before["fragHtml"], None) + + # Clicking button should load and insert the fragment + await page.locator("button").click() + + # Wait until both JS and CSS are loaded + await page.locator(".frag").wait_for(state="visible") + await page.wait_for_function("() => document.head.innerHTML.includes(' { + const targetEl = document.querySelector("#target"); + const targetHtml = targetEl ? targetEl.outerHTML : null; + const fragEl = document.querySelector(".frag"); + const fragHtml = fragEl ? fragEl.outerHTML : null; + + // Get the stylings defined via CSS + const fragBg = fragEl ? globalThis.getComputedStyle(fragEl).getPropertyValue('background') : null; + + return { targetHtml, fragHtml, fragBg }; + }""" + + data = await page.evaluate(test_js) + + self.assertEqual(data["targetHtml"], None) + self.assertHTMLEqual('
123 xxx
', data["fragHtml"]) + self.assertIn("rgb(0, 0, 255)", data["fragBg"]) # AKA 'background: blue' + + await page.close() + + # Fragment loaded by AlpineJS + @with_playwright + async def test_fragment_alpine(self): + page: Page = await self.browser.new_page() + await page.goto(f"{TEST_SERVER_URL}/fragment/base/alpine?frag=comp") + + test_before_js: types.js = """() => { + const targetEl = document.querySelector("#target"); + const targetHtml = targetEl ? targetEl.outerHTML : null; + const fragEl = document.querySelector(".frag"); + const fragHtml = fragEl ? fragEl.outerHTML : null; + + return { targetHtml, fragHtml }; + }""" + + data_before = await page.evaluate(test_before_js) + + self.assertEqual(data_before["targetHtml"], '
OLD
') + self.assertEqual(data_before["fragHtml"], None) + + # Clicking button should load and insert the fragment + await page.locator("button").click() + + # Wait until both JS and CSS are loaded + await page.locator(".frag").wait_for(state="visible") + await page.wait_for_function( + "() => document.head.innerHTML.includes(' { + const targetEl = document.querySelector("#target"); + const targetHtml = targetEl ? targetEl.outerHTML : null; + const fragEl = document.querySelector(".frag"); + const fragHtml = fragEl ? fragEl.outerHTML : null; + + // Get the stylings defined via CSS + const fragBg = fragEl ? globalThis.getComputedStyle(fragEl).getPropertyValue('background') : null; + + return { targetHtml, fragHtml, fragBg }; + }""" + + data = await page.evaluate(test_js) + + # NOTE: Unlike the vanilla JS tests, for the Alpine test we don't remove the targetHtml, + # but only change its contents. + self.assertInHTML( + '
123 xxx
', + data["targetHtml"], + ) + self.assertHTMLEqual(data["fragHtml"], '
123 xxx
') + self.assertIn("rgb(0, 0, 255)", data["fragBg"]) # AKA 'background: blue' + + await page.close() + + # Fragment loaded by HTMX + @with_playwright + async def test_fragment_htmx(self): + page: Page = await self.browser.new_page() + await page.goto(f"{TEST_SERVER_URL}/fragment/base/htmx?frag=comp") + + test_before_js: types.js = """() => { + const targetEl = document.querySelector("#target"); + const targetHtml = targetEl ? targetEl.outerHTML : null; + const fragEl = document.querySelector(".frag"); + const fragHtml = fragEl ? fragEl.outerHTML : null; + + return { targetHtml, fragHtml }; + }""" + + data_before = await page.evaluate(test_before_js) + + self.assertEqual(data_before["targetHtml"], '
OLD
') + self.assertEqual(data_before["fragHtml"], None) + + # Clicking button should load and insert the fragment + await page.locator("button").click() + + # Wait until both JS and CSS are loaded + await page.locator(".frag").wait_for(state="visible") + await page.wait_for_function( + "() => document.head.innerHTML.includes(' { + const targetEl = document.querySelector("#target"); + const targetHtml = targetEl ? targetEl.outerHTML : null; + const fragEl = document.querySelector(".frag"); + const fragInnerHtml = fragEl ? fragEl.innerHTML : null; + + // Get the stylings defined via CSS + const fragBg = fragEl ? globalThis.getComputedStyle(fragEl).getPropertyValue('background') : null; + + return { targetHtml, fragInnerHtml, fragBg }; + }""" + + data = await page.evaluate(test_js) + + self.assertEqual(data["targetHtml"], None) + # NOTE: We test only the inner HTML, because the element itself may or may not have + # extra CSS classes added by HTMX, which results in flaky tests. + self.assertHTMLEqual( + data["fragInnerHtml"], + '123 xxx', + ) + self.assertIn("rgb(0, 0, 255)", data["fragBg"]) # AKA 'background: blue' + + await page.close() + @with_playwright async def test_alpine__head(self): single_comp_url = TEST_SERVER_URL + "/alpine/head" From 6bb73bd8afd350db84889efc09b74255cea08979 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Thu, 19 Dec 2024 10:26:06 +0100 Subject: [PATCH 160/487] chore: bump v0.122 (#860) --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95550fa5..341e86ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Release notes +## v0.122 + +#### Feat + +- Add support for HTML fragments. HTML fragments can be rendered by passing `type="fragment"` to `Component.render()` or `Component.render_to_response()`. Read more on how to [use HTML fragments with HTMX, AlpineJS, or vanillaJS](https://EmilStenstrom.github.io/django-components/latest/concepts/advanced/html_tragments). + ## v0.121 #### Fix diff --git a/pyproject.toml b/pyproject.toml index 80c86e28..bf8e1c43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "django_components" -version = "0.121" +version = "0.122" requires-python = ">=3.8, <4.0" description = "A way to create simple reusable template components in Django." keywords = ["django", "components", "css", "js", "html"] From 85fc6e3497086f89ae57f5f1bed1796e590dfd7b Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Mon, 23 Dec 2024 12:58:03 +0100 Subject: [PATCH 161/487] fix: TemplateDoesNotExist when using {% extends %} on main template and two components with same parent template (#862) --- .gitignore | 2 + src/django_components/component.py | 5 + src/django_components/context.py | 4 + src/django_components/slots.py | 14 +- .../blocked_and_slotted_template.html | 7 + .../blocked_and_slotted_template_2.html | 7 + tests/templates/included.html | 4 + tests/test_templatetags.py | 390 -------- tests/test_templatetags_extends.py | 837 ++++++++++++++++++ 9 files changed, 876 insertions(+), 394 deletions(-) create mode 100644 tests/templates/blocked_and_slotted_template.html create mode 100644 tests/templates/blocked_and_slotted_template_2.html create mode 100644 tests/templates/included.html create mode 100644 tests/test_templatetags_extends.py diff --git a/.gitignore b/.gitignore index 1d20d106..50cb9c64 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,8 @@ poetry.lock .DS_Store .python-version site +.direnv/ +.envrc # JS, NPM Dependency directories node_modules/ diff --git a/src/django_components/component.py b/src/django_components/component.py index 1e450f06..8675cc85 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -692,6 +692,10 @@ class Component( if not isinstance(context, Context): context = RequestContext(request, context) if request else Context(context) + # Required for compatibility with Django's {% extends %} tag + # See https://github.com/EmilStenstrom/django-components/pull/859 + context.render_context.push({BLOCK_CONTEXT_KEY: context.render_context.get(BLOCK_CONTEXT_KEY, {})}) + # By adding the current input to the stack, we temporarily allow users # to access the provided context, slots, etc. Also required so users can # call `self.inject()` from within `get_context_data()`. @@ -768,6 +772,7 @@ class Component( # After rendering is done, remove the current state from the stack, which means # properties like `self.context` will no longer return the current state. self._render_stack.pop() + context.render_context.pop() return output diff --git a/src/django_components/context.py b/src/django_components/context.py index 4a222569..0d2a8503 100644 --- a/src/django_components/context.py +++ b/src/django_components/context.py @@ -22,6 +22,10 @@ def make_isolated_context_copy(context: Context) -> Context: context_copy = context.new() copy_forloop_context(context, context_copy) + # Required for compatibility with Django's {% extends %} tag + # See https://github.com/EmilStenstrom/django-components/pull/859 + context_copy.render_context = context.render_context + # Pass through our internal keys context_copy[_REGISTRY_CONTEXT_KEY] = context.get(_REGISTRY_CONTEXT_KEY, None) if _ROOT_CTX_CONTEXT_KEY in context: diff --git a/src/django_components/slots.py b/src/django_components/slots.py index d021c78d..5535d702 100644 --- a/src/django_components/slots.py +++ b/src/django_components/slots.py @@ -338,10 +338,16 @@ class SlotNode(BaseNode): # came from (or current context if configured so) used_ctx = self._resolve_slot_context(context, slot_fill) with used_ctx.update(extra_context): - # Render slot as a function - # NOTE: While `{% fill %}` tag has to opt in for the `default` and `data` variables, - # the render function ALWAYS receives them. - output = slot_fill.slot(used_ctx, kwargs, slot_ref) + # Required for compatibility with Django's {% extends %} tag + # This makes sure that the render context used outside of a component + # is the same as the one used inside the slot. + # See https://github.com/EmilStenstrom/django-components/pull/859 + render_ctx_layer = used_ctx.render_context.dicts[-2] if len(used_ctx.render_context.dicts) > 1 else {} + with used_ctx.render_context.push(render_ctx_layer): + # Render slot as a function + # NOTE: While `{% fill %}` tag has to opt in for the `default` and `data` variables, + # the render function ALWAYS receives them. + output = slot_fill.slot(used_ctx, kwargs, slot_ref) trace_msg("RENDR", "SLOT", self.trace_id, self.node_id, msg="...Done!") return output diff --git a/tests/templates/blocked_and_slotted_template.html b/tests/templates/blocked_and_slotted_template.html new file mode 100644 index 00000000..94abb18f --- /dev/null +++ b/tests/templates/blocked_and_slotted_template.html @@ -0,0 +1,7 @@ +{% load component_tags %} +{% block before_custom %}{% endblock %} + +
{% slot "header" %}Default header{% endslot %}
+
{% slot "main" %}Default main{% endslot %}
+
{% slot "footer" %}Default footer{% endslot %}
+
diff --git a/tests/templates/blocked_and_slotted_template_2.html b/tests/templates/blocked_and_slotted_template_2.html new file mode 100644 index 00000000..94abb18f --- /dev/null +++ b/tests/templates/blocked_and_slotted_template_2.html @@ -0,0 +1,7 @@ +{% load component_tags %} +{% block before_custom %}{% endblock %} + +
{% slot "header" %}Default header{% endslot %}
+
{% slot "main" %}Default main{% endslot %}
+
{% slot "footer" %}Default footer{% endslot %}
+
diff --git a/tests/templates/included.html b/tests/templates/included.html new file mode 100644 index 00000000..25ef907c --- /dev/null +++ b/tests/templates/included.html @@ -0,0 +1,4 @@ +{% extends "simple_template.html" %} +{% block before_custom %} +
BLOCK OVERRIDEN
+{% endblock %} diff --git a/tests/test_templatetags.py b/tests/test_templatetags.py index 624fd6fa..65bbfbf7 100644 --- a/tests/test_templatetags.py +++ b/tests/test_templatetags.py @@ -94,396 +94,6 @@ class TemplateInstrumentationTest(BaseTestCase): self.assertIn("simple_template.html", templates_used) -class BlockCompatTests(BaseTestCase): - @parametrize_context_behavior(["django", "isolated"]) - def test_slots_inside_extends(self): - registry.register("slotted_component", SlottedComponent) - - @register("slot_inside_extends") - class SlotInsideExtendsComponent(Component): - template: types.django_html = """ - {% extends "block_in_slot_in_component.html" %} - """ - - template: types.django_html = """ - {% load component_tags %} - {% component "slot_inside_extends" %} - {% fill "body" %} - BODY_FROM_FILL - {% endfill %} - {% endcomponent %} - """ - rendered = Template(template).render(Context()) - expected = """ - - - - -
-
BODY_FROM_FILL
-
Default footer
-
- - - """ - self.assertHTMLEqual(rendered, expected) - - @parametrize_context_behavior(["django", "isolated"]) - def test_slots_inside_include(self): - registry.register("slotted_component", SlottedComponent) - - @register("slot_inside_include") - class SlotInsideIncludeComponent(Component): - template: types.django_html = """ - {% include "block_in_slot_in_component.html" %} - """ - - template: types.django_html = """ - {% load component_tags %} - {% component "slot_inside_include" %} - {% fill "body" %} - BODY_FROM_FILL - {% endfill %} - {% endcomponent %} - """ - rendered = Template(template).render(Context()) - expected = """ - - - - -
-
BODY_FROM_FILL
-
Default footer
-
- - - """ - self.assertHTMLEqual(rendered, expected) - - @parametrize_context_behavior(["django", "isolated"]) - def test_component_inside_block(self): - registry.register("slotted_component", SlottedComponent) - template: types.django_html = """ - {% extends "block.html" %} - {% load component_tags %} - {% block body %} - {% component "slotted_component" %} - {% fill "header" %}{% endfill %} - {% fill "main" %} - TEST - {% endfill %} - {% fill "footer" %}{% endfill %} - {% endcomponent %} - {% endblock %} - """ - rendered = Template(template).render(Context()) - expected = """ - - - -
-
- -
-
TEST
-
-
-
-
- - - """ - self.assertHTMLEqual(rendered, expected) - - @parametrize_context_behavior(["django", "isolated"]) - def test_block_inside_component(self): - registry.register("slotted_component", SlottedComponent) - - template: types.django_html = """ - {% extends "block_in_component.html" %} - {% load component_tags %} - {% block body %} -
- 58 giraffes and 2 pantaloons -
- {% endblock %} - """ - rendered = Template(template).render(Context()) - expected = """ - - - - -
-
-
58 giraffes and 2 pantaloons
-
-
Default footer
-
- - - """ - self.assertHTMLEqual(rendered, expected) - - @parametrize_context_behavior(["django", "isolated"]) - def test_block_inside_component_parent(self): - registry.register("slotted_component", SlottedComponent) - - @register("block_in_component_parent") - class BlockInCompParent(Component): - template_name = "block_in_component_parent.html" - - template: types.django_html = """ - {% load component_tags %} - {% component "block_in_component_parent" %}{% endcomponent %} - """ - rendered = Template(template).render(Context()) - expected = """ - - - - -
-
-
58 giraffes and 2 pantaloons
-
-
Default footer
-
- - - """ - self.assertHTMLEqual(rendered, expected) - - @parametrize_context_behavior(["django", "isolated"]) - def test_block_does_not_affect_inside_component(self): - """ - Assert that when we call a component with `{% component %}`, that - the `{% block %}` will NOT affect the inner component. - """ - registry.register("slotted_component", SlottedComponent) - - @register("block_inside_slot_v1") - class BlockInSlotInComponent(Component): - template_name = "block_in_slot_in_component.html" - - template: types.django_html = """ - {% load component_tags %} - {% component "block_inside_slot_v1" %} - {% fill "body" %} - BODY_FROM_FILL - {% endfill %} - {% endcomponent %} - {% block inner %} - wow - {% endblock %} - """ - rendered = Template(template).render(Context()) - expected = """ - - - - -
-
BODY_FROM_FILL
-
Default footer
-
- - - wow - """ - self.assertHTMLEqual(rendered, expected) - - @parametrize_context_behavior(["django", "isolated"]) - def test_slot_inside_block__slot_default_block_default(self): - registry.register("slotted_component", SlottedComponent) - - @register("slot_inside_block") - class _SlotInsideBlockComponent(Component): - template: types.django_html = """ - {% extends "slot_inside_block.html" %} - """ - - template: types.django_html = """ - {% load component_tags %} - {% component "slot_inside_block" %}{% endcomponent %} - """ - rendered = Template(template).render(Context()) - expected = """ - - - - -
-
- Helloodiddoo - Default inner -
-
Default footer
-
- - - """ - self.assertHTMLEqual(rendered, expected) - - @parametrize_context_behavior(["django", "isolated"]) - def test_slot_inside_block__slot_default_block_override(self): - registry.clear() - registry.register("slotted_component", SlottedComponent) - - @register("slot_inside_block") - class _SlotInsideBlockComponent(Component): - template: types.django_html = """ - {% extends "slot_inside_block.html" %} - {% block inner %} - INNER BLOCK OVERRIDEN - {% endblock %} - """ - - template: types.django_html = """ - {% load component_tags %} - {% component "slot_inside_block" %}{% endcomponent %} - """ - rendered = Template(template).render(Context()) - expected = """ - - - - -
-
- Helloodiddoo - INNER BLOCK OVERRIDEN -
-
Default footer
-
- - - """ - self.assertHTMLEqual(rendered, expected) - - @parametrize_context_behavior(["isolated", "django"]) - def test_slot_inside_block__slot_overriden_block_default(self): - registry.register("slotted_component", SlottedComponent) - - @register("slot_inside_block") - class _SlotInsideBlockComponent(Component): - template: types.django_html = """ - {% extends "slot_inside_block.html" %} - """ - - template: types.django_html = """ - {% load component_tags %} - {% component "slot_inside_block" %} - {% fill "body" %} - SLOT OVERRIDEN - {% endfill %} - {% endcomponent %} - """ - rendered = Template(template).render(Context()) - expected = """ - - - - -
-
- Helloodiddoo - SLOT OVERRIDEN -
-
Default footer
-
- - - """ - self.assertHTMLEqual(rendered, expected) - - @parametrize_context_behavior(["django", "isolated"]) - def test_slot_inside_block__slot_overriden_block_overriden(self): - registry.register("slotted_component", SlottedComponent) - - @register("slot_inside_block") - class _SlotInsideBlockComponent(Component): - template: types.django_html = """ - {% extends "slot_inside_block.html" %} - {% block inner %} - {% load component_tags %} - {% slot "new_slot" %}{% endslot %} - {% endblock %} - whut - """ - - # NOTE: The "body" fill will NOT show up, because we override the `inner` block - # with a different slot. But the "new_slot" WILL show up. - template: types.django_html = """ - {% load component_tags %} - {% component "slot_inside_block" %} - {% fill "body" %} - SLOT_BODY__OVERRIDEN - {% endfill %} - {% fill "new_slot" %} - SLOT_NEW__OVERRIDEN - {% endfill %} - {% endcomponent %} - """ - rendered = Template(template).render(Context()) - expected = """ - - - - -
-
- Helloodiddoo - SLOT_NEW__OVERRIDEN -
-
Default footer
-
- - - """ - self.assertHTMLEqual(rendered, expected) - - @parametrize_context_behavior(["django", "isolated"]) - def test_inject_inside_block(self): - registry.register("slotted_component", SlottedComponent) - - @register("injectee") - class InjectComponent(Component): - template: types.django_html = """ -
injected: {{ var|safe }}
- """ - - def get_context_data(self): - var = self.inject("block_provide") - return {"var": var} - - template: types.django_html = """ - {% extends "block_in_component_provide.html" %} - {% load component_tags %} - {% block body %} - {% component "injectee" %} - {% endcomponent %} - {% endblock %} - """ - rendered = Template(template).render(Context()) - expected = """ - - - - -
-
-
injected: DepInject(hello='from_block')
-
-
Default footer
-
- - - """ - self.assertHTMLEqual(rendered, expected) - - class MultilineTagsTests(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_multiline_tags(self): diff --git a/tests/test_templatetags_extends.py b/tests/test_templatetags_extends.py new file mode 100644 index 00000000..8c6eb009 --- /dev/null +++ b/tests/test_templatetags_extends.py @@ -0,0 +1,837 @@ +"""Catch-all for tests that use template tags and don't fit other files""" + +from django.template import Context, Template + +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({"autodiscover": False}) + + +class SlottedComponent(Component): + template_name = "slotted_template.html" + + +class BlockedAndSlottedComponent(Component): + template_name = "blocked_and_slotted_template.html" + + +####################### +# TESTS +####################### + + +class ExtendsCompatTests(BaseTestCase): + @parametrize_context_behavior(["isolated", "django"]) + def test_double_extends_on_main_template_and_component_one_component(self): + registry.register("blocked_and_slotted_component", BlockedAndSlottedComponent) + + @register("extended_component") + class _ExtendedComponent(Component): + template: types.django_html = """ + {% extends "blocked_and_slotted_template.html" %} + {% block before_custom %} +
BLOCK OVERRIDEN
+ {% endblock %} + """ + + template: types.django_html = """ + {% extends 'block.html' %} + {% load component_tags %} + {% block body %} + {% component "extended_component" %} + {% fill "header" %} + SLOT OVERRIDEN + {% endfill %} + {% endcomponent %} + {% endblock %} + """ + rendered = Template(template).render(Context()) + expected = """ + + + +
+
+
BLOCK OVERRIDEN
+ +
SLOT OVERRIDEN
+
Default main
+
Default footer
+
+
+
+ + + """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["isolated", "django"]) + def test_double_extends_on_main_template_and_component_two_identical_components(self): + registry.register("blocked_and_slotted_component", BlockedAndSlottedComponent) + + @register("extended_component") + class _ExtendedComponent(Component): + template: types.django_html = """ + {% extends "blocked_and_slotted_template.html" %} + {% block before_custom %} +
BLOCK OVERRIDEN
+ {% endblock %} + """ + + template: types.django_html = """ + {% extends 'block.html' %} + {% load component_tags %} + {% block body %} + {% component "extended_component" %} + {% fill "header" %} + SLOT OVERRIDEN + {% endfill %} + {% endcomponent %} + {% component "extended_component" %} + {% fill "header" %} + SLOT OVERRIDEN 2 + {% endfill %} + {% endcomponent %} + {% endblock %} + """ + rendered = Template(template).render(Context()) + + expected = """ + + + +
+
+
BLOCK OVERRIDEN
+ +
SLOT OVERRIDEN
+
Default main
+
Default footer
+
+
BLOCK OVERRIDEN
+ +
SLOT OVERRIDEN 2
+
Default main
+
Default footer
+
+
+
+ + + """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["isolated", "django"]) + def test_double_extends_on_main_template_and_component_two_different_components_same_parent(self): + registry.register("blocked_and_slotted_component", BlockedAndSlottedComponent) + + @register("extended_component") + class _ExtendedComponent(Component): + template: types.django_html = """ + {% extends "blocked_and_slotted_template.html" %} + {% block before_custom %} +
BLOCK OVERRIDEN
+ {% endblock %} + """ + + @register("second_extended_component") + class _SecondExtendedComponent(Component): + template: types.django_html = """ + {% extends "blocked_and_slotted_template.html" %} + {% block before_custom %} +
BLOCK OVERRIDEN
+ {% endblock %} + """ + + template_str: types.django_html = """ + {% extends 'block.html' %} + {% load component_tags %} + {% block body %} + {% component "extended_component" %} + {% fill "header" %} + SLOT OVERRIDEN + {% endfill %} + {% endcomponent %} + {% component "second_extended_component" %} + {% fill "header" %} + SLOT OVERRIDEN 2 + {% endfill %} + {% endcomponent %} + {% endblock %} + """ + template = Template(template_str) + rendered = template.render(Context()) + + expected = """ + + + +
+
+
BLOCK OVERRIDEN
+ +
SLOT OVERRIDEN
+
Default main
+
Default footer
+
+
BLOCK OVERRIDEN
+ +
SLOT OVERRIDEN 2
+
Default main
+
Default footer
+
+
+
+ + + """ + + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["isolated", "django"]) + def test_double_extends_on_main_template_and_component_two_different_components_different_parent(self): + registry.register("blocked_and_slotted_component", BlockedAndSlottedComponent) + + @register("extended_component") + class _ExtendedComponent(Component): + template: types.django_html = """ + {% extends "blocked_and_slotted_template.html" %} + {% block before_custom %} +
BLOCK OVERRIDEN
+ {% endblock %} + """ + + @register("second_extended_component") + class _SecondExtendedComponent(Component): + template: types.django_html = """ + {% extends "blocked_and_slotted_template_2.html" %} + {% block before_custom %} +
BLOCK OVERRIDEN
+ {% endblock %} + """ + + template: types.django_html = """ + {% extends 'block.html' %} + {% load component_tags %} + {% block body %} + {% component "extended_component" %} + {% fill "header" %} + SLOT OVERRIDEN + {% endfill %} + {% endcomponent %} + {% component "second_extended_component" %} + {% fill "header" %} + SLOT OVERRIDEN 2 + {% endfill %} + {% endcomponent %} + {% endblock %} + """ + rendered = Template(template).render(Context()) + expected = """ + + + +
+
+
BLOCK OVERRIDEN
+ +
SLOT OVERRIDEN
+
Default main
+
Default footer
+
+
BLOCK OVERRIDEN
+ +
SLOT OVERRIDEN 2
+
Default main
+
Default footer
+
+
+
+ + + """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["isolated", "django"]) + def test_extends_on_component_one_component(self): + registry.register("blocked_and_slotted_component", BlockedAndSlottedComponent) + + @register("extended_component") + class _ExtendedComponent(Component): + template: types.django_html = """ + {% extends "blocked_and_slotted_template.html" %} + {% block before_custom %} +
BLOCK OVERRIDEN
+ {% endblock %} + """ + + template: types.django_html = """ + {% load component_tags %} + + + + {% component "extended_component" %} + {% fill "header" %} + SLOT OVERRIDEN + {% endfill %} + {% endcomponent %} + + + """ + rendered = Template(template).render(Context()) + + expected = """ + + + +
BLOCK OVERRIDEN
+ +
SLOT OVERRIDEN
+
Default main
+
Default footer
+
+ + + """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["isolated", "django"]) + def test_extends_on_component_two_component(self): + registry.register("blocked_and_slotted_component", BlockedAndSlottedComponent) + + @register("extended_component") + class _ExtendedComponent(Component): + template: types.django_html = """ + {% extends "blocked_and_slotted_template.html" %} + {% block before_custom %} +
BLOCK OVERRIDEN
+ {% endblock %} + """ + + template: types.django_html = """ + {% load component_tags %} + + + + {% component "extended_component" %} + {% fill "header" %} + SLOT OVERRIDEN + {% endfill %} + {% endcomponent %} + {% component "extended_component" %} + {% fill "header" %} + SLOT OVERRIDEN 2 + {% endfill %} + {% endcomponent %} + + + """ + rendered = Template(template).render(Context()) + + expected = """ + + + +
BLOCK OVERRIDEN
+ +
SLOT OVERRIDEN
+
Default main
+
Default footer
+
+
BLOCK OVERRIDEN
+ +
SLOT OVERRIDEN 2
+
Default main
+
Default footer
+
+ + + """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["isolated", "django"]) + def test_double_extends_on_main_template_and_nested_component(self): + registry.register("slotted_component", SlottedComponent) + registry.register("blocked_and_slotted_component", BlockedAndSlottedComponent) + + @register("extended_component") + class _ExtendedComponent(Component): + template: types.django_html = """ + {% extends "blocked_and_slotted_template.html" %} + {% block before_custom %} +
BLOCK OVERRIDEN
+ {% endblock %} + """ + + template: types.django_html = """ + {% extends 'block.html' %} + {% load component_tags %} + {% block body %} + {% component "slotted_component" %} + {% fill "main" %} + {% component "extended_component" %} + {% fill "header" %} + SLOT OVERRIDEN + {% endfill %} + {% endcomponent %} + {% endfill %} + {% endcomponent %} + {% endblock %} + """ + rendered = Template(template).render(Context()) + + expected = """ + + + +
+
+ +
Default header
+
+
BLOCK OVERRIDEN
+ +
SLOT OVERRIDEN
+
Default main
+
Default footer
+
+
+
Default footer
+
+
+
+ + + """ + + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["isolated", "django"]) + def test_double_extends_on_main_template_and_nested_component_and_include(self): + registry.register("slotted_component", SlottedComponent) + registry.register("blocked_and_slotted_component", BlockedAndSlottedComponent) + + @register("extended_component") + class _ExtendedComponent(Component): + template_name = "included.html" + + template: types.django_html = """ + {% extends 'block.html' %} + {% load component_tags %} + {% block body %} + {% include 'included.html' %} + {% component "extended_component" / %} + {% endblock %} + """ + rendered = Template(template).render(Context()) + + expected = """ + + + +
+
+ Variable: + Variable: +
+
+ + + """ + self.assertHTMLEqual(rendered, expected) + + # second rendering after cache built + rendered_2 = Template(template).render(Context()) + expected_2 = expected.replace("data-djc-id-a1bc3f", "data-djc-id-a1bc41") + self.assertHTMLEqual(rendered_2, expected_2) + + @parametrize_context_behavior(["django", "isolated"]) + def test_slots_inside_extends(self): + registry.register("slotted_component", SlottedComponent) + + @register("slot_inside_extends") + class SlotInsideExtendsComponent(Component): + template: types.django_html = """ + {% extends "block_in_slot_in_component.html" %} + """ + + template: types.django_html = """ + {% load component_tags %} + {% component "slot_inside_extends" %} + {% fill "body" %} + BODY_FROM_FILL + {% endfill %} + {% endcomponent %} + """ + rendered = Template(template).render(Context()) + expected = """ + + + + +
+
BODY_FROM_FILL
+
Default footer
+
+ + + """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["django", "isolated"]) + def test_slots_inside_include(self): + registry.register("slotted_component", SlottedComponent) + + @register("slot_inside_include") + class SlotInsideIncludeComponent(Component): + template: types.django_html = """ + {% include "block_in_slot_in_component.html" %} + """ + + template: types.django_html = """ + {% load component_tags %} + {% component "slot_inside_include" %} + {% fill "body" %} + BODY_FROM_FILL + {% endfill %} + {% endcomponent %} + """ + rendered = Template(template).render(Context()) + expected = """ + + + + +
+
BODY_FROM_FILL
+
Default footer
+
+ + + """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["django", "isolated"]) + def test_component_inside_block(self): + registry.register("slotted_component", SlottedComponent) + template: types.django_html = """ + {% extends "block.html" %} + {% load component_tags %} + {% block body %} + {% component "slotted_component" %} + {% fill "header" %}{% endfill %} + {% fill "main" %} + TEST + {% endfill %} + {% fill "footer" %}{% endfill %} + {% endcomponent %} + {% endblock %} + """ + rendered = Template(template).render(Context()) + expected = """ + + + +
+
+ +
+
TEST
+
+
+
+
+ + + """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["django", "isolated"]) + def test_block_inside_component(self): + registry.register("slotted_component", SlottedComponent) + + template: types.django_html = """ + {% extends "block_in_component.html" %} + {% load component_tags %} + {% block body %} +
+ 58 giraffes and 2 pantaloons +
+ {% endblock %} + """ + rendered = Template(template).render(Context()) + expected = """ + + + + +
+
+
58 giraffes and 2 pantaloons
+
+
Default footer
+
+ + + """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["django", "isolated"]) + def test_block_inside_component_parent(self): + registry.register("slotted_component", SlottedComponent) + + @register("block_in_component_parent") + class BlockInCompParent(Component): + template_name = "block_in_component_parent.html" + + template: types.django_html = """ + {% load component_tags %} + {% component "block_in_component_parent" %}{% endcomponent %} + """ + rendered = Template(template).render(Context()) + expected = """ + + + + +
+
+
58 giraffes and 2 pantaloons
+
+
Default footer
+
+ + + """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["django", "isolated"]) + def test_block_does_not_affect_inside_component(self): + """ + Assert that when we call a component with `{% component %}`, that + the `{% block %}` will NOT affect the inner component. + """ + registry.register("slotted_component", SlottedComponent) + + @register("block_inside_slot_v1") + class BlockInSlotInComponent(Component): + template_name = "block_in_slot_in_component.html" + + template: types.django_html = """ + {% load component_tags %} + {% component "block_inside_slot_v1" %} + {% fill "body" %} + BODY_FROM_FILL + {% endfill %} + {% endcomponent %} + {% block inner %} + wow + {% endblock %} + """ + rendered = Template(template).render(Context()) + expected = """ + + + + +
+
BODY_FROM_FILL
+
Default footer
+
+ + + wow + """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["django", "isolated"]) + def test_slot_inside_block__slot_default_block_default(self): + registry.register("slotted_component", SlottedComponent) + + @register("slot_inside_block") + class _SlotInsideBlockComponent(Component): + template: types.django_html = """ + {% extends "slot_inside_block.html" %} + """ + + template: types.django_html = """ + {% load component_tags %} + {% component "slot_inside_block" %}{% endcomponent %} + """ + rendered = Template(template).render(Context()) + expected = """ + + + + +
+
+ Helloodiddoo + Default inner +
+
Default footer
+
+ + + """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["django", "isolated"]) + def test_slot_inside_block__slot_default_block_override(self): + registry.clear() + registry.register("slotted_component", SlottedComponent) + + @register("slot_inside_block") + class _SlotInsideBlockComponent(Component): + template: types.django_html = """ + {% extends "slot_inside_block.html" %} + {% block inner %} + INNER BLOCK OVERRIDEN + {% endblock %} + """ + + template: types.django_html = """ + {% load component_tags %} + {% component "slot_inside_block" %}{% endcomponent %} + """ + rendered = Template(template).render(Context()) + expected = """ + + + + +
+
+ Helloodiddoo + INNER BLOCK OVERRIDEN +
+
Default footer
+
+ + + """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["isolated", "django"]) + def test_slot_inside_block__slot_overriden_block_default(self): + registry.register("slotted_component", SlottedComponent) + + @register("slot_inside_block") + class _SlotInsideBlockComponent(Component): + template: types.django_html = """ + {% extends "slot_inside_block.html" %} + """ + + template: types.django_html = """ + {% load component_tags %} + {% component "slot_inside_block" %} + {% fill "body" %} + SLOT OVERRIDEN + {% endfill %} + {% endcomponent %} + """ + rendered = Template(template).render(Context()) + expected = """ + + + + +
+
+ Helloodiddoo + SLOT OVERRIDEN +
+
Default footer
+
+ + + """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["django", "isolated"]) + def test_slot_inside_block__slot_overriden_block_overriden(self): + registry.register("slotted_component", SlottedComponent) + + @register("slot_inside_block") + class _SlotInsideBlockComponent(Component): + template: types.django_html = """ + {% extends "slot_inside_block.html" %} + {% block inner %} + {% load component_tags %} + {% slot "new_slot" %}{% endslot %} + {% endblock %} + whut + """ + + # NOTE: The "body" fill will NOT show up, because we override the `inner` block + # with a different slot. But the "new_slot" WILL show up. + template: types.django_html = """ + {% load component_tags %} + {% component "slot_inside_block" %} + {% fill "body" %} + SLOT_BODY__OVERRIDEN + {% endfill %} + {% fill "new_slot" %} + SLOT_NEW__OVERRIDEN + {% endfill %} + {% endcomponent %} + """ + rendered = Template(template).render(Context()) + expected = """ + + + + +
+
+ Helloodiddoo + SLOT_NEW__OVERRIDEN +
+
Default footer
+
+ + + """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["django", "isolated"]) + def test_inject_inside_block(self): + registry.register("slotted_component", SlottedComponent) + + @register("injectee") + class InjectComponent(Component): + template: types.django_html = """ +
injected: {{ var|safe }}
+ """ + + def get_context_data(self): + var = self.inject("block_provide") + return {"var": var} + + template: types.django_html = """ + {% extends "block_in_component_provide.html" %} + {% load component_tags %} + {% block body %} + {% component "injectee" %} + {% endcomponent %} + {% endblock %} + """ + rendered = Template(template).render(Context()) + expected = """ + + + + +
+
+
injected: DepInject(hello='from_block')
+
+
Default footer
+
+ + + """ + self.assertHTMLEqual(rendered, expected) From c76f8198dd497b34735fc17a0f7a678bdd811a3f Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Mon, 23 Dec 2024 13:09:46 +0100 Subject: [PATCH 162/487] chore: bump v0.123 (#863) --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 341e86ca..4f4610d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Release notes +## v0.123 + +#### Fix + +- Fix edge cases around rendering components whose templates used the `{% extends %}` template tag ([#859](https://github.com/EmilStenstrom/django-components/pull/859)) + ## v0.122 #### Feat diff --git a/pyproject.toml b/pyproject.toml index bf8e1c43..258760d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "django_components" -version = "0.122" +version = "0.123" requires-python = ">=3.8, <4.0" description = "A way to create simple reusable template components in Django." keywords = ["django", "components", "css", "js", "html"] From b2ce52dc532d46fa20faee9987109cc1ced91380 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Dec 2024 17:32:14 +0000 Subject: [PATCH 163/487] build(deps-dev): bump mypy from 1.13.0 to 1.14.0 Bumps [mypy](https://github.com/python/mypy) from 1.13.0 to 1.14.0. - [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md) - [Commits](https://github.com/python/mypy/compare/v1.13.0...v1.14.0) --- updated-dependencies: - dependency-name: mypy dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index e7e4fca6..7bf66438 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -50,7 +50,7 @@ isort==5.13.2 # via -r requirements-dev.in mccabe==0.7.0 # via flake8 -mypy==1.13.0 +mypy==1.14.0 # via -r requirements-dev.in mypy-extensions==1.0.0 # via From 8f950cddaa47eb056f2eabe3ffaa26222f4a9c55 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Dec 2024 17:39:53 +0000 Subject: [PATCH 164/487] build(deps): bump urllib3 from 2.2.3 to 2.3.0 Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.2.3 to 2.3.0. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/2.2.3...2.3.0) --- updated-dependencies: - dependency-name: urllib3 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 491f34f5..93fab1d5 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -223,7 +223,7 @@ tinycss2==1.4.0 # cssselect2 tzdata==2024.2 # via django -urllib3==2.2.3 +urllib3==2.3.0 # via requests verspec==0.1.0 # via mike From 081ef1b85fa4043728ac16dc0dd2b66a8a89c9d0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Dec 2024 17:46:55 +0000 Subject: [PATCH 165/487] build(deps): bump pymdown-extensions from 10.12 to 10.13 Bumps [pymdown-extensions](https://github.com/facelessuser/pymdown-extensions) from 10.12 to 10.13. - [Release notes](https://github.com/facelessuser/pymdown-extensions/releases) - [Commits](https://github.com/facelessuser/pymdown-extensions/compare/10.12...10.13) --- updated-dependencies: - dependency-name: pymdown-extensions dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 93fab1d5..811ae9dc 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -182,7 +182,7 @@ pycparser==2.22 # via cffi pygments==2.18.0 # via mkdocs-material -pymdown-extensions==10.12 +pymdown-extensions==10.13 # via # hatch.envs.docs # markdown-exec From 0985c8efc6a777be3479835366386d96cc834e79 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Dec 2024 17:53:12 +0000 Subject: [PATCH 166/487] build(deps): bump click from 8.1.7 to 8.1.8 Bumps [click](https://github.com/pallets/click) from 8.1.7 to 8.1.8. - [Release notes](https://github.com/pallets/click/releases) - [Changelog](https://github.com/pallets/click/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/click/compare/8.1.7...8.1.8) --- updated-dependencies: - dependency-name: click dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 811ae9dc..f5139235 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -45,7 +45,7 @@ cffi==1.17.1 # via cairocffi charset-normalizer==3.4.0 # via requests -click==8.1.7 +click==8.1.8 # via # black # mkdocs From ed26aec18d9ab96565589f2d82ccddac9bdba22a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Dec 2024 17:59:29 +0000 Subject: [PATCH 167/487] build(deps): bump jinja2 from 3.1.4 to 3.1.5 Bumps [jinja2](https://github.com/pallets/jinja) from 3.1.4 to 3.1.5. - [Release notes](https://github.com/pallets/jinja/releases) - [Changelog](https://github.com/pallets/jinja/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/jinja/compare/3.1.4...3.1.5) --- updated-dependencies: - dependency-name: jinja2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index f5139235..baf8192a 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -80,7 +80,7 @@ importlib-metadata==8.5.0 # via mike importlib-resources==6.4.5 # via mike -jinja2==3.1.4 +jinja2==3.1.5 # via # mike # mkdocs From fe67d9054760bd6298f6b50257ec1e82099893f8 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Sat, 28 Dec 2024 19:27:19 +0100 Subject: [PATCH 168/487] refactor: Backbone for passing JS and CSS variables (#861) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- CHANGELOG.md | 10 + src/django_components/component.py | 42 +- src/django_components/components/dynamic.py | 1 - src/django_components/dependencies.py | 359 +++++++++++++----- src/django_components/provide.py | 4 - .../django_components.min.js | 2 +- src/django_components/util/misc.py | 4 + src/django_components_js/README.md | 2 +- src/django_components_js/src/manager.ts | 2 +- tests/test_attributes.py | 20 +- tests/test_component.py | 46 +-- tests/test_component_as_view.py | 36 +- tests/test_component_media.py | 14 +- tests/test_context.py | 125 +++--- tests/test_dependencies.py | 85 +++-- tests/test_dependency_manager.py | 14 +- tests/test_dependency_rendering.py | 144 ++++++- tests/test_dependency_rendering_e2e.py | 106 ++++-- tests/test_expression.py | 67 +++- tests/test_registry.py | 4 +- tests/test_tag_formatter.py | 20 +- tests/test_templatetags.py | 12 +- tests/test_templatetags_component.py | 231 +++++++---- tests/test_templatetags_extends.py | 90 ++--- tests/test_templatetags_provide.py | 63 +-- tests/test_templatetags_slot_fill.py | 201 +++++----- tests/test_templatetags_templating.py | 65 ++-- tests/testutils.py | 6 + 28 files changed, 1181 insertions(+), 594 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f4610d2..1bc1f41a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Release notes +## v0.124 + +#### Refactor + +- The undocumented `Component.component_id` was removed. Instead, use `Component.id`. Changes: + + - While `component_id` was unique every time you instantiated `Component`, The new `id` is unique + every time you render the component (e.g. with `Component.render()`) + - The new `id` is available only during render, so e.g. from within `get_context_data()` + ## v0.123 #### Fix diff --git a/src/django_components/component.py b/src/django_components/component.py index 8675cc85..4f71643b 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -45,7 +45,14 @@ from django_components.context import ( get_injected_context_var, make_isolated_context_copy, ) -from django_components.dependencies import RenderType, cache_inlined_css, cache_inlined_js, postprocess_component_html +from django_components.dependencies import ( + RenderType, + cache_component_css, + cache_component_css_vars, + cache_component_js, + cache_component_js_vars, + postprocess_component_html, +) from django_components.expression import Expression, RuntimeKwargs, safe_resolve_list from django_components.node import BaseNode from django_components.slots import ( @@ -92,6 +99,7 @@ _type = type @dataclass(frozen=True) class RenderInput(Generic[ArgsType, KwargsType, SlotsType]): + id: str context: Context args: ArgsType kwargs: KwargsType @@ -297,7 +305,6 @@ class Component( def __init__( self, registered_name: Optional[str] = None, - component_id: Optional[str] = None, outer_context: Optional[Context] = None, registry: Optional[ComponentRegistry] = None, # noqa F811 ): @@ -318,7 +325,6 @@ class Component( self.registered_name: Optional[str] = registered_name self.outer_context: Context = outer_context or Context() - self.component_id = component_id or gen_id() self.registry = registry or registry_ self._render_stack: Deque[RenderStackItem[ArgsType, KwargsType, SlotsType]] = deque() # None == uninitialized, False == No types, Tuple == types @@ -331,6 +337,19 @@ class Component( def name(self) -> str: return self.registered_name or self.__class__.__name__ + @property + def id(self) -> str: + """ + Render ID - This ID is unique for every time a `Component.render()` (or equivalent) is called. + + This is useful for logging or debugging. + + Render IDs have the chance of collision 1 in 3.3M. + + Raises RuntimeError if called outside of rendering execution. + """ + return self.input.id + @property def input(self) -> RenderInput[ArgsType, KwargsType, SlotsType]: """ @@ -702,6 +721,7 @@ class Component( self._render_stack.append( RenderStackItem( input=RenderInput( + id=gen_id(), context=context, slots=slots, args=args, @@ -716,9 +736,14 @@ class Component( context_data = self.get_context_data(*args, **kwargs) self._validate_outputs(data=context_data) - # Process JS and CSS files - cache_inlined_js(self.__class__, self.js or "") - cache_inlined_css(self.__class__, self.css or "") + # Process Component's JS and CSS + js_data: Dict = {} # TODO + cache_component_js(self.__class__) + js_input_hash = cache_component_js_vars(self.__class__, js_data) if js_data else None + + css_data: Dict = {} # TODO + cache_component_css(self.__class__) + css_input_hash = cache_component_css_vars(self.__class__, css_data) if css_data else None with _prepare_template(self, context, context_data) as template: # For users, we expose boolean variables that they may check @@ -763,8 +788,10 @@ class Component( output = postprocess_component_html( component_cls=self.__class__, - component_id=self.component_id, + component_id=self.id, html_content=html_content, + css_input_hash=css_input_hash, + js_input_hash=js_input_hash, type=type, render_dependencies=render_dependencies, ) @@ -951,7 +978,6 @@ class ComponentNode(BaseNode): component: Component = component_cls( registered_name=self.name, outer_context=context, - component_id=self.node_id, registry=self.registry, ) diff --git a/src/django_components/components/dynamic.py b/src/django_components/components/dynamic.py index c8e2126c..511f5872 100644 --- a/src/django_components/components/dynamic.py +++ b/src/django_components/components/dynamic.py @@ -113,7 +113,6 @@ class DynamicComponent(Component): # NOTE: Slots are passed at component instantiation comp = comp_class( registered_name=self.registered_name, - component_id=self.component_id, outer_context=self.outer_context, registry=self.registry, ) diff --git a/src/django_components/dependencies.py b/src/django_components/dependencies.py index 4710c478..8956b52d 100644 --- a/src/django_components/dependencies.py +++ b/src/django_components/dependencies.py @@ -11,11 +11,10 @@ from typing import ( TYPE_CHECKING, Callable, Dict, - Iterable, List, Literal, - NamedTuple, Optional, + Sequence, Set, Tuple, Type, @@ -35,7 +34,7 @@ from django.utils.decorators import sync_and_async_middleware from django.utils.safestring import SafeString, mark_safe from django_components.util.html import SoupNode -from django_components.util.misc import get_import_path +from django_components.util.misc import get_import_path, is_nonempty_str if TYPE_CHECKING: from django_components.component import Component @@ -112,16 +111,21 @@ def _hash_comp_cls(comp_cls: Type["Component"]) -> str: def _gen_cache_key( comp_cls_hash: str, script_type: ScriptType, + input_hash: Optional[str], ) -> str: - return f"__components:{comp_cls_hash}:{script_type}" + if input_hash: + return f"__components:{comp_cls_hash}:{script_type}:{input_hash}" + else: + return f"__components:{comp_cls_hash}:{script_type}" def _is_script_in_cache( comp_cls: Type["Component"], script_type: ScriptType, + input_hash: Optional[str], ) -> bool: comp_cls_hash = _hash_comp_cls(comp_cls) - cache_key = _gen_cache_key(comp_cls_hash, script_type) + cache_key = _gen_cache_key(comp_cls_hash, script_type, input_hash) return comp_media_cache.has(cache_key) @@ -129,6 +133,7 @@ def _cache_script( comp_cls: Type["Component"], script: str, script_type: ScriptType, + input_hash: Optional[str], ) -> None: """ Given a component and it's inlined JS or CSS, store the JS/CSS in a cache, @@ -138,7 +143,7 @@ def _cache_script( # E.g. `__components:MyButton:js:df7c6d10` if script_type in ("js", "css"): - cache_key = _gen_cache_key(comp_cls_hash, script_type) + cache_key = _gen_cache_key(comp_cls_hash, script_type, input_hash) else: raise ValueError(f"Unexpected script_type '{script_type}'") @@ -147,33 +152,115 @@ def _cache_script( comp_media_cache.set(cache_key, script.strip()) -def cache_inlined_js(comp_cls: Type["Component"], content: str) -> None: - if not _is_nonempty_str(comp_cls.js): - return +def cache_component_js(comp_cls: Type["Component"]) -> None: + """ + Cache the content from `Component.js`. This is the common JS that's shared + among all instances of the same component. So even if the component is rendered multiple + times, this JS is loaded only once. + """ + if not comp_cls.js or not is_nonempty_str(comp_cls.js) or _is_script_in_cache(comp_cls, "js", None): + return None - # Prepare the script that's common to all instances of the same component - # E.g. `my_table.js` - if not _is_script_in_cache(comp_cls, "js"): + _cache_script( + comp_cls=comp_cls, + script=comp_cls.js, + script_type="js", + input_hash=None, + ) + + +# NOTE: In CSS, we link the CSS vars to the component via a stylesheet that defines +# the CSS vars under `[data-djc-css-a1b2c3]`. Because of this we define the variables +# separately from the rest of the CSS definition. +# +# We use conceptually similar approach for JS, except in JS we have to manually associate +# the JS variables ("stylesheet") with the target HTML element ("component"). +# +# It involves 3 steps: +# 1. Register the common logic (equivalent to registering common CSS). +# with `Components.manager.registerComponent`. +# 2. Register the unique set of JS variables (equivalent to defining CSS vars) +# with `Components.manager.registerComponentData`. +# 3. Actually run a component's JS instance with `Components.manager.callComponent`, +# specifying the components HTML elements with `component_id`, and JS vars with `input_hash`. +def cache_component_js_vars(comp_cls: Type["Component"], js_vars: Dict) -> Optional[str]: + if not is_nonempty_str(comp_cls.js): + return None + + # The hash for the file that holds the JS variables is derived from the variables themselves. + json_data = json.dumps(js_vars) + input_hash = md5(json_data.encode()).hexdigest()[0:6] + + # Generate and cache a JS script that contains the JS variables. + if not _is_script_in_cache(comp_cls, "js", input_hash): _cache_script( comp_cls=comp_cls, - script=content, + script="", # TODO script_type="js", + input_hash=input_hash, ) + return input_hash -def cache_inlined_css(comp_cls: Type["Component"], content: str) -> None: - if not _is_nonempty_str(comp_cls.js): - return - # Prepare the script that's common to all instances of the same component - if not _is_script_in_cache(comp_cls, "css"): - # E.g. `my_table.css` +def wrap_component_js(comp_cls: Type["Component"], content: str) -> SafeString: + if "' end tag. " + "This is not allowed, as it would break the HTML." + ) + return f"" + + +def cache_component_css(comp_cls: Type["Component"]) -> None: + """ + Cache the content from `Component.css`. This is the common CSS that's shared + among all instances of the same component. So even if the component is rendered multiple + times, this CSS is loaded only once. + """ + if not comp_cls.css or not is_nonempty_str(comp_cls.css) or _is_script_in_cache(comp_cls, "css", None): + return None + + _cache_script( + comp_cls=comp_cls, + script=comp_cls.css, + script_type="css", + input_hash=None, + ) + + +# NOTE: In CSS, we link the CSS vars to the component via a stylesheet that defines +# the CSS vars under the CSS selector `[data-djc-css-a1b2c3]`. We define the stylesheet +# with variables separately from `Component.css`, because different instances may return different +# data from `get_css_data()`, which will live in different stylesheets. +def cache_component_css_vars(comp_cls: Type["Component"], css_vars: Dict) -> Optional[str]: + if not is_nonempty_str(comp_cls.css): + return None + + # The hash for the file that holds the CSS variables is derived from the variables themselves. + json_data = json.dumps(css_vars) + input_hash = md5(json_data.encode()).hexdigest()[0:6] + + # Generate and cache a CSS stylesheet that contains the CSS variables. + if not _is_script_in_cache(comp_cls, "css", input_hash): _cache_script( comp_cls=comp_cls, - script=content, + script="", # TODO script_type="css", + input_hash=input_hash, ) + return input_hash + + +def wrap_component_css(comp_cls: Type["Component"], content: str) -> SafeString: + if "' end tag. " + "This is not allowed, as it would break the HTML." + ) + return f"" + ######################################################### # 2. Modify the HTML to use the same IDs defined in previous @@ -183,16 +270,43 @@ def cache_inlined_css(comp_cls: Type["Component"], content: str) -> None: ######################################################### -class Dependencies(NamedTuple): - # NOTE: We pass around the component CLASS, so the dependencies logic is not - # dependent on ComponentRegistries - component_cls: Type["Component"] - component_id: str +def _link_dependencies_with_component_html( + component_id: str, + html_content: str, + css_input_hash: Optional[str], +) -> str: + elems = SoupNode.from_fragment(html_content) + + # Insert component ID + for elem in elems: + # Ignore comments, text, doctype, etc. + if not elem.is_element(): + continue + + # Component ID is used for executing JS script, e.g. `data-djc-id-a1b2c3` + # + # NOTE: We use `data-djc-css-a1b2c3` and `data-djc-id-a1b2c3` instead of + # `data-djc-css="a1b2c3"` and `data-djc-id="a1b2c3"`, to allow + # multiple values to be associated with the same element, which may happen if + # One component renders another. + elem.set_attr(f"data-djc-id-{component_id}", True) + + # Attribute by which we bind the CSS variables to the component's CSS, + # e.g. `data-djc-css-a1b2c3` + if css_input_hash: + elem.set_attr(f"data-djc-css-{css_input_hash}", True) + + return SoupNode.to_html_multiroot(elems) def _insert_component_comment( content: str, - deps: Dependencies, + # NOTE: We pass around the component CLASS, so the dependencies logic is not + # dependent on ComponentRegistries + component_cls: Type["Component"], + component_id: str, + js_input_hash: Optional[str], + css_input_hash: Optional[str], ) -> str: """ Given some textual content, prepend it with a short string that @@ -200,14 +314,14 @@ def _insert_component_comment( declared JS / CSS scripts. """ # Add components to the cache - comp_cls_hash = _hash_comp_cls(deps.component_cls) - comp_hash_mapping[comp_cls_hash] = deps.component_cls + comp_cls_hash = _hash_comp_cls(component_cls) + comp_hash_mapping[comp_cls_hash] = component_cls - data = f"{comp_cls_hash},{deps.component_id}" + data = f"{comp_cls_hash},{component_id},{js_input_hash or ''},{css_input_hash or ''}" # NOTE: It's important that we put the comment BEFORE the content, so we can # use the order of comments to evaluate components' instance JS code in the correct order. - output = mark_safe(COMPONENT_DEPS_COMMENT.format(data=data)) + content + output = mark_safe(COMPONENT_DEPS_COMMENT.format(data=data) + content) return output @@ -217,9 +331,18 @@ def postprocess_component_html( component_cls: Type["Component"], component_id: str, html_content: str, + css_input_hash: Optional[str], + js_input_hash: Optional[str], type: RenderType, render_dependencies: bool, ) -> str: + # Make the HTML work with JS and CSS dependencies + html_content = _link_dependencies_with_component_html( + component_id=component_id, + html_content=html_content, + css_input_hash=css_input_hash, + ) + # NOTE: To better understand the next section, consider this: # # We define and cache the component's JS and CSS at the same time as @@ -242,10 +365,10 @@ def postprocess_component_html( # scripts are associated with it. output = _insert_component_comment( html_content, - Dependencies( - component_cls=component_cls, - component_id=component_id, - ), + component_cls=component_cls, + component_id=component_id, + js_input_hash=js_input_hash, + css_input_hash=css_input_hash, ) if render_dependencies: @@ -266,21 +389,34 @@ def postprocess_component_html( TContent = TypeVar("TContent", bound=Union[bytes, str]) -CSS_DEPENDENCY_PLACEHOLDER = '' -JS_DEPENDENCY_PLACEHOLDER = '' - -CSS_PLACEHOLDER_BYTES = bytes(CSS_DEPENDENCY_PLACEHOLDER, encoding="utf-8") -JS_PLACEHOLDER_BYTES = bytes(JS_DEPENDENCY_PLACEHOLDER, encoding="utf-8") +CSS_PLACEHOLDER_NAME = "CSS_PLACEHOLDER" +CSS_PLACEHOLDER_NAME_B = CSS_PLACEHOLDER_NAME.encode() +JS_PLACEHOLDER_NAME = "JS_PLACEHOLDER" +JS_PLACEHOLDER_NAME_B = JS_PLACEHOLDER_NAME.encode() +CSS_DEPENDENCY_PLACEHOLDER = f'' +JS_DEPENDENCY_PLACEHOLDER = f'' COMPONENT_DEPS_COMMENT = "" -# E.g. `` -COMPONENT_COMMENT_REGEX = re.compile(rb"") -# E.g. `table,123` -SCRIPT_NAME_REGEX = re.compile(rb"^(?P[\w\-\./]+?),(?P[\w]+?)$") + +# E.g. `` +COMPONENT_COMMENT_REGEX = re.compile(rb"") +# E.g. `table,123,a92ef298,bd002c3` +# - comp_cls_hash - Cache key of the component class that was rendered +# - id - Component render ID +# - js - Cache key for the JS data from `get_js_data()` +# - css - Cache key for the CSS data from `get_css_data()` +SCRIPT_NAME_REGEX = re.compile( + rb"^(?P[\w\-\./]+?),(?P[\w]+?),(?P[0-9a-f]*?),(?P[0-9a-f]*?)$" +) +# E.g. `data-djc-id-a1b2c3` +MAYBE_COMP_ID = r"(?: data-djc-id-\w{6})?" +# E.g. `data-djc-css-99914b` +MAYBE_COMP_CSS_ID = r"(?: data-djc-css-\w{6})?" + PLACEHOLDER_REGEX = re.compile( r"{css_placeholder}|{js_placeholder}".format( - css_placeholder=CSS_DEPENDENCY_PLACEHOLDER, - js_placeholder=JS_DEPENDENCY_PLACEHOLDER, + css_placeholder=f'', + js_placeholder=f'', ).encode() ) @@ -350,10 +486,10 @@ def render_dependencies(content: TContent, type: RenderType = "document") -> TCo nonlocal did_find_css_placeholder nonlocal did_find_js_placeholder - if match[0] == CSS_PLACEHOLDER_BYTES: + if CSS_PLACEHOLDER_NAME_B in match[0]: replacement = css_replacement did_find_css_placeholder = True - elif match[0] == JS_PLACEHOLDER_BYTES: + elif JS_PLACEHOLDER_NAME_B in match[0]: replacement = js_replacement did_find_js_placeholder = True else: @@ -418,11 +554,11 @@ def _process_dep_declarations(content: bytes, type: RenderType) -> Tuple[bytes, Process a textual content that may include metadata on rendered components. The metadata has format like this - `` + `` E.g. - `` + `` """ # Extract all matched instances of `` while also removing them from the text all_parts: List[bytes] = list() @@ -436,23 +572,49 @@ def _process_dep_declarations(content: bytes, type: RenderType) -> Tuple[bytes, # NOTE: Python's set does NOT preserve order seen_comp_hashes: Set[str] = set() comp_hashes: List[str] = [] + # Used for passing Python vars to JS/CSS + inputs_data: List[Tuple[str, ScriptType, Optional[str]]] = [] + comp_data: List[Tuple[str, ScriptType, Optional[str]]] = [] - # Process individual parts. Each part is like a CSV row of `name,id`. + # Process individual parts. Each part is like a CSV row of `name,id,js,css`. # E.g. something like this: - # `table_10bac31,1234` + # `table_10bac31,1234,a92ef298,a92ef298` for part in all_parts: part_match = SCRIPT_NAME_REGEX.match(part) if not part_match: raise RuntimeError("Malformed dependencies data") - comp_cls_hash = part_match.group("comp_cls_hash").decode("utf-8") + comp_cls_hash: str = part_match.group("comp_cls_hash").decode("utf-8") + js_input_hash: Optional[str] = part_match.group("js").decode("utf-8") or None + css_input_hash: Optional[str] = part_match.group("css").decode("utf-8") or None + if comp_cls_hash in seen_comp_hashes: continue comp_hashes.append(comp_cls_hash) seen_comp_hashes.add(comp_cls_hash) + # Schedule to load the `' end tag. " - "This is not allowed, as it would break the HTML." - ) - return f"" - + content = wrap_component_js(comp_cls, content) elif script_type == "css": - if "' end tag. " - "This is not allowed, as it would break the HTML." - ) + content = wrap_component_css(comp_cls, content) + else: + raise ValueError(f"Unexpected script_type '{script_type}'") - return f"" - - return script + return content def get_script_url( script_type: ScriptType, comp_cls: Type["Component"], + input_hash: Optional[str], ) -> str: comp_cls_hash = _hash_comp_cls(comp_cls) @@ -693,6 +855,7 @@ def get_script_url( kwargs={ "comp_cls_hash": comp_cls_hash, "script_type": script_type, + **({"input_hash": input_hash} if input_hash is not None else {}), }, ) @@ -703,10 +866,11 @@ def _gen_exec_script( loaded_js_urls: List[str], loaded_css_urls: List[str], ) -> Optional[str]: - if not to_load_js_tags and not to_load_css_tags and not loaded_css_urls and not loaded_js_urls: + # Return None if all lists are empty + if not any([to_load_js_tags, to_load_css_tags, loaded_css_urls, loaded_js_urls]): return None - def map_to_base64(lst: List[str]) -> List[str]: + def map_to_base64(lst: Sequence[str]) -> List[str]: return [base64.b64encode(tag.encode()).decode() for tag in lst] # Generate JSON that will tell the JS dependency manager which JS and CSS to load @@ -797,14 +961,16 @@ def cached_script_view( req: HttpRequest, comp_cls_hash: str, script_type: ScriptType, + input_hash: Optional[str] = None, ) -> HttpResponse: if req.method != "GET": return HttpResponseNotAllowed(["GET"]) - # Otherwise check if the file is among the dynamically generated files in the cache - cache_key = _gen_cache_key(comp_cls_hash, script_type) - script = comp_media_cache.get(cache_key) + comp_cls = comp_hash_mapping.get(comp_cls_hash) + if comp_cls is None: + return HttpResponseNotFound() + script = get_script_content(script_type, comp_cls, input_hash) if script is None: return HttpResponseNotFound() @@ -813,7 +979,8 @@ def cached_script_view( urlpatterns = [ - # E.g. `/components/cache/table.js` + # E.g. `/components/cache/table.js` or `/components/cache/table.0ab2c3.js` + path("cache/..", cached_script_view, name=CACHE_ENDPOINT_NAME), path("cache/.", cached_script_view, name=CACHE_ENDPOINT_NAME), ] diff --git a/src/django_components/provide.py b/src/django_components/provide.py index 2c168353..f34133fb 100644 --- a/src/django_components/provide.py +++ b/src/django_components/provide.py @@ -8,7 +8,6 @@ from django_components.context import set_provided_context_var from django_components.expression import RuntimeKwargs from django_components.node import BaseNode from django_components.util.logger import trace_msg -from django_components.util.misc import gen_id PROVIDE_NAME_KWARG = "name" @@ -28,10 +27,7 @@ class ProvideNode(BaseNode): ): super().__init__(nodelist=nodelist, args=None, kwargs=kwargs, node_id=node_id) - self.nodelist = nodelist - self.node_id = node_id or gen_id() self.trace_id = trace_id - self.kwargs = kwargs or RuntimeKwargs({}) def __repr__(self) -> str: return f"" diff --git a/src/django_components/static/django_components/django_components.min.js b/src/django_components/static/django_components/django_components.min.js index 2b944672..89b7f3cf 100644 --- a/src/django_components/static/django_components/django_components.min.js +++ b/src/django_components/static/django_components/django_components.min.js @@ -1 +1 @@ -(()=>{var x=o=>new DOMParser().parseFromString(o,"text/html").documentElement.textContent,E=Array.isArray,m=o=>typeof o=="function",H=o=>o!==null&&typeof o=="object",S=o=>(H(o)||m(o))&&m(o.then)&&m(o.catch);function N(o,i){try{return i?o.apply(null,i):o()}catch(s){L(s)}}function g(o,i){if(m(o)){let s=N(o,i);return s&&S(s)&&s.catch(c=>{L(c)}),[s]}if(E(o)){let s=[];for(let c=0;c{let i=new MutationObserver(s=>{for(let c of s)c.type==="childList"&&c.addedNodes.forEach(p=>{p.nodeName==="SCRIPT"&&p.hasAttribute("data-djc")&&o(p)})});return i.observe(document,{childList:!0,subtree:!0}),i};var y=()=>{let o=new Set,i=new Set,s={},c={},p=t=>{let e=new DOMParser().parseFromString(t,"text/html").querySelector("script");if(!e)throw Error("[Components] Failed to extract '), 1) self.assertEqual(rendered_raw.count(" - Variable: foo + Variable: foo @@ -415,7 +422,7 @@ class RenderDependenciesTests(BaseTestCase): 1 - Variable: hi + Variable: hi @@ -471,37 +478,59 @@ class MiddlewareTests(BaseTestCase): request = Mock() self.assertEqual(response, middleware(request=request)) - def test_middleware_response_with_components_with_slash_dash_and_underscore( - self, - ): + def test_middleware_response_with_components_with_slash_dash_and_underscore(self): registry.register("dynamic", DynamicComponent) + registry.register("test-component", component=SimpleComponent) + registry.register("test/component", component=SimpleComponent) + registry.register("test_component", component=SimpleComponent) - component_names = [ - "test-component", - "test/component", - "test_component", - ] - for component_name in component_names: - registry.register(name=component_name, component=SimpleComponent) - template_str: types.django_html = """ - {% load component_tags %} - {% component_css_dependencies %} - {% component_js_dependencies %} - {% component "dynamic" is=component_name variable='value' / %} - """ - template = Template(template_str) - rendered = create_and_process_template_response( - template, context=Context({"component_name": component_name}) - ) + template_str: types.django_html = """ + {% load component_tags %} + {% component_css_dependencies %} + {% component_js_dependencies %} + {% component "dynamic" is=component_name variable='value' / %} + """ + template = Template(template_str) + def assert_dependencies(content: str): # Dependency manager script (empty) - self.assertInHTML('', rendered, count=1) + self.assertInHTML('', content, count=1) # Inlined JS - self.assertInHTML('', rendered, count=1) + self.assertInHTML('', content, count=1) # Inlined CSS - self.assertInHTML("", rendered, count=1) + self.assertInHTML("", content, count=1) # Media.css - self.assertInHTML('', rendered, count=1) + self.assertInHTML('', content, count=1) - self.assertEqual(rendered.count("Variable: value"), 1) + rendered1 = create_and_process_template_response( + template, + context=Context({"component_name": "test-component"}), + ) + + assert_dependencies(rendered1) + self.assertEqual( + rendered1.count('Variable: value'), + 1, + ) + + rendered2 = create_and_process_template_response( + template, + context=Context({"component_name": "test-component"}), + ) + assert_dependencies(rendered2) + self.assertEqual( + rendered2.count('Variable: value'), + 1, + ) + + rendered3 = create_and_process_template_response( + template, + context=Context({"component_name": "test_component"}), + ) + + assert_dependencies(rendered3) + self.assertEqual( + rendered3.count('Variable: value'), + 1, + ) diff --git a/tests/test_dependency_manager.py b/tests/test_dependency_manager.py index 21980718..6eb4d86d 100644 --- a/tests/test_dependency_manager.py +++ b/tests/test_dependency_manager.py @@ -215,7 +215,7 @@ class CallComponentTests(_BaseDepManagerTestCase): const inputHash = 'input-abc'; // Pretend that this HTML belongs to our component - document.body.insertAdjacentHTML('beforeend', '
abc
'); + document.body.insertAdjacentHTML('beforeend', '
abc
'); let captured = null; manager.registerComponent(compName, (data, ctx) => { @@ -248,7 +248,7 @@ class CallComponentTests(_BaseDepManagerTestCase): "hello": "world", }, "ctx": { - "els": ['
abc
'], + "els": ['
abc
'], "id": "12345", "name": "my_comp", }, @@ -269,7 +269,7 @@ class CallComponentTests(_BaseDepManagerTestCase): const inputHash = 'input-abc'; // Pretend that this HTML belongs to our component - document.body.insertAdjacentHTML('beforeend', '
abc
'); + document.body.insertAdjacentHTML('beforeend', '
abc
'); manager.registerComponent(compName, (data, ctx) => { return Promise.resolve(123); @@ -309,7 +309,7 @@ class CallComponentTests(_BaseDepManagerTestCase): const inputHash = 'input-abc'; // Pretend that this HTML belongs to our component - document.body.insertAdjacentHTML('beforeend', '
abc
'); + document.body.insertAdjacentHTML('beforeend', '
abc
'); manager.registerComponent(compName, (data, ctx) => { throw Error('Oops!'); @@ -343,7 +343,7 @@ class CallComponentTests(_BaseDepManagerTestCase): const inputHash = 'input-abc'; // Pretend that this HTML belongs to our component - document.body.insertAdjacentHTML('beforeend', '
abc
'); + document.body.insertAdjacentHTML('beforeend', '
abc
'); manager.registerComponent(compName, async (data, ctx) => { throw Error('Oops!'); @@ -408,7 +408,7 @@ class CallComponentTests(_BaseDepManagerTestCase): const compId = '12345'; const inputHash = 'input-abc'; - document.body.insertAdjacentHTML('beforeend', '
abc
'); + document.body.insertAdjacentHTML('beforeend', '
abc
'); manager.registerComponent(compName, (data, ctx) => { return Promise.resolve(123); @@ -434,7 +434,7 @@ class CallComponentTests(_BaseDepManagerTestCase): const compId = '12345'; const inputHash = 'input-abc'; - document.body.insertAdjacentHTML('beforeend', '
abc
'); + document.body.insertAdjacentHTML('beforeend', '
abc
'); manager.registerComponentData(compName, inputHash, () => { return { hello: 'world' }; diff --git a/tests/test_dependency_rendering.py b/tests/test_dependency_rendering.py index 9875d8b1..7b8b5bc9 100644 --- a/tests/test_dependency_rendering.py +++ b/tests/test_dependency_rendering.py @@ -5,7 +5,7 @@ During actual rendering, the HTML is then picked up by the JS-side dependency ma import re -from django.template import Template +from django.template import Context, Template from django_components import Component, registry, types @@ -456,8 +456,8 @@ class DependencyRenderingTests(BaseTestCase): # `c3R5bGUyLmNzcw==` -> `style2.css` # `eHl6MS5jc3M=` -> `xyz1.css` # `L2NvbXBvbmVudHMvY2FjaGUvT3RoZXJDb21wb25lbnRfNjMyOWFlLmNzcw==` -> `/components/cache/OtherComponent_6329ae.css` - # `L2NvbXBvbmVudHMvY2FjaGUvU2ltcGxlQ29tcG9uZW50TmVzdGVkX2YwMmQzMi5jc3M=` -> `/components/cache/SimpleComponentNested_f02d32.css` # `L2NvbXBvbmVudHMvY2FjaGUvT3RoZXJDb21wb25lbnRfNjMyOWFlLmpz` -> `/components/cache/OtherComponent_6329ae.js` + # `L2NvbXBvbmVudHMvY2FjaGUvU2ltcGxlQ29tcG9uZW50TmVzdGVkX2YwMmQzMi5jc3M=` -> `/components/cache/SimpleComponentNested_f02d32.css` # `L2NvbXBvbmVudHMvY2FjaGUvU2ltcGxlQ29tcG9uZW50TmVzdGVkX2YwMmQzMi5qcw==` -> `/components/cache/SimpleComponentNested_f02d32.js` # `c2NyaXB0Lmpz` -> `script.js` # `c2NyaXB0Mi5qcw==` -> `script2.js` @@ -465,8 +465,16 @@ class DependencyRenderingTests(BaseTestCase): self.assertInHTML( """ @@ -491,3 +499,131 @@ class DependencyRenderingTests(BaseTestCase): template = Template(template_str) rendered = create_and_process_template_response(template) self.assertNotIn("_RENDERED", rendered) + + def test_adds_component_id_html_attr_single(self): + registry.register(name="test", component=SimpleComponent) + + template_str: types.django_html = """ + {% load component_tags %} + {% component 'test' variable='foo' / %} + """ + template = Template(template_str) + rendered = create_and_process_template_response(template) + + self.assertHTMLEqual(rendered, "Variable: foo") + + def test_adds_component_id_html_attr_single_multiroot(self): + class SimpleMultiroot(SimpleComponent): + template: types.django_html = """ + Variable: {{ variable }} + Variable2:
{{ variable }}
+ Variable3: {{ variable }} + """ + + registry.register(name="test", component=SimpleMultiroot) + + template_str: types.django_html = """ + {% load component_tags %} + {% component 'test' variable='foo' / %} + """ + template = Template(template_str) + rendered = create_and_process_template_response(template) + + self.assertHTMLEqual( + rendered, + """ + Variable: foo + Variable2:
foo
+ Variable3: foo + """, + ) + + # Test that, if multiple components share the same root HTML elements, + # then those elemens will have the `data-djc-id-` attribute added for each component. + def test_adds_component_id_html_attr_nested(self): + class SimpleMultiroot(SimpleComponent): + template: types.django_html = """ + Variable: {{ variable }} + Variable2:
{{ variable }}
+ Variable3: {{ variable }} + """ + + class SimpleOuter(SimpleComponent): + template: types.django_html = """ + {% load component_tags %} + {% component 'multiroot' variable='foo' / %} +
Another
+ """ + + registry.register(name="multiroot", component=SimpleMultiroot) + registry.register(name="outer", component=SimpleOuter) + + template_str: types.django_html = """ + {% load component_tags %} + {% component 'outer' variable='foo' / %} + """ + template = Template(template_str) + rendered = create_and_process_template_response(template) + + self.assertHTMLEqual( + rendered, + """ + Variable: foo + Variable2:
foo
+ Variable3: foo +
Another
+ """, + ) + + # `data-djc-id-` attribute should be added on each instance in the RESULTING HTML. + # So if in a loop, each iteration creates a new component, and each of those should + # have a unique `data-djc-id-` attribute. + def test_adds_component_id_html_attr_loops(self): + class SimpleMultiroot(SimpleComponent): + template: types.django_html = """ + Variable: {{ variable }} + Variable2:
{{ variable }}
+ Variable3: {{ variable }} + """ + + class SimpleOuter(SimpleComponent): + template: types.django_html = """ + {% load component_tags %} + {% component 'multiroot' variable='foo' / %} +
Another
+ """ + + registry.register(name="multiroot", component=SimpleMultiroot) + registry.register(name="outer", component=SimpleOuter) + + template_str: types.django_html = """ + {% load component_tags %} + {% for i in lst %} + {% component 'outer' variable='foo' / %} + {% endfor %} + """ + template = Template(template_str) + rendered = create_and_process_template_response( + template, + context=Context({"lst": range(3)}), + ) + + self.assertHTMLEqual( + rendered, + """ + Variable: foo + Variable2:
foo
+ Variable3: foo +
Another
+ + Variable: foo + Variable2:
foo
+ Variable3: foo +
Another
+ + Variable: foo + Variable2:
foo
+ Variable3: foo +
Another
+ """, + ) diff --git a/tests/test_dependency_rendering_e2e.py b/tests/test_dependency_rendering_e2e.py index d016a7c1..127407fe 100644 --- a/tests/test_dependency_rendering_e2e.py +++ b/tests/test_dependency_rendering_e2e.py @@ -3,6 +3,8 @@ Here we check that all parts of managing JS and CSS dependencies work together in an actual browser. """ +import re + from playwright.async_api import Page from django_components import types @@ -44,7 +46,10 @@ class E2eDependencyRenderingTests(BaseTestCase): data = await page.evaluate(test_js) # Check that the actual HTML content was loaded - self.assertIn('Variable: foo', data["bodyHTML"]) + self.assertRegex( + data["bodyHTML"], + re.compile(r'Variable: foo'), + ) self.assertInHTML('
123
', data["bodyHTML"], count=1) self.assertInHTML('
xyz
', data["bodyHTML"], count=1) @@ -106,17 +111,34 @@ class E2eDependencyRenderingTests(BaseTestCase): data = await page.evaluate(test_js) # Check that the actual HTML content was loaded - self.assertInHTML( - """ -
- Variable: variable - XYZ: variable_inner -
-
123
-
xyz
- """, + self.assertRegex( data["bodyHTML"], - count=1, + #
+ # Variable: + # + # variable + # + # XYZ: + # + # variable_inner + # + #
+ #
123
+ #
xyz
+ re.compile( + r'
\s*' + r"Variable:\s*" + r'\s*' + r"variable\s*" + r"<\/strong>\s*" + r"XYZ:\s*" + r'\s*' + r"variable_inner\s*" + r"<\/strong>\s*" + r"<\/div>\s*" + r'
123<\/div>\s*' + r'
xyz<\/div>\s*' + ), ) # Check components' inlined JS got loaded @@ -183,17 +205,34 @@ class E2eDependencyRenderingTests(BaseTestCase): data = await page.evaluate(test_js) # Check that the actual HTML content was loaded - self.assertInHTML( - """ -
- Variable: variable - XYZ: variable_inner -
-
123
-
xyz
- """, + self.assertRegex( data["bodyHTML"], - count=1, + #
+ # Variable: + # + # variable + # + # XYZ: + # + # variable_inner + # + #
+ #
123
+ #
xyz
+ re.compile( + r'
\s*' + r"Variable:\s*" + r'\s*' + r"variable\s*" + r"<\/strong>\s*" + r"XYZ:\s*" + r'\s*' + r"variable_inner\s*" + r"<\/strong>\s*" + r"<\/div>\s*" + r'
123<\/div>\s*' + r'
xyz<\/div>\s*' + ), ) # Check components' inlined JS did NOT get loaded @@ -342,7 +381,12 @@ class E2eDependencyRenderingTests(BaseTestCase): data = await page.evaluate(test_js) self.assertEqual(data["targetHtml"], None) - self.assertHTMLEqual('
123 xxx
', data["fragHtml"]) + self.assertRegex( + data["fragHtml"], + re.compile( + r'
\s*' r"123\s*" r'xxx\s*' r"
" + ), + ) self.assertIn("rgb(0, 0, 255)", data["fragBg"]) # AKA 'background: blue' await page.close() @@ -390,7 +434,12 @@ class E2eDependencyRenderingTests(BaseTestCase): data = await page.evaluate(test_js) self.assertEqual(data["targetHtml"], None) - self.assertHTMLEqual('
123 xxx
', data["fragHtml"]) + self.assertRegex( + data["fragHtml"], + re.compile( + r'
\s*' r"123\s*" r'xxx\s*' r"
" + ), + ) self.assertIn("rgb(0, 0, 255)", data["fragBg"]) # AKA 'background: blue' await page.close() @@ -441,11 +490,12 @@ class E2eDependencyRenderingTests(BaseTestCase): # NOTE: Unlike the vanilla JS tests, for the Alpine test we don't remove the targetHtml, # but only change its contents. - self.assertInHTML( - '
123 xxx
', + self.assertRegex( data["targetHtml"], + re.compile( + r'
\s*' r"123\s*" r'xxx\s*' r"
" + ), ) - self.assertHTMLEqual(data["fragHtml"], '
123 xxx
') self.assertIn("rgb(0, 0, 255)", data["fragBg"]) # AKA 'background: blue' await page.close() @@ -497,9 +547,9 @@ class E2eDependencyRenderingTests(BaseTestCase): self.assertEqual(data["targetHtml"], None) # NOTE: We test only the inner HTML, because the element itself may or may not have # extra CSS classes added by HTMX, which results in flaky tests. - self.assertHTMLEqual( + self.assertRegex( data["fragInnerHtml"], - '123 xxx', + re.compile(r'123\s*xxx'), ) self.assertIn("rgb(0, 0, 255)", data["fragBg"]) # AKA 'background: blue' diff --git a/tests/test_expression.py b/tests/test_expression.py index 065b895d..f3ac9299 100644 --- a/tests/test_expression.py +++ b/tests/test_expression.py @@ -150,7 +150,7 @@ class DynamicExprTests(BaseTestCase): self.assertEqual( rendered.strip(), - "\n
lorem
\n
True
\n
[{'a': 1}, {'a': 2}]
", # noqa: E501 + "\n
lorem
\n
True
\n
[{'a': 1}, {'a': 2}]
", # noqa: E501 ) @parametrize_context_behavior(["django", "isolated"]) @@ -220,7 +220,13 @@ class DynamicExprTests(BaseTestCase): self.assertEqual( rendered.strip(), - "\n
lorem ipsum dolor
\n
True
\n
[{'a': 1}, {'a': 2}]
\n
{'a': 3}
", # noqa E501 + ( + "\n" + "
lorem ipsum dolor
\n" + "
True
\n" + "
[{'a': 1}, {'a': 2}]
\n" + "
{'a': 3}
" + ), ) @parametrize_context_behavior(["django", "isolated"]) @@ -290,7 +296,13 @@ class DynamicExprTests(BaseTestCase): self.assertEqual( rendered.strip(), - "\n
\n
abc
\n
\n
", # noqa E501 + ( + "\n" + "
\n" + "
abc
\n" + "
\n" + "
" + ), ) @parametrize_context_behavior(["django", "isolated"]) @@ -364,7 +376,14 @@ class DynamicExprTests(BaseTestCase): self.assertEqual( rendered.strip(), - "\n
lorem ipsum dolor
\n
lorem ipsum dolor [{'a': 1}]
\n
True
\n
[{'a': 1}, {'a': 2}]
\n
{'a': 3}
", # noqa E501 + ( + "\n" + "
lorem ipsum dolor
\n" + "
lorem ipsum dolor [{'a': 1}]
\n" + "
True
\n" + "
[{'a': 1}, {'a': 2}]
\n" + "
{'a': 3}
" + ), ) @parametrize_context_behavior(["django", "isolated"]) @@ -408,7 +427,12 @@ class DynamicExprTests(BaseTestCase): self.assertEqual( rendered.strip(), - '\n
"
\n
{%}
\n
True
', # noqa: E501 + ( + "\n" + '
"
\n' + "
{%}
\n" + "
True
" + ), ) @parametrize_context_behavior(["django", "isolated"]) @@ -457,7 +481,14 @@ class DynamicExprTests(BaseTestCase): self.assertEqual( rendered.strip(), - "\n
\n
3
\n
True
\n
\n
True
", # noqa E501 + ( + "\n" + "
\n" + '
3
\n' + '
True
\n' + "
\n" + "
True
" + ), ) @@ -528,11 +559,11 @@ class SpreadOperatorTests(BaseTestCase): self.assertHTMLEqual( rendered, """ -
LoREM
-
{'@click': '() => {}', 'style': 'height: 20px'}
-
[1, 2, 3]
-
1
-
123
+
LoREM
+
{'@click': '() => {}', 'style': 'height: 20px'}
+
[1, 2, 3]
+
1
+
123
""", ) @@ -665,9 +696,9 @@ class SpreadOperatorTests(BaseTestCase): self.assertHTMLEqual( rendered, """ -
{'@click': '() => {}', 'style': 'height: 20px'}
-
[1, 2, 3]
-
1
+
{'@click': '() => {}', 'style': 'height: 20px'}
+
[1, 2, 3]
+
1
""", ) @@ -748,10 +779,10 @@ class SpreadOperatorTests(BaseTestCase): self.assertHTMLEqual( rendered, """ -
{'@click': '() => {}', 'style': 'OVERWRITTEN'}
-
[1, 2, 3]
-
1
-
OVERWRITTEN_X
+
{'@click': '() => {}', 'style': 'OVERWRITTEN'}
+
[1, 2, 3]
+
1
+
OVERWRITTEN_X
""", ) diff --git a/tests/test_registry.py b/tests/test_registry.py index 1f8e8511..bbb0a37f 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -203,11 +203,11 @@ class MultipleComponentRegistriesTest(BaseTestCase): self.assertHTMLEqual( rendered, """ - Variable: 123 + Variable: 123 Slot: SLOT 123 - Variable: 123 + Variable: 123 Slot: SLOT ABC """, diff --git a/tests/test_tag_formatter.py b/tests/test_tag_formatter.py index 0d6c769e..2b59298d 100644 --- a/tests/test_tag_formatter.py +++ b/tests/test_tag_formatter.py @@ -68,7 +68,7 @@ class ComponentTagTests(BaseTestCase): rendered, """ hello1 -
+
SLOT_DEFAULT
hello2 @@ -101,7 +101,7 @@ class ComponentTagTests(BaseTestCase): rendered, """ hello1 -
+
OVERRIDEN!
hello2 @@ -139,7 +139,7 @@ class ComponentTagTests(BaseTestCase): rendered, """ hello1 -
+
SLOT_DEFAULT
hello2 @@ -179,7 +179,7 @@ class ComponentTagTests(BaseTestCase): rendered, """ hello1 -
+
OVERRIDEN!
hello2 @@ -217,7 +217,7 @@ class ComponentTagTests(BaseTestCase): rendered, """ hello1 -
+
SLOT_DEFAULT
hello2 @@ -257,7 +257,7 @@ class ComponentTagTests(BaseTestCase): rendered, """ hello1 -
+
OVERRIDEN!
hello2 @@ -297,7 +297,7 @@ class ComponentTagTests(BaseTestCase): rendered, """ hello1 -
+
OVERRIDEN!
hello2 @@ -334,7 +334,7 @@ class ComponentTagTests(BaseTestCase): self.assertHTMLEqual( rendered, """ -
+
OVERRIDEN!
""", @@ -419,7 +419,7 @@ class ComponentTagTests(BaseTestCase): rendered, """ hello1 -
+
SLOT_DEFAULT
hello2 @@ -439,7 +439,7 @@ class ComponentTagTests(BaseTestCase): rendered, """ hello1 -
+
OVERRIDEN!
hello2 diff --git a/tests/test_templatetags.py b/tests/test_templatetags.py index 65bbfbf7..dc9f5b71 100644 --- a/tests/test_templatetags.py +++ b/tests/test_templatetags.py @@ -120,7 +120,7 @@ class MultilineTagsTests(BaseTestCase): """ rendered = Template(template).render(Context()) expected = """ - Variable: 123 + Variable: 123 """ self.assertHTMLEqual(rendered, expected) @@ -147,7 +147,7 @@ class NestedTagsTests(BaseTestCase): """ rendered = Template(template).render(Context()) expected = """ - Variable: lorem + Variable: lorem """ self.assertHTMLEqual(rendered, expected) @@ -161,7 +161,7 @@ class NestedTagsTests(BaseTestCase): """ rendered = Template(template).render(Context()) expected = """ - Variable: organisation's + Variable: organisation's """ self.assertHTMLEqual(rendered, expected) @@ -175,7 +175,7 @@ class NestedTagsTests(BaseTestCase): """ rendered = Template(template).render(Context()) expected = """ - Variable: organisation's + Variable: organisation's """ self.assertHTMLEqual(rendered, expected) @@ -189,7 +189,7 @@ class NestedTagsTests(BaseTestCase): """ rendered = Template(template).render(Context()) expected = """ - Variable: organisation"s + Variable: organisation"s """ self.assertHTMLEqual(rendered, expected) @@ -203,6 +203,6 @@ class NestedTagsTests(BaseTestCase): """ rendered = Template(template).render(Context()) expected = """ - Variable: organisation"s + Variable: organisation"s """ self.assertHTMLEqual(rendered, expected) diff --git a/tests/test_templatetags_component.py b/tests/test_templatetags_component.py index 2a6132ce..9260d37d 100644 --- a/tests/test_templatetags_component.py +++ b/tests/test_templatetags_component.py @@ -1,5 +1,3 @@ -import textwrap - from django.template import Context, Template, TemplateSyntaxError from django_components import AlreadyRegistered, Component, NotRegistered, register, registry, types @@ -60,7 +58,7 @@ class ComponentTemplateTagTest(BaseTestCase): template = Template(simple_tag_template) rendered = template.render(Context({})) - self.assertHTMLEqual(rendered, "Variable: variable\n") + self.assertHTMLEqual(rendered, "Variable: variable\n") @parametrize_context_behavior(["django", "isolated"]) def test_single_component_self_closing(self): @@ -73,7 +71,7 @@ class ComponentTemplateTagTest(BaseTestCase): template = Template(simple_tag_template) rendered = template.render(Context({})) - self.assertHTMLEqual(rendered, "Variable: variable\n") + self.assertHTMLEqual(rendered, "Variable: variable\n") @parametrize_context_behavior(["django", "isolated"]) def test_call_with_invalid_name(self): @@ -99,7 +97,7 @@ class ComponentTemplateTagTest(BaseTestCase): template = Template(simple_tag_template) rendered = template.render(Context({})) - self.assertHTMLEqual(rendered, "Variable: variable\n") + self.assertHTMLEqual(rendered, "Variable: variable\n") @parametrize_context_behavior(["django", "isolated"]) def test_call_component_with_two_variables(self): @@ -129,8 +127,13 @@ class ComponentTemplateTagTest(BaseTestCase): template = Template(simple_tag_template) rendered = template.render(Context({})) - expected_outcome = """Variable: variable\n""" """Variable2: hej""" - self.assertHTMLEqual(rendered, textwrap.dedent(expected_outcome)) + self.assertHTMLEqual( + rendered, + """ + Variable: variable + Variable2: hej + """, + ) @parametrize_context_behavior(["django", "isolated"]) def test_component_called_with_singlequoted_name(self): @@ -143,7 +146,7 @@ class ComponentTemplateTagTest(BaseTestCase): template = Template(simple_tag_template) rendered = template.render(Context({})) - self.assertHTMLEqual(rendered, "Variable: variable\n") + self.assertHTMLEqual(rendered, "Variable: variable\n") @parametrize_context_behavior(["django", "isolated"]) def test_raises_on_component_called_with_variable_as_name(self): @@ -183,7 +186,10 @@ class ComponentTemplateTagTest(BaseTestCase): rendered = template.render(Context({})) self.assertHTMLEqual( rendered, - "Provided variable: provided value\nDefault:

default text

", + """ + Provided variable: provided value + Default:

default text

+ """, ) @@ -222,7 +228,10 @@ class DynamicComponentTemplateTagTest(BaseTestCase): template = Template(simple_tag_template) rendered = template.render(Context({})) - self.assertHTMLEqual(rendered, "Variable: variable\n") + self.assertHTMLEqual( + rendered, + "Variable: variable", + ) @parametrize_context_behavior(["django", "isolated"]) def test_call_with_invalid_name(self): @@ -250,7 +259,10 @@ class DynamicComponentTemplateTagTest(BaseTestCase): template = Template(simple_tag_template) rendered = template.render(Context({})) - self.assertHTMLEqual(rendered, "Variable: variable\n") + self.assertHTMLEqual( + rendered, + "Variable: variable", + ) @parametrize_context_behavior(["django", "isolated"]) def test_component_called_with_variable_as_spread(self): @@ -272,7 +284,10 @@ class DynamicComponentTemplateTagTest(BaseTestCase): } ) ) - self.assertHTMLEqual(rendered, "Variable: variable\n") + self.assertHTMLEqual( + rendered, + "Variable: variable", + ) @parametrize_context_behavior(["django", "isolated"]) def test_component_as_class(self): @@ -291,7 +306,10 @@ class DynamicComponentTemplateTagTest(BaseTestCase): } ) ) - self.assertHTMLEqual(rendered, "Variable: variable\n") + self.assertHTMLEqual( + rendered, + "Variable: variable", + ) @parametrize_context_behavior( ["django", "isolated"], @@ -316,7 +334,7 @@ class DynamicComponentTemplateTagTest(BaseTestCase): template = Template(simple_tag_template) rendered = template.render(Context({})) - self.assertHTMLEqual(rendered, "Variable: variable\n") + self.assertHTMLEqual(rendered, "Variable: variable\n") @parametrize_context_behavior( ["django", "isolated"], @@ -342,7 +360,10 @@ class DynamicComponentTemplateTagTest(BaseTestCase): template = Template(simple_tag_template) rendered = template.render(Context({})) - self.assertHTMLEqual(rendered, "Variable: variable\n") + self.assertHTMLEqual( + rendered, + "Variable: variable", + ) @parametrize_context_behavior(["django", "isolated"]) def test_raises_already_registered_on_name_conflict(self): @@ -380,7 +401,7 @@ class DynamicComponentTemplateTagTest(BaseTestCase): self.assertHTMLEqual( rendered, """ - Variable: variable + Variable: variable Slot: HELLO_FROM_SLOT """, ) @@ -422,7 +443,7 @@ class DynamicComponentTemplateTagTest(BaseTestCase): self.assertHTMLEqual( rendered, """ - Variable: variable + Variable: variable Slot 1: HELLO_FROM_SLOT_1 Slot 2: HELLO_FROM_SLOT_2 """, @@ -465,7 +486,7 @@ class DynamicComponentTemplateTagTest(BaseTestCase): self.assertHTMLEqual( rendered, """ - Variable: variable + Variable: variable Slot 1: HELLO_FROM_SLOT_1 Slot 2: """, @@ -488,75 +509,149 @@ class DynamicComponentTemplateTagTest(BaseTestCase): class MultiComponentTests(BaseTestCase): - def register_components(self): + @parametrize_context_behavior(["django", "isolated"]) + def test_both_components_render_correctly_with_no_slots(self): registry.register("first_component", SlottedComponent) registry.register("second_component", SlottedComponentWithContext) - def make_template(self, first_slot: str = "", second_slot: str = "") -> Template: - template_str: types.django_html = f""" - {{% load component_tags %}} - {{% component 'first_component' %}} - {first_slot} - {{% endcomponent %}} - {{% component 'second_component' variable='xyz' %}} - {second_slot} - {{% endcomponent %}} + template_str: types.django_html = """ + {% load component_tags %} + {% component 'first_component' %} + {% endcomponent %} + {% component 'second_component' variable='xyz' %} + {% endcomponent %} """ - return Template(template_str) + template = Template(template_str) + rendered = template.render(Context()) - def expected_result(self, first_slot: str = "", second_slot: str = "") -> str: - first_slot = first_slot or "Default header" - second_slot = second_slot or "Default header" - return f""" - -
{first_slot}
+ self.assertHTMLEqual( + rendered, + """ + +
+ Default header +
Default main
Default footer
- -
{second_slot}
+ +
+ Default header +
Default main
Default footer
- """ - - def wrap_with_slot_tags(self, s): - return '{% fill "header" %}' + s + "{% endfill %}" - - @parametrize_context_behavior(["django", "isolated"]) - def test_both_components_render_correctly_with_no_slots(self): - self.register_components() - rendered = self.make_template().render(Context({})) - self.assertHTMLEqual(rendered, self.expected_result()) + """, + ) @parametrize_context_behavior(["django", "isolated"]) def test_both_components_render_correctly_with_slots(self): - self.register_components() - first_slot_content = "

Slot #1

" - second_slot_content = "
Slot #2
" - first_slot = self.wrap_with_slot_tags(first_slot_content) - second_slot = self.wrap_with_slot_tags(second_slot_content) - rendered = self.make_template(first_slot, second_slot).render(Context({})) + registry.register("first_component", SlottedComponent) + registry.register("second_component", SlottedComponentWithContext) + + template_str: types.django_html = """ + {% load component_tags %} + {% component 'first_component' %} + {% fill "header" %}

Slot #1

{% endfill %} + {% endcomponent %} + {% component 'second_component' variable='xyz' %} + {% fill "header" %}
Slot #2
{% endfill %} + {% endcomponent %} + """ + template = Template(template_str) + rendered = template.render(Context()) + self.assertHTMLEqual( rendered, - self.expected_result(first_slot_content, second_slot_content), + """ + +
+

Slot #1

+
+
Default main
+
Default footer
+
+ +
+
Slot #2
+
+
Default main
+
Default footer
+
+ """, ) @parametrize_context_behavior(["django", "isolated"]) def test_both_components_render_correctly_when_only_first_has_slots(self): - self.register_components() - first_slot_content = "

Slot #1

" - first_slot = self.wrap_with_slot_tags(first_slot_content) - rendered = self.make_template(first_slot).render(Context({})) - self.assertHTMLEqual(rendered, self.expected_result(first_slot_content)) + registry.register("first_component", SlottedComponent) + registry.register("second_component", SlottedComponentWithContext) + + template_str: types.django_html = """ + {% load component_tags %} + {% component 'first_component' %} + {% fill "header" %}

Slot #1

{% endfill %} + {% endcomponent %} + {% component 'second_component' variable='xyz' %} + {% endcomponent %} + """ + template = Template(template_str) + rendered = template.render(Context({})) + + self.assertHTMLEqual( + rendered, + """ + +
+

Slot #1

+
+
Default main
+
Default footer
+
+ +
+ Default header +
+
Default main
+
Default footer
+
+ """, + ) @parametrize_context_behavior(["django", "isolated"]) def test_both_components_render_correctly_when_only_second_has_slots(self): - self.register_components() - second_slot_content = "
Slot #2
" - second_slot = self.wrap_with_slot_tags(second_slot_content) - rendered = self.make_template("", second_slot).render(Context({})) - self.assertHTMLEqual(rendered, self.expected_result("", second_slot_content)) + registry.register("first_component", SlottedComponent) + registry.register("second_component", SlottedComponentWithContext) + + template_str: types.django_html = """ + {% load component_tags %} + {% component 'first_component' %} + {% endcomponent %} + {% component 'second_component' variable='xyz' %} + {% fill "header" %}
Slot #2
{% endfill %} + {% endcomponent %} + """ + template = Template(template_str) + rendered = template.render(Context({})) + + self.assertHTMLEqual( + rendered, + """ + +
+ Default header +
+
Default main
+
Default footer
+
+ +
+
Slot #2
+
+
Default main
+
Default footer
+
+ """, + ) class ComponentIsolationTests(BaseTestCase): @@ -596,17 +691,17 @@ class ComponentIsolationTests(BaseTestCase): self.assertHTMLEqual( rendered, """ - +
Override header
Default main
Default footer
- +
Default header
Override main
Default footer
- +
Default header
Default main
Override footer
@@ -641,7 +736,7 @@ class AggregateInputTests(BaseTestCase): self.assertHTMLEqual( rendered, """ -
+
attrs: {'@click.stop': "dispatch('click_event')", 'x-data': "{hello: 'world'}", 'class': 'padding-top-8'} my_dict: {'one': 2}
diff --git a/tests/test_templatetags_extends.py b/tests/test_templatetags_extends.py index 8c6eb009..1479cf08 100644 --- a/tests/test_templatetags_extends.py +++ b/tests/test_templatetags_extends.py @@ -49,14 +49,15 @@ class ExtendsCompatTests(BaseTestCase): {% endblock %} """ rendered = Template(template).render(Context()) + expected = """
-
BLOCK OVERRIDEN
- +
BLOCK OVERRIDEN
+
SLOT OVERRIDEN
Default main
Default footer
@@ -105,14 +106,14 @@ class ExtendsCompatTests(BaseTestCase):
-
BLOCK OVERRIDEN
- +
BLOCK OVERRIDEN
+
SLOT OVERRIDEN
Default main
Default footer
-
BLOCK OVERRIDEN
- +
BLOCK OVERRIDEN
+
SLOT OVERRIDEN 2
Default main
Default footer
@@ -171,14 +172,14 @@ class ExtendsCompatTests(BaseTestCase):
-
BLOCK OVERRIDEN
- +
BLOCK OVERRIDEN
+
SLOT OVERRIDEN
Default main
Default footer
-
BLOCK OVERRIDEN
- +
BLOCK OVERRIDEN
+
SLOT OVERRIDEN 2
Default main
Default footer
@@ -230,20 +231,21 @@ class ExtendsCompatTests(BaseTestCase): {% endblock %} """ rendered = Template(template).render(Context()) + expected = """
-
BLOCK OVERRIDEN
- +
BLOCK OVERRIDEN
+
SLOT OVERRIDEN
Default main
Default footer
-
BLOCK OVERRIDEN
- +
BLOCK OVERRIDEN
+
SLOT OVERRIDEN 2
Default main
Default footer
@@ -287,8 +289,8 @@ class ExtendsCompatTests(BaseTestCase): -
BLOCK OVERRIDEN
- +
BLOCK OVERRIDEN
+
SLOT OVERRIDEN
Default main
Default footer
@@ -335,14 +337,14 @@ class ExtendsCompatTests(BaseTestCase): -
BLOCK OVERRIDEN
- +
BLOCK OVERRIDEN
+
SLOT OVERRIDEN
Default main
Default footer
-
BLOCK OVERRIDEN
- +
BLOCK OVERRIDEN
+
SLOT OVERRIDEN 2
Default main
Default footer
@@ -389,11 +391,11 @@ class ExtendsCompatTests(BaseTestCase):
- +
Default header
-
BLOCK OVERRIDEN
- +
BLOCK OVERRIDEN
+
SLOT OVERRIDEN
Default main
Default footer
@@ -435,7 +437,7 @@ class ExtendsCompatTests(BaseTestCase):
Variable: - Variable: + Variable:
@@ -469,9 +471,9 @@ class ExtendsCompatTests(BaseTestCase): rendered = Template(template).render(Context()) expected = """ - + - +
BODY_FROM_FILL
Default footer
@@ -502,9 +504,9 @@ class ExtendsCompatTests(BaseTestCase): rendered = Template(template).render(Context()) expected = """ - + - +
BODY_FROM_FILL
Default footer
@@ -537,7 +539,7 @@ class ExtendsCompatTests(BaseTestCase):
- +
TEST
@@ -567,7 +569,7 @@ class ExtendsCompatTests(BaseTestCase): - +
58 giraffes and 2 pantaloons
@@ -594,9 +596,9 @@ class ExtendsCompatTests(BaseTestCase): rendered = Template(template).render(Context()) expected = """ - + - +
58 giraffes and 2 pantaloons
@@ -634,9 +636,9 @@ class ExtendsCompatTests(BaseTestCase): rendered = Template(template).render(Context()) expected = """ - + - +
BODY_FROM_FILL
Default footer
@@ -664,9 +666,9 @@ class ExtendsCompatTests(BaseTestCase): rendered = Template(template).render(Context()) expected = """ - + - +
Helloodiddoo @@ -700,9 +702,9 @@ class ExtendsCompatTests(BaseTestCase): rendered = Template(template).render(Context()) expected = """ - + - +
Helloodiddoo @@ -736,9 +738,9 @@ class ExtendsCompatTests(BaseTestCase): rendered = Template(template).render(Context()) expected = """ - + - +
Helloodiddoo @@ -782,9 +784,9 @@ class ExtendsCompatTests(BaseTestCase): rendered = Template(template).render(Context()) expected = """ - + - +
Helloodiddoo @@ -824,10 +826,10 @@ class ExtendsCompatTests(BaseTestCase): - +
-
injected: DepInject(hello='from_block')
+
injected: DepInject(hello='from_block')
Default footer
diff --git a/tests/test_templatetags_provide.py b/tests/test_templatetags_provide.py index 957b03e9..be0b00ad 100644 --- a/tests/test_templatetags_provide.py +++ b/tests/test_templatetags_provide.py @@ -36,7 +36,7 @@ class ProvideTemplateTagTest(BaseTestCase): self.assertHTMLEqual( rendered, """ -
injected: DepInject(key='hi', another=123)
+
injected: DepInject(key='hi', another=123)
""", ) @@ -87,8 +87,8 @@ class ProvideTemplateTagTest(BaseTestCase): self.assertHTMLEqual( rendered, """ -
key: hi
-
another: 123
+
key: hi
+
another: 123
""", ) @@ -120,8 +120,8 @@ class ProvideTemplateTagTest(BaseTestCase): self.assertHTMLEqual( rendered, """ -
key: hi
-
another: 123
+
key: hi
+
another: 123
""", ) @@ -150,7 +150,7 @@ class ProvideTemplateTagTest(BaseTestCase): self.assertHTMLEqual( rendered, """ -
injected: default
+
injected: default
""", ) @@ -183,8 +183,8 @@ class ProvideTemplateTagTest(BaseTestCase): self.assertHTMLEqual( rendered, """ -
injected: DepInject()
-
injected: default
+
injected: DepInject()
+
injected: default
""", ) @@ -216,8 +216,8 @@ class ProvideTemplateTagTest(BaseTestCase): self.assertHTMLEqual( rendered, """ -
-
+
+
""", ) @@ -248,8 +248,8 @@ class ProvideTemplateTagTest(BaseTestCase): self.assertHTMLEqual( rendered, """ -
injected: DepInject(key='hi', another=123)
-
injected: default
+
injected: DepInject(key='hi', another=123)
+
injected: default
""", ) @@ -286,8 +286,8 @@ class ProvideTemplateTagTest(BaseTestCase): self.assertHTMLEqual( rendered, """ -
injected: DepInject(key='hi', another=123)
-
injected: default
+
injected: DepInject(key='hi', another=123)
+
injected: default
""", ) @@ -328,8 +328,8 @@ class ProvideTemplateTagTest(BaseTestCase): self.assertHTMLEqual( rendered, """ -
injected: DepInject(key='hi', another=123)
-
injected: default
+
injected: DepInject(key='hi', another=123)
+
injected: default
""", ) @@ -431,7 +431,7 @@ class ProvideTemplateTagTest(BaseTestCase): self.assertHTMLEqual( rendered, """ -
injected: DepInject(var1={'key': 'hi', 'another': 123}, var2={'x': 'y'})
+
injected: DepInject(var1={'key': 'hi', 'another': 123}, var2={'x': 'y'})
""", ) @@ -505,9 +505,9 @@ class ProvideTemplateTagTest(BaseTestCase): self.assertHTMLEqual( rendered, """ -
injected: DepInject(key='hi1', another=1231, new=3)
-
injected: DepInject(key='hi', another=123, lost=0)
-
injected: default
+
injected: DepInject(key='hi1', another=1231, new=3)
+
injected: DepInject(key='hi', another=123, lost=0)
+
injected: default
""", ) @@ -545,8 +545,8 @@ class ProvideTemplateTagTest(BaseTestCase): self.assertHTMLEqual( rendered, """ -
first_provide: DepInject(key='hi', another=123, lost=0)
-
second_provide: DepInject(key='hi1', another=1231, new=3)
+
first_provide: DepInject(key='hi', another=123, lost=0)
+
second_provide: DepInject(key='hi1', another=1231, new=3)
""", ) @@ -575,7 +575,7 @@ class ProvideTemplateTagTest(BaseTestCase): rendered, """
-
injected: DepInject(key='hi', another=123)
+
injected: DepInject(key='hi', another=123)
""", ) @@ -613,7 +613,7 @@ class ProvideTemplateTagTest(BaseTestCase): self.assertHTMLEqual( rendered, """ -
+
injected: DepInject(key='hi', another=123)
""", @@ -646,7 +646,7 @@ class InjectTest(BaseTestCase): self.assertHTMLEqual( rendered, """ -
injected: DepInject(key='hi', another=123)
+
injected: DepInject(key='hi', another=123)
""", ) @@ -694,7 +694,7 @@ class InjectTest(BaseTestCase): self.assertHTMLEqual( rendered, """ -
injected: default
+
injected: default
""", ) @@ -799,10 +799,12 @@ class InjectTest(BaseTestCase): self.assertHTMLEqual( rendered, """ -
+
injected: DepInject(key='hi', data=123)
-
456
+
+ 456 +
""", ) @@ -861,9 +863,10 @@ class InjectTest(BaseTestCase): self.assertHTMLEqual( rendered, """ -
+
injected: DepInject(key='hi', data=123)
-
+
+
""", ) diff --git a/tests/test_templatetags_slot_fill.py b/tests/test_templatetags_slot_fill.py index 1a49c3ea..dba20ce2 100644 --- a/tests/test_templatetags_slot_fill.py +++ b/tests/test_templatetags_slot_fill.py @@ -67,9 +67,11 @@ class ComponentSlotTests(BaseTestCase): self.assertHTMLEqual( rendered, """ - +
Custom header
-
Variable: variable
+
+ Variable: variable +
Default footer
""", @@ -117,8 +119,10 @@ class ComponentSlotTests(BaseTestCase): self.assertHTMLEqual( rendered, """ - -
Variable: variable
+ +
+ Variable: variable +
@@ -149,7 +153,7 @@ class ComponentSlotTests(BaseTestCase): self.assertHTMLEqual( rendered, f""" - +
Default header
test123 - {context_behavior_data}
test321
@@ -171,7 +175,7 @@ class ComponentSlotTests(BaseTestCase): self.assertHTMLEqual( rendered, """ - +
Default header
Default main
Default footer
@@ -194,7 +198,7 @@ class ComponentSlotTests(BaseTestCase): template = Template(template_str) rendered = template.render(Context({})) - self.assertHTMLEqual(rendered, "") + self.assertHTMLEqual(rendered, "") @parametrize_context_behavior(["django", "isolated"]) def test_slotted_template_without_slots_and_single_quotes(self): @@ -211,7 +215,7 @@ class ComponentSlotTests(BaseTestCase): template = Template(template_str) rendered = template.render(Context({})) - self.assertHTMLEqual(rendered, "") + self.assertHTMLEqual(rendered, "") @parametrize_context_behavior(["django", "isolated"]) def test_variable_fill_name(self): @@ -227,7 +231,7 @@ class ComponentSlotTests(BaseTestCase): template = Template(template_str) rendered = template.render(Context({})) expected = """ - +
Hi there!
Default main
Default footer
@@ -296,7 +300,7 @@ class ComponentSlotTests(BaseTestCase): rendered, """ -
+
ABC: carl var
@@ -327,7 +331,7 @@ class ComponentSlotTests(BaseTestCase): self.assertHTMLEqual( rendered, """ -
+

Custom title

Default subtitle

@@ -451,8 +455,8 @@ class ComponentSlotTests(BaseTestCase): render_dependencies=False, ) - self.assertInHTML(rendered1, "
MAIN
") - self.assertInHTML(rendered2, "
MAIN
") + self.assertHTMLEqual(rendered1, "
MAIN
") + self.assertHTMLEqual(rendered2, "
MAIN
") # 3. Specify the required slot by its name rendered3 = TestComp.render( @@ -462,7 +466,7 @@ class ComponentSlotTests(BaseTestCase): }, render_dependencies=False, ) - self.assertInHTML(rendered3, "
MAIN
MAIN
") + self.assertHTMLEqual(rendered3, "
MAIN
MAIN
") # 4. RAISES: Specify the required slot by the "default" name # This raises because the slot that is marked as 'required' is NOT marked as 'default'. @@ -499,7 +503,7 @@ class ComponentSlotTests(BaseTestCase): rendered = Template(template_str).render(Context({})) expected = """ - +
Custom header
Custom main
Custom footer
@@ -553,8 +557,10 @@ class ComponentSlotDefaultTests(BaseTestCase): template = Template(template_str) expected = """ -
-

This fills the 'main' slot.

+
+
+

This fills the 'main' slot.

+
""" rendered = template.render(Context({})) @@ -579,8 +585,10 @@ class ComponentSlotDefaultTests(BaseTestCase): """ template = Template(template_str) expected = """ -
-

This fills the 'main' slot.

+
+
+

This fills the 'main' slot.

+
""" rendered = template.render(Context({})) @@ -606,9 +614,9 @@ class ComponentSlotDefaultTests(BaseTestCase): """ template = Template(template_str) expected = """ -
-

This fills the 'main' slot.

-

This fills the 'main' slot.

+
+

This fills the 'main' slot.

+

This fills the 'main' slot.

""" rendered = template.render(Context({})) @@ -686,14 +694,14 @@ class ComponentSlotDefaultTests(BaseTestCase): """ template = Template(template_str) expected = """ -
-
- -
This Is Allowed
-
-
-
-
+
+
+ +
This Is Allowed
+
+
+
+
""" rendered = template.render(Context({})) @@ -761,7 +769,7 @@ class ComponentSlotDefaultTests(BaseTestCase): self.assertHTMLEqual( rendered, """ - +
Default header
Default main
Default footer
@@ -799,7 +807,7 @@ class ComponentSlotDefaultTests(BaseTestCase): self.assertHTMLEqual( rendered_truthy, """ - +
123
Default main
Default footer
@@ -811,7 +819,7 @@ class ComponentSlotDefaultTests(BaseTestCase): self.assertHTMLEqual( rendered_falsy, """ - +
Default main
Default footer
@@ -862,7 +870,7 @@ class PassthroughSlotsTest(BaseTestCase): self.assertHTMLEqual( rendered, """ - +
OVERRIDEN_SLOT "header" - INDEX 0 - ORIGINAL "Default header"
@@ -910,7 +918,7 @@ class PassthroughSlotsTest(BaseTestCase): self.assertHTMLEqual( rendered, """ - +
OVERRIDEN_SLOT "header" - ORIGINAL "Default header"
@@ -997,9 +1005,9 @@ class PassthroughSlotsTest(BaseTestCase): rendered = template.render(Context()) expected = """ -
CUSTOM HEADER
-
CUSTOM MAIN
-
footer
+
CUSTOM HEADER
+
CUSTOM MAIN
+
footer
""" self.assertHTMLEqual(rendered, expected) @@ -1042,11 +1050,11 @@ class PassthroughSlotsTest(BaseTestCase): rendered = template.render(Context()) expected = """ -
- -
CUSTOM HEADER
-
CUSTOM MAIN
-
Default footer
+
+ +
CUSTOM HEADER
+
CUSTOM MAIN
+
Default footer
""" @@ -1093,11 +1101,11 @@ class PassthroughSlotsTest(BaseTestCase): rendered = template.render(Context()) expected = """ -
- -
Default header
-
CUSTOM MAIN
-
Default footer
+
+ +
Default header
+
CUSTOM MAIN
+
Default footer
""" @@ -1145,7 +1153,7 @@ class NestedSlotsTests(BaseTestCase): rendered = Template(template_str).render(Context()) expected = """ -
+
Wrapper Default
Parent1 Default @@ -1175,7 +1183,7 @@ class NestedSlotsTests(BaseTestCase): rendered = Template(template_str).render(Context()) expected = """ -
+
Entire Wrapper Replaced
""" @@ -1196,7 +1204,7 @@ class NestedSlotsTests(BaseTestCase): rendered = Template(template_str).render(Context()) expected = """ -
+
Wrapper Default
Parent1 Replaced @@ -1223,7 +1231,7 @@ class NestedSlotsTests(BaseTestCase): rendered = Template(template_str).render(Context()) expected = """ -
+
Wrapper Default
Parent1 Default @@ -1263,7 +1271,7 @@ class NestedSlotsTests(BaseTestCase): rendered = Template(template_str).render(Context()) expected = """ -
+
Entire Wrapper Replaced
""" @@ -1295,7 +1303,7 @@ class SlottedTemplateRegressionTests(BaseTestCase): self.assertHTMLEqual( rendered, """ - +
Default header
Default main
Default footer
@@ -1325,7 +1333,7 @@ class SlotDefaultTests(BaseTestCase): self.assertHTMLEqual( rendered, """ - +
Before: Default header
Default main
Default footer, after
@@ -1350,7 +1358,7 @@ class SlotDefaultTests(BaseTestCase): self.assertHTMLEqual( rendered, """ - +
First: Default header; Second: Default header
Default main
Default footer
@@ -1380,7 +1388,7 @@ class SlotDefaultTests(BaseTestCase): self.assertHTMLEqual( rendered, """ - +
First Default header Later Default header Later Default header
Default main
Default footer
@@ -1414,10 +1422,10 @@ class SlotDefaultTests(BaseTestCase): self.assertHTMLEqual( rendered, """ - +
header1_in_header1: Default header - +
header1_in_header2: Default header header2_in_header2: Default header @@ -1465,7 +1473,7 @@ class ScopedSlotTest(BaseTestCase): """ rendered = Template(template).render(Context()) expected = """ -
+
def 456
@@ -1500,7 +1508,7 @@ class ScopedSlotTest(BaseTestCase): """ rendered = Template(template).render(Context()) expected = """ -
+
def 456
@@ -1536,7 +1544,7 @@ class ScopedSlotTest(BaseTestCase): """ rendered = Template(template).render(Context()) expected = """ -
+
Default text def 456 @@ -1573,7 +1581,7 @@ class ScopedSlotTest(BaseTestCase): """ rendered = Template(template).render(Context()) expected = """ -
+
def 456
@@ -1611,7 +1619,7 @@ class ScopedSlotTest(BaseTestCase): """ rendered = Template(template).render(Context()) expected = """ -
+
def 456
@@ -1648,7 +1656,7 @@ class ScopedSlotTest(BaseTestCase): """ rendered = Template(template).render(Context()) expected = """ -
+
Default text A xyz Default text B 456
@@ -1712,7 +1720,7 @@ class ScopedSlotTest(BaseTestCase): {% endcomponent %} """ rendered = Template(template).render(Context()) - expected = "
overriden
" + expected = "
overriden
" self.assertHTMLEqual(rendered, expected) @parametrize_context_behavior(["django", "isolated"]) @@ -1735,7 +1743,7 @@ class ScopedSlotTest(BaseTestCase): {% endcomponent %} """ rendered = Template(template).render(Context()) - expected = "
{}
" + expected = "
{}
" self.assertHTMLEqual(rendered, expected) @parametrize_context_behavior(["django", "isolated"]) @@ -1761,7 +1769,7 @@ class ScopedSlotTest(BaseTestCase): {% endcomponent %} """ rendered = Template(template).render(Context()) - expected = "
Default text
" + expected = "
Default text
" self.assertHTMLEqual(rendered, expected) @parametrize_context_behavior(["django", "isolated"]) @@ -1800,7 +1808,7 @@ class ScopedSlotTest(BaseTestCase): ) expected = """ -
+
def 456
@@ -1845,7 +1853,7 @@ class ScopedSlotTest(BaseTestCase): ) expected = """ -
+
def 456
@@ -1889,9 +1897,9 @@ class ScopedSlotTest(BaseTestCase): self.assertHTMLEqual( rendered, """ -
+
data1_in_slot1: {'abc': 'def', 'input': 1} -
+
data1_in_slot2: {'abc': 'def', 'input': 1} data2_in_slot2: {'abc': 'def', 'input': 2}
@@ -1990,9 +1998,9 @@ class DuplicateSlotTest(BaseTestCase): self.assertHTMLEqual( rendered, """ -
Name: Jannete
-
Name: Jannete
-
Hello
+
Name: Jannete
+
Name: Jannete
+
Hello
""", ) @@ -2010,9 +2018,9 @@ class DuplicateSlotTest(BaseTestCase): self.assertHTMLEqual( rendered, """ -
Default header
-
Default main header
-
Default footer
+
Default header
+
Default main header
+
Default footer
""", ) @@ -2034,8 +2042,8 @@ class DuplicateSlotTest(BaseTestCase): rendered, """ OVERRIDDEN! -
-
+
+

OVERRIDDEN!

@@ -2071,8 +2079,8 @@ class DuplicateSlotTest(BaseTestCase): rendered, """ START -
-
+
+

NESTED

@@ -2198,11 +2206,11 @@ class SlotBehaviorTests(BaseTestCase): self.assertHTMLEqual( rendered, """ - +
Name: Igor
Day: Monday