mirror of
https://github.com/django-components/django-components.git
synced 2025-09-26 15:39:08 +00:00
feat: paths as objects + user-provided Media cls + handle static (#526)
Co-authored-by: Emil Stenström <emil@emilstenstrom.se>
This commit is contained in:
parent
1d0d960211
commit
3c5a7ad823
10 changed files with 1106 additions and 146 deletions
219
README.md
219
README.md
|
@ -35,7 +35,8 @@ Read on to learn about the details!
|
||||||
- [Rendering HTML attributes](#rendering-html-attributes)
|
- [Rendering HTML attributes](#rendering-html-attributes)
|
||||||
- [Prop drilling and dependency injection (provide / inject)](#prop-drilling-and-dependency-injection-provide--inject)
|
- [Prop drilling and dependency injection (provide / inject)](#prop-drilling-and-dependency-injection-provide--inject)
|
||||||
- [Component context and scope](#component-context-and-scope)
|
- [Component context and scope](#component-context-and-scope)
|
||||||
- [Rendering JS and CSS dependencies](#rendering-js-and-css-dependencies)
|
- [Defining HTML/JS/CSS files](#defining-htmljscss-files)
|
||||||
|
- [Rendering JS/CSS dependencies](#rendering-jscss-dependencies)
|
||||||
- [Available settings](#available-settings)
|
- [Available settings](#available-settings)
|
||||||
- [Logging and debugging](#logging-and-debugging)
|
- [Logging and debugging](#logging-and-debugging)
|
||||||
- [Management Command](#management-command)
|
- [Management Command](#management-command)
|
||||||
|
@ -262,9 +263,12 @@ from django_components import component
|
||||||
|
|
||||||
@component.register("calendar")
|
@component.register("calendar")
|
||||||
class Calendar(component.Component):
|
class Calendar(component.Component):
|
||||||
# Templates inside `[your apps]/components` dir and `[project root]/components` dir will be automatically found. To customize which template to use based on context
|
# Templates inside `[your apps]/components` dir and `[project root]/components` dir
|
||||||
# you can override def get_template_name() instead of specifying the below variable.
|
# will be automatically found. To customize which template to use based on context
|
||||||
template_name = "calendar/template.html"
|
# you can override method `get_template_name` instead of specifying `template_name`.
|
||||||
|
#
|
||||||
|
# `template_name` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS
|
||||||
|
template_name = "template.html"
|
||||||
|
|
||||||
# This component takes one parameter, a date string to show in the template
|
# This component takes one parameter, a date string to show in the template
|
||||||
def get_context_data(self, date):
|
def get_context_data(self, date):
|
||||||
|
@ -272,9 +276,10 @@ class Calendar(component.Component):
|
||||||
"date": date,
|
"date": date,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Both `css` and `js` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS
|
||||||
class Media:
|
class Media:
|
||||||
css = "calendar/style.css"
|
css = "style.css"
|
||||||
js = "calendar/script.js"
|
js = "script.js"
|
||||||
```
|
```
|
||||||
|
|
||||||
And voilá!! We've created our first component.
|
And voilá!! We've created our first component.
|
||||||
|
@ -1645,7 +1650,207 @@ If you find yourself using the `only` modifier often, you can set the [context_b
|
||||||
|
|
||||||
Components can also access the outer context in their context methods like `get_context_data` by accessing the property `self.outer_context`.
|
Components can also access the outer context in their context methods like `get_context_data` by accessing the property `self.outer_context`.
|
||||||
|
|
||||||
## Rendering JS and CSS dependencies
|
## Defining HTML/JS/CSS files
|
||||||
|
|
||||||
|
django_component's management of files builds on top of [Django's `Media` class](https://docs.djangoproject.com/en/5.0/topics/forms/media/).
|
||||||
|
|
||||||
|
To be familiar with how Django handles static files, we recommend reading also:
|
||||||
|
- [How to manage static files (e.g. images, JavaScript, CSS)](https://docs.djangoproject.com/en/5.0/howto/static-files/)
|
||||||
|
|
||||||
|
### Defining file paths relative to component or static dirs
|
||||||
|
|
||||||
|
As seen in the [getting started example](#create-your-first-component), to associate HTML/JS/CSS
|
||||||
|
files with a component, you set them as `template_name`, `Media.js` and `Media.css` respectively:
|
||||||
|
|
||||||
|
```py
|
||||||
|
# In a file [project root]/components/calendar/calendar.py
|
||||||
|
from django_components import component
|
||||||
|
|
||||||
|
@component.register("calendar")
|
||||||
|
class Calendar(component.Component):
|
||||||
|
template_name = "template.html"
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
css = "style.css"
|
||||||
|
js = "script.js"
|
||||||
|
```
|
||||||
|
|
||||||
|
In the example above, the files are defined relative to the directory where `component.py` is.
|
||||||
|
|
||||||
|
Alternatively, you can specify the file paths relative to the directories set in `STATICFILES_DIRS`.
|
||||||
|
|
||||||
|
Assuming that `STATICFILES_DIRS` contains path `[project root]/components`, we can rewrite the example as:
|
||||||
|
|
||||||
|
```py
|
||||||
|
# In a file [project root]/components/calendar/calendar.py
|
||||||
|
from django_components import component
|
||||||
|
|
||||||
|
@component.register("calendar")
|
||||||
|
class Calendar(component.Component):
|
||||||
|
template_name = "calendar/template.html"
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
css = "calendar/style.css"
|
||||||
|
js = "calendar/script.js"
|
||||||
|
```
|
||||||
|
|
||||||
|
NOTE: In case of conflict, the preference goes to resolving the files relative to the component's directory.
|
||||||
|
|
||||||
|
### Defining multiple paths
|
||||||
|
|
||||||
|
Each component can have only a single template. However, you can define as many JS or CSS files as you want using a list.
|
||||||
|
|
||||||
|
```py
|
||||||
|
class MyComponent(component.Component):
|
||||||
|
class Media:
|
||||||
|
js = ["path/to/script1.js", "path/to/script2.js"]
|
||||||
|
css = ["path/to/style1.css", "path/to/style2.css"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuring CSS Media Types
|
||||||
|
|
||||||
|
You can define which stylesheets will be associated with which
|
||||||
|
[CSS Media types](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_media_queries/Using_media_queries#targeting_media_types). You do so by defining CSS files as a dictionary.
|
||||||
|
|
||||||
|
See the corresponding [Django Documentation](https://docs.djangoproject.com/en/5.0/topics/forms/media/#css).
|
||||||
|
|
||||||
|
Again, you can set either a single file or a list of files per media type:
|
||||||
|
|
||||||
|
```py
|
||||||
|
class MyComponent(component.Component):
|
||||||
|
class Media:
|
||||||
|
css = {
|
||||||
|
"all": "path/to/style1.css",
|
||||||
|
"print": "path/to/style2.css",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```py
|
||||||
|
class MyComponent(component.Component):
|
||||||
|
class Media:
|
||||||
|
css = {
|
||||||
|
"all": ["path/to/style1.css", "path/to/style2.css"],
|
||||||
|
"print": ["path/to/style3.css", "path/to/style4.css"],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
NOTE: When you define CSS as a string or a list, the `all` media type is implied.
|
||||||
|
|
||||||
|
|
||||||
|
### Supported types for file paths
|
||||||
|
|
||||||
|
File paths can be any of:
|
||||||
|
- `str`
|
||||||
|
- `bytes`
|
||||||
|
- `PathLike` (`__fspath__` method)
|
||||||
|
- `SafeData` (`__html__` method)
|
||||||
|
- `Callable` that returns any of the above, evaluated at class creation (`__new__`)
|
||||||
|
|
||||||
|
```py
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
|
class SimpleComponent(component.Component):
|
||||||
|
class Media:
|
||||||
|
css = [
|
||||||
|
mark_safe('<link href="/static/calendar/style.css" rel="stylesheet" />'),
|
||||||
|
Path("calendar/style1.css"),
|
||||||
|
"calendar/style2.css",
|
||||||
|
b"calendar/style3.css",
|
||||||
|
lambda: "calendar/style4.css",
|
||||||
|
]
|
||||||
|
js = [
|
||||||
|
mark_safe('<script src="/static/calendar/script.js"></script>'),
|
||||||
|
Path("calendar/script1.js"),
|
||||||
|
"calendar/script2.js",
|
||||||
|
b"calendar/script3.js",
|
||||||
|
lambda: "calendar/script4.js",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Path as objects
|
||||||
|
|
||||||
|
In the example [above](#supported-types-for-file-paths), you could see that when we used `mark_safe` to mark a string as a `SafeString`, we had to define the full `<script>`/`<link>` tag.
|
||||||
|
|
||||||
|
This is an extension of Django's [Paths as objects](https://docs.djangoproject.com/en/5.0/topics/forms/media/#paths-as-objects) feature, where "safe" strings are taken as is, and accessed only at render time.
|
||||||
|
|
||||||
|
Because of that, the paths defined as "safe" strings are NEVER resolved, neither relative to component's directory, nor relative to `STATICFILES_DIRS`.
|
||||||
|
|
||||||
|
"Safe" strings can be used to lazily resolve a path, or to customize the `<script>` or `<link>` tag for individual paths:
|
||||||
|
|
||||||
|
```py
|
||||||
|
class LazyJsPath:
|
||||||
|
def __init__(self, static_path: str) -> None:
|
||||||
|
self.static_path = static_path
|
||||||
|
|
||||||
|
def __html__(self):
|
||||||
|
full_path = static(self.static_path)
|
||||||
|
return format_html(
|
||||||
|
f'<script type="module" src="{full_path}"></script>'
|
||||||
|
)
|
||||||
|
|
||||||
|
@component.register("calendar")
|
||||||
|
class Calendar(component.Component):
|
||||||
|
template_name = "calendar/template.html"
|
||||||
|
|
||||||
|
def get_context_data(self, date):
|
||||||
|
return {
|
||||||
|
"date": date,
|
||||||
|
}
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
css = "calendar/style.css"
|
||||||
|
js = [
|
||||||
|
# <script> tag constructed by Media class
|
||||||
|
"calendar/script1.js",
|
||||||
|
# Custom <script> tag
|
||||||
|
LazyJsPath("calendar/script2.js"),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Customize how paths are rendered into HTML tags with `media_class`
|
||||||
|
|
||||||
|
Sometimes you may need to change how all CSS `<link>` or JS `<script>` tags are rendered for a given component. You can achieve this by providing your own subclass of [Django's `Media` class](https://docs.djangoproject.com/en/5.0/topics/forms/media) to component's `media_class` attribute.
|
||||||
|
|
||||||
|
Normally, the JS and CSS paths are passed to `Media` class, which decides how the paths are resolved and how the `<link>` and `<script>` tags are constructed.
|
||||||
|
|
||||||
|
To change how the tags are constructed, you can override the [`Media.render_js` and `Media.render_css` methods](https://github.com/django/django/blob/fa7848146738a9fe1d415ee4808664e54739eeb7/django/forms/widgets.py#L102):
|
||||||
|
|
||||||
|
```py
|
||||||
|
from django.forms.widgets import Media
|
||||||
|
from django_components import component
|
||||||
|
|
||||||
|
class MyMedia(Media):
|
||||||
|
# Same as original Media.render_js, except
|
||||||
|
# the `<script>` tag has also `type="module"`
|
||||||
|
def render_js(self):
|
||||||
|
tags = []
|
||||||
|
for path in self._js:
|
||||||
|
if hasattr(path, "__html__"):
|
||||||
|
tag = path.__html__()
|
||||||
|
else:
|
||||||
|
tag = format_html(
|
||||||
|
'<script type="module" src="{}"></script>',
|
||||||
|
self.absolute_path(path)
|
||||||
|
)
|
||||||
|
return tags
|
||||||
|
|
||||||
|
@component.register("calendar")
|
||||||
|
class Calendar(component.Component):
|
||||||
|
template_name = "calendar/template.html"
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
css = "calendar/style.css"
|
||||||
|
js = "calendar/script.js"
|
||||||
|
|
||||||
|
# Override the behavior of Media class
|
||||||
|
media_class = MyMedia
|
||||||
|
```
|
||||||
|
|
||||||
|
NOTE: The instance of the `Media` class (or it's subclass) is available under `Component.media` after the class creation (`__new__`).
|
||||||
|
|
||||||
|
## Rendering JS/CSS dependencies
|
||||||
|
|
||||||
The JS and CSS files included in components are not automatically rendered.
|
The JS and CSS files included in components are not automatically rendered.
|
||||||
Instead, use the following tags to specify where to render the dependencies:
|
Instead, use the following tags to specify where to render the dependencies:
|
||||||
|
|
|
@ -1,12 +1,9 @@
|
||||||
import inspect
|
import inspect
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import types
|
import types
|
||||||
from pathlib import Path
|
from typing import Any, ClassVar, Dict, List, Mapping, Optional, Tuple, Type, Union
|
||||||
from typing import Any, ClassVar, Dict, List, Mapping, MutableMapping, Optional, Tuple, Type, Union
|
|
||||||
|
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.forms.widgets import Media, MediaDefiningClass
|
from django.forms.widgets import Media
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.template.base import FilterExpression, Node, NodeList, Template, TextNode
|
from django.template.base import FilterExpression, Node, NodeList, Template, TextNode
|
||||||
from django.template.context import Context
|
from django.template.context import Context
|
||||||
|
@ -17,6 +14,8 @@ from django.utils.html import escape
|
||||||
from django.utils.safestring import SafeString, mark_safe
|
from django.utils.safestring import SafeString, mark_safe
|
||||||
from django.views import View
|
from django.views import View
|
||||||
|
|
||||||
|
from django_components.component_media import ComponentMediaInput, MediaMeta
|
||||||
|
|
||||||
# Global registry var and register() function moved to separate module.
|
# Global registry var and register() function moved to separate module.
|
||||||
# Defining them here made little sense, since 1) component_tags.py and component.py
|
# Defining them here made little sense, since 1) component_tags.py and component.py
|
||||||
# rely on them equally, and 2) it made it difficult to avoid circularity in the
|
# rely on them equally, and 2) it made it difficult to avoid circularity in the
|
||||||
|
@ -35,7 +34,7 @@ from django_components.context import (
|
||||||
prepare_context,
|
prepare_context,
|
||||||
)
|
)
|
||||||
from django_components.expression import safe_resolve_dict, safe_resolve_list
|
from django_components.expression import safe_resolve_dict, safe_resolve_list
|
||||||
from django_components.logger import logger, trace_msg
|
from django_components.logger import trace_msg
|
||||||
from django_components.middleware import is_dependency_middleware_active
|
from django_components.middleware import is_dependency_middleware_active
|
||||||
from django_components.slots import (
|
from django_components.slots import (
|
||||||
DEFAULT_SLOT_KEY,
|
DEFAULT_SLOT_KEY,
|
||||||
|
@ -49,145 +48,22 @@ from django_components.slots import (
|
||||||
resolve_slots,
|
resolve_slots,
|
||||||
)
|
)
|
||||||
from django_components.template_parser import process_aggregate_kwargs
|
from django_components.template_parser import process_aggregate_kwargs
|
||||||
from django_components.utils import gen_id, search
|
from django_components.utils import gen_id
|
||||||
|
|
||||||
RENDERED_COMMENT_TEMPLATE = "<!-- _RENDERED {name} -->"
|
RENDERED_COMMENT_TEMPLATE = "<!-- _RENDERED {name} -->"
|
||||||
|
|
||||||
|
|
||||||
class SimplifiedInterfaceMediaDefiningClass(MediaDefiningClass):
|
class ComponentMeta(MediaMeta):
|
||||||
def __new__(mcs, name: str, bases: Tuple[Type, ...], attrs: Dict[str, Any]) -> Type:
|
def __new__(mcs, name: str, bases: Tuple[Type, ...], attrs: Dict[str, Any]) -> Type:
|
||||||
# NOTE: Skip template/media file resolution when then Component class ITSELF
|
# NOTE: Skip template/media file resolution when then Component class ITSELF
|
||||||
# is being created.
|
# is being created.
|
||||||
if "__module__" in attrs and attrs["__module__"] == "django_components.component":
|
if "__module__" in attrs and attrs["__module__"] == "django_components.component":
|
||||||
return super().__new__(mcs, name, bases, attrs)
|
return super().__new__(mcs, name, bases, attrs)
|
||||||
|
|
||||||
if "Media" in attrs:
|
|
||||||
media: Component.Media = attrs["Media"]
|
|
||||||
|
|
||||||
# Allow: class Media: css = "style.css"
|
|
||||||
if hasattr(media, "css") and isinstance(media.css, str):
|
|
||||||
media.css = [media.css]
|
|
||||||
|
|
||||||
# Allow: class Media: css = ["style.css"]
|
|
||||||
if hasattr(media, "css") and isinstance(media.css, list):
|
|
||||||
media.css = {"all": media.css}
|
|
||||||
|
|
||||||
# Allow: class Media: css = {"all": "style.css"}
|
|
||||||
if hasattr(media, "css") and isinstance(media.css, dict):
|
|
||||||
for media_type, path_list in media.css.items():
|
|
||||||
if isinstance(path_list, str):
|
|
||||||
media.css[media_type] = [path_list] # type: ignore
|
|
||||||
|
|
||||||
# Allow: class Media: js = "script.js"
|
|
||||||
if hasattr(media, "js") and isinstance(media.js, str):
|
|
||||||
media.js = [media.js]
|
|
||||||
|
|
||||||
_resolve_component_relative_files(attrs)
|
|
||||||
|
|
||||||
return super().__new__(mcs, name, bases, attrs)
|
return super().__new__(mcs, name, bases, attrs)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_component_relative_files(attrs: MutableMapping) -> None:
|
class Component(View, metaclass=ComponentMeta):
|
||||||
"""
|
|
||||||
Check if component's HTML, JS and CSS files refer to files in the same directory
|
|
||||||
as the component class. If so, modify the attributes so the class Django's rendering
|
|
||||||
will pick up these files correctly.
|
|
||||||
"""
|
|
||||||
component_name = attrs["__qualname__"]
|
|
||||||
# Derive the full path of the file where the component was defined
|
|
||||||
module_name = attrs["__module__"]
|
|
||||||
module_obj = sys.modules[module_name]
|
|
||||||
file_path = module_obj.__file__
|
|
||||||
|
|
||||||
if not file_path:
|
|
||||||
logger.debug(
|
|
||||||
f"Could not resolve the path to the file for component '{component_name}'."
|
|
||||||
" Paths for HTML, JS or CSS templates will NOT be resolved relative to the component file."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Prepare all possible directories we need to check when searching for
|
|
||||||
# component's template and media files
|
|
||||||
components_dirs = search().searched_dirs
|
|
||||||
|
|
||||||
# Get the directory where the component class is defined
|
|
||||||
try:
|
|
||||||
comp_dir_abs, comp_dir_rel = _get_dir_path_from_component_path(file_path, components_dirs)
|
|
||||||
except RuntimeError:
|
|
||||||
# If no dir was found, we assume that the path is NOT relative to the component dir
|
|
||||||
logger.debug(
|
|
||||||
f"No component directory found for component '{component_name}' in {file_path}"
|
|
||||||
" If this component defines HTML, JS or CSS templates relatively to the component file,"
|
|
||||||
" then check that the component's directory is accessible from one of the paths"
|
|
||||||
" specified in the Django's 'STATICFILES_DIRS' settings."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if filepath refers to a file that's in the same directory as the component class.
|
|
||||||
# If yes, modify the path to refer to the relative file.
|
|
||||||
# If not, don't modify anything.
|
|
||||||
def resolve_file(filepath: str) -> str:
|
|
||||||
maybe_resolved_filepath = os.path.join(comp_dir_abs, filepath)
|
|
||||||
component_import_filepath = os.path.join(comp_dir_rel, filepath)
|
|
||||||
|
|
||||||
if os.path.isfile(maybe_resolved_filepath):
|
|
||||||
logger.debug(
|
|
||||||
f"Interpreting template '{filepath}' of component '{module_name}' relatively to component file"
|
|
||||||
)
|
|
||||||
return component_import_filepath
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
f"Interpreting template '{filepath}' of component '{module_name}' relatively to components directory"
|
|
||||||
)
|
|
||||||
return filepath
|
|
||||||
|
|
||||||
# Check if template name is a local file or not
|
|
||||||
if "template_name" in attrs and attrs["template_name"]:
|
|
||||||
attrs["template_name"] = resolve_file(attrs["template_name"])
|
|
||||||
|
|
||||||
if "Media" in attrs:
|
|
||||||
media = attrs["Media"]
|
|
||||||
|
|
||||||
# Now check the same for CSS files
|
|
||||||
if hasattr(media, "css") and isinstance(media.css, dict):
|
|
||||||
for media_type, path_list in media.css.items():
|
|
||||||
media.css[media_type] = [resolve_file(filepath) for filepath in path_list]
|
|
||||||
|
|
||||||
# And JS
|
|
||||||
if hasattr(media, "js") and isinstance(media.js, list):
|
|
||||||
media.js = [resolve_file(filepath) for filepath in media.js]
|
|
||||||
|
|
||||||
|
|
||||||
def _get_dir_path_from_component_path(
|
|
||||||
abs_component_file_path: str,
|
|
||||||
candidate_dirs: Union[List[str], List[Path]],
|
|
||||||
) -> Tuple[str, str]:
|
|
||||||
comp_dir_path_abs = os.path.dirname(abs_component_file_path)
|
|
||||||
|
|
||||||
# From all dirs defined in settings.STATICFILES_DIRS, find one that's the parent
|
|
||||||
# to the component file.
|
|
||||||
root_dir_abs = None
|
|
||||||
for candidate_dir in candidate_dirs:
|
|
||||||
candidate_dir_abs = os.path.abspath(candidate_dir)
|
|
||||||
if comp_dir_path_abs.startswith(candidate_dir_abs):
|
|
||||||
root_dir_abs = candidate_dir_abs
|
|
||||||
break
|
|
||||||
|
|
||||||
if root_dir_abs is None:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Failed to resolve template directory for component file '{abs_component_file_path}'",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Derive the path from matched STATICFILES_DIRS to the dir where the current component file is.
|
|
||||||
comp_dir_path_rel = os.path.relpath(comp_dir_path_abs, candidate_dir_abs)
|
|
||||||
|
|
||||||
# Return both absolute and relative paths:
|
|
||||||
# - Absolute path is used to check if the file exists
|
|
||||||
# - Relative path is used for defining the import on the component class
|
|
||||||
return comp_dir_path_abs, comp_dir_path_rel
|
|
||||||
|
|
||||||
|
|
||||||
class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
|
|
||||||
# Either template_name or template must be set on subclass OR subclass must implement get_template() with
|
# Either template_name or template must be set on subclass OR subclass must implement get_template() with
|
||||||
# non-null return.
|
# non-null return.
|
||||||
_class_hash: ClassVar[int]
|
_class_hash: ClassVar[int]
|
||||||
|
@ -206,15 +82,13 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
|
||||||
|
|
||||||
NOTE: This field is generated from Component.Media class.
|
NOTE: This field is generated from Component.Media class.
|
||||||
"""
|
"""
|
||||||
|
media_class: Media = Media
|
||||||
response_class = HttpResponse
|
response_class = HttpResponse
|
||||||
"""This allows to configure what class is used to generate response from `render_to_response`"""
|
"""This allows to configure what class is used to generate response from `render_to_response`"""
|
||||||
|
|
||||||
class Media:
|
Media = ComponentMediaInput
|
||||||
"""Defines JS and CSS media files associated with this component."""
|
"""Defines JS and CSS media files associated with this component."""
|
||||||
|
|
||||||
css: Optional[Union[str, List[str], Dict[str, str], Dict[str, List[str]]]] = None
|
|
||||||
js: Optional[Union[str, List[str]]] = None
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
registered_name: Optional[str] = None,
|
registered_name: Optional[str] = None,
|
||||||
|
|
350
src/django_components/component_media.py
Normal file
350
src/django_components/component_media.py
Normal file
|
@ -0,0 +1,350 @@
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any, Callable, Dict, List, MutableMapping, Optional, Tuple, Type, Union
|
||||||
|
|
||||||
|
from django.forms.widgets import Media, MediaDefiningClass
|
||||||
|
from django.utils.safestring import SafeData
|
||||||
|
|
||||||
|
from django_components.logger import logger
|
||||||
|
from django_components.utils import search
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from django_components.component import Component
|
||||||
|
|
||||||
|
|
||||||
|
class ComponentMediaInput:
|
||||||
|
"""Defines JS and CSS media files associated with this component."""
|
||||||
|
|
||||||
|
css: Optional[Union[str, List[str], Dict[str, str], Dict[str, List[str]]]] = None
|
||||||
|
js: Optional[Union[str, List[str]]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MediaMeta(MediaDefiningClass):
|
||||||
|
"""
|
||||||
|
Metaclass for handling media files for components.
|
||||||
|
|
||||||
|
Similar to `MediaDefiningClass`, this class supports the use of `Media` attribute
|
||||||
|
to define associated JS/CSS files, which are then available under `media`
|
||||||
|
attribute as a instance of `Media` class.
|
||||||
|
|
||||||
|
This subclass has following changes:
|
||||||
|
|
||||||
|
### 1. Support for multiple interfaces of JS/CSS
|
||||||
|
|
||||||
|
1. As plain strings
|
||||||
|
```py
|
||||||
|
class MyComponent(component.Component):
|
||||||
|
class Media:
|
||||||
|
js = "path/to/script.js"
|
||||||
|
css = "path/to/style.css"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. As lists
|
||||||
|
```py
|
||||||
|
class MyComponent(component.Component):
|
||||||
|
class Media:
|
||||||
|
js = ["path/to/script1.js", "path/to/script2.js"]
|
||||||
|
css = ["path/to/style1.css", "path/to/style2.css"]
|
||||||
|
```
|
||||||
|
|
||||||
|
3. [CSS ONLY] Dicts of strings
|
||||||
|
```py
|
||||||
|
class MyComponent(component.Component):
|
||||||
|
class Media:
|
||||||
|
css = {
|
||||||
|
"all": "path/to/style1.css",
|
||||||
|
"print": "path/to/style2.css",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. [CSS ONLY] Dicts of lists
|
||||||
|
```py
|
||||||
|
class MyComponent(component.Component):
|
||||||
|
class Media:
|
||||||
|
css = {
|
||||||
|
"all": ["path/to/style1.css"],
|
||||||
|
"print": ["path/to/style2.css"],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Media are first resolved relative to class definition file
|
||||||
|
|
||||||
|
E.g. if in a directory `my_comp` you have `script.js` and `my_comp.py`,
|
||||||
|
and `my_comp.py` looks like this:
|
||||||
|
|
||||||
|
```py
|
||||||
|
class MyComponent(component.Component):
|
||||||
|
class Media:
|
||||||
|
js = "script.js"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then `script.js` will be resolved as `my_comp/script.js`.
|
||||||
|
|
||||||
|
### 3. Media can be defined as str, bytes, PathLike, SafeString, or function of thereof
|
||||||
|
|
||||||
|
E.g.:
|
||||||
|
|
||||||
|
```py
|
||||||
|
def lazy_eval_css():
|
||||||
|
# do something
|
||||||
|
return path
|
||||||
|
|
||||||
|
class MyComponent(component.Component):
|
||||||
|
class Media:
|
||||||
|
js = b"script.js"
|
||||||
|
css = lazy_eval_css
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Subclass `Media` class with `media_class`
|
||||||
|
|
||||||
|
Normal `MediaDefiningClass` creates an instance of `Media` class under the `media` attribute.
|
||||||
|
This class allows to override which class will be instantiated with `media_class` attribute:
|
||||||
|
|
||||||
|
```py
|
||||||
|
class MyMedia(Media):
|
||||||
|
def render_js(self):
|
||||||
|
...
|
||||||
|
|
||||||
|
class MyComponent(component.Component):
|
||||||
|
media_class = MyMedia
|
||||||
|
def get_context_data(self):
|
||||||
|
assert isinstance(self.media, MyMedia)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __new__(mcs, name: str, bases: Tuple[Type, ...], attrs: Dict[str, Any]) -> Type:
|
||||||
|
if "Media" in attrs:
|
||||||
|
media_data: ComponentMediaInput = attrs["Media"]
|
||||||
|
# Normalize the various forms of Media inputs we allow
|
||||||
|
_normalize_media(media_data)
|
||||||
|
# Given a predictable structure of Media class, get all the various JS/CSS paths
|
||||||
|
# that user has defined, and normalize them too.
|
||||||
|
#
|
||||||
|
# Because we can accept:
|
||||||
|
# str, bytes, PathLike, SafeData (AKA Django's "path as object") or a callable
|
||||||
|
#
|
||||||
|
# And we want to convert that to:
|
||||||
|
# str and SafeData
|
||||||
|
_map_media_filepaths(media_data, _normalize_media_filepath)
|
||||||
|
|
||||||
|
# Once the inputs are normalized, attempt to resolve the JS/CSS filepaths
|
||||||
|
# as relative to the directory where the component class is defined.
|
||||||
|
_resolve_component_relative_files(attrs)
|
||||||
|
|
||||||
|
# Since we're inheriting from `MediaDefiningClass`, it should take the inputs
|
||||||
|
# from `cls.Media`, and set the `cls.media` to an instance of Django's `Media` class
|
||||||
|
cls = super().__new__(mcs, name, bases, attrs)
|
||||||
|
|
||||||
|
# Lastly, if the class defines `media_class` attribute, transform `cls.media`
|
||||||
|
# to the instance of `media_class`.
|
||||||
|
_monkeypatch_media_property(cls)
|
||||||
|
|
||||||
|
return cls
|
||||||
|
|
||||||
|
|
||||||
|
# Allow users to provide custom subclasses of Media via `media_class`.
|
||||||
|
# `MediaDefiningClass` defines `media` as a getter (defined in django.forms.widgets.media_property).
|
||||||
|
# So we reused that and convert it to user-defined Media class
|
||||||
|
def _monkeypatch_media_property(comp_cls: Type["Component"]) -> None:
|
||||||
|
if not hasattr(comp_cls, "media_class"):
|
||||||
|
return
|
||||||
|
|
||||||
|
media_prop: property = comp_cls.media
|
||||||
|
media_getter = media_prop.fget
|
||||||
|
|
||||||
|
def media_wrapper(self: "Component") -> Any:
|
||||||
|
if not media_getter:
|
||||||
|
return None
|
||||||
|
media: Media = media_getter(self)
|
||||||
|
return self.media_class(js=media._js, css=media._css)
|
||||||
|
|
||||||
|
comp_cls.media = property(media_wrapper)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_media(media: ComponentMediaInput) -> None:
|
||||||
|
if hasattr(media, "css") and media.css:
|
||||||
|
# Allow: class Media: css = "style.css"
|
||||||
|
if _is_media_filepath(media.css):
|
||||||
|
media.css = [media.css] # type: ignore[list-item]
|
||||||
|
|
||||||
|
# Allow: class Media: css = ["style.css"]
|
||||||
|
if isinstance(media.css, (list, tuple)):
|
||||||
|
media.css = {"all": media.css}
|
||||||
|
|
||||||
|
# Allow: class Media: css = {"all": "style.css"}
|
||||||
|
if isinstance(media.css, dict):
|
||||||
|
for media_type, path_list in media.css.items():
|
||||||
|
if _is_media_filepath(path_list):
|
||||||
|
media.css[media_type] = [path_list] # type: ignore
|
||||||
|
|
||||||
|
if hasattr(media, "js") and media.js:
|
||||||
|
# Allow: class Media: js = "script.js"
|
||||||
|
if _is_media_filepath(media.js):
|
||||||
|
media.js = [media.js] # type: ignore[list-item]
|
||||||
|
|
||||||
|
|
||||||
|
def _map_media_filepaths(media: ComponentMediaInput, map_fn: Callable[[Any], Any]) -> None:
|
||||||
|
if hasattr(media, "css") and media.css:
|
||||||
|
if not isinstance(media.css, dict):
|
||||||
|
raise ValueError(f"Media.css must be a dict, got {type(media.css)}")
|
||||||
|
|
||||||
|
for media_type, path_list in media.css.items():
|
||||||
|
media.css[media_type] = list(map(map_fn, path_list)) # type: ignore[assignment]
|
||||||
|
|
||||||
|
if hasattr(media, "js") and media.js:
|
||||||
|
if not isinstance(media.js, (list, tuple)):
|
||||||
|
raise ValueError(f"Media.css must be a list, got {type(media.css)}")
|
||||||
|
|
||||||
|
media.js = list(map(map_fn, media.js))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_media_filepath(filepath: Any) -> bool:
|
||||||
|
if callable(filepath):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if isinstance(filepath, SafeData) or hasattr(filepath, "__html__"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif isinstance(filepath, (Path, os.PathLike)) or hasattr(filepath, "__fspath__"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if isinstance(filepath, bytes):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if isinstance(filepath, str):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_media_filepath(filepath: Any) -> Union[str, SafeData]:
|
||||||
|
if callable(filepath):
|
||||||
|
filepath = filepath()
|
||||||
|
|
||||||
|
if isinstance(filepath, SafeData) or hasattr(filepath, "__html__"):
|
||||||
|
return filepath
|
||||||
|
|
||||||
|
if isinstance(filepath, (Path, os.PathLike)) or hasattr(filepath, "__fspath__"):
|
||||||
|
filepath = filepath.__fspath__()
|
||||||
|
|
||||||
|
if isinstance(filepath, bytes):
|
||||||
|
filepath = filepath.decode("utf-8")
|
||||||
|
|
||||||
|
if isinstance(filepath, str):
|
||||||
|
return filepath
|
||||||
|
|
||||||
|
raise ValueError(
|
||||||
|
"Unknown filepath. Must be str, bytes, PathLike, SafeString, or a function that returns one of the former"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_component_relative_files(attrs: MutableMapping) -> None:
|
||||||
|
"""
|
||||||
|
Check if component's HTML, JS and CSS files refer to files in the same directory
|
||||||
|
as the component class. If so, modify the attributes so the class Django's rendering
|
||||||
|
will pick up these files correctly.
|
||||||
|
"""
|
||||||
|
# First check if we even need to resolve anything. If the class doesn't define any
|
||||||
|
# JS/CSS files, just skip.
|
||||||
|
will_resolve_files = False
|
||||||
|
if attrs.get("template_name", None):
|
||||||
|
will_resolve_files = True
|
||||||
|
if not will_resolve_files and "Media" in attrs:
|
||||||
|
media: ComponentMediaInput = attrs["Media"]
|
||||||
|
if getattr(media, "css", None) or getattr(media, "js", None):
|
||||||
|
will_resolve_files = True
|
||||||
|
|
||||||
|
if not will_resolve_files:
|
||||||
|
return
|
||||||
|
|
||||||
|
component_name = attrs["__qualname__"]
|
||||||
|
# Derive the full path of the file where the component was defined
|
||||||
|
module_name = attrs["__module__"]
|
||||||
|
module_obj = sys.modules[module_name]
|
||||||
|
file_path = module_obj.__file__
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
logger.debug(
|
||||||
|
f"Could not resolve the path to the file for component '{component_name}'."
|
||||||
|
" Paths for HTML, JS or CSS templates will NOT be resolved relative to the component file."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Prepare all possible directories we need to check when searching for
|
||||||
|
# component's template and media files
|
||||||
|
components_dirs = search().searched_dirs
|
||||||
|
|
||||||
|
# Get the directory where the component class is defined
|
||||||
|
try:
|
||||||
|
comp_dir_abs, comp_dir_rel = _get_dir_path_from_component_path(file_path, components_dirs)
|
||||||
|
except RuntimeError:
|
||||||
|
# If no dir was found, we assume that the path is NOT relative to the component dir
|
||||||
|
logger.debug(
|
||||||
|
f"No component directory found for component '{component_name}' in {file_path}"
|
||||||
|
" If this component defines HTML, JS or CSS templates relatively to the component file,"
|
||||||
|
" then check that the component's directory is accessible from one of the paths"
|
||||||
|
" specified in the Django's 'STATICFILES_DIRS' settings."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if filepath refers to a file that's in the same directory as the component class.
|
||||||
|
# If yes, modify the path to refer to the relative file.
|
||||||
|
# If not, don't modify anything.
|
||||||
|
def resolve_file(filepath: Union[str, SafeData]) -> Union[str, SafeData]:
|
||||||
|
if isinstance(filepath, str):
|
||||||
|
maybe_resolved_filepath = os.path.join(comp_dir_abs, filepath)
|
||||||
|
component_import_filepath = os.path.join(comp_dir_rel, filepath)
|
||||||
|
|
||||||
|
if os.path.isfile(maybe_resolved_filepath):
|
||||||
|
# NOTE: It's important to use `repr`, so we don't trigger __str__ on SafeStrings
|
||||||
|
logger.debug(
|
||||||
|
f"Interpreting template '{repr(filepath)}' of component '{module_name}'"
|
||||||
|
" relatively to component file"
|
||||||
|
)
|
||||||
|
|
||||||
|
return component_import_filepath
|
||||||
|
return filepath
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Interpreting template '{repr(filepath)}' of component '{module_name}'"
|
||||||
|
" relatively to components directory"
|
||||||
|
)
|
||||||
|
return filepath
|
||||||
|
|
||||||
|
# Check if template name is a local file or not
|
||||||
|
if "template_name" in attrs and attrs["template_name"]:
|
||||||
|
attrs["template_name"] = resolve_file(attrs["template_name"])
|
||||||
|
|
||||||
|
if "Media" in attrs:
|
||||||
|
media = attrs["Media"]
|
||||||
|
_map_media_filepaths(media, resolve_file)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dir_path_from_component_path(
|
||||||
|
abs_component_file_path: str,
|
||||||
|
candidate_dirs: Union[List[str], List[Path]],
|
||||||
|
) -> Tuple[str, str]:
|
||||||
|
comp_dir_path_abs = os.path.dirname(abs_component_file_path)
|
||||||
|
|
||||||
|
# From all dirs defined in settings.STATICFILES_DIRS, find one that's the parent
|
||||||
|
# to the component file.
|
||||||
|
root_dir_abs = None
|
||||||
|
for candidate_dir in candidate_dirs:
|
||||||
|
candidate_dir_abs = os.path.abspath(candidate_dir)
|
||||||
|
if comp_dir_path_abs.startswith(candidate_dir_abs):
|
||||||
|
root_dir_abs = candidate_dir_abs
|
||||||
|
break
|
||||||
|
|
||||||
|
if root_dir_abs is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to resolve template directory for component file '{abs_component_file_path}'",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Derive the path from matched STATICFILES_DIRS to the dir where the current component file is.
|
||||||
|
comp_dir_path_rel = os.path.relpath(comp_dir_path_abs, candidate_dir_abs)
|
||||||
|
|
||||||
|
# Return both absolute and relative paths:
|
||||||
|
# - Absolute path is used to check if the file exists
|
||||||
|
# - Relative path is used for defining the import on the component class
|
||||||
|
return comp_dir_path_abs, comp_dir_path_rel
|
|
@ -0,0 +1,3 @@
|
||||||
|
.html-css-only {
|
||||||
|
color: blue;
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="text" name="variable" value="{{ variable }}">
|
||||||
|
<input type="submit">
|
||||||
|
</form>
|
|
@ -0,0 +1 @@
|
||||||
|
console.log("JS file");
|
|
@ -0,0 +1,33 @@
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from django.templatetags.static import static
|
||||||
|
from django.utils.html import format_html, html_safe
|
||||||
|
|
||||||
|
from django_components import component
|
||||||
|
|
||||||
|
|
||||||
|
# Format as mentioned in https://github.com/EmilStenstrom/django-components/issues/522#issuecomment-2173577094
|
||||||
|
@html_safe
|
||||||
|
class PathObj:
|
||||||
|
def __init__(self, static_path: str) -> None:
|
||||||
|
self.static_path = static_path
|
||||||
|
self.throw_on_calling_str = True
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
# This error will notify us when we've hit __str__ when we shouldn't have
|
||||||
|
if self.throw_on_calling_str:
|
||||||
|
raise RuntimeError("__str__ method of 'relative_file_pathobj_component' was triggered when not allow to")
|
||||||
|
|
||||||
|
return format_html('<script type="module" src="{}"></script>', static(self.static_path))
|
||||||
|
|
||||||
|
|
||||||
|
@component.register("relative_file_pathobj_component")
|
||||||
|
class RelativeFileWithPathObjComponent(component.Component):
|
||||||
|
template_name = "relative_file_pathobj.html"
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
js = PathObj("relative_file_pathobj.js")
|
||||||
|
css = PathObj("relative_file_pathobj.css")
|
||||||
|
|
||||||
|
def get_context_data(self, variable, *args, **kwargs) -> Dict[str, Any]:
|
||||||
|
return {"variable": variable}
|
12
tests/static_root/staticfiles.json
Normal file
12
tests/static_root/staticfiles.json
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"__COMMENT__": "This file is used by tests in `test_component_media.py` to test integration with Django's `staticfiles`",
|
||||||
|
"__COMMENT2__": "Under normal conditions, this JSON would be generated by running Django's `collectstatic` with `ManifestStaticFilesStorage`",
|
||||||
|
"__COMMENT3__": "See https://docs.djangoproject.com/en/5.0/ref/contrib/staticfiles/#manifeststaticfilesstorage",
|
||||||
|
|
||||||
|
"paths": {
|
||||||
|
"calendar/script.js": "calendar/script.e1815e23e0ec.js",
|
||||||
|
"calendar/style.css": "calendar/style.0eeb72042b59.css"
|
||||||
|
},
|
||||||
|
"version": "1.1",
|
||||||
|
"hash": "f53e7ffd18c4"
|
||||||
|
}
|
|
@ -26,6 +26,8 @@ class TestAutodiscover(BaseTestCase):
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
del settings.SETTINGS_MODULE # noqa
|
del settings.SETTINGS_MODULE # noqa
|
||||||
|
|
||||||
|
# TODO: As part of this test, check that `autoimport()` imports the components
|
||||||
|
# from the `tests/components` dir?
|
||||||
def test_autodiscover_with_components_as_views(self):
|
def test_autodiscover_with_components_as_views(self):
|
||||||
all_components_before = component_registry.registry.all().copy()
|
all_components_before = component_registry.registry.all().copy()
|
||||||
|
|
||||||
|
@ -36,7 +38,7 @@ class TestAutodiscover(BaseTestCase):
|
||||||
|
|
||||||
all_components_after = component_registry.registry.all().copy()
|
all_components_after = component_registry.registry.all().copy()
|
||||||
imported_components_count = len(all_components_after) - len(all_components_before)
|
imported_components_count = len(all_components_after) - len(all_components_before)
|
||||||
self.assertEqual(imported_components_count, 1)
|
self.assertEqual(imported_components_count, 2)
|
||||||
|
|
||||||
|
|
||||||
class TestLoaderSettingsModule(BaseTestCase):
|
class TestLoaderSettingsModule(BaseTestCase):
|
||||||
|
|
|
@ -1,8 +1,13 @@
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.forms.widgets import Media
|
||||||
from django.template import Context, Template
|
from django.template import Context, Template
|
||||||
|
from django.templatetags.static import static
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
|
from django.utils.html import format_html, html_safe
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
# isort: off
|
# isort: off
|
||||||
from .django_test_setup import * # NOQA
|
from .django_test_setup import * # NOQA
|
||||||
|
@ -261,7 +266,7 @@ class ComponentMediaTests(BaseTestCase):
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_css_js_as_dict_and_list(self):
|
def test_css_as_dict(self):
|
||||||
class SimpleComponent(component.Component):
|
class SimpleComponent(component.Component):
|
||||||
class Media:
|
class Media:
|
||||||
css = {
|
css = {
|
||||||
|
@ -282,6 +287,432 @@ class ComponentMediaTests(BaseTestCase):
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_media_custom_render_js(self):
|
||||||
|
class MyMedia(Media):
|
||||||
|
def render_js(self):
|
||||||
|
tags: list[str] = []
|
||||||
|
for path in self._js: # type: ignore[attr-defined]
|
||||||
|
abs_path = self.absolute_path(path) # type: ignore[attr-defined]
|
||||||
|
tags.append(f'<my_script_tag src="{abs_path}"></my_script_tag>')
|
||||||
|
return tags
|
||||||
|
|
||||||
|
class SimpleComponent(component.Component):
|
||||||
|
media_class = MyMedia
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
js = ["path/to/script.js", "path/to/script2.js"]
|
||||||
|
|
||||||
|
comp = SimpleComponent()
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
comp.render_dependencies(),
|
||||||
|
"""
|
||||||
|
<my_script_tag src="path/to/script.js"></my_script_tag>
|
||||||
|
<my_script_tag src="path/to/script2.js"></my_script_tag>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_media_custom_render_css(self):
|
||||||
|
class MyMedia(Media):
|
||||||
|
def render_css(self):
|
||||||
|
tags: list[str] = []
|
||||||
|
media = sorted(self._css) # type: ignore[attr-defined]
|
||||||
|
for medium in media:
|
||||||
|
for path in self._css[medium]: # type: ignore[attr-defined]
|
||||||
|
tags.append(f'<my_link href="{path}" media="{medium}" rel="stylesheet" />')
|
||||||
|
return tags
|
||||||
|
|
||||||
|
class SimpleComponent(component.Component):
|
||||||
|
media_class = MyMedia
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
css = {
|
||||||
|
"all": "path/to/style.css",
|
||||||
|
"print": ["path/to/style2.css"],
|
||||||
|
"screen": "path/to/style3.css",
|
||||||
|
}
|
||||||
|
|
||||||
|
comp = SimpleComponent()
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
comp.render_dependencies(),
|
||||||
|
"""
|
||||||
|
<my_link href="path/to/style.css" media="all" rel="stylesheet" />
|
||||||
|
<my_link href="path/to/style2.css" media="print" rel="stylesheet" />
|
||||||
|
<my_link href="path/to/style3.css" media="screen" rel="stylesheet" />
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MediaPathAsObjectTests(BaseTestCase):
|
||||||
|
def test_safestring(self):
|
||||||
|
"""
|
||||||
|
Test that media work with paths defined as instances of classes that define
|
||||||
|
the `__html__` method.
|
||||||
|
|
||||||
|
See https://docs.djangoproject.com/en/5.0/topics/forms/media/#paths-as-objects
|
||||||
|
"""
|
||||||
|
|
||||||
|
# NOTE: @html_safe adds __html__ method from __str__
|
||||||
|
@html_safe
|
||||||
|
class JSTag:
|
||||||
|
def __init__(self, path: str) -> None:
|
||||||
|
self.path = path
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'<script js_tag src="{self.path}" type="module"></script>'
|
||||||
|
|
||||||
|
@html_safe
|
||||||
|
class CSSTag:
|
||||||
|
def __init__(self, path: str) -> None:
|
||||||
|
self.path = path
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'<link css_tag href="{self.path}" rel="stylesheet" />'
|
||||||
|
|
||||||
|
# Format as mentioned in https://github.com/EmilStenstrom/django-components/issues/522#issuecomment-2173577094
|
||||||
|
@html_safe
|
||||||
|
class PathObj:
|
||||||
|
def __init__(self, static_path: str) -> None:
|
||||||
|
self.static_path = static_path
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return format_html('<script type="module" src="{}"></script>', static(self.static_path))
|
||||||
|
|
||||||
|
class SimpleComponent(component.Component):
|
||||||
|
class Media:
|
||||||
|
css = {
|
||||||
|
"all": [
|
||||||
|
CSSTag("path/to/style.css"), # Formatted by CSSTag
|
||||||
|
mark_safe('<link hi href="path/to/style2.css" rel="stylesheet" />'), # Literal
|
||||||
|
],
|
||||||
|
"print": [
|
||||||
|
CSSTag("path/to/style3.css"), # Formatted by CSSTag
|
||||||
|
],
|
||||||
|
"screen": "path/to/style4.css", # Formatted by Media.render_css
|
||||||
|
}
|
||||||
|
js = [
|
||||||
|
JSTag("path/to/script.js"), # Formatted by JSTag
|
||||||
|
mark_safe('<script hi src="path/to/script2.js"></script>'), # Literal
|
||||||
|
PathObj("path/to/script3.js"), # Literal
|
||||||
|
"path/to/script4.js", # Formatted by Media.render_js
|
||||||
|
]
|
||||||
|
|
||||||
|
comp = SimpleComponent()
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
comp.render_dependencies(),
|
||||||
|
"""
|
||||||
|
<link css_tag href="path/to/style.css" rel="stylesheet" />
|
||||||
|
<link hi href="path/to/style2.css" rel="stylesheet" />
|
||||||
|
<link css_tag href="path/to/style3.css" rel="stylesheet" />
|
||||||
|
<link href="path/to/style4.css" media="screen" rel="stylesheet">
|
||||||
|
|
||||||
|
<script js_tag src="path/to/script.js" type="module"></script>
|
||||||
|
<script hi src="path/to/script2.js"></script>
|
||||||
|
<script type="module" src="path/to/script3.js"></script>
|
||||||
|
<script src="path/to/script4.js"></script>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pathlike(self):
|
||||||
|
"""
|
||||||
|
Test that media work with paths defined as instances of classes that define
|
||||||
|
the `__fspath__` method.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class MyPath(os.PathLike):
|
||||||
|
def __init__(self, path: str) -> None:
|
||||||
|
self.path = path
|
||||||
|
|
||||||
|
def __fspath__(self):
|
||||||
|
return self.path
|
||||||
|
|
||||||
|
class SimpleComponent(component.Component):
|
||||||
|
class Media:
|
||||||
|
css = {
|
||||||
|
"all": [
|
||||||
|
MyPath("path/to/style.css"),
|
||||||
|
Path("path/to/style2.css"),
|
||||||
|
],
|
||||||
|
"print": [
|
||||||
|
MyPath("path/to/style3.css"),
|
||||||
|
],
|
||||||
|
"screen": "path/to/style4.css",
|
||||||
|
}
|
||||||
|
js = [
|
||||||
|
MyPath("path/to/script.js"),
|
||||||
|
Path("path/to/script2.js"),
|
||||||
|
"path/to/script3.js",
|
||||||
|
]
|
||||||
|
|
||||||
|
comp = SimpleComponent()
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
comp.render_dependencies(),
|
||||||
|
"""
|
||||||
|
<link href="path/to/style.css" media="all" rel="stylesheet">
|
||||||
|
<link href="path/to/style2.css" media="all" rel="stylesheet">
|
||||||
|
<link href="path/to/style3.css" media="print" rel="stylesheet">
|
||||||
|
<link href="path/to/style4.css" media="screen" rel="stylesheet">
|
||||||
|
|
||||||
|
<script src="path/to/script.js"></script>
|
||||||
|
<script src="path/to/script2.js"></script>
|
||||||
|
<script src="path/to/script3.js"></script>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_str(self):
|
||||||
|
"""
|
||||||
|
Test that media work with paths defined as instances of classes that
|
||||||
|
subclass 'str'.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class MyStr(str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class SimpleComponent(component.Component):
|
||||||
|
class Media:
|
||||||
|
css = {
|
||||||
|
"all": [
|
||||||
|
MyStr("path/to/style.css"),
|
||||||
|
"path/to/style2.css",
|
||||||
|
],
|
||||||
|
"print": [
|
||||||
|
MyStr("path/to/style3.css"),
|
||||||
|
],
|
||||||
|
"screen": "path/to/style4.css",
|
||||||
|
}
|
||||||
|
js = [
|
||||||
|
MyStr("path/to/script.js"),
|
||||||
|
"path/to/script2.js",
|
||||||
|
]
|
||||||
|
|
||||||
|
comp = SimpleComponent()
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
comp.render_dependencies(),
|
||||||
|
"""
|
||||||
|
<link href="path/to/style.css" media="all" rel="stylesheet">
|
||||||
|
<link href="path/to/style2.css" media="all" rel="stylesheet">
|
||||||
|
<link href="path/to/style3.css" media="print" rel="stylesheet">
|
||||||
|
<link href="path/to/style4.css" media="screen" rel="stylesheet">
|
||||||
|
|
||||||
|
<script src="path/to/script.js"></script>
|
||||||
|
<script src="path/to/script2.js"></script>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bytes(self):
|
||||||
|
"""
|
||||||
|
Test that media work with paths defined as instances of classes that
|
||||||
|
subclass 'bytes'.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class MyBytes(bytes):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class SimpleComponent(component.Component):
|
||||||
|
class Media:
|
||||||
|
css = {
|
||||||
|
"all": [
|
||||||
|
MyBytes(b"path/to/style.css"),
|
||||||
|
b"path/to/style2.css",
|
||||||
|
],
|
||||||
|
"print": [
|
||||||
|
MyBytes(b"path/to/style3.css"),
|
||||||
|
],
|
||||||
|
"screen": b"path/to/style4.css",
|
||||||
|
}
|
||||||
|
js = [
|
||||||
|
MyBytes(b"path/to/script.js"),
|
||||||
|
"path/to/script2.js",
|
||||||
|
]
|
||||||
|
|
||||||
|
comp = SimpleComponent()
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
comp.render_dependencies(),
|
||||||
|
"""
|
||||||
|
<link href="path/to/style.css" media="all" rel="stylesheet">
|
||||||
|
<link href="path/to/style2.css" media="all" rel="stylesheet">
|
||||||
|
<link href="path/to/style3.css" media="print" rel="stylesheet">
|
||||||
|
<link href="path/to/style4.css" media="screen" rel="stylesheet">
|
||||||
|
|
||||||
|
<script src="path/to/script.js"></script>
|
||||||
|
<script src="path/to/script2.js"></script>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_function(self):
|
||||||
|
class SimpleComponent(component.Component):
|
||||||
|
class Media:
|
||||||
|
css = [
|
||||||
|
lambda: mark_safe('<link hi href="calendar/style.css" rel="stylesheet" />'), # Literal
|
||||||
|
lambda: Path("calendar/style1.css"),
|
||||||
|
lambda: "calendar/style2.css",
|
||||||
|
lambda: b"calendar/style3.css",
|
||||||
|
]
|
||||||
|
js = [
|
||||||
|
lambda: mark_safe('<script hi src="calendar/script.js"></script>'), # Literal
|
||||||
|
lambda: Path("calendar/script1.js"),
|
||||||
|
lambda: "calendar/script2.js",
|
||||||
|
lambda: b"calendar/script3.js",
|
||||||
|
]
|
||||||
|
|
||||||
|
comp = SimpleComponent()
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
comp.render_dependencies(),
|
||||||
|
"""
|
||||||
|
<link hi href="calendar/style.css" rel="stylesheet" />
|
||||||
|
<link href="calendar/style1.css" media="all" rel="stylesheet">
|
||||||
|
<link href="calendar/style2.css" media="all" rel="stylesheet">
|
||||||
|
<link href="calendar/style3.css" media="all" rel="stylesheet">
|
||||||
|
|
||||||
|
<script hi src="calendar/script.js"></script>
|
||||||
|
<script src="calendar/script1.js"></script>
|
||||||
|
<script src="calendar/script2.js"></script>
|
||||||
|
<script src="calendar/script3.js"></script>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_settings(STATIC_URL="static/")
|
||||||
|
def test_works_with_static(self):
|
||||||
|
"""Test that all the different ways of defining media files works with Django's staticfiles"""
|
||||||
|
|
||||||
|
class SimpleComponent(component.Component):
|
||||||
|
class Media:
|
||||||
|
css = [
|
||||||
|
mark_safe(f'<link hi href="{static("calendar/style.css")}" rel="stylesheet" />'), # Literal
|
||||||
|
Path("calendar/style1.css"),
|
||||||
|
"calendar/style2.css",
|
||||||
|
b"calendar/style3.css",
|
||||||
|
lambda: "calendar/style4.css",
|
||||||
|
]
|
||||||
|
js = [
|
||||||
|
mark_safe(f'<script hi src="{static("calendar/script.js")}"></script>'), # Literal
|
||||||
|
Path("calendar/script1.js"),
|
||||||
|
"calendar/script2.js",
|
||||||
|
b"calendar/script3.js",
|
||||||
|
lambda: "calendar/script4.js",
|
||||||
|
]
|
||||||
|
|
||||||
|
comp = SimpleComponent()
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
comp.render_dependencies(),
|
||||||
|
"""
|
||||||
|
<link hi href="/static/calendar/style.css" rel="stylesheet" />
|
||||||
|
<link href="/static/calendar/style1.css" media="all" rel="stylesheet">
|
||||||
|
<link href="/static/calendar/style2.css" media="all" rel="stylesheet">
|
||||||
|
<link href="/static/calendar/style3.css" media="all" rel="stylesheet">
|
||||||
|
<link href="/static/calendar/style4.css" media="all" rel="stylesheet">
|
||||||
|
|
||||||
|
<script hi src="/static/calendar/script.js"></script>
|
||||||
|
<script src="/static/calendar/script1.js"></script>
|
||||||
|
<script src="/static/calendar/script2.js"></script>
|
||||||
|
<script src="/static/calendar/script3.js"></script>
|
||||||
|
<script src="/static/calendar/script4.js"></script>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MediaStaticfilesTests(BaseTestCase):
|
||||||
|
# For context see https://github.com/EmilStenstrom/django-components/issues/522
|
||||||
|
@override_settings(
|
||||||
|
# Configure static files. The dummy files are set up in the `./static_root` dir.
|
||||||
|
# The URL should have path prefix /static/.
|
||||||
|
# NOTE: We don't need STATICFILES_DIRS, because we don't run collectstatic
|
||||||
|
# See https://docs.djangoproject.com/en/5.0/ref/settings/#std-setting-STATICFILES_DIRS
|
||||||
|
STATIC_URL="static/",
|
||||||
|
STATIC_ROOT=os.path.join(Path(__file__).resolve().parent, "static_root"),
|
||||||
|
# Either `django.contrib.staticfiles` or `django_components.safer_staticfiles` MUST
|
||||||
|
# be installed for staticfiles resolution to work.
|
||||||
|
INSTALLED_APPS=[
|
||||||
|
"django_components.safer_staticfiles", # Or django.contrib.staticfiles
|
||||||
|
"django_components",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_default_static_files_storage(self):
|
||||||
|
"""Test integration with Django's staticfiles app"""
|
||||||
|
|
||||||
|
class MyMedia(Media):
|
||||||
|
def render_js(self):
|
||||||
|
tags: list[str] = []
|
||||||
|
for path in self._js: # type: ignore[attr-defined]
|
||||||
|
abs_path = self.absolute_path(path) # type: ignore[attr-defined]
|
||||||
|
tags.append(f'<my_script_tag src="{abs_path}"></my_script_tag>')
|
||||||
|
return tags
|
||||||
|
|
||||||
|
class SimpleComponent(component.Component):
|
||||||
|
media_class = MyMedia
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
css = "calendar/style.css"
|
||||||
|
js = "calendar/script.js"
|
||||||
|
|
||||||
|
comp = SimpleComponent()
|
||||||
|
|
||||||
|
# NOTE: Since we're using the default storage class for staticfiles, the files should
|
||||||
|
# be searched as specified above (e.g. `calendar/script.js`) inside `static_root` dir.
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
comp.render_dependencies(),
|
||||||
|
"""
|
||||||
|
<link href="/static/calendar/style.css" media="all" rel="stylesheet">
|
||||||
|
<my_script_tag src="/static/calendar/script.js"></my_script_tag>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
# For context see https://github.com/EmilStenstrom/django-components/issues/522
|
||||||
|
@override_settings(
|
||||||
|
# Configure static files. The dummy files are set up in the `./static_root` dir.
|
||||||
|
# The URL should have path prefix /static/.
|
||||||
|
# NOTE: We don't need STATICFILES_DIRS, because we don't run collectstatic
|
||||||
|
# See https://docs.djangoproject.com/en/5.0/ref/settings/#std-setting-STATICFILES_DIRS
|
||||||
|
STATIC_URL="static/",
|
||||||
|
STATIC_ROOT=os.path.join(Path(__file__).resolve().parent, "static_root"),
|
||||||
|
# NOTE: STATICFILES_STORAGE is deprecated since 5.1, use STORAGES instead
|
||||||
|
# See https://docs.djangoproject.com/en/5.0/ref/settings/#staticfiles-storage
|
||||||
|
STORAGES={
|
||||||
|
# This was NOT changed
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
||||||
|
},
|
||||||
|
# This WAS changed so that static files are looked up by the `staticfiles.json`
|
||||||
|
"staticfiles": {
|
||||||
|
"BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
# Either `django.contrib.staticfiles` or `django_components.safer_staticfiles` MUST
|
||||||
|
# be installed for staticfiles resolution to work.
|
||||||
|
INSTALLED_APPS=[
|
||||||
|
"django_components.safer_staticfiles", # Or django.contrib.staticfiles
|
||||||
|
"django_components",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_manifest_static_files_storage(self):
|
||||||
|
"""Test integration with Django's staticfiles app and ManifestStaticFilesStorage"""
|
||||||
|
|
||||||
|
class MyMedia(Media):
|
||||||
|
def render_js(self):
|
||||||
|
tags: list[str] = []
|
||||||
|
for path in self._js: # type: ignore[attr-defined]
|
||||||
|
abs_path = self.absolute_path(path) # type: ignore[attr-defined]
|
||||||
|
tags.append(f'<my_script_tag src="{abs_path}"></my_script_tag>')
|
||||||
|
return tags
|
||||||
|
|
||||||
|
class SimpleComponent(component.Component):
|
||||||
|
media_class = MyMedia
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
css = "calendar/style.css"
|
||||||
|
js = "calendar/script.js"
|
||||||
|
|
||||||
|
comp = SimpleComponent()
|
||||||
|
|
||||||
|
# NOTE: Since we're using ManifestStaticFilesStorage, we expect the rendered media to link
|
||||||
|
# to the files as defined in staticfiles.json
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
comp.render_dependencies(),
|
||||||
|
"""
|
||||||
|
<link href="/static/calendar/style.0eeb72042b59.css" media="all" rel="stylesheet">
|
||||||
|
<my_script_tag src="/static/calendar/script.e1815e23e0ec.js"></my_script_tag>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MediaRelativePathTests(BaseTestCase):
|
class MediaRelativePathTests(BaseTestCase):
|
||||||
class ParentComponent(component.Component):
|
class ParentComponent(component.Component):
|
||||||
|
@ -339,6 +770,8 @@ class MediaRelativePathTests(BaseTestCase):
|
||||||
|
|
||||||
# Fix the paths, since the "components" dir is nested
|
# Fix the paths, since the "components" dir is nested
|
||||||
with autodiscover_with_cleanup(map_import_paths=lambda p: f"tests.{p}"):
|
with autodiscover_with_cleanup(map_import_paths=lambda p: f"tests.{p}"):
|
||||||
|
component.registry.unregister("relative_file_pathobj_component")
|
||||||
|
|
||||||
template_str: types.django_html = """
|
template_str: types.django_html = """
|
||||||
{% load component_tags %}{% component_dependencies %}
|
{% load component_tags %}{% component_dependencies %}
|
||||||
{% component name='relative_file_component' variable=variable %}
|
{% component name='relative_file_component' variable=variable %}
|
||||||
|
@ -372,6 +805,8 @@ class MediaRelativePathTests(BaseTestCase):
|
||||||
|
|
||||||
# Fix the paths, since the "components" dir is nested
|
# Fix the paths, since the "components" dir is nested
|
||||||
with autodiscover_with_cleanup(map_import_paths=lambda p: f"tests.{p}"):
|
with autodiscover_with_cleanup(map_import_paths=lambda p: f"tests.{p}"):
|
||||||
|
component.registry.unregister("relative_file_pathobj_component")
|
||||||
|
|
||||||
template_str: types.django_html = """
|
template_str: types.django_html = """
|
||||||
{% load component_tags %}{% component_dependencies %}
|
{% load component_tags %}{% component_dependencies %}
|
||||||
{% component 'parent_component' %}
|
{% component 'parent_component' %}
|
||||||
|
@ -384,3 +819,43 @@ class MediaRelativePathTests(BaseTestCase):
|
||||||
template = Template(template_str)
|
template = Template(template_str)
|
||||||
rendered = template.render(Context({}))
|
rendered = template.render(Context({}))
|
||||||
self.assertIn('<input type="text" name="variable" value="hello">', rendered, rendered)
|
self.assertIn('<input type="text" name="variable" value="hello">', rendered, rendered)
|
||||||
|
|
||||||
|
# Settings required for autodiscover to work
|
||||||
|
@override_settings(
|
||||||
|
BASE_DIR=Path(__file__).resolve().parent,
|
||||||
|
STATICFILES_DIRS=[
|
||||||
|
Path(__file__).resolve().parent / "components",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_component_with_relative_media_does_not_trigger_safestring_path_at__new__(self):
|
||||||
|
"""
|
||||||
|
Test that, for the __html__ objects are not coerced into string throughout
|
||||||
|
the class creation. This is important to allow to call `collectstatic` command.
|
||||||
|
Because some users use `static` inside the `__html__` or `__str__` methods.
|
||||||
|
So if we "render" the safestring using str() during component class creation (__new__),
|
||||||
|
then we force to call `static`. And if this happens during `collectstatic` run,
|
||||||
|
then this triggers an error, because `static` is called before the static files exist.
|
||||||
|
|
||||||
|
https://github.com/EmilStenstrom/django-components/issues/522#issuecomment-2173577094
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Ensure that the module is executed again after import in autodiscovery
|
||||||
|
if "tests.components.relative_file_pathobj.relative_file_pathobj" in sys.modules:
|
||||||
|
del sys.modules["tests.components.relative_file_pathobj.relative_file_pathobj"]
|
||||||
|
|
||||||
|
# Fix the paths, since the "components" dir is nested
|
||||||
|
with autodiscover_with_cleanup(map_import_paths=lambda p: f"tests.{p}"):
|
||||||
|
# Mark the PathObj instances of 'relative_file_pathobj_component' so they won raise
|
||||||
|
# error PathObj.__str__ is triggered.
|
||||||
|
CompCls = component.registry.get("relative_file_pathobj_component")
|
||||||
|
CompCls.Media.js[0].throw_on_calling_str = False # type: ignore
|
||||||
|
CompCls.Media.css["all"][0].throw_on_calling_str = False # type: ignore
|
||||||
|
|
||||||
|
rendered = CompCls().render_dependencies()
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
rendered,
|
||||||
|
"""
|
||||||
|
<script type="module" src="relative_file_pathobj.css"></script>
|
||||||
|
<script type="module" src="relative_file_pathobj.js"></script>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue