feat: Add dynamic component (#627)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Juro Oravec 2024-08-29 11:28:00 +02:00 committed by GitHub
parent 8c5b088c31
commit e76227b8df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 503 additions and 34 deletions

View file

@ -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`

View file

@ -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,

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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:
"""

View file

@ -0,0 +1,3 @@
# flake8: noqa F401
from django_components.components.dynamic import DynamicComponent as DynamicComponent

View file

@ -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

View file

@ -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), "

View file

@ -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: <strong>variable</strong>\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: <strong>{{ variable }}</strong>
"""
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: <strong>variable</strong>\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: <strong>variable</strong>\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: <strong>variable</strong>\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: <strong>variable</strong>\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: <strong>variable</strong>\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: <strong>variable</strong>\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: <strong>{{ variable }}</strong>
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: <strong>variable</strong>
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: <strong>{{ variable }}</strong>
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: <strong>variable</strong>
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: <strong>{{ variable }}</strong>
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)