From 46e524e37df9e67f9c9ad6599f42b2f39ab7d14c Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Tue, 3 Jun 2025 12:58:48 +0200 Subject: [PATCH] refactor: Add Node metadata (#1229) * refactor: `Slot.source` replaced with `Slot.fill_node`, new `Component.node` property, and `slot_node` available in `on_slot_rendered()` hook. * refactor: fix windows path error in tests --- CHANGELOG.md | 67 +++++++- docs/concepts/advanced/template_tags.md | 14 +- docs/concepts/fundamentals/render_api.md | 51 +++++- docs/concepts/fundamentals/slots.md | 34 +++- docs/reference/api.md | 16 ++ docs/reference/extension_hooks.md | 1 + docs/reference/template_tags.md | 49 +++--- src/django_components/__init__.py | 8 + src/django_components/component.py | 69 +++++++- src/django_components/extension.py | 24 ++- src/django_components/node.py | 210 ++++++++++++++++++++++- src/django_components/provide.py | 7 +- src/django_components/slots.py | 80 +++++---- tests/test_component.py | 106 ++++++++++++ tests/test_extension.py | 28 ++- tests/test_node.py | 52 +++++- tests/test_slots.py | 15 +- 17 files changed, 728 insertions(+), 103 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4db47bac..12646a51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -158,12 +158,17 @@ Summary: ```py def render_to_response( context: Optional[Union[Dict[str, Any], Context]] = None, - args: Optional[Tuple[Any, ...]] = None, - kwargs: Optional[Mapping] = None, - slots: Optional[Mapping] = None, + args: Optional[Any] = None, + kwargs: Optional[Any] = None, + slots: Optional[Any] = None, deps_strategy: DependenciesStrategy = "document", - render_dependencies: bool = True, + type: Optional[DependenciesStrategy] = None, # Deprecated, use `deps_strategy` + render_dependencies: bool = True, # Deprecated, use `deps_strategy="ignore"` + outer_context: Optional[Context] = None, request: Optional[HttpRequest] = None, + registry: Optional[ComponentRegistry] = None, + registered_name: Optional[str] = None, + node: Optional[ComponentNode] = None, **response_kwargs: Any, ) -> HttpResponse: ``` @@ -1008,6 +1013,13 @@ Summary: Then, the `contents` attribute of the `BaseNode` instance will contain the string `"Hello, world!"`. +- The `BaseNode` class also has two new metadata attributes: + + - `template_name` - the name of the template that rendered the node. + - `template_component` - the component class that the template belongs to. + + This is useful for debugging purposes. + - `Slot` class now has 3 new metadata fields: 1. `Slot.contents` attribute contains the original contents: @@ -1018,10 +1030,11 @@ Summary: 2. `Slot.extra` attribute where you can put arbitrary metadata about the slot. - 3. `Slot.source` attribute tells where the slot comes from: + 3. `Slot.fill_node` attribute tells where the slot comes from: - - `'template'` if the slot was created from `{% fill %}` tag. - - `'python'` if the slot was created from string, function, or `Slot` instance. + - `FillNode` instance if the slot was created from `{% fill %}` tag. + - `ComponentNode` instance if the slot was created as a default slot from a `{% component %}` tag. + - `None` if the slot was created from a string, function, or `Slot` instance. See [Slot metadata](https://django-components.github.io/django-components/0.140/concepts/fundamentals/slots/#slot-metadata). @@ -1048,6 +1061,46 @@ Summary: {% endcomponent %} ``` +- You can now access the `{% component %}` tag (`ComponentNode` instance) from which a Component + was created. Use `Component.node` to access it. + + This is mostly useful for extensions, which can use this to detect if the given Component + comes from a `{% component %}` tag or from a different source (such as `Component.render()`). + + `Component.node` is `None` if the component is created by `Component.render()` (but you + can pass in the `node` kwarg yourself). + + ```py + class MyComponent(Component): + def get_template_data(self, context, template): + if self.node is not None: + assert self.node.name == "my_component" + ``` + +- Node classes `ComponentNode`, `FillNode`, `ProvideNode`, and `SlotNode` are part of the public API. + + These classes are what is instantiated when you use `{% component %}`, `{% fill %}`, `{% provide %}`, and `{% slot %}` tags. + + You can for example use these for type hints: + + ```py + from django_components import Component, ComponentNode + + class MyTable(Component): + def get_template_data(self, args, kwargs, slots, context): + if kwargs.get("show_owner"): + node: Optional[ComponentNode] = self.node + owner: Optional[Component] = self.node.template_component + else: + node = None + owner = None + + return { + "owner": owner, + "node": node, + } + ``` + - Component caching can now take slots into account, by setting `Component.Cache.include_slots` to `True`. ```py diff --git a/docs/concepts/advanced/template_tags.md b/docs/concepts/advanced/template_tags.md index 4ea7efb4..ff40181c 100644 --- a/docs/concepts/advanced/template_tags.md +++ b/docs/concepts/advanced/template_tags.md @@ -153,12 +153,14 @@ GreetNode.register(library) When using [`BaseNode`](../../../reference/api#django_components.BaseNode), you have access to several useful properties: -- `node_id`: A unique identifier for this node instance -- `flags`: Dictionary of flag values (e.g. `{"required": True}`) -- `params`: List of raw parameters passed to the tag -- `nodelist`: The template nodes between the start and end tags -- `contents`: The raw contents between the start and end tags -- `active_flags`: List of flags that are currently set to True +- [`node_id`](../../../reference/api#django_components.BaseNode.node_id): A unique identifier for this node instance +- [`flags`](../../../reference/api#django_components.BaseNode.flags): Dictionary of flag values (e.g. `{"required": True}`) +- [`params`](../../../reference/api#django_components.BaseNode.params): List of raw parameters passed to the tag +- [`nodelist`](../../../reference/api#django_components.BaseNode.nodelist): The template nodes between the start and end tags +- [`contents`](../../../reference/api#django_components.BaseNode.contents): The raw contents between the start and end tags +- [`active_flags`](../../../reference/api#django_components.BaseNode.active_flags): List of flags that are currently set to True +- [`template_name`](../../../reference/api#django_components.BaseNode.template_name): The name of the `Template` instance inside which the node was defined +- [`template_component`](../../../reference/api#django_components.BaseNode.template_component): The component class that the `Template` belongs to This is what the `node` parameter in the [`@template_tag`](../../../reference/api#django_components.template_tag) decorator gives you access to - it's the instance of the node class that was automatically created for your template tag. diff --git a/docs/concepts/fundamentals/render_api.md b/docs/concepts/fundamentals/render_api.md index a0cec772..3ec170bc 100644 --- a/docs/concepts/fundamentals/render_api.md +++ b/docs/concepts/fundamentals/render_api.md @@ -57,6 +57,7 @@ The Render API includes: - [`self.inject()`](../render_api/#provide-inject) - Inject data into the component - Template tag metadata: + - [`self.node`](../render_api/#template-tag-metadata) - The [`ComponentNode`](../../../reference/api/#django_components.ComponentNode) instance - [`self.registry`](../render_api/#template-tag-metadata) - The [`ComponentRegistry`](../../../reference/api/#django_components.ComponentRegistry) instance - [`self.registered_name`](../render_api/#template-tag-metadata) - The name under which the component was registered - [`self.outer_context`](../render_api/#template-tag-metadata) - The context outside of the [`{% component %}`](../../../reference/template_tags#component) tag @@ -337,17 +338,18 @@ class Table(Component): If the component is rendered with [`{% component %}`](../../../reference/template_tags#component) template tag, the following metadata is available: +- [`self.node`](../../../reference/api/#django_components.Component.node) - The [`ComponentNode`](../../../reference/api/#django_components.ComponentNode) instance - [`self.registry`](../../../reference/api/#django_components.Component.registry) - The [`ComponentRegistry`](../../../reference/api/#django_components.ComponentRegistry) instance that was used to render the component - [`self.registered_name`](../../../reference/api/#django_components.Component.registered_name) - The name under which the component was registered - [`self.outer_context`](../../../reference/api/#django_components.Component.outer_context) - The context outside of the [`{% component %}`](../../../reference/template_tags#component) tag -```django -{% with abc=123 %} - {{ abc }} {# <--- This is in outer context #} - {% component "my_component" / %} -{% endwith %} -``` + ```django + {% with abc=123 %} + {{ abc }} {# <--- This is in outer context #} + {% component "my_component" / %} + {% endwith %} + ``` You can use these to check whether the component was rendered inside a template with [`{% component %}`](../../../reference/template_tags#component) tag or in Python with [`Component.render()`](../../../reference/api/#django_components.Component.render). @@ -360,3 +362,40 @@ class MyComponent(Component): else: # Do something for the {% component %} template tag ``` + +You can access the [`ComponentNode`](../../../reference/api/#django_components.ComponentNode) under [`Component.node`](../../../reference/api/#django_components.Component.node): + +```py +class MyComponent(Component): + def get_template_data(self, context, template): + if self.node is not None: + assert self.node.name == "my_component" +``` + +Accessing the [`ComponentNode`](../../../reference/api/#django_components.ComponentNode) is mostly useful for extensions, which can modify their behaviour based on the source of the Component. + +For example, if `MyComponent` was used in another component - that is, +with a `{% component "my_component" %}` tag +in a template that belongs to another component - then you can use +[`self.node.template_component`](../../../reference/api/#django_components.ComponentNode.template_component) +to access the owner [`Component`](../../../reference/api/#django_components.Component) class. + +```djc_py +class Parent(Component): + template: types.django_html = """ +
+ {% component "my_component" / %} +
+ """ + +@register("my_component") +class MyComponent(Component): + def get_template_data(self, context, template): + if self.node is not None: + assert self.node.template_component == Parent +``` + +!!! info + + `Component.node` is `None` if the component is created by [`Component.render()`](../../../reference/api/#django_components.Component.render) + (but you can pass in the `node` kwarg yourself). diff --git a/docs/concepts/fundamentals/slots.md b/docs/concepts/fundamentals/slots.md index 9686b980..fbef9a04 100644 --- a/docs/concepts/fundamentals/slots.md +++ b/docs/concepts/fundamentals/slots.md @@ -728,7 +728,7 @@ with extra metadata: - [`component_name`](../../../reference/api#django_components.Slot.component_name) - [`slot_name`](../../../reference/api#django_components.Slot.slot_name) - [`nodelist`](../../../reference/api#django_components.Slot.nodelist) -- [`source`](../../../reference/api#django_components.Slot.source) +- [`fill_node`](../../../reference/api#django_components.Slot.fill_node) - [`extra`](../../../reference/api#django_components.Slot.extra) These are populated the first time a slot is passed to a component. @@ -738,10 +738,33 @@ still point to the first component that received the slot. You can use these for debugging, such as printing out the slot's component name and slot name. -Extensions can use [`Slot.source`](../../../reference/api#django_components.Slot.source) +**Fill node** + +Components or extensions can use [`Slot.fill_node`](../../../reference/api#django_components.Slot.fill_node) to handle slots differently based on whether the slot -was defined in the template with [`{% fill %}`](../../../reference/template_tags#fill) tag -or in the component's Python code. See an example in [Pass slot metadata](../../advanced/extensions#pass-slot-metadata). +was defined in the template with [`{% fill %}`](../../../reference/template_tags#fill) and +[`{% component %}`](../../../reference/template_tags#component) tags, +or in the component's Python code. + +If the slot was created from a [`{% fill %}`](../../../reference/template_tags#fill) tag, +this will be the [`FillNode`](../../../reference/api#django_components.FillNode) instance. + +If the slot was a default slot created from a [`{% component %}`](../../../reference/template_tags#component) tag, +this will be the [`ComponentNode`](../../../reference/api#django_components.ComponentNode) instance. + +You can use this to find the [`Component`](../../../reference/api#django_components.Component) in whose +template the [`{% fill %}`](../../../reference/template_tags#fill) tag was defined: + +```python +class MyTable(Component): + def get_template_data(self, args, kwargs, slots, context): + footer_slot = slots.get("footer") + if footer_slot is not None and footer_slot.fill_node is not None: + owner_component = footer_slot.fill_node.template_component + # ... +``` + +**Extra** You can also pass any additional data along with the slot by setting it in [`Slot.extra`](../../../reference/api#django_components.Slot.extra): @@ -761,7 +784,6 @@ slot = Slot( # Optional component_name="table", slot_name="name", - source="python", extra={}, ) @@ -771,6 +793,8 @@ slot.slot_name = "name" slot.extra["foo"] = "bar" ``` +Read more in [Pass slot metadata](../../advanced/extensions#pass-slot-metadata). + ### Slot contents Whether you create a slot from a function, a string, or from the [`{% fill %}`](../../../reference/template_tags#fill) tags, diff --git a/docs/reference/api.md b/docs/reference/api.md index 0fb902e3..6f748151 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -47,6 +47,10 @@ options: show_if_no_docstring: true +::: django_components.ComponentNode + options: + show_if_no_docstring: true + ::: django_components.ComponentRegistry options: show_if_no_docstring: true @@ -83,6 +87,14 @@ options: show_if_no_docstring: true +::: django_components.FillNode + options: + show_if_no_docstring: true + +::: django_components.ProvideNode + options: + show_if_no_docstring: true + ::: django_components.RegistrySettings options: show_if_no_docstring: true @@ -111,6 +123,10 @@ options: show_if_no_docstring: true +::: django_components.SlotNode + options: + show_if_no_docstring: true + ::: django_components.SlotRef options: show_if_no_docstring: true diff --git a/docs/reference/extension_hooks.md b/docs/reference/extension_hooks.md index f4e7fdfb..3d1b85b6 100644 --- a/docs/reference/extension_hooks.md +++ b/docs/reference/extension_hooks.md @@ -204,6 +204,7 @@ name | type | description `slot_is_default` | `bool` | Whether the slot is default `slot_is_required` | `bool` | Whether the slot is required `slot_name` | `str` | The name of the `{% slot %}` tag +`slot_node` | `SlotNode` | The node instance of the `{% slot %}` tag ## Objects diff --git a/docs/reference/template_tags.md b/docs/reference/template_tags.md index af62e154..21992f2c 100644 --- a/docs/reference/template_tags.md +++ b/docs/reference/template_tags.md @@ -67,7 +67,7 @@ If you insert this tag multiple times, ALL JS scripts will be duplicately insert -See source code +See source code @@ -75,7 +75,7 @@ Renders one of the components that was previously registered with [`@register()`](./api.md#django_components.register) decorator. -The `{% component %}` tag takes: +The [`{% component %}`](../template_tags#component) tag takes: - Component's registered name as the first positional argument, - Followed by any number of positional and keyword arguments. @@ -92,7 +92,8 @@ The component name must be a string literal. ### Inserting slot fills If the component defined any [slots](../concepts/fundamentals/slots.md), you can -"fill" these slots by placing the [`{% fill %}`](#fill) tags within the `{% component %}` tag: +"fill" these slots by placing the [`{% fill %}`](../template_tags#fill) tags +within the [`{% component %}`](../template_tags#component) tag: ```django {% component "my_table" rows=rows headers=headers %} @@ -102,7 +103,7 @@ If the component defined any [slots](../concepts/fundamentals/slots.md), you can {% endcomponent %} ``` -You can even nest [`{% fill %}`](#fill) tags within +You can even nest [`{% fill %}`](../template_tags#fill) tags within [`{% if %}`](https://docs.djangoproject.com/en/5.2/ref/templates/builtins/#if), [`{% for %}`](https://docs.djangoproject.com/en/5.2/ref/templates/builtins/#for) and other tags: @@ -141,7 +142,7 @@ COMPONENTS = { } ``` -### Omitting the `component` keyword +### Omitting the component keyword If you would like to omit the `component` keyword, and simply refer to your components by their registered names: @@ -169,19 +170,20 @@ COMPONENTS = { -See source code +See source code -Use this tag to insert content into component's slots. +Use [`{% fill %}`](../template_tags#fill) tag to insert content into component's +[slots](../../concepts/fundamentals/slots). -`{% fill %}` tag may be used only within a `{% component %}..{% endcomponent %}` block, +[`{% fill %}`](../template_tags#fill) tag may be used only within a `{% component %}..{% endcomponent %}` block, and raises a `TemplateSyntaxError` if used outside of a component. **Args:** - `name` (str, required): Name of the slot to insert this content into. Use `"default"` for - the default slot. + the [default slot](../../concepts/fundamentals/slots#default-slot). - `data` (str, optional): This argument allows you to access the data passed to the slot under the specified variable name. See [Slot data](../../concepts/fundamentals/slots#slot-data). - `fallback` (str, optional): This argument allows you to access the original content of the slot @@ -266,7 +268,7 @@ Fill: ### Using default slot To access slot data and the fallback slot content on the default slot, -use `{% fill %}` with `name` set to `"default"`: +use [`{% fill %}`](../template_tags#fill) with `name` set to `"default"`: ```django {% component "button" %} @@ -280,7 +282,7 @@ use `{% fill %}` with `name` set to `"default"`: ### Slot fills from Python You can pass a slot fill from Python to a component by setting the `body` kwarg -on the `{% fill %}` tag. +on the [`{% fill %}`](../template_tags#fill) tag. First pass a [`Slot`](../api#django_components.Slot) instance to the template with the [`get_template_data()`](../api#django_components.Component.get_template_data) @@ -296,7 +298,7 @@ class Table(Component): } ``` -Then pass the slot to the `{% fill %}` tag: +Then pass the slot to the [`{% fill %}`](../template_tags#fill) tag: ```django {% component "table" %} @@ -306,7 +308,7 @@ Then pass the slot to the `{% fill %}` tag: !!! warning - If you define both the `body` kwarg and the `{% fill %}` tag's body, + If you define both the `body` kwarg and the [`{% fill %}`](../template_tags#fill) tag's body, an error will be raised. ```django @@ -391,8 +393,11 @@ See more usage examples in -The "provider" part of the [provide / inject feature](../../concepts/advanced/provide_inject). +The [`{% provide %}`](../template_tags#provide) tag is part of the "provider" part of +the [provide / inject feature](../../concepts/advanced/provide_inject). + Pass kwargs to this tag to define the provider's data. + Any components defined within the `{% provide %}..{% endprovide %}` tags will be able to access this data with [`Component.inject()`](../api#django_components.Component.inject). @@ -445,7 +450,7 @@ class Child(Component): } ``` -Notice that the keys defined on the `{% provide %}` tag are then accessed as attributes +Notice that the keys defined on the [`{% provide %}`](../template_tags#provide) tag are then accessed as attributes when accessing them with [`Component.inject()`](../api#django_components.Component.inject). ✅ Do this @@ -467,11 +472,11 @@ user = self.inject("user_data")["user"] -See source code +See source code -Slot tag marks a place inside a component where content can be inserted +[`{% slot %}`](../template_tags#slot) tag marks a place inside a component where content can be inserted from outside. [Learn more](../../concepts/fundamentals/slots) about using slots. @@ -485,7 +490,7 @@ or [React's `children`](https://react.dev/learn/passing-props-to-a-component#pas - `name` (str, required): Registered name of the component to render - `default`: Optional flag. If there is a default slot, you can pass the component slot content - without using the [`{% fill %}`](#fill) tag. See + without using the [`{% fill %}`](../template_tags#fill) tag. See [Default slot](../../concepts/fundamentals/slots#default-slot) - `required`: Optional flag. Will raise an error if a slot is required but not given. - `**kwargs`: Any extra kwargs will be passed as the slot data. @@ -527,8 +532,8 @@ class Parent(Component): ### Slot data -Any extra kwargs will be considered as slot data, and will be accessible in the [`{% fill %}`](#fill) -tag via fill's `data` kwarg: +Any extra kwargs will be considered as slot data, and will be accessible +in the [`{% fill %}`](../template_tags#fill) tag via fill's `data` kwarg: Read more about [Slot data](../../concepts/fundamentals/slots#slot-data). @@ -565,8 +570,8 @@ class Parent(Component): The content between the `{% slot %}..{% endslot %}` tags is the fallback content that will be rendered if no fill is given for the slot. -This fallback content can then be accessed from within the [`{% fill %}`](#fill) tag using -the fill's `fallback` kwarg. +This fallback content can then be accessed from within the [`{% fill %}`](../template_tags#fill) tag +using the fill's `fallback` kwarg. This is useful if you need to wrap / prepend / append the original slot's content. ```djc_py diff --git a/src/django_components/__init__.py b/src/django_components/__init__.py index deac7a2d..f0674b0e 100644 --- a/src/django_components/__init__.py +++ b/src/django_components/__init__.py @@ -18,6 +18,7 @@ from django_components.util.command import ( from django_components.component import ( Component, ComponentInput, + ComponentNode, ComponentVars, all_components, get_component_by_class_id, @@ -52,13 +53,16 @@ from django_components.extensions.debug_highlight import ComponentDebugHighlight from django_components.extensions.view import ComponentView, get_component_url from django_components.library import TagProtectedError from django_components.node import BaseNode, template_tag +from django_components.provide import ProvideNode from django_components.slots import ( + FillNode, Slot, SlotContent, SlotContext, SlotFallback, SlotFunc, SlotInput, + SlotNode, SlotRef, SlotResult, ) @@ -103,6 +107,7 @@ __all__ = [ "ComponentInput", "ComponentMediaInput", "ComponentMediaInputPath", + "ComponentNode", "ComponentRegistry", "ComponentVars", "ComponentView", @@ -115,6 +120,7 @@ __all__ = [ "DynamicComponent", "Empty", "ExtensionComponentConfig", + "FillNode", "format_attributes", "get_component_by_class_id", "get_component_dirs", @@ -131,6 +137,7 @@ __all__ = [ "OnComponentUnregisteredContext", "OnRegistryCreatedContext", "OnRegistryDeletedContext", + "ProvideNode", "register", "registry", "RegistrySettings", @@ -142,6 +149,7 @@ __all__ = [ "SlotFallback", "SlotFunc", "SlotInput", + "SlotNode", "SlotRef", "SlotResult", "TagFormatterABC", diff --git a/src/django_components/component.py b/src/django_components/component.py index 914083ab..d9ef144f 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -1941,6 +1941,7 @@ class Component(metaclass=ComponentMeta): slots: Optional[Any] = None, deps_strategy: Optional[DependenciesStrategy] = None, request: Optional[HttpRequest] = None, + node: Optional["ComponentNode"] = None, id: Optional[str] = None, ): # TODO_v1 - Remove this whole block in v1. This is for backwards compatibility with pre-v0.140 @@ -2009,6 +2010,7 @@ class Component(metaclass=ComponentMeta): self.request = request self.outer_context: Optional[Context] = outer_context self.registry = default(registry, registry_) + self.node = node extensions._init_component_instance(self) @@ -2346,6 +2348,51 @@ class Component(metaclass=ComponentMeta): that was used to render the component. """ + node: Optional["ComponentNode"] + """ + The [`ComponentNode`](../api/#django_components.ComponentNode) instance + that was used to render the component. + + This will be set only if the component was rendered with the + [`{% component %}`](../template_tags#component) tag. + + Accessing the [`ComponentNode`](../api/#django_components.ComponentNode) is mostly useful for extensions, + which can modify their behaviour based on the source of the Component. + + ```py + class MyComponent(Component): + def get_template_data(self, context, template): + if self.node is not None: + assert self.node.name == "my_component" + ``` + + For example, if `MyComponent` was used in another component - that is, + with a `{% component "my_component" %}` tag + in a template that belongs to another component - then you can use + [`self.node.template_component`](../api/#django_components.ComponentNode.template_component) + to access the owner [`Component`](../api/#django_components.Component) class. + + ```djc_py + class Parent(Component): + template: types.django_html = ''' +
+ {% component "my_component" / %} +
+ ''' + + @register("my_component") + class MyComponent(Component): + def get_template_data(self, context, template): + if self.node is not None: + assert self.node.template_component == Parent + ``` + + !!! info + + `Component.node` is `None` if the component is created by + [`Component.render()`](../api/#django_components.Component.render) + (but you can pass in the `node` kwarg yourself). + """ # TODO_v1 - Remove, superseded by `Component.slots` is_filled: SlotIsFilled """ @@ -2535,6 +2582,7 @@ class Component(metaclass=ComponentMeta): # TODO_v2 - Remove `registered_name` and `registry` registry: Optional[ComponentRegistry] = None, registered_name: Optional[str] = None, + node: Optional["ComponentNode"] = None, **response_kwargs: Any, ) -> HttpResponse: """ @@ -2603,6 +2651,7 @@ class Component(metaclass=ComponentMeta): # TODO_v2 - Remove `registered_name` and `registry` registry=registry, registered_name=registered_name, + node=node, ) return cls.response_class(content, **response_kwargs) @@ -2623,6 +2672,7 @@ class Component(metaclass=ComponentMeta): # TODO_v2 - Remove `registered_name` and `registry` registry: Optional[ComponentRegistry] = None, registered_name: Optional[str] = None, + node: Optional["ComponentNode"] = None, ) -> str: """ Render the component into a string. This is the equivalent of calling @@ -2830,6 +2880,7 @@ class Component(metaclass=ComponentMeta): # TODO_v2 - Remove `registered_name` and `registry` registry=registry, registered_name=registered_name, + node=node, ) # This is the internal entrypoint for the render function @@ -2846,6 +2897,7 @@ class Component(metaclass=ComponentMeta): # TODO_v2 - Remove `registered_name` and `registry` registry: Optional[ComponentRegistry] = None, registered_name: Optional[str] = None, + node: Optional["ComponentNode"] = None, ) -> str: component_name = _get_component_name(cls, registered_name) @@ -2862,6 +2914,7 @@ class Component(metaclass=ComponentMeta): # TODO_v2 - Remove `registered_name` and `registry` registry=registry, registered_name=registered_name, + node=node, ) @classmethod @@ -2877,6 +2930,7 @@ class Component(metaclass=ComponentMeta): # TODO_v2 - Remove `registered_name` and `registry` registry: Optional[ComponentRegistry] = None, registered_name: Optional[str] = None, + node: Optional["ComponentNode"] = None, ) -> str: ###################################### # 1. Handle inputs @@ -2929,6 +2983,7 @@ class Component(metaclass=ComponentMeta): # TODO_v2 - Remove `registered_name` and `registry` registry=registry, registered_name=registered_name, + node=node, ) # Allow plugins to modify or validate the inputs @@ -3316,7 +3371,7 @@ class ComponentNode(BaseNode): [`@register()`](./api.md#django_components.register) decorator. - The `{% component %}` tag takes: + The [`{% component %}`](../template_tags#component) tag takes: - Component's registered name as the first positional argument, - Followed by any number of positional and keyword arguments. @@ -3333,7 +3388,8 @@ class ComponentNode(BaseNode): ### Inserting slot fills If the component defined any [slots](../concepts/fundamentals/slots.md), you can - "fill" these slots by placing the [`{% fill %}`](#fill) tags within the `{% component %}` tag: + "fill" these slots by placing the [`{% fill %}`](../template_tags#fill) tags + within the [`{% component %}`](../template_tags#component) tag: ```django {% component "my_table" rows=rows headers=headers %} @@ -3343,7 +3399,7 @@ class ComponentNode(BaseNode): {% endcomponent %} ``` - You can even nest [`{% fill %}`](#fill) tags within + You can even nest [`{% fill %}`](../template_tags#fill) tags within [`{% if %}`](https://docs.djangoproject.com/en/5.2/ref/templates/builtins/#if), [`{% for %}`](https://docs.djangoproject.com/en/5.2/ref/templates/builtins/#for) and other tags: @@ -3382,7 +3438,7 @@ class ComponentNode(BaseNode): } ``` - ### Omitting the `component` keyword + ### Omitting the component keyword If you would like to omit the `component` keyword, and simply refer to your components by their registered names: @@ -3417,6 +3473,8 @@ class ComponentNode(BaseNode): nodelist: Optional[NodeList] = None, node_id: Optional[str] = None, contents: Optional[str] = None, + template_name: Optional[str] = None, + template_component: Optional[Type["Component"]] = None, ) -> None: super().__init__( params=params, @@ -3424,6 +3482,8 @@ class ComponentNode(BaseNode): nodelist=nodelist, node_id=node_id, contents=contents, + template_name=template_name, + template_component=template_component, ) self.name = name @@ -3499,6 +3559,7 @@ class ComponentNode(BaseNode): registered_name=self.name, outer_context=context, registry=self.registry, + node=self, ) return output diff --git a/src/django_components/extension.py b/src/django_components/extension.py index 3a41e19d..6e25b405 100644 --- a/src/django_components/extension.py +++ b/src/django_components/extension.py @@ -28,7 +28,7 @@ from django_components.util.routing import URLRoute if TYPE_CHECKING: from django_components import Component from django_components.component_registry import ComponentRegistry - from django_components.slots import Slot, SlotResult + from django_components.slots import Slot, SlotNode, SlotResult TCallable = TypeVar("TCallable", bound=Callable) @@ -155,6 +155,8 @@ class OnSlotRenderedContext(NamedTuple): """The Slot instance that was rendered""" slot_name: str """The name of the `{% slot %}` tag""" + slot_node: "SlotNode" + """The node instance of the `{% slot %}` tag""" slot_is_required: bool """Whether the slot is required""" slot_is_default: bool @@ -744,6 +746,26 @@ class ComponentExtension(metaclass=ExtensionMeta): # Append a comment to the slot's rendered output return ctx.result + "" ``` + + **Access slot metadata:** + + You can access the [`{% slot %}` tag](../template_tags#slot) + node ([`SlotNode`](../api#django_components.SlotNode)) and its metadata using `ctx.slot_node`. + + For example, to find the [`Component`](../api#django_components.Component) class to which + belongs the template where the [`{% slot %}`](../template_tags#slot) tag is defined, you can use + [`ctx.slot_node.template_component`](../api#django_components.SlotNode.template_component): + + ```python + from django_components import ComponentExtension, OnSlotRenderedContext + + class MyExtension(ComponentExtension): + def on_slot_rendered(self, ctx: OnSlotRenderedContext) -> Optional[str]: + # Access slot metadata + slot_node = ctx.slot_node + slot_owner = slot_node.template_component + print(f"Slot owner: {slot_owner}") + ``` """ pass diff --git a/src/django_components/node.py b/src/django_components/node.py index fa0e114e..f458c939 100644 --- a/src/django_components/node.py +++ b/src/django_components/node.py @@ -1,7 +1,7 @@ import functools import inspect import keyword -from typing import Any, Callable, Dict, List, Optional, Tuple, Type, cast +from typing import TYPE_CHECKING, Any, Callable, ClassVar, Dict, List, Optional, Tuple, Type, cast from django.template import Context, Library from django.template.base import Node, NodeList, Parser, Token @@ -15,6 +15,9 @@ from django_components.util.template_tag import ( validate_params, ) +if TYPE_CHECKING: + from django_components.component import Component + # Normally, when `Node.render()` is called, it receives only a single argument `context`. # @@ -252,32 +255,77 @@ class BaseNode(Node, metaclass=NodeMeta): # PUBLIC API (Configurable by users) # ##################################### - tag: str + tag: ClassVar[str] """ The tag name. E.g. `"component"` or `"slot"` will make this class match template tags `{% component %}` or `{% slot %}`. + + ```python + class SlotNode(BaseNode): + tag = "slot" + end_tag = "endslot" + ``` + + This will allow the template tag `{% slot %}` to be used like this: + + ```django + {% slot %} ... {% endslot %} + ``` """ - end_tag: Optional[str] = None + end_tag: ClassVar[Optional[str]] = None """ The end tag name. E.g. `"endcomponent"` or `"endslot"` will make this class match template tags `{% endcomponent %}` or `{% endslot %}`. + ```python + class SlotNode(BaseNode): + tag = "slot" + end_tag = "endslot" + ``` + + This will allow the template tag `{% slot %}` to be used like this: + + ```django + {% slot %} ... {% endslot %} + ``` + If not set, then this template tag has no end tag. So instead of `{% component %} ... {% endcomponent %}`, you'd use only `{% component %}`. + + ```python + class MyNode(BaseNode): + tag = "mytag" + end_tag = None + ``` """ - allowed_flags: Optional[List[str]] = None + allowed_flags: ClassVar[Optional[List[str]]] = None """ - The allowed flags for this tag. + The list of all *possible* flags for this tag. E.g. `["required"]` will allow this tag to be used like `{% slot required %}`. + + ```python + class SlotNode(BaseNode): + tag = "slot" + end_tag = "endslot" + allowed_flags = ["required", "default"] + ``` + + This will allow the template tag `{% slot %}` to be used like this: + + ```django + {% slot required %} ... {% endslot %} + {% slot default %} ... {% endslot %} + {% slot required default %} ... {% endslot %} + ``` """ def render(self, context: Context, *args: Any, **kwargs: Any) -> str: @@ -303,6 +351,133 @@ class BaseNode(Node, metaclass=NodeMeta): """ return self.nodelist.render(context) + # ##################################### + # Attributes + # ##################################### + + params: List[TagAttr] + """ + The parameters to the tag in the template. + + A single param represents an arg or kwarg of the template tag. + + E.g. the following tag: + + ```django + {% component "my_comp" key=val key2='val2 two' %} + ``` + + Has 3 params: + + - Posiitonal arg `"my_comp"` + - Keyword arg `key=val` + - Keyword arg `key2='val2 two'` + """ + + flags: Dict[str, bool] + """ + Dictionary of all [`allowed_flags`](../api#django_components.BaseNode.allowed_flags) + that were set on the tag. + + Flags that were set are `True`, and the rest are `False`. + + E.g. the following tag: + + ```python + class SlotNode(BaseNode): + tag = "slot" + end_tag = "endslot" + allowed_flags = ["default", "required"] + ``` + + ```django + {% slot "content" default %} + ``` + + Has 2 flags, `default` and `required`, but only `default` was set. + + The `flags` dictionary will be: + + ```python + { + "default": True, + "required": False, + } + ``` + + You can check if a flag is set by doing: + + ```python + if node.flags["default"]: + ... + ``` + """ + + nodelist: NodeList + """ + The nodelist of the tag. + + This is the text between the opening and closing tags, e.g. + + ```django + {% slot "content" default required %} +
+ ... +
+ {% endslot %} + ``` + + The `nodelist` will contain the `
...
` part. + + Unlike [`contents`](../api#django_components.BaseNode.contents), + the `nodelist` contains the actual Nodes, not just the text. + """ + + contents: Optional[str] + """ + The contents of the tag. + + This is the text between the opening and closing tags, e.g. + + ```django + {% slot "content" default required %} +
+ ... +
+ {% endslot %} + ``` + + The `contents` will be `"
...
"`. + """ + + node_id: str + """ + The unique ID of the node. + + Extensions can use this ID to store additional information. + """ + + template_name: Optional[str] + """ + The name of the [`Template`](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Template) + that contains this node. + + The template name is set by Django's + [template loaders](https://docs.djangoproject.com/en/5.2/topics/templates/#loaders). + + For example, the filesystem template loader will set this to the absolute path of the template file. + + ``` + "/home/user/project/templates/my_template.html" + ``` + """ + + template_component: Optional[Type["Component"]] + """ + If the template that contains this node belongs to a [`Component`](../api#django_components.Component), + then this will be the [`Component`](../api#django_components.Component) class. + """ + # ##################################### # MISC # ##################################### @@ -314,12 +489,16 @@ class BaseNode(Node, metaclass=NodeMeta): nodelist: Optional[NodeList] = None, node_id: Optional[str] = None, contents: Optional[str] = None, + template_name: Optional[str] = None, + template_component: Optional[Type["Component"]] = None, ): self.params = params self.flags = flags or {flag: False for flag in self.allowed_flags or []} self.nodelist = nodelist or NodeList() self.node_id = node_id or gen_id() self.contents = contents + self.template_name = template_name + self.template_component = template_component def __repr__(self) -> str: return ( @@ -329,7 +508,21 @@ class BaseNode(Node, metaclass=NodeMeta): @property def active_flags(self) -> List[str]: - """Flags that were set for this specific instance.""" + """ + Flags that were set for this specific instance as a list of strings. + + E.g. the following tag: + + ```django + {% slot "content" default required / %} + ``` + + Will have the following flags: + + ```python + ["default", "required"] + ``` + """ flags = [] for flag, value in self.flags.items(): if value: @@ -347,6 +540,9 @@ class BaseNode(Node, metaclass=NodeMeta): To register the tag, you can use [`BaseNode.register()`](../api#django_components.BaseNode.register). """ + # NOTE: Avoids circular import + from django_components.template import get_component_from_origin + tag_id = gen_id() tag = parse_template_tag(cls.tag, cls.end_tag, cls.allowed_flags, parser, token) @@ -359,6 +555,8 @@ class BaseNode(Node, metaclass=NodeMeta): params=tag.params, flags=tag.flags, contents=contents, + template_name=parser.origin.name if parser.origin else None, + template_component=get_component_from_origin(parser.origin) if parser.origin else None, **kwargs, ) diff --git a/src/django_components/provide.py b/src/django_components/provide.py index 16cb5909..41e2367f 100644 --- a/src/django_components/provide.py +++ b/src/django_components/provide.py @@ -12,8 +12,11 @@ from django_components.util.misc import gen_id class ProvideNode(BaseNode): """ - The "provider" part of the [provide / inject feature](../../concepts/advanced/provide_inject). + The [`{% provide %}`](../template_tags#provide) tag is part of the "provider" part of + the [provide / inject feature](../../concepts/advanced/provide_inject). + Pass kwargs to this tag to define the provider's data. + Any components defined within the `{% provide %}..{% endprovide %}` tags will be able to access this data with [`Component.inject()`](../api#django_components.Component.inject). @@ -66,7 +69,7 @@ class ProvideNode(BaseNode): } ``` - Notice that the keys defined on the `{% provide %}` tag are then accessed as attributes + Notice that the keys defined on the [`{% provide %}`](../template_tags#provide) tag are then accessed as attributes when accessing them with [`Component.inject()`](../api#django_components.Component.inject). ✅ Do this diff --git a/src/django_components/slots.py b/src/django_components/slots.py index ab502ca4..13e9f9c6 100644 --- a/src/django_components/slots.py +++ b/src/django_components/slots.py @@ -265,14 +265,33 @@ class Slot(Generic[TSlotData]): See [Slot metadata](../../concepts/fundamentals/slots#slot-metadata). """ - source: Literal["template", "python"] = "python" + fill_node: Optional[Union["FillNode", "ComponentNode"]] = None """ - Whether the slot was created from a [`{% fill %}`](../template_tags#fill) tag (`'template'`), - or Python (`'python'`). + If the slot was created from a [`{% fill %}`](../template_tags#fill) tag, + this will be the [`FillNode`](../api/#django_components.FillNode) instance. + + If the slot was a default slot created from a [`{% component %}`](../template_tags#component) tag, + this will be the [`ComponentNode`](../api/#django_components.ComponentNode) instance. + + Otherwise, this will be `None`. Extensions can use this info to handle slots differently based on their source. See [Slot metadata](../../concepts/fundamentals/slots#slot-metadata). + + **Example:** + + You can use this to find the [`Component`](../api/#django_components.Component) in whose + template the [`{% fill %}`](../template_tags#fill) tag was defined: + + ```python + class MyTable(Component): + def get_template_data(self, args, kwargs, slots, context): + footer_slot = slots.get("footer") + if footer_slot is not None and footer_slot.fill_node is not None: + owner_component = footer_slot.fill_node.template_component + # ... + ``` """ extra: Dict[str, Any] = field(default_factory=dict) """ @@ -494,7 +513,7 @@ class SlotIsFilled(dict): class SlotNode(BaseNode): """ - Slot tag marks a place inside a component where content can be inserted + [`{% slot %}`](../template_tags#slot) tag marks a place inside a component where content can be inserted from outside. [Learn more](../../concepts/fundamentals/slots) about using slots. @@ -508,7 +527,7 @@ class SlotNode(BaseNode): - `name` (str, required): Registered name of the component to render - `default`: Optional flag. If there is a default slot, you can pass the component slot content - without using the [`{% fill %}`](#fill) tag. See + without using the [`{% fill %}`](../template_tags#fill) tag. See [Default slot](../../concepts/fundamentals/slots#default-slot) - `required`: Optional flag. Will raise an error if a slot is required but not given. - `**kwargs`: Any extra kwargs will be passed as the slot data. @@ -550,8 +569,8 @@ class SlotNode(BaseNode): ### Slot data - Any extra kwargs will be considered as slot data, and will be accessible in the [`{% fill %}`](#fill) - tag via fill's `data` kwarg: + Any extra kwargs will be considered as slot data, and will be accessible + in the [`{% fill %}`](../template_tags#fill) tag via fill's `data` kwarg: Read more about [Slot data](../../concepts/fundamentals/slots#slot-data). @@ -588,8 +607,8 @@ class SlotNode(BaseNode): The content between the `{% slot %}..{% endslot %}` tags is the fallback content that will be rendered if no fill is given for the slot. - This fallback content can then be accessed from within the [`{% fill %}`](#fill) tag using - the fill's `fallback` kwarg. + This fallback content can then be accessed from within the [`{% fill %}`](../template_tags#fill) tag + using the fill's `fallback` kwarg. This is useful if you need to wrap / prepend / append the original slot's content. ```djc_py @@ -926,6 +945,7 @@ class SlotNode(BaseNode): component_id=component_id, slot=slot, slot_name=slot_name, + slot_node=self, slot_is_required=is_required, slot_is_default=is_default, result=output, @@ -968,15 +988,16 @@ class SlotNode(BaseNode): class FillNode(BaseNode): """ - Use this tag to insert content into component's slots. + Use [`{% fill %}`](../template_tags#fill) tag to insert content into component's + [slots](../../concepts/fundamentals/slots). - `{% fill %}` tag may be used only within a `{% component %}..{% endcomponent %}` block, + [`{% fill %}`](../template_tags#fill) tag may be used only within a `{% component %}..{% endcomponent %}` block, and raises a `TemplateSyntaxError` if used outside of a component. **Args:** - `name` (str, required): Name of the slot to insert this content into. Use `"default"` for - the default slot. + the [default slot](../../concepts/fundamentals/slots#default-slot). - `data` (str, optional): This argument allows you to access the data passed to the slot under the specified variable name. See [Slot data](../../concepts/fundamentals/slots#slot-data). - `fallback` (str, optional): This argument allows you to access the original content of the slot @@ -1061,7 +1082,7 @@ class FillNode(BaseNode): ### Using default slot To access slot data and the fallback slot content on the default slot, - use `{% fill %}` with `name` set to `"default"`: + use [`{% fill %}`](../template_tags#fill) with `name` set to `"default"`: ```django {% component "button" %} @@ -1075,7 +1096,7 @@ class FillNode(BaseNode): ### Slot fills from Python You can pass a slot fill from Python to a component by setting the `body` kwarg - on the `{% fill %}` tag. + on the [`{% fill %}`](../template_tags#fill) tag. First pass a [`Slot`](../api#django_components.Slot) instance to the template with the [`get_template_data()`](../api#django_components.Component.get_template_data) @@ -1091,7 +1112,7 @@ class FillNode(BaseNode): } ``` - Then pass the slot to the `{% fill %}` tag: + Then pass the slot to the [`{% fill %}`](../template_tags#fill) tag: ```django {% component "table" %} @@ -1101,7 +1122,7 @@ class FillNode(BaseNode): !!! warning - If you define both the `body` kwarg and the `{% fill %}` tag's body, + If you define both the `body` kwarg and the [`{% fill %}`](../template_tags#fill) tag's body, an error will be raised. ```django @@ -1395,7 +1416,7 @@ def resolve_fills( contents=contents, data_var=None, fallback_var=None, - source="template", + fill_node=component_node, ) # The content has fills @@ -1405,14 +1426,15 @@ def resolve_fills( for fill in maybe_fills: # Case: Slot fill was explicitly defined as `{% fill body=... / %}` if fill.body is not None: + # Set `Slot.fill_node` so the slot fill behaves the same as if it was defined inside + # a `{% fill %}` tag. + # This for example allows CSS scoping to work even on slots that are defined + # as `{% fill ... body=... / %}` if isinstance(fill.body, Slot): - # Make a copy of the Slot instance and set it to `source="template"`, - # so it behaves the same as if the content was written inside the `{% fill %}` tag. - # This for example allows CSS scoping to work even on slots that are defined - # as `{% fill ... body=... / %}` - slot_fill = dataclass_replace(fill.body, source="template") + # Make a copy of the Slot instance and set its `fill_node`. + slot_fill = dataclass_replace(fill.body, fill_node=fill.fill) else: - slot_fill = Slot(fill.body) + slot_fill = Slot(fill.body, fill_node=fill.fill) # Case: Slot fill was defined as the body of `{% fill / %}...{% endfill %}` else: slot_fill = _nodelist_to_slot( @@ -1423,7 +1445,7 @@ def resolve_fills( data_var=fill.data_var, fallback_var=fill.fallback_var, extra_context=fill.extra_context, - source="template", + fill_node=fill.fill, ) slots[fill.name] = slot_fill @@ -1497,14 +1519,14 @@ def normalize_slot_fills( used_slot_name = content.slot_name or slot_name used_nodelist = content.nodelist used_contents = content.contents if content.contents is not None else content_func - used_source = content.source + used_fill_node = content.fill_node used_extra = content.extra.copy() else: used_component_name = component_name used_slot_name = slot_name used_nodelist = None used_contents = content_func - used_source = "python" + used_fill_node = None used_extra = {} slot = Slot( @@ -1513,7 +1535,7 @@ def normalize_slot_fills( component_name=used_component_name, slot_name=used_slot_name, nodelist=used_nodelist, - source=used_source, + fill_node=used_fill_node, extra=used_extra, ) @@ -1544,7 +1566,7 @@ def _nodelist_to_slot( data_var: Optional[str] = None, fallback_var: Optional[str] = None, extra_context: Optional[Dict[str, Any]] = None, - source: Optional[Literal["template", "python"]] = None, + fill_node: Optional[Union[FillNode, "ComponentNode"]] = None, extra: Optional[Dict[str, Any]] = None, ) -> Slot: if data_var: @@ -1638,7 +1660,7 @@ def _nodelist_to_slot( # But `Slot(contents=None)` would result in `Slot.contents` being the render function. # So we need to special-case this. contents=default(contents, ""), - source=default(source, "python"), + fill_node=default(fill_node, None), extra=default(extra, {}), ) diff --git a/tests/test_component.py b/tests/test_component.py index a6ee40fe..f92e2de4 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -3,6 +3,7 @@ Tests focusing on the Component class. For tests focusing on the `component` tag, see `test_templatetags_component.py` """ +import os import re from typing import Any, NamedTuple @@ -17,6 +18,7 @@ from pytest_django.asserts import assertHTMLEqual, assertInHTML from django_components import ( Component, + ComponentRegistry, ComponentView, Slot, SlotInput, @@ -598,6 +600,110 @@ class TestComponentRenderAPI: assert comp.slots == {} # type: ignore[attr-defined] assert comp.context == Context() # type: ignore[attr-defined] + def test_metadata__template(self): + comp: Any = None + + @register("test") + class TestComponent(Component): + template = "hello" + + def get_template_data(self, args, kwargs, slots, context): + nonlocal comp + comp = self + + template_str: types.django_html = """ + {% load component_tags %} +
+ {% component "test" / %} +
+ """ + template = Template(template_str) + rendered = template.render(Context()) + + assertHTMLEqual(rendered, '
hello
') + + assert isinstance(comp, TestComponent) + + assert isinstance(comp.outer_context, Context) + assert comp.outer_context == Context() + + assert isinstance(comp.registry, ComponentRegistry) + assert comp.registered_name == "test" + + assert comp.node is not None + assert comp.node.template_component is None + assert comp.node.template_name == "" + + def test_metadata__component(self): + comp: Any = None + + @register("test") + class TestComponent(Component): + template = "hello" + + def get_template_data(self, args, kwargs, slots, context): + nonlocal comp + comp = self + + class Outer(Component): + template = "{% component 'test' only / %}" + + rendered = Outer.render() + + assert rendered == 'hello' + + assert isinstance(comp, TestComponent) + + assert isinstance(comp.outer_context, Context) + assert comp.outer_context is not comp.context + + assert isinstance(comp.registry, ComponentRegistry) + assert comp.registered_name == "test" + + assert comp.node is not None + assert comp.node.template_component == Outer + + if os.name == "nt": + assert comp.node.template_name.endswith("tests\\test_component.py::Outer") # type: ignore + else: + assert comp.node.template_name.endswith("tests/test_component.py::Outer") # type: ignore + + def test_metadata__python(self): + comp: Any = None + + @register("test") + class TestComponent(Component): + template = "hello" + + def get_template_data(self, args, kwargs, slots, context): + nonlocal comp + comp = self + + rendered = TestComponent.render( + context=Context(), + args=(), + kwargs={}, + slots={}, + deps_strategy="document", + render_dependencies=True, + request=None, + outer_context=Context(), + registry=ComponentRegistry(), + registered_name="test", + ) + + assert rendered == 'hello' + + assert isinstance(comp, TestComponent) + + assert isinstance(comp.outer_context, Context) + assert comp.outer_context == Context() + + assert isinstance(comp.registry, ComponentRegistry) + assert comp.registered_name == "test" + + assert comp.node is None + @djc_test class TestComponentTemplateVars: diff --git a/tests/test_extension.py b/tests/test_extension.py index f54a9b94..d0fa776d 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -6,7 +6,7 @@ from django.http import HttpRequest, HttpResponse from django.template import Context from django.test import Client -from django_components import Component, Slot, register, registry +from django_components import Component, Slot, SlotNode, register, registry from django_components.app_settings import app_settings from django_components.component_registry import ComponentRegistry from django_components.extension import ( @@ -140,6 +140,13 @@ class RenderExtension(ComponentExtension): name = "render" +class SlotOverrideExtension(ComponentExtension): + name = "slot_override" + + def on_slot_rendered(self, ctx: OnSlotRenderedContext): + return "OVERRIDEN BY EXTENSION" + + def with_component_cls(on_created: Callable): class TempComponent(Component): template = "Hello {{ name }}!" @@ -341,13 +348,15 @@ class TestExtensionHooks: # Render the component with some args and kwargs test_context = Context({"foo": "bar"}) - TestComponent.render( + rendered = TestComponent.render( context=test_context, args=("arg1", "arg2"), kwargs={"name": "Test"}, slots={"content": "Some content"}, ) + assert rendered == "Hello Some content!" + extension = cast(DummyExtension, app_settings.EXTENSIONS[4]) # Verify on_slot_rendered was called with correct args @@ -359,10 +368,25 @@ class TestExtensionHooks: assert slot_call.component_id == "ca1bc3e" assert isinstance(slot_call.slot, Slot) assert slot_call.slot_name == "content" + assert isinstance(slot_call.slot_node, SlotNode) + assert slot_call.slot_node.template_name.endswith("test_extension.py::TestComponent") # type: ignore + assert slot_call.slot_node.template_component == TestComponent assert slot_call.slot_is_required is True assert slot_call.slot_is_default is True assert slot_call.result == "Some content" + @djc_test(components_settings={"extensions": [SlotOverrideExtension]}) + def test_on_slot_rendered__override(self): + @register("test_comp") + class TestComponent(Component): + template = "Hello {% slot 'content' required default / %}!" + + rendered = TestComponent.render( + slots={"content": "Some content"}, + ) + + assert rendered == "Hello OVERRIDEN BY EXTENSION!" + @djc_test class TestExtensionViews: diff --git a/tests/test_node.py b/tests/test_node.py index d3118e3a..e62e4fb7 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -1,12 +1,15 @@ import inspect +import os import re +from typing import cast + import pytest from django.template import Context, Template from django.template.base import TextNode, VariableNode from django.template.defaulttags import IfNode, LoremNode from django.template.exceptions import TemplateSyntaxError -from django_components import types +from django_components import Component, types from django_components.node import BaseNode, template_tag from django_components.templatetags import component_tags from django_components.util.tag_parser import TagAttr @@ -849,7 +852,14 @@ class TestSignatureBasedValidation: @force_signature_validation def render(self, context: Context, name: str, **kwargs) -> str: nonlocal captured - captured = self.params, self.nodelist, self.node_id, self.contents + captured = ( + self.params, + self.nodelist, + self.node_id, + self.contents, + self.template_name, + self.template_component, + ) return f"Hello, {name}!" # Case 1 - Node with end tag and NOT self-closing @@ -864,7 +874,7 @@ class TestSignatureBasedValidation: template1 = Template(template_str1) template1.render(Context({})) - params1, nodelist1, node_id1, contents1 = captured # type: ignore + params1, nodelist1, node_id1, contents1, template_name1, template_component1 = captured # type: ignore assert len(params1) == 1 assert isinstance(params1[0], TagAttr) # NOTE: The comment node is not included in the nodelist @@ -879,6 +889,8 @@ class TestSignatureBasedValidation: assert isinstance(nodelist1[7], TextNode) assert contents1 == "\n INSIDE TAG {{ my_var }} {# comment #} {% lorem 1 w %} {% if True %} henlo {% endif %}\n " # noqa: E501 assert node_id1 == "a1bc3e" + assert template_name1 == '' + assert template_component1 is None captured = None # Reset captured @@ -890,12 +902,14 @@ class TestSignatureBasedValidation: template2 = Template(template_str2) template2.render(Context({})) - params2, nodelist2, node_id2, contents2 = captured # type: ignore + params2, nodelist2, node_id2, contents2, template_name2, template_component2 = captured # type: ignore assert len(params2) == 1 # type: ignore assert isinstance(params2[0], TagAttr) # type: ignore assert len(nodelist2) == 0 # type: ignore assert contents2 is None # type: ignore assert node_id2 == "a1bc3f" # type: ignore + assert template_name2 == '' # type: ignore + assert template_component2 is None # type: ignore captured = None # Reset captured @@ -906,7 +920,7 @@ class TestSignatureBasedValidation: @force_signature_validation def render(self, context: Context, name: str, **kwargs) -> str: nonlocal captured - captured = self.params, self.nodelist, self.node_id, self.contents + captured = self.params, self.nodelist, self.node_id, self.contents, self.template_name, self.template_component # noqa: E501 return f"Hello, {name}!" TestNodeWithoutEndTag.register(component_tags.register) @@ -918,12 +932,38 @@ class TestSignatureBasedValidation: template3 = Template(template_str3) template3.render(Context({})) - params3, nodelist3, node_id3, contents3 = captured # type: ignore + params3, nodelist3, node_id3, contents3, template_name3, template_component3 = captured # type: ignore assert len(params3) == 1 # type: ignore assert isinstance(params3[0], TagAttr) # type: ignore assert len(nodelist3) == 0 # type: ignore assert contents3 is None # type: ignore assert node_id3 == "a1bc40" # type: ignore + assert template_name3 == '' # type: ignore + assert template_component3 is None # type: ignore + + # Case 4 - Node nested in Component end tag + class TestComponent(Component): + template = """ + {% load component_tags %} + {% mytag2 'John' %} + """ + + TestComponent.render(Context({})) + + params4, nodelist4, node_id4, contents4, template_name4, template_component4 = captured # type: ignore + assert len(params4) == 1 # type: ignore + assert isinstance(params4[0], TagAttr) # type: ignore + assert len(nodelist4) == 0 # type: ignore + assert contents4 is None # type: ignore + assert node_id4 == "a1bc42" # type: ignore + + if os.name == "nt": + assert cast(str, template_name4).endswith("\\tests\\test_node.py::TestComponent") # type: ignore + else: + assert cast(str, template_name4).endswith("/tests/test_node.py::TestComponent") # type: ignore + + assert template_name4 == f"{__file__}::TestComponent" # type: ignore + assert template_component4 is TestComponent # type: ignore # Cleanup TestNodeWithEndTag.unregister(component_tags.register) diff --git a/tests/test_slots.py b/tests/test_slots.py index 5fc0328f..7a574b57 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -12,7 +12,8 @@ from django.template.base import NodeList, TextNode from pytest_django.asserts import assertHTMLEqual from django_components import Component, register, types -from django_components.slots import Slot, SlotContext, SlotFallback +from django_components.component import ComponentNode +from django_components.slots import FillNode, Slot, SlotContext, SlotFallback from django_components.testing import djc_test from .testutils import PARAMETRIZE_CONTEXT_BEHAVIOR, setup_test_config @@ -431,7 +432,7 @@ class TestSlot: assert isinstance(first_slot_func, Slot) assert first_slot_func.component_name == "SimpleComponent" assert first_slot_func.slot_name == "first" - assert first_slot_func.source == "python" + assert first_slot_func.fill_node is None assert first_slot_func.extra == {} first_nodelist: NodeList = first_slot_func.nodelist @@ -465,7 +466,7 @@ class TestSlot: assert isinstance(first_slot_func, Slot) assert first_slot_func.component_name == "SimpleComponent" assert first_slot_func.slot_name == "first" - assert first_slot_func.source == "python" + assert first_slot_func.fill_node is None assert first_slot_func.extra == {} assert first_slot_func.nodelist is None @@ -495,7 +496,7 @@ class TestSlot: assert isinstance(first_slot_func, Slot) assert first_slot_func.component_name == "SimpleComponent" assert first_slot_func.slot_name == "whoop" - assert first_slot_func.source == "python" + assert first_slot_func.fill_node is None assert first_slot_func.extra == {"foo": "bar"} assert first_slot_func.nodelist is None @@ -530,7 +531,7 @@ class TestSlot: assert isinstance(first_slot_func, Slot) assert first_slot_func.component_name == "test" assert first_slot_func.slot_name == "default" - assert first_slot_func.source == "template" + assert isinstance(first_slot_func.fill_node, ComponentNode) assert first_slot_func.extra == {} first_nodelist: NodeList = first_slot_func.nodelist @@ -571,7 +572,7 @@ class TestSlot: assert isinstance(first_slot_func, Slot) assert first_slot_func.component_name == "test" assert first_slot_func.slot_name == "first" - assert first_slot_func.source == "template" + assert isinstance(first_slot_func.fill_node, FillNode) assert first_slot_func.extra == {} first_nodelist: NodeList = first_slot_func.nodelist @@ -616,7 +617,7 @@ class TestSlot: assert isinstance(first_slot_func, Slot) assert first_slot_func.component_name == "test" assert first_slot_func.slot_name == "whoop" - assert first_slot_func.source == "template" + assert isinstance(first_slot_func.fill_node, FillNode) assert first_slot_func.extra == {"foo": "bar"} assert first_slot_func.nodelist is None