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)
|
||||
- [Prop drilling and dependency injection (provide / inject)](#prop-drilling-and-dependency-injection-provide--inject)
|
||||
- [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)
|
||||
- [Logging and debugging](#logging-and-debugging)
|
||||
- [Management Command](#management-command)
|
||||
|
@ -262,9 +263,12 @@ from django_components import component
|
|||
|
||||
@component.register("calendar")
|
||||
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
|
||||
# you can override def get_template_name() instead of specifying the below variable.
|
||||
template_name = "calendar/template.html"
|
||||
# Templates inside `[your apps]/components` dir and `[project root]/components` dir
|
||||
# will be automatically found. To customize which template to use based on context
|
||||
# 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
|
||||
def get_context_data(self, date):
|
||||
|
@ -272,9 +276,10 @@ class Calendar(component.Component):
|
|||
"date": date,
|
||||
}
|
||||
|
||||
# Both `css` and `js` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS
|
||||
class Media:
|
||||
css = "calendar/style.css"
|
||||
js = "calendar/script.js"
|
||||
css = "style.css"
|
||||
js = "script.js"
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
## 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.
|
||||
Instead, use the following tags to specify where to render the dependencies:
|
||||
|
|
|
@ -1,12 +1,9 @@
|
|||
import inspect
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar, Dict, List, Mapping, MutableMapping, Optional, Tuple, Type, Union
|
||||
from typing import Any, ClassVar, Dict, List, Mapping, Optional, Tuple, Type, Union
|
||||
|
||||
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.template.base import FilterExpression, Node, NodeList, Template, TextNode
|
||||
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.views import View
|
||||
|
||||
from django_components.component_media import ComponentMediaInput, MediaMeta
|
||||
|
||||
# Global registry var and register() function moved to separate module.
|
||||
# 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
|
||||
|
@ -35,7 +34,7 @@ from django_components.context import (
|
|||
prepare_context,
|
||||
)
|
||||
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.slots import (
|
||||
DEFAULT_SLOT_KEY,
|
||||
|
@ -49,145 +48,22 @@ from django_components.slots import (
|
|||
resolve_slots,
|
||||
)
|
||||
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} -->"
|
||||
|
||||
|
||||
class SimplifiedInterfaceMediaDefiningClass(MediaDefiningClass):
|
||||
class ComponentMeta(MediaMeta):
|
||||
def __new__(mcs, name: str, bases: Tuple[Type, ...], attrs: Dict[str, Any]) -> Type:
|
||||
# NOTE: Skip template/media file resolution when then Component class ITSELF
|
||||
# is being created.
|
||||
if "__module__" in attrs and attrs["__module__"] == "django_components.component":
|
||||
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)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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):
|
||||
class Component(View, metaclass=ComponentMeta):
|
||||
# Either template_name or template must be set on subclass OR subclass must implement get_template() with
|
||||
# non-null return.
|
||||
_class_hash: ClassVar[int]
|
||||
|
@ -206,14 +82,12 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
|
|||
|
||||
NOTE: This field is generated from Component.Media class.
|
||||
"""
|
||||
media_class: Media = Media
|
||||
response_class = HttpResponse
|
||||
"""This allows to configure what class is used to generate response from `render_to_response`"""
|
||||
|
||||
class Media:
|
||||
"""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
|
||||
Media = ComponentMediaInput
|
||||
"""Defines JS and CSS media files associated with this component."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
|
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:
|
||||
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):
|
||||
all_components_before = component_registry.registry.all().copy()
|
||||
|
||||
|
@ -36,7 +38,7 @@ class TestAutodiscover(BaseTestCase):
|
|||
|
||||
all_components_after = component_registry.registry.all().copy()
|
||||
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):
|
||||
|
|
|
@ -1,8 +1,13 @@
|
|||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from django.forms.widgets import Media
|
||||
from django.template import Context, Template
|
||||
from django.templatetags.static import static
|
||||
from django.test import override_settings
|
||||
from django.utils.html import format_html, html_safe
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
# isort: off
|
||||
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 Media:
|
||||
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 ParentComponent(component.Component):
|
||||
|
@ -339,6 +770,8 @@ class MediaRelativePathTests(BaseTestCase):
|
|||
|
||||
# Fix the paths, since the "components" dir is nested
|
||||
with autodiscover_with_cleanup(map_import_paths=lambda p: f"tests.{p}"):
|
||||
component.registry.unregister("relative_file_pathobj_component")
|
||||
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}{% component_dependencies %}
|
||||
{% component name='relative_file_component' variable=variable %}
|
||||
|
@ -372,6 +805,8 @@ class MediaRelativePathTests(BaseTestCase):
|
|||
|
||||
# Fix the paths, since the "components" dir is nested
|
||||
with autodiscover_with_cleanup(map_import_paths=lambda p: f"tests.{p}"):
|
||||
component.registry.unregister("relative_file_pathobj_component")
|
||||
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}{% component_dependencies %}
|
||||
{% component 'parent_component' %}
|
||||
|
@ -384,3 +819,43 @@ class MediaRelativePathTests(BaseTestCase):
|
|||
template = Template(template_str)
|
||||
rendered = template.render(Context({}))
|
||||
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