mirror of
https://github.com/django-components/django-components.git
synced 2025-07-07 17:34:59 +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
|
||||
|
||||
- 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,
|
||||
where each class name or style property can be managed separately.
|
||||
|
||||
|
|
|
@ -140,7 +140,6 @@ def prepare_templating_benchmark(
|
|||
context_mode: DjcContextMode,
|
||||
imports_only: bool = False,
|
||||
):
|
||||
global do_render
|
||||
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
|
||||
|
|
|
@ -3,6 +3,7 @@ nav:
|
|||
- Single-file components: single_file_components.md
|
||||
- Components in Python: components_in_python.md
|
||||
- Accessing component inputs: access_component_input.md
|
||||
- Component defaults: component_defaults.md
|
||||
- Component context and scope: component_context_scope.md
|
||||
- Template tag syntax: template_tag_syntax.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):
|
||||
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 {
|
||||
"date": date,
|
||||
"extra_class": extra_class,
|
||||
|
@ -197,7 +197,7 @@ def to_workweek_date(d: date):
|
|||
class Calendar(Component):
|
||||
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
|
||||
return {
|
||||
"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)
|
||||
|
||||
### 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:
|
||||
show_if_no_docstring: true
|
||||
|
||||
::: django_components.Default
|
||||
options:
|
||||
show_if_no_docstring: true
|
||||
|
||||
::: django_components.EmptyDict
|
||||
options:
|
||||
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,
|
||||
OnComponentDataContext,
|
||||
)
|
||||
from django_components.extensions.defaults import Default
|
||||
from django_components.extensions.view import ComponentView
|
||||
from django_components.library import TagProtectedError
|
||||
from django_components.node import BaseNode, template_tag
|
||||
|
@ -88,6 +89,7 @@ __all__ = [
|
|||
"component_formatter",
|
||||
"component_shorthand_formatter",
|
||||
"ContextBehavior",
|
||||
"Default",
|
||||
"DynamicComponent",
|
||||
"EmptyTuple",
|
||||
"EmptyDict",
|
||||
|
|
|
@ -750,9 +750,10 @@ class InternalSettings:
|
|||
)
|
||||
|
||||
# Prepend built-in extensions
|
||||
from django_components.extensions.defaults import DefaultsExtension
|
||||
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.
|
||||
extension_instances: List["ComponentExtension"] = []
|
||||
|
|
|
@ -962,6 +962,8 @@ class Component(
|
|||
- `"document"` (default) - JS dependencies are inserted into `{% component_js_dependencies %}`,
|
||||
or to the end of the `<body>` tag. CSS dependencies are inserted into
|
||||
`{% 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,
|
||||
e.g. to enable template `context_processors`.
|
||||
|
||||
|
@ -1030,6 +1032,8 @@ class Component(
|
|||
- `"document"` (default) - JS dependencies are inserted into `{% component_js_dependencies %}`,
|
||||
or to the end of the `<body>` tag. CSS dependencies are inserted into
|
||||
`{% 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.
|
||||
- `request` - The request object. This is only required when needing to use RequestContext,
|
||||
e.g. to enable template `context_processors`.
|
||||
|
@ -1107,9 +1111,14 @@ class Component(
|
|||
request = parent_comp_ctx.request
|
||||
|
||||
# Allow to provide no args/kwargs/slots/context
|
||||
args = cast(ArgsType, args or ())
|
||||
kwargs = cast(KwargsType, kwargs or {})
|
||||
slots_untyped = self._normalize_slot_fills(slots or {}, escape_slots_content)
|
||||
# NOTE: We make copies of args / kwargs / slots, so that plugins can modify them
|
||||
# without affecting the original values.
|
||||
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)
|
||||
# Use RequestContext if request is provided, so that child non-component template tags
|
||||
# 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
|
||||
|
||||
def taken_n(n: int) -> str:
|
||||
nonlocal index
|
||||
result = text[index : index + n] # noqa: E203
|
||||
add_token(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, ...]],
|
||||
ignore: Optional[Sequence[str]] = None,
|
||||
) -> str:
|
||||
nonlocal index
|
||||
nonlocal text
|
||||
|
||||
result = ""
|
||||
while not is_at_end():
|
||||
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"])
|
||||
def take_while(tokens: Union[List[str], Tuple[str, ...]]) -> str:
|
||||
nonlocal index
|
||||
nonlocal text
|
||||
|
||||
result = ""
|
||||
while not is_at_end():
|
||||
char = text[index]
|
||||
|
|
|
@ -97,7 +97,7 @@ class TestExtensionsListCommand:
|
|||
call_command("components", "ext", "list")
|
||||
output = out.getvalue()
|
||||
|
||||
assert output.strip() == "name\n====\nview"
|
||||
assert output.strip() == "name \n========\ndefaults\nview"
|
||||
|
||||
@djc_test(
|
||||
components_settings={"extensions": [EmptyExtension, DummyExtension]},
|
||||
|
@ -108,7 +108,7 @@ class TestExtensionsListCommand:
|
|||
call_command("components", "ext", "list")
|
||||
output = out.getvalue()
|
||||
|
||||
assert output.strip() == "name \n=====\nview \nempty\ndummy"
|
||||
assert output.strip() == "name \n========\ndefaults\nview \nempty \ndummy"
|
||||
|
||||
@djc_test(
|
||||
components_settings={"extensions": [EmptyExtension, DummyExtension]},
|
||||
|
@ -119,7 +119,7 @@ class TestExtensionsListCommand:
|
|||
call_command("components", "ext", "list", "--all")
|
||||
output = out.getvalue()
|
||||
|
||||
assert output.strip() == "name \n=====\nview \nempty\ndummy"
|
||||
assert output.strip() == "name \n========\ndefaults\nview \nempty \ndummy"
|
||||
|
||||
@djc_test(
|
||||
components_settings={"extensions": [EmptyExtension, DummyExtension]},
|
||||
|
@ -130,7 +130,7 @@ class TestExtensionsListCommand:
|
|||
call_command("components", "ext", "list", "--columns", "name")
|
||||
output = out.getvalue()
|
||||
|
||||
assert output.strip() == "name \n=====\nview \nempty\ndummy"
|
||||
assert output.strip() == "name \n========\ndefaults\nview \nempty \ndummy"
|
||||
|
||||
@djc_test(
|
||||
components_settings={"extensions": [EmptyExtension, DummyExtension]},
|
||||
|
@ -141,7 +141,7 @@ class TestExtensionsListCommand:
|
|||
call_command("components", "ext", "list", "--simple")
|
||||
output = out.getvalue()
|
||||
|
||||
assert output.strip() == "view \nempty\ndummy"
|
||||
assert output.strip() == "defaults\nview \nempty \ndummy"
|
||||
|
||||
|
||||
@djc_test
|
||||
|
@ -159,18 +159,19 @@ class TestExtensionsRunCommand:
|
|||
output
|
||||
== dedent(
|
||||
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.
|
||||
|
||||
{OPTIONS_TITLE}:
|
||||
-h, --help show this help message and exit
|
||||
-h, --help show this help message and exit
|
||||
|
||||
subcommands:
|
||||
{{view,empty,dummy}}
|
||||
view Run commands added by the 'view' extension.
|
||||
empty Run commands added by the 'empty' extension.
|
||||
dummy Run commands added by the 'dummy' extension.
|
||||
{{defaults,view,empty,dummy}}
|
||||
defaults Run commands added by the 'defaults' extension.
|
||||
view Run commands added by the 'view' extension.
|
||||
empty Run commands added by the 'empty' extension.
|
||||
dummy Run commands added by the 'dummy' extension.
|
||||
"""
|
||||
).lstrip()
|
||||
)
|
||||
|
|
|
@ -293,7 +293,7 @@ class TestComponent:
|
|||
class TestComponent(Component):
|
||||
@no_type_check
|
||||
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 isinstance(self.input.context, Context)
|
||||
assert list(self.input.slots.keys()) == ["my_slot"]
|
||||
|
@ -305,7 +305,7 @@ class TestComponent:
|
|||
|
||||
@no_type_check
|
||||
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 isinstance(self.input.context, Context)
|
||||
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,
|
||||
OnComponentDataContext,
|
||||
)
|
||||
from django_components.extensions.defaults import DefaultsExtension
|
||||
from django_components.extensions.view import ViewExtension
|
||||
|
||||
from django_components.testing import djc_test
|
||||
|
@ -125,9 +126,10 @@ def with_registry(on_created: Callable):
|
|||
class TestExtension:
|
||||
@djc_test(components_settings={"extensions": [DummyExtension]})
|
||||
def test_extensions_setting(self):
|
||||
assert len(app_settings.EXTENSIONS) == 2
|
||||
assert isinstance(app_settings.EXTENSIONS[0], ViewExtension)
|
||||
assert isinstance(app_settings.EXTENSIONS[1], DummyExtension)
|
||||
assert len(app_settings.EXTENSIONS) == 3
|
||||
assert isinstance(app_settings.EXTENSIONS[0], DefaultsExtension)
|
||||
assert isinstance(app_settings.EXTENSIONS[1], ViewExtension)
|
||||
assert isinstance(app_settings.EXTENSIONS[2], DummyExtension)
|
||||
|
||||
@djc_test(components_settings={"extensions": [DummyExtension]})
|
||||
def test_access_component_from_extension(self):
|
||||
|
@ -150,7 +152,7 @@ class TestExtension:
|
|||
class TestExtensionHooks:
|
||||
@djc_test(components_settings={"extensions": [DummyExtension]})
|
||||
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_deleted"]) == 0
|
||||
|
@ -182,7 +184,7 @@ class TestExtensionHooks:
|
|||
|
||||
@djc_test(components_settings={"extensions": [DummyExtension]})
|
||||
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_deleted"]) == 0
|
||||
|
@ -219,7 +221,7 @@ class TestExtensionHooks:
|
|||
return {"name": name}
|
||||
|
||||
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
|
||||
assert len(extension.calls["on_component_registered"]) == 1
|
||||
|
@ -257,14 +259,14 @@ class TestExtensionHooks:
|
|||
test_slots = {"content": "Some content"}
|
||||
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
|
||||
assert len(extension.calls["on_component_input"]) == 1
|
||||
input_call: OnComponentInputContext = extension.calls["on_component_input"][0]
|
||||
assert input_call.component_cls == TestComponent
|
||||
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 len(input_call.slots) == 1
|
||||
assert isinstance(input_call.slots["content"], Slot)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue