mirror of
https://github.com/django-components/django-components.git
synced 2025-09-20 12:49:45 +00:00
feat: allow to set defaults (#1072)
* feat: allow to set defaults * docs: update changelog * refactor: fix new linter errors
This commit is contained in:
parent
48dd3b7a5a
commit
f07818fc7d
16 changed files with 553 additions and 36 deletions
33
CHANGELOG.md
33
CHANGELOG.md
|
@ -4,6 +4,39 @@
|
||||||
|
|
||||||
#### Feat
|
#### Feat
|
||||||
|
|
||||||
|
- Add defaults for the component inputs with the `Component.Defaults` nested class. Defaults
|
||||||
|
are applied if the argument is not given, or if it set to `None`.
|
||||||
|
|
||||||
|
For lists, dictionaries, or other objects, wrap the value in `Default()` class to mark it as a factory
|
||||||
|
function:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django_components import Default
|
||||||
|
|
||||||
|
class Table(Component):
|
||||||
|
class Defaults:
|
||||||
|
position = "left"
|
||||||
|
width = "200px"
|
||||||
|
options = Default(lambda: ["left", "right", "center"])
|
||||||
|
|
||||||
|
def get_context_data(self, position, width, options):
|
||||||
|
return {
|
||||||
|
"position": position,
|
||||||
|
"width": width,
|
||||||
|
"options": options,
|
||||||
|
}
|
||||||
|
|
||||||
|
# `position` is used as given, `"right"`
|
||||||
|
# `width` uses default because it's `None`
|
||||||
|
# `options` uses default because it's missing
|
||||||
|
Table.render(
|
||||||
|
kwargs={
|
||||||
|
"position": "right",
|
||||||
|
"width": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
- `{% html_attrs %}` now offers a Vue-like granular control over `class` and `style` HTML attributes,
|
- `{% html_attrs %}` now offers a Vue-like granular control over `class` and `style` HTML attributes,
|
||||||
where each class name or style property can be managed separately.
|
where each class name or style property can be managed separately.
|
||||||
|
|
||||||
|
|
|
@ -140,7 +140,6 @@ def prepare_templating_benchmark(
|
||||||
context_mode: DjcContextMode,
|
context_mode: DjcContextMode,
|
||||||
imports_only: bool = False,
|
imports_only: bool = False,
|
||||||
):
|
):
|
||||||
global do_render
|
|
||||||
setup_script = _get_templating_script(renderer, size, context_mode, imports_only)
|
setup_script = _get_templating_script(renderer, size, context_mode, imports_only)
|
||||||
|
|
||||||
# If we're testing the startup time, then the setup is actually the tested code
|
# If we're testing the startup time, then the setup is actually the tested code
|
||||||
|
|
|
@ -3,6 +3,7 @@ nav:
|
||||||
- Single-file components: single_file_components.md
|
- Single-file components: single_file_components.md
|
||||||
- Components in Python: components_in_python.md
|
- Components in Python: components_in_python.md
|
||||||
- Accessing component inputs: access_component_input.md
|
- Accessing component inputs: access_component_input.md
|
||||||
|
- Component defaults: component_defaults.md
|
||||||
- Component context and scope: component_context_scope.md
|
- Component context and scope: component_context_scope.md
|
||||||
- Template tag syntax: template_tag_syntax.md
|
- Template tag syntax: template_tag_syntax.md
|
||||||
- Slots: slots.md
|
- Slots: slots.md
|
||||||
|
|
133
docs/concepts/fundamentals/component_defaults.md
Normal file
133
docs/concepts/fundamentals/component_defaults.md
Normal file
|
@ -0,0 +1,133 @@
|
||||||
|
When a component is being rendered, the component inputs are passed to various methods like
|
||||||
|
[`get_context_data()`](../../../reference/api#django_components.Component.get_context_data),
|
||||||
|
[`get_js_data()`](../../../reference/api#django_components.Component.get_js_data),
|
||||||
|
or [`get_css_data()`](../../../reference/api#django_components.Component.get_css_data).
|
||||||
|
|
||||||
|
It can be cumbersome to specify default values for each input in each method.
|
||||||
|
|
||||||
|
To make things easier, Components can specify their defaults. Defaults are used when
|
||||||
|
no value is provided, or when the value is set to `None` for a particular input.
|
||||||
|
|
||||||
|
### Defining defaults
|
||||||
|
|
||||||
|
To define defaults for a component, you create a nested `Defaults` class within your
|
||||||
|
[`Component`](../../../reference/api#django_components.Component) class.
|
||||||
|
Each attribute in the `Defaults` class represents a default value for a corresponding input.
|
||||||
|
|
||||||
|
```py
|
||||||
|
from django_components import Component, Default, register
|
||||||
|
|
||||||
|
@register("my_table")
|
||||||
|
class MyTable(Component):
|
||||||
|
|
||||||
|
class Defaults:
|
||||||
|
position = "left"
|
||||||
|
selected_items = Default(lambda: [1, 2, 3])
|
||||||
|
|
||||||
|
def get_context_data(self, position, selected_items):
|
||||||
|
return {
|
||||||
|
"position": position,
|
||||||
|
"selected_items": selected_items,
|
||||||
|
}
|
||||||
|
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
In this example, `position` is a simple default value, while `selected_items` uses a factory function wrapped in `Default` to ensure a new list is created each time the default is used.
|
||||||
|
|
||||||
|
Now, when we render the component, the defaults will be applied:
|
||||||
|
|
||||||
|
```django
|
||||||
|
{% component "my_table" position="right" / %}
|
||||||
|
```
|
||||||
|
|
||||||
|
In this case:
|
||||||
|
|
||||||
|
- `position` input is set to `right`, so no defaults applied
|
||||||
|
- `selected_items` is not set, so it will be set to `[1, 2, 3]`.
|
||||||
|
|
||||||
|
Same applies to rendering the Component in Python with the
|
||||||
|
[`render()`](../../../reference/api#django_components.Component.render) method:
|
||||||
|
|
||||||
|
```py
|
||||||
|
MyTable.render(
|
||||||
|
kwargs={
|
||||||
|
"position": "right",
|
||||||
|
"selected_items": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Notice that we've set `selected_items` to `None`. `None` values are treated as missing values,
|
||||||
|
and so `selected_items` will be set to `[1, 2, 3]`.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
The defaults are aplied only to keyword arguments. They are NOT applied to positional arguments!
|
||||||
|
|
||||||
|
### Default factories
|
||||||
|
|
||||||
|
For objects such as lists, dictionaries or other instances, you have to be careful - if you simply set a default value, this instance will be shared across all instances of the component!
|
||||||
|
|
||||||
|
```py
|
||||||
|
from django_components import Component
|
||||||
|
|
||||||
|
class MyTable(Component):
|
||||||
|
class Defaults:
|
||||||
|
# All instances will share the same list!
|
||||||
|
selected_items = [1, 2, 3]
|
||||||
|
```
|
||||||
|
|
||||||
|
To avoid this, you can use a factory function wrapped in `Default`.
|
||||||
|
|
||||||
|
```py
|
||||||
|
from django_components import Component, Default
|
||||||
|
|
||||||
|
class MyTable(Component):
|
||||||
|
class Defaults:
|
||||||
|
# A new list is created for each instance
|
||||||
|
selected_items = Default(lambda: [1, 2, 3])
|
||||||
|
```
|
||||||
|
|
||||||
|
This is similar to how the dataclass fields work.
|
||||||
|
|
||||||
|
In fact, you can also use the dataclass's [`field`](https://docs.python.org/3/library/dataclasses.html#dataclasses.field) function to define the factories:
|
||||||
|
|
||||||
|
```py
|
||||||
|
from dataclasses import field
|
||||||
|
from django_components import Component
|
||||||
|
|
||||||
|
class MyTable(Component):
|
||||||
|
class Defaults:
|
||||||
|
selected_items = field(default_factory=lambda: [1, 2, 3])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accessing defaults
|
||||||
|
|
||||||
|
Since the defaults are defined on the component class, you can access the defaults for a component with the `Component.Defaults` property.
|
||||||
|
|
||||||
|
So if we have a component like this:
|
||||||
|
|
||||||
|
```py
|
||||||
|
from django_components import Component, Default, register
|
||||||
|
|
||||||
|
@register("my_table")
|
||||||
|
class MyTable(Component):
|
||||||
|
|
||||||
|
class Defaults:
|
||||||
|
position = "left"
|
||||||
|
selected_items = Default(lambda: [1, 2, 3])
|
||||||
|
|
||||||
|
def get_context_data(self, position, selected_items):
|
||||||
|
return {
|
||||||
|
"position": position,
|
||||||
|
"selected_items": selected_items,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
We can access individual defaults like this:
|
||||||
|
|
||||||
|
```py
|
||||||
|
print(MyTable.Defaults.position)
|
||||||
|
print(MyTable.Defaults.selected_items)
|
||||||
|
```
|
|
@ -68,7 +68,7 @@ from django_components import Component, register
|
||||||
class Calendar(Component):
|
class Calendar(Component):
|
||||||
template_file = "calendar.html"
|
template_file = "calendar.html"
|
||||||
...
|
...
|
||||||
def get_context_data(self, date: date, extra_class: str | None = None):
|
def get_context_data(self, date: date, extra_class: str = "text-blue"):
|
||||||
return {
|
return {
|
||||||
"date": date,
|
"date": date,
|
||||||
"extra_class": extra_class,
|
"extra_class": extra_class,
|
||||||
|
@ -197,7 +197,7 @@ def to_workweek_date(d: date):
|
||||||
class Calendar(Component):
|
class Calendar(Component):
|
||||||
template_file = "calendar.html"
|
template_file = "calendar.html"
|
||||||
...
|
...
|
||||||
def get_context_data(self, date: date, extra_class: str | None = None):
|
def get_context_data(self, date: date, extra_class: str = "text-blue"):
|
||||||
workweek_date = to_workweek_date(date) # <--- new
|
workweek_date = to_workweek_date(date) # <--- new
|
||||||
return {
|
return {
|
||||||
"date": workweek_date, # <--- changed
|
"date": workweek_date, # <--- changed
|
||||||
|
@ -220,3 +220,38 @@ the parametrized version of the component:
|
||||||
---
|
---
|
||||||
|
|
||||||
Next, you will learn [how to use slots give your components even more flexibility ➡️](./adding_slots.md)
|
Next, you will learn [how to use slots give your components even more flexibility ➡️](./adding_slots.md)
|
||||||
|
|
||||||
|
### 5. Add defaults
|
||||||
|
|
||||||
|
In our example, we've set the `extra_class` to default to `"text-blue"` by setting it in the
|
||||||
|
[`get_context_data()`](../../reference/api#django_components.Component.get_context_data)
|
||||||
|
method.
|
||||||
|
|
||||||
|
However, you may want to use the same default value in multiple methods, like
|
||||||
|
[`get_js_data()`](../../reference/api#django_components.Component.get_js_data)
|
||||||
|
or [`get_css_data()`](../../reference/api#django_components.Component.get_css_data).
|
||||||
|
|
||||||
|
To make things easier, Components can specify their defaults. Defaults are used when
|
||||||
|
no value is provided, or when the value is set to `None` for a particular input.
|
||||||
|
|
||||||
|
To define defaults for a component, you create a nested `Defaults` class within your
|
||||||
|
[`Component`](../../reference/api#django_components.Component) class.
|
||||||
|
Each attribute in the `Defaults` class represents a default value for a corresponding input.
|
||||||
|
|
||||||
|
```py
|
||||||
|
from django_components import Component, Default, register
|
||||||
|
|
||||||
|
@register("calendar")
|
||||||
|
class Calendar(Component):
|
||||||
|
template_file = "calendar.html"
|
||||||
|
|
||||||
|
class Defaults: # <--- new
|
||||||
|
extra_class = "text-blue"
|
||||||
|
|
||||||
|
def get_context_data(self, date: date, extra_class: str): # <--- changed
|
||||||
|
workweek_date = to_workweek_date(date)
|
||||||
|
return {
|
||||||
|
"date": workweek_date,
|
||||||
|
"extra_class": extra_class,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
|
@ -75,6 +75,10 @@
|
||||||
options:
|
options:
|
||||||
show_if_no_docstring: true
|
show_if_no_docstring: true
|
||||||
|
|
||||||
|
::: django_components.Default
|
||||||
|
options:
|
||||||
|
show_if_no_docstring: true
|
||||||
|
|
||||||
::: django_components.EmptyDict
|
::: django_components.EmptyDict
|
||||||
options:
|
options:
|
||||||
show_if_no_docstring: true
|
show_if_no_docstring: true
|
||||||
|
|
|
@ -67,7 +67,7 @@ If you insert this tag multiple times, ALL JS scripts will be duplicately insert
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1584" target="_blank">See source code</a>
|
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1494" target="_blank">See source code</a>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -40,6 +40,7 @@ from django_components.extension import (
|
||||||
OnComponentInputContext,
|
OnComponentInputContext,
|
||||||
OnComponentDataContext,
|
OnComponentDataContext,
|
||||||
)
|
)
|
||||||
|
from django_components.extensions.defaults import Default
|
||||||
from django_components.extensions.view import ComponentView
|
from django_components.extensions.view import ComponentView
|
||||||
from django_components.library import TagProtectedError
|
from django_components.library import TagProtectedError
|
||||||
from django_components.node import BaseNode, template_tag
|
from django_components.node import BaseNode, template_tag
|
||||||
|
@ -88,6 +89,7 @@ __all__ = [
|
||||||
"component_formatter",
|
"component_formatter",
|
||||||
"component_shorthand_formatter",
|
"component_shorthand_formatter",
|
||||||
"ContextBehavior",
|
"ContextBehavior",
|
||||||
|
"Default",
|
||||||
"DynamicComponent",
|
"DynamicComponent",
|
||||||
"EmptyTuple",
|
"EmptyTuple",
|
||||||
"EmptyDict",
|
"EmptyDict",
|
||||||
|
|
|
@ -750,9 +750,10 @@ class InternalSettings:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Prepend built-in extensions
|
# Prepend built-in extensions
|
||||||
|
from django_components.extensions.defaults import DefaultsExtension
|
||||||
from django_components.extensions.view import ViewExtension
|
from django_components.extensions.view import ViewExtension
|
||||||
|
|
||||||
extensions = [ViewExtension] + list(extensions)
|
extensions = [DefaultsExtension, ViewExtension] + list(extensions)
|
||||||
|
|
||||||
# Extensions may be passed in either as classes or import strings.
|
# Extensions may be passed in either as classes or import strings.
|
||||||
extension_instances: List["ComponentExtension"] = []
|
extension_instances: List["ComponentExtension"] = []
|
||||||
|
|
|
@ -962,6 +962,8 @@ class Component(
|
||||||
- `"document"` (default) - JS dependencies are inserted into `{% component_js_dependencies %}`,
|
- `"document"` (default) - JS dependencies are inserted into `{% component_js_dependencies %}`,
|
||||||
or to the end of the `<body>` tag. CSS dependencies are inserted into
|
or to the end of the `<body>` tag. CSS dependencies are inserted into
|
||||||
`{% component_css_dependencies %}`, or the end of the `<head>` tag.
|
`{% component_css_dependencies %}`, or the end of the `<head>` tag.
|
||||||
|
- `"fragment"` - `{% component_js_dependencies %}` and `{% component_css_dependencies %}`,
|
||||||
|
are ignored, and a script that loads the JS and CSS dependencies is appended to the HTML.
|
||||||
- `request` - The request object. This is only required when needing to use RequestContext,
|
- `request` - The request object. This is only required when needing to use RequestContext,
|
||||||
e.g. to enable template `context_processors`.
|
e.g. to enable template `context_processors`.
|
||||||
|
|
||||||
|
@ -1030,6 +1032,8 @@ class Component(
|
||||||
- `"document"` (default) - JS dependencies are inserted into `{% component_js_dependencies %}`,
|
- `"document"` (default) - JS dependencies are inserted into `{% component_js_dependencies %}`,
|
||||||
or to the end of the `<body>` tag. CSS dependencies are inserted into
|
or to the end of the `<body>` tag. CSS dependencies are inserted into
|
||||||
`{% component_css_dependencies %}`, or the end of the `<head>` tag.
|
`{% component_css_dependencies %}`, or the end of the `<head>` tag.
|
||||||
|
- `"fragment"` - `{% component_js_dependencies %}` and `{% component_css_dependencies %}`,
|
||||||
|
are ignored, and a script that loads the JS and CSS dependencies is appended to the HTML.
|
||||||
- `render_dependencies` - Set this to `False` if you want to insert the resulting HTML into another component.
|
- `render_dependencies` - Set this to `False` if you want to insert the resulting HTML into another component.
|
||||||
- `request` - The request object. This is only required when needing to use RequestContext,
|
- `request` - The request object. This is only required when needing to use RequestContext,
|
||||||
e.g. to enable template `context_processors`.
|
e.g. to enable template `context_processors`.
|
||||||
|
@ -1107,9 +1111,14 @@ class Component(
|
||||||
request = parent_comp_ctx.request
|
request = parent_comp_ctx.request
|
||||||
|
|
||||||
# Allow to provide no args/kwargs/slots/context
|
# Allow to provide no args/kwargs/slots/context
|
||||||
args = cast(ArgsType, args or ())
|
# NOTE: We make copies of args / kwargs / slots, so that plugins can modify them
|
||||||
kwargs = cast(KwargsType, kwargs or {})
|
# without affecting the original values.
|
||||||
slots_untyped = self._normalize_slot_fills(slots or {}, escape_slots_content)
|
args = cast(ArgsType, list(args) if args is not None else ())
|
||||||
|
kwargs = cast(KwargsType, dict(kwargs) if kwargs is not None else {})
|
||||||
|
slots_untyped = self._normalize_slot_fills(
|
||||||
|
dict(slots) if slots is not None else {},
|
||||||
|
escape_slots_content,
|
||||||
|
)
|
||||||
slots = cast(SlotsType, slots_untyped)
|
slots = cast(SlotsType, slots_untyped)
|
||||||
# Use RequestContext if request is provided, so that child non-component template tags
|
# Use RequestContext if request is provided, so that child non-component template tags
|
||||||
# can access the request object too.
|
# can access the request object too.
|
||||||
|
|
157
src/django_components/extensions/defaults.py
Normal file
157
src/django_components/extensions/defaults.py
Normal file
|
@ -0,0 +1,157 @@
|
||||||
|
import sys
|
||||||
|
from dataclasses import MISSING, Field, dataclass
|
||||||
|
from typing import TYPE_CHECKING, Any, Callable, Dict, List, NamedTuple, Optional, Type
|
||||||
|
from weakref import WeakKeyDictionary
|
||||||
|
|
||||||
|
from django_components.extension import ComponentExtension, OnComponentClassCreatedContext, OnComponentInputContext
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from django_components.component import Component
|
||||||
|
|
||||||
|
|
||||||
|
# NOTE: `WeakKeyDictionary` is NOT a generic pre-3.9
|
||||||
|
if sys.version_info >= (3, 9):
|
||||||
|
ComponentDefaultsCache = WeakKeyDictionary[Type["Component"], List["ComponentDefaultField"]]
|
||||||
|
else:
|
||||||
|
ComponentDefaultsCache = WeakKeyDictionary
|
||||||
|
|
||||||
|
|
||||||
|
defaults_by_component: ComponentDefaultsCache = WeakKeyDictionary()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Default:
|
||||||
|
"""
|
||||||
|
Use this class to mark a field on the `Component.Defaults` class as a factory.
|
||||||
|
|
||||||
|
Read more about [Component defaults](../../concepts/fundamentals/component_defaults).
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```py
|
||||||
|
from django_components import Default
|
||||||
|
|
||||||
|
class MyComponent(Component):
|
||||||
|
class Defaults:
|
||||||
|
# Plain value doesn't need a factory
|
||||||
|
position = "left"
|
||||||
|
# Lists and dicts need to be wrapped in `Default`
|
||||||
|
# Otherwise all instances will share the same value
|
||||||
|
selected_items = Default(lambda: [1, 2, 3])
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
value: Callable[[], Any]
|
||||||
|
|
||||||
|
|
||||||
|
class ComponentDefaultField(NamedTuple):
|
||||||
|
"""Internal representation of a field on the `Defaults` class."""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
value: Any
|
||||||
|
is_factory: bool
|
||||||
|
|
||||||
|
|
||||||
|
# Figure out which defaults are factories and which are not, at class creation,
|
||||||
|
# so that the actual creation of the defaults dictionary is simple.
|
||||||
|
def _extract_defaults(defaults: Optional[Type]) -> List[ComponentDefaultField]:
|
||||||
|
defaults_fields: List[ComponentDefaultField] = []
|
||||||
|
if defaults is None:
|
||||||
|
return defaults_fields
|
||||||
|
|
||||||
|
for default_field_key in dir(defaults):
|
||||||
|
# Iterate only over fields set by the user (so non-dunder fields).
|
||||||
|
# Plus ignore `component_class` because that was set by the extension system.
|
||||||
|
if default_field_key.startswith("__") or default_field_key == "component_class":
|
||||||
|
continue
|
||||||
|
|
||||||
|
default_field = getattr(defaults, default_field_key)
|
||||||
|
|
||||||
|
# If the field was defined with dataclass.field(), take the default / factory from there.
|
||||||
|
if isinstance(default_field, Field):
|
||||||
|
if default_field.default is not MISSING:
|
||||||
|
field_value = default_field.default
|
||||||
|
is_factory = False
|
||||||
|
elif default_field.default_factory is not MISSING:
|
||||||
|
field_value = default_field.default_factory
|
||||||
|
is_factory = True
|
||||||
|
else:
|
||||||
|
field_value = None
|
||||||
|
is_factory = False
|
||||||
|
|
||||||
|
# If the field was defined with our `Default` class, it defined a factory
|
||||||
|
elif isinstance(default_field, Default):
|
||||||
|
field_value = default_field.value
|
||||||
|
is_factory = True
|
||||||
|
|
||||||
|
# If the field was defined with a simple assignment, assume it's NOT a factory.
|
||||||
|
else:
|
||||||
|
field_value = default_field
|
||||||
|
is_factory = False
|
||||||
|
|
||||||
|
field_data = ComponentDefaultField(
|
||||||
|
key=default_field_key,
|
||||||
|
value=field_value,
|
||||||
|
is_factory=is_factory,
|
||||||
|
)
|
||||||
|
defaults_fields.append(field_data)
|
||||||
|
|
||||||
|
return defaults_fields
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_defaults(kwargs: Dict, defaults: List[ComponentDefaultField]) -> None:
|
||||||
|
"""
|
||||||
|
Apply the defaults from `Component.Defaults` to the given `kwargs`.
|
||||||
|
|
||||||
|
Defaults are applied only to missing or `None` values.
|
||||||
|
"""
|
||||||
|
for default_field in defaults:
|
||||||
|
# Defaults are applied only to missing or `None` values
|
||||||
|
given_value = kwargs.get(default_field.key, None)
|
||||||
|
if given_value is not None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if default_field.is_factory:
|
||||||
|
default_value = default_field.value()
|
||||||
|
else:
|
||||||
|
default_value = default_field.value
|
||||||
|
|
||||||
|
kwargs[default_field.key] = default_value
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultsExtension(ComponentExtension):
|
||||||
|
"""
|
||||||
|
This extension adds a nested `Defaults` class to each `Component`.
|
||||||
|
|
||||||
|
This nested `Defaults` class is used to set default values for the component's kwargs.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```py
|
||||||
|
from django_components import Component, Default
|
||||||
|
|
||||||
|
class MyComponent(Component):
|
||||||
|
class Defaults:
|
||||||
|
position = "left"
|
||||||
|
# Factory values need to be wrapped in `Default`
|
||||||
|
selected_items = Default(lambda: [1, 2, 3])
|
||||||
|
```
|
||||||
|
|
||||||
|
This extension is automatically added to all components.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "defaults"
|
||||||
|
|
||||||
|
# Preprocess the `Component.Defaults` class, if given, so we don't have to do it
|
||||||
|
# each time a component is rendered.
|
||||||
|
def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
|
||||||
|
defaults_cls = getattr(ctx.component_cls, "Defaults", None)
|
||||||
|
defaults_by_component[ctx.component_cls] = _extract_defaults(defaults_cls)
|
||||||
|
|
||||||
|
# Apply defaults to missing or `None` values in `kwargs`
|
||||||
|
def on_component_input(self, ctx: OnComponentInputContext) -> None:
|
||||||
|
defaults = defaults_by_component.get(ctx.component_cls, None)
|
||||||
|
if defaults is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
_apply_defaults(ctx.kwargs, defaults)
|
|
@ -447,7 +447,6 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def taken_n(n: int) -> str:
|
def taken_n(n: int) -> str:
|
||||||
nonlocal index
|
|
||||||
result = text[index : index + n] # noqa: E203
|
result = text[index : index + n] # noqa: E203
|
||||||
add_token(result)
|
add_token(result)
|
||||||
return result
|
return result
|
||||||
|
@ -457,9 +456,6 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
|
||||||
tokens: Union[List[str], Tuple[str, ...]],
|
tokens: Union[List[str], Tuple[str, ...]],
|
||||||
ignore: Optional[Sequence[str]] = None,
|
ignore: Optional[Sequence[str]] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
nonlocal index
|
|
||||||
nonlocal text
|
|
||||||
|
|
||||||
result = ""
|
result = ""
|
||||||
while not is_at_end():
|
while not is_at_end():
|
||||||
char = text[index]
|
char = text[index]
|
||||||
|
@ -483,9 +479,6 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
|
||||||
|
|
||||||
# tag_name = take_while([" ", "\t", "\n", "\r", "\f"])
|
# tag_name = take_while([" ", "\t", "\n", "\r", "\f"])
|
||||||
def take_while(tokens: Union[List[str], Tuple[str, ...]]) -> str:
|
def take_while(tokens: Union[List[str], Tuple[str, ...]]) -> str:
|
||||||
nonlocal index
|
|
||||||
nonlocal text
|
|
||||||
|
|
||||||
result = ""
|
result = ""
|
||||||
while not is_at_end():
|
while not is_at_end():
|
||||||
char = text[index]
|
char = text[index]
|
||||||
|
|
|
@ -97,7 +97,7 @@ class TestExtensionsListCommand:
|
||||||
call_command("components", "ext", "list")
|
call_command("components", "ext", "list")
|
||||||
output = out.getvalue()
|
output = out.getvalue()
|
||||||
|
|
||||||
assert output.strip() == "name\n====\nview"
|
assert output.strip() == "name \n========\ndefaults\nview"
|
||||||
|
|
||||||
@djc_test(
|
@djc_test(
|
||||||
components_settings={"extensions": [EmptyExtension, DummyExtension]},
|
components_settings={"extensions": [EmptyExtension, DummyExtension]},
|
||||||
|
@ -108,7 +108,7 @@ class TestExtensionsListCommand:
|
||||||
call_command("components", "ext", "list")
|
call_command("components", "ext", "list")
|
||||||
output = out.getvalue()
|
output = out.getvalue()
|
||||||
|
|
||||||
assert output.strip() == "name \n=====\nview \nempty\ndummy"
|
assert output.strip() == "name \n========\ndefaults\nview \nempty \ndummy"
|
||||||
|
|
||||||
@djc_test(
|
@djc_test(
|
||||||
components_settings={"extensions": [EmptyExtension, DummyExtension]},
|
components_settings={"extensions": [EmptyExtension, DummyExtension]},
|
||||||
|
@ -119,7 +119,7 @@ class TestExtensionsListCommand:
|
||||||
call_command("components", "ext", "list", "--all")
|
call_command("components", "ext", "list", "--all")
|
||||||
output = out.getvalue()
|
output = out.getvalue()
|
||||||
|
|
||||||
assert output.strip() == "name \n=====\nview \nempty\ndummy"
|
assert output.strip() == "name \n========\ndefaults\nview \nempty \ndummy"
|
||||||
|
|
||||||
@djc_test(
|
@djc_test(
|
||||||
components_settings={"extensions": [EmptyExtension, DummyExtension]},
|
components_settings={"extensions": [EmptyExtension, DummyExtension]},
|
||||||
|
@ -130,7 +130,7 @@ class TestExtensionsListCommand:
|
||||||
call_command("components", "ext", "list", "--columns", "name")
|
call_command("components", "ext", "list", "--columns", "name")
|
||||||
output = out.getvalue()
|
output = out.getvalue()
|
||||||
|
|
||||||
assert output.strip() == "name \n=====\nview \nempty\ndummy"
|
assert output.strip() == "name \n========\ndefaults\nview \nempty \ndummy"
|
||||||
|
|
||||||
@djc_test(
|
@djc_test(
|
||||||
components_settings={"extensions": [EmptyExtension, DummyExtension]},
|
components_settings={"extensions": [EmptyExtension, DummyExtension]},
|
||||||
|
@ -141,7 +141,7 @@ class TestExtensionsListCommand:
|
||||||
call_command("components", "ext", "list", "--simple")
|
call_command("components", "ext", "list", "--simple")
|
||||||
output = out.getvalue()
|
output = out.getvalue()
|
||||||
|
|
||||||
assert output.strip() == "view \nempty\ndummy"
|
assert output.strip() == "defaults\nview \nempty \ndummy"
|
||||||
|
|
||||||
|
|
||||||
@djc_test
|
@djc_test
|
||||||
|
@ -159,18 +159,19 @@ class TestExtensionsRunCommand:
|
||||||
output
|
output
|
||||||
== dedent(
|
== dedent(
|
||||||
f"""
|
f"""
|
||||||
usage: components ext run [-h] {{view,empty,dummy}} ...
|
usage: components ext run [-h] {{defaults,view,empty,dummy}} ...
|
||||||
|
|
||||||
Run a command added by an extension.
|
Run a command added by an extension.
|
||||||
|
|
||||||
{OPTIONS_TITLE}:
|
{OPTIONS_TITLE}:
|
||||||
-h, --help show this help message and exit
|
-h, --help show this help message and exit
|
||||||
|
|
||||||
subcommands:
|
subcommands:
|
||||||
{{view,empty,dummy}}
|
{{defaults,view,empty,dummy}}
|
||||||
view Run commands added by the 'view' extension.
|
defaults Run commands added by the 'defaults' extension.
|
||||||
empty Run commands added by the 'empty' extension.
|
view Run commands added by the 'view' extension.
|
||||||
dummy Run commands added by the 'dummy' extension.
|
empty Run commands added by the 'empty' extension.
|
||||||
|
dummy Run commands added by the 'dummy' extension.
|
||||||
"""
|
"""
|
||||||
).lstrip()
|
).lstrip()
|
||||||
)
|
)
|
||||||
|
|
|
@ -293,7 +293,7 @@ class TestComponent:
|
||||||
class TestComponent(Component):
|
class TestComponent(Component):
|
||||||
@no_type_check
|
@no_type_check
|
||||||
def get_context_data(self, var1, var2, variable, another, **attrs):
|
def get_context_data(self, var1, var2, variable, another, **attrs):
|
||||||
assert self.input.args == (123, "str")
|
assert self.input.args == [123, "str"]
|
||||||
assert self.input.kwargs == {"variable": "test", "another": 1}
|
assert self.input.kwargs == {"variable": "test", "another": 1}
|
||||||
assert isinstance(self.input.context, Context)
|
assert isinstance(self.input.context, Context)
|
||||||
assert list(self.input.slots.keys()) == ["my_slot"]
|
assert list(self.input.slots.keys()) == ["my_slot"]
|
||||||
|
@ -305,7 +305,7 @@ class TestComponent:
|
||||||
|
|
||||||
@no_type_check
|
@no_type_check
|
||||||
def get_template(self, context):
|
def get_template(self, context):
|
||||||
assert self.input.args == (123, "str")
|
assert self.input.args == [123, "str"]
|
||||||
assert self.input.kwargs == {"variable": "test", "another": 1}
|
assert self.input.kwargs == {"variable": "test", "another": 1}
|
||||||
assert isinstance(self.input.context, Context)
|
assert isinstance(self.input.context, Context)
|
||||||
assert list(self.input.slots.keys()) == ["my_slot"]
|
assert list(self.input.slots.keys()) == ["my_slot"]
|
||||||
|
|
147
tests/test_component_defaults.py
Normal file
147
tests/test_component_defaults.py
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
from dataclasses import field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.template import Context
|
||||||
|
|
||||||
|
from django_components import Component, Default
|
||||||
|
|
||||||
|
from django_components.testing import djc_test
|
||||||
|
from .testutils import setup_test_config
|
||||||
|
|
||||||
|
setup_test_config({"autodiscover": False})
|
||||||
|
|
||||||
|
|
||||||
|
@djc_test
|
||||||
|
class TestComponentDefaults:
|
||||||
|
def test_input_defaults(self):
|
||||||
|
did_call_context = False
|
||||||
|
|
||||||
|
class TestComponent(Component):
|
||||||
|
template = ""
|
||||||
|
|
||||||
|
class Defaults:
|
||||||
|
variable = "test"
|
||||||
|
another = 1
|
||||||
|
extra = "extra"
|
||||||
|
fn = lambda: "fn_as_val" # noqa: E731
|
||||||
|
|
||||||
|
def get_context_data(self, arg1: Any, variable: Any, another: Any, **attrs: Any):
|
||||||
|
nonlocal did_call_context
|
||||||
|
did_call_context = True
|
||||||
|
|
||||||
|
# Check that args and slots are NOT affected by the defaults
|
||||||
|
assert self.input.args == [123]
|
||||||
|
assert [*self.input.slots.keys()] == ["my_slot"]
|
||||||
|
assert self.input.slots["my_slot"](Context(), None, None) == "MY_SLOT"
|
||||||
|
|
||||||
|
assert self.input.kwargs == {
|
||||||
|
"variable": "test", # User-given
|
||||||
|
"another": 1, # Default because missing
|
||||||
|
"extra": "extra", # Default because `None` was given
|
||||||
|
"fn": self.Defaults.fn, # Default because missing
|
||||||
|
}
|
||||||
|
assert isinstance(self.input.context, Context)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"variable": variable,
|
||||||
|
}
|
||||||
|
|
||||||
|
TestComponent.render(
|
||||||
|
args=(123,),
|
||||||
|
kwargs={"variable": "test", "extra": None},
|
||||||
|
slots={"my_slot": "MY_SLOT"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert did_call_context
|
||||||
|
|
||||||
|
def test_factory_from_class(self):
|
||||||
|
did_call_context = False
|
||||||
|
|
||||||
|
class TestComponent(Component):
|
||||||
|
template = ""
|
||||||
|
|
||||||
|
class Defaults:
|
||||||
|
variable = "test"
|
||||||
|
fn = Default(lambda: "fn_as_factory")
|
||||||
|
|
||||||
|
def get_context_data(self, variable: Any, **attrs: Any):
|
||||||
|
nonlocal did_call_context
|
||||||
|
did_call_context = True
|
||||||
|
|
||||||
|
assert self.input.kwargs == {
|
||||||
|
"variable": "test", # User-given
|
||||||
|
"fn": "fn_as_factory", # Default because missing
|
||||||
|
}
|
||||||
|
assert isinstance(self.input.context, Context)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"variable": variable,
|
||||||
|
}
|
||||||
|
|
||||||
|
TestComponent.render(
|
||||||
|
kwargs={"variable": "test"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert did_call_context
|
||||||
|
|
||||||
|
def test_factory_from_dataclass_field_value(self):
|
||||||
|
did_call_context = False
|
||||||
|
|
||||||
|
class TestComponent(Component):
|
||||||
|
template = ""
|
||||||
|
|
||||||
|
class Defaults:
|
||||||
|
variable = "test"
|
||||||
|
fn = field(default=lambda: "fn_as_factory")
|
||||||
|
|
||||||
|
def get_context_data(self, variable: Any, **attrs: Any):
|
||||||
|
nonlocal did_call_context
|
||||||
|
did_call_context = True
|
||||||
|
|
||||||
|
assert self.input.kwargs == {
|
||||||
|
"variable": "test", # User-given
|
||||||
|
# NOTE: NOT a factory, because it was set as `field(default=...)`
|
||||||
|
"fn": self.Defaults.fn.default, # type: ignore[attr-defined]
|
||||||
|
}
|
||||||
|
assert isinstance(self.input.context, Context)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"variable": variable,
|
||||||
|
}
|
||||||
|
|
||||||
|
TestComponent.render(
|
||||||
|
kwargs={"variable": "test"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert did_call_context
|
||||||
|
|
||||||
|
def test_factory_from_dataclass_field_factory(self):
|
||||||
|
did_call_context = False
|
||||||
|
|
||||||
|
class TestComponent(Component):
|
||||||
|
template = ""
|
||||||
|
|
||||||
|
class Defaults:
|
||||||
|
variable = "test"
|
||||||
|
fn = field(default_factory=lambda: "fn_as_factory")
|
||||||
|
|
||||||
|
def get_context_data(self, variable: Any, **attrs: Any):
|
||||||
|
nonlocal did_call_context
|
||||||
|
did_call_context = True
|
||||||
|
|
||||||
|
assert self.input.kwargs == {
|
||||||
|
"variable": "test", # User-given
|
||||||
|
# NOTE: IS a factory, because it was set as `field(default_factory=...)`
|
||||||
|
"fn": "fn_as_factory", # Default because missing
|
||||||
|
}
|
||||||
|
assert isinstance(self.input.context, Context)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"variable": variable,
|
||||||
|
}
|
||||||
|
|
||||||
|
TestComponent.render(
|
||||||
|
kwargs={"variable": "test"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert did_call_context
|
|
@ -20,6 +20,7 @@ from django_components.extension import (
|
||||||
OnComponentInputContext,
|
OnComponentInputContext,
|
||||||
OnComponentDataContext,
|
OnComponentDataContext,
|
||||||
)
|
)
|
||||||
|
from django_components.extensions.defaults import DefaultsExtension
|
||||||
from django_components.extensions.view import ViewExtension
|
from django_components.extensions.view import ViewExtension
|
||||||
|
|
||||||
from django_components.testing import djc_test
|
from django_components.testing import djc_test
|
||||||
|
@ -125,9 +126,10 @@ def with_registry(on_created: Callable):
|
||||||
class TestExtension:
|
class TestExtension:
|
||||||
@djc_test(components_settings={"extensions": [DummyExtension]})
|
@djc_test(components_settings={"extensions": [DummyExtension]})
|
||||||
def test_extensions_setting(self):
|
def test_extensions_setting(self):
|
||||||
assert len(app_settings.EXTENSIONS) == 2
|
assert len(app_settings.EXTENSIONS) == 3
|
||||||
assert isinstance(app_settings.EXTENSIONS[0], ViewExtension)
|
assert isinstance(app_settings.EXTENSIONS[0], DefaultsExtension)
|
||||||
assert isinstance(app_settings.EXTENSIONS[1], DummyExtension)
|
assert isinstance(app_settings.EXTENSIONS[1], ViewExtension)
|
||||||
|
assert isinstance(app_settings.EXTENSIONS[2], DummyExtension)
|
||||||
|
|
||||||
@djc_test(components_settings={"extensions": [DummyExtension]})
|
@djc_test(components_settings={"extensions": [DummyExtension]})
|
||||||
def test_access_component_from_extension(self):
|
def test_access_component_from_extension(self):
|
||||||
|
@ -150,7 +152,7 @@ class TestExtension:
|
||||||
class TestExtensionHooks:
|
class TestExtensionHooks:
|
||||||
@djc_test(components_settings={"extensions": [DummyExtension]})
|
@djc_test(components_settings={"extensions": [DummyExtension]})
|
||||||
def test_component_class_lifecycle_hooks(self):
|
def test_component_class_lifecycle_hooks(self):
|
||||||
extension = cast(DummyExtension, app_settings.EXTENSIONS[1])
|
extension = cast(DummyExtension, app_settings.EXTENSIONS[2])
|
||||||
|
|
||||||
assert len(extension.calls["on_component_class_created"]) == 0
|
assert len(extension.calls["on_component_class_created"]) == 0
|
||||||
assert len(extension.calls["on_component_class_deleted"]) == 0
|
assert len(extension.calls["on_component_class_deleted"]) == 0
|
||||||
|
@ -182,7 +184,7 @@ class TestExtensionHooks:
|
||||||
|
|
||||||
@djc_test(components_settings={"extensions": [DummyExtension]})
|
@djc_test(components_settings={"extensions": [DummyExtension]})
|
||||||
def test_registry_lifecycle_hooks(self):
|
def test_registry_lifecycle_hooks(self):
|
||||||
extension = cast(DummyExtension, app_settings.EXTENSIONS[1])
|
extension = cast(DummyExtension, app_settings.EXTENSIONS[2])
|
||||||
|
|
||||||
assert len(extension.calls["on_registry_created"]) == 0
|
assert len(extension.calls["on_registry_created"]) == 0
|
||||||
assert len(extension.calls["on_registry_deleted"]) == 0
|
assert len(extension.calls["on_registry_deleted"]) == 0
|
||||||
|
@ -219,7 +221,7 @@ class TestExtensionHooks:
|
||||||
return {"name": name}
|
return {"name": name}
|
||||||
|
|
||||||
registry.register("test_comp", TestComponent)
|
registry.register("test_comp", TestComponent)
|
||||||
extension = cast(DummyExtension, app_settings.EXTENSIONS[1])
|
extension = cast(DummyExtension, app_settings.EXTENSIONS[2])
|
||||||
|
|
||||||
# Verify on_component_registered was called
|
# Verify on_component_registered was called
|
||||||
assert len(extension.calls["on_component_registered"]) == 1
|
assert len(extension.calls["on_component_registered"]) == 1
|
||||||
|
@ -257,14 +259,14 @@ class TestExtensionHooks:
|
||||||
test_slots = {"content": "Some content"}
|
test_slots = {"content": "Some content"}
|
||||||
TestComponent.render(context=test_context, args=("arg1", "arg2"), kwargs={"name": "Test"}, slots=test_slots)
|
TestComponent.render(context=test_context, args=("arg1", "arg2"), kwargs={"name": "Test"}, slots=test_slots)
|
||||||
|
|
||||||
extension = cast(DummyExtension, app_settings.EXTENSIONS[1])
|
extension = cast(DummyExtension, app_settings.EXTENSIONS[2])
|
||||||
|
|
||||||
# Verify on_component_input was called with correct args
|
# Verify on_component_input was called with correct args
|
||||||
assert len(extension.calls["on_component_input"]) == 1
|
assert len(extension.calls["on_component_input"]) == 1
|
||||||
input_call: OnComponentInputContext = extension.calls["on_component_input"][0]
|
input_call: OnComponentInputContext = extension.calls["on_component_input"][0]
|
||||||
assert input_call.component_cls == TestComponent
|
assert input_call.component_cls == TestComponent
|
||||||
assert isinstance(input_call.component_id, str)
|
assert isinstance(input_call.component_id, str)
|
||||||
assert input_call.args == ("arg1", "arg2")
|
assert input_call.args == ["arg1", "arg2"]
|
||||||
assert input_call.kwargs == {"name": "Test"}
|
assert input_call.kwargs == {"name": "Test"}
|
||||||
assert len(input_call.slots) == 1
|
assert len(input_call.slots) == 1
|
||||||
assert isinstance(input_call.slots["content"], Slot)
|
assert isinstance(input_call.slots["content"], Slot)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue