feat: allow to set main JS and CSS from files + lazy-load component m… (#870)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Juro Oravec 2024-12-30 18:00:46 +01:00 committed by GitHub
parent 8fcb84c002
commit 715bf7d447
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1014 additions and 248 deletions

View file

@ -6,6 +6,78 @@
Django-components is a package that introduces component-based architecture to Django's server-side rendering. It aims to combine Django's templating system with the modularity seen in modern frontend frameworks.
A component in django-components can be as simple as a Django template and Python code to declare the component:
```htmldjango title="calendar.html"
<div class="calendar">
Today's date is <span>{{ date }}</span>
</div>
```
```py title="calendar.py"
from django_components import Component
class Calendar(Component):
template_name = "calendar.html"
```
Or a combination of Django template, Python, CSS, and Javascript:
```htmldjango title="calendar.html"
<div class="calendar">
Today's date is <span>{{ date }}</span>
</div>
```
```css title="calendar.css"
.calendar {
width: 200px;
background: pink;
}
```
```js title="calendar.js"
document.querySelector(".calendar").onclick = function () {
alert("Clicked calendar!");
};
```
```py title="calendar.py"
from django_components import Component
class Calendar(Component):
template_name = "calendar.html"
js_file = "calendar.js"
css_file = "calendar.css"
```
Alternatively, you can "inline" HTML, JS, and CSS right into the component class:
```py
from django_components import Component
class Calendar(Component):
template = """
<div class="calendar">
Today's date is <span>{{ date }}</span>
</div>
"""
css = """
.calendar {
width: 200px;
background: pink;
}
"""
js = """
document.querySelector(".calendar").onclick = function () {
alert("Clicked calendar!");
};
"""
```
## Features
1. 🧩 **Reusability:** Allows creation of self-contained, reusable UI elements.

View file

@ -24,16 +24,15 @@ class SimpleComponent(Component):
Variable: <strong>{{ variable }}</strong>
"""
css_file = "style.css"
js_file = "script.js"
def get_context_data(self, variable, variable2="default"):
return {
"variable": variable,
"variable2": variable2,
}
class Media:
css = {"all": ["style.css"]}
js = ["script.js"]
class BreadcrumbComponent(Component):
template: types.django_html = """
@ -53,6 +52,9 @@ class BreadcrumbComponent(Component):
</div>
"""
css_file = "test.css"
js_file = "test.js"
LINKS = [
(
"https://developer.mozilla.org/en-US/docs/Learn",
@ -79,10 +81,6 @@ class BreadcrumbComponent(Component):
items = 0
return {"links": self.LINKS[: items - 1]}
class Media:
css = {"all": ["test.css"]}
js = ["test.js"]
EXPECTED_CSS = """<link href="test.css" media="all" rel="stylesheet">"""
EXPECTED_JS = """<script src="test.js"></script>"""

View file

@ -3,7 +3,7 @@ title: Defining HTML / JS / CSS files
weight: 8
---
django_component's management of files builds on top of [Django's `Media` class](https://docs.djangoproject.com/en/5.0/topics/forms/media/).
django_component's management of files is inspired by [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:
@ -12,7 +12,7 @@ To be familiar with how Django handles static files, we recommend reading also:
## 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:
files with a component, you set them as `template_name`, `js_file` and `css_file` respectively:
```py
# In a file [project root]/components/calendar/calendar.py
@ -21,10 +21,8 @@ from django_components import Component, register
@register("calendar")
class Calendar(Component):
template_name = "template.html"
class Media:
css = "style.css"
js = "script.js"
css_file = "style.css"
js_file = "script.js"
```
In the example above, the files are defined relative to the directory where `component.py` is.
@ -40,17 +38,24 @@ from django_components import Component, register
@register("calendar")
class Calendar(Component):
template_name = "calendar/template.html"
class Media:
css = "calendar/style.css"
js = "calendar/script.js"
css_file = "calendar/style.css"
js_file = "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.
Each component can have only a single template, and single main JS and CSS. However, you can define additional JS or CSS
using the nested [`Media` class](../../../reference/api#django_components.Component.Media).
This `Media` class behaves similarly to [Django's Media class](https://docs.djangoproject.com/en/5.1/topics/forms/media/#assets-as-a-static-definition),
with a few differences:
1. Our Media class accepts various formats for the JS and CSS files: either a single file, a list, or (CSS-only) a dictonary (See below)
2. Individual JS / CSS files can be any of `str`, `bytes`, `Path`, [`SafeString`](https://dev.to/doridoro/django-safestring-afj), or a function.
3. Our Media class does NOT support [Django's `extend` keyword](https://docs.djangoproject.com/en/5.1/topics/forms/media/#extend)
```py
class MyComponent(Component):
@ -106,14 +111,14 @@ from django.utils.safestring import mark_safe
class SimpleComponent(Component):
class Media:
css = [
mark_safe('<link href="/static/calendar/style.css" rel="stylesheet" />'),
mark_safe('<link href="/static/calendar/style1.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>'),
mark_safe('<script src="/static/calendar/script1.js"></script>'),
Path("calendar/script1.js"),
"calendar/script2.js",
b"calendar/script3.js",
@ -152,7 +157,7 @@ class Calendar(Component):
}
class Media:
css = "calendar/style.css"
css = "calendar/style1.css"
js = [
# <script> tag constructed by Media class
"calendar/script1.js",
@ -191,10 +196,12 @@ class MyMedia(Media):
@register("calendar")
class Calendar(Component):
template_name = "calendar/template.html"
css_file = "calendar/style.css"
js_file = "calendar/script.js"
class Media:
css = "calendar/style.css"
js = "calendar/script.js"
css = "calendar/style1.css"
js = "calendar/script2.js"
# Override the behavior of Media class
media_class = MyMedia

View file

@ -3,7 +3,7 @@ title: Single-file components
weight: 1
---
Components can also be defined in a single file, which is useful for small components. To do this, you can use the `template`, `js`, and `css` class attributes instead of the `template_name` and `Media`. For example, here's the calendar component from above, defined in a single file:
Components can be defined in a single file, which is useful for small components. To do this, you can use the `template`, `js`, and `css` class attributes instead of the `template_name` and `Media`. For example, here's the calendar component from above, defined in a single file:
```python title="[project root]/components/calendar.py"
# In a file called [project root]/components/calendar.py
@ -35,3 +35,5 @@ class Calendar(Component):
```
This makes it easy to create small components without having to create a separate template, CSS, and JS file.
To add syntax highlighting to these snippets, head over to [Syntax highlighting](../../guides/setup/syntax_highlight.md).

View file

@ -177,18 +177,16 @@ So in your HTML, you may see something like this:
Finally, we return to our Python component in `calendar.py` to tie this together.
To link JS and CSS defined in other files, use the `Media` nested class
([Learn more about using Media](../fundamentals/defining_js_css_html_files.md)).
To link JS and CSS defined in other files, use [`js_file`](../../../reference/api#django_components.Component.js_file)
and [`css_file`](../../../reference/api#django_components.Component.css_file) attributes:
```python title="[project root]/components/calendar/calendar.py"
from django_components import Component
class Calendar(Component):
template_name = "calendar.html"
class Media: # <--- new
js = "calendar.js"
css = "calendar.css"
js_file = "calendar.js" # <--- new
css_file = "calendar.css" # <--- new
def get_context_data(self):
return {
@ -196,6 +194,86 @@ class Calendar(Component):
}
```
And that's it! If you were to embed this component in an HTML, django-components will
automatically embed the associated JS and CSS.
!!! note
Similarly to the template file, the JS and CSS file paths can be either:
1. Relative to the Python component file (as seen above),
2. Relative to any of the component directories as defined by
[`COMPONENTS.dirs`](../../reference/settings.md#django_components.app_settings.ComponentsSettings.dirs)
and/or [`COMPONENTS.app_dirs`](../../reference/settings.md#django_components.app_settings.ComponentsSettings.app_dirs)
(e.g. `[your apps]/components` dir and `[project root]/components`)
3. Relative to any of the directories defined by `STATICFILES_DIRS`.
<!-- TODO: UPDATE AFTER AT LEAST ONE IMPLEMENTED
!!! info
Special role of `css` and `js`:
The "primary" JS and CSS you that specify via `js/css` and `js_file/css_file` have special role in many of django-components'
features:
- CSS scoping [a la Vue](https://vuejs.org/api/sfc-css-features.html#scoped-css)
- CSS variables from Python are available
- JS variables from Python are available
- JS can pass a callback to special JS method `$onLoad()`, which will be called every time
a component is rendered on the page.
This is not true for JS and CSS defined in `Media.js/css`, where the linked JS / CSS are rendered as is.
-->
### 5. Link additional JS and CSS to a component
Your components may depend on third-party packages or styling, or other shared logic.
To load these additional dependencies, you can use a nested [`Media` class](../../../reference/api#django_components.Component.Media).
This `Media` class behaves similarly to [Django's Media class](https://docs.djangoproject.com/en/5.1/topics/forms/media/#assets-as-a-static-definition),
with a few differences:
1. Our Media class accepts various formats for the JS and CSS files: either a single file, a list, or (CSS-only) a dictonary (see below).
2. Individual JS / CSS files can be any of `str`, `bytes`, `Path`, [`SafeString`](https://dev.to/doridoro/django-safestring-afj), or a function.
3. Our Media class does NOT support [Django's `extend` keyword](https://docs.djangoproject.com/en/5.1/topics/forms/media/#extend).
[Learn more](../fundamentals/defining_js_css_html_files.md) about using Media.
```python title="[project root]/components/calendar/calendar.py"
from django_components import Component
class Calendar(Component):
template_name = "calendar.html"
js_file = "calendar.js"
css_file = "calendar.css"
class Media: # <--- new
js = [
"path/to/shared.js",
"https://unpkg.com/alpinejs@3.14.7/dist/cdn.min.js", # AlpineJS
]
css = [
"path/to/shared.css",
"https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css", # Tailwind
]
def get_context_data(self):
return {
"date": "1970-01-01",
}
```
!!! note
Same as with the "primary" JS and CSS, the file paths files can be either:
1. Relative to the Python component file (as seen above),
2. Relative to any of the component directories as defined by
[`COMPONENTS.dirs`](../../reference/settings.md#django_components.app_settings.ComponentsSettings.dirs)
and/or [`COMPONENTS.app_dirs`](../../reference/settings.md#django_components.app_settings.ComponentsSettings.app_dirs)
(e.g. `[your apps]/components` dir and `[project root]/components`)
!!! info
The `Media` nested class is shaped based on [Django's Media class](https://docs.djangoproject.com/en/5.1/topics/forms/media/).
@ -223,18 +301,57 @@ class Calendar(Component):
If you define a list of JS files, they will be executed one-by-one, left-to-right.
!!! note
#### Rules of execution of scripts in `Media.js`
Same as with the template file, the file paths for the JS and CSS files can be either:
The scripts defined in `Media.js` still follow the rules outlined above:
1. Relative to the Python component file (as seen above),
2. Relative to any of the component directories as defined by
[`COMPONENTS.dirs`](../../reference/settings.md#django_components.app_settings.ComponentsSettings.dirs)
and/or [`COMPONENTS.app_dirs`](../../reference/settings.md#django_components.app_settings.ComponentsSettings.app_dirs)
(e.g. `[your apps]/components` dir and `[project root]/components`)
1. JS is executed in the order in which the components are found in the HTML.
2. JS will be executed only once, even if there is multiple instances of the same component.
Additionally to `Media.js` applies that:
And that's it! If you were to embed this component in an HTML, django-components will
automatically embed the associated JS and CSS.
1. JS in `Media.js` is executed **before** the component's primary JS.
2. JS in `Media.js` is executed **in the same order** as it was defined.
3. If there is multiple components that specify the same JS path or URL in `Media.js`,
this JS will be still loaded and executed only once.
Putting all of this together, our `Calendar` component above would render HTML like so:
```html
<html>
<head>
...
<!-- CSS from Media.css -->
<link href="/static/path/to/shared.css" media="all" rel="stylesheet">
<link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" media="all" rel="stylesheet">
<!-- CSS from Component.css_file -->
<style>
.calendar {
width: 200px;
background: pink;
}
.calendar span {
font-weight: bold;
}
</style>
</head>
<body>
...
<!-- JS from Media.js -->
<script src="/static/path/to/shared.js"></script>
<script src="https://unpkg.com/alpinejs@3.14.7/dist/cdn.min.js"></script>
<!-- JS from Component.js_file -->
<script>
(function () {
document.querySelector(".calendar").onclick = () => {
alert("Clicked calendar!");
};
})();
</script>
</body>
</html>
```
---
Now that we have a fully-defined component, [next let's use it in a Django template ➡️](./components_in_templates.md).

View file

@ -32,10 +32,8 @@ from django_components import Component, register # <--- new
@register("calendar") # <--- new
class Calendar(Component):
template_name = "calendar.html"
class Media:
js = "calendar.js"
css = "calendar.css"
js_file = "calendar.js"
css_file = "calendar.css"
def get_context_data(self):
return {
@ -48,7 +46,7 @@ by calling `{% load component_tags %}` inside the template.
!!! info
Why do we have to register components?
**Why do we have to register components?**
We want to use our component as a template tag (`{% ... %}`) in Django template.
@ -170,7 +168,7 @@ and keeping your CSS and Javascript in the static directory.
!!! info
How does django-components pick up registered components?
**How does django-components pick up registered components?**
Notice that it was enough to add [`@register`](../../../reference/api#django_components.register) to the component.
We didn't need to import the component file anywhere to execute it.
@ -191,6 +189,9 @@ and keeping your CSS and Javascript in the static directory.
...
```
You can now render the components! But our component will render the same content now matter where
and how many times we use it. [Let's parametrise some of its state, so that our Calendar component
You can now render the components in templates!
---
Currently our component always renders the same content. [Let's parametrise it, so that our Calendar component
is configurable from within the template ➡️](./parametrising_components.md)

View file

@ -222,4 +222,6 @@ the parametrized version of the component:
</div>
```
---
Next, you will learn [how to use slots give your components even more flexibility ➡️](./adding_slots.md)

View file

@ -5,19 +5,51 @@ weight: 1
A component in django-components can be as simple as a Django template and Python code to declare the component:
```py
```htmldjango title="calendar.html"
<div class="calendar">
Today's date is <span>{{ date }}</span>
</div>
```
```py title="calendar.py"
from django_components import Component
class Calendar(Component):
template = """
<div class="calendar">
Today's date is <span>{{ date }}</span>
</div>
"""
template_name = "calendar.html"
```
Or a combination of Django template, Python, CSS, and Javascript:
```htmldjango title="calendar.html"
<div class="calendar">
Today's date is <span>{{ date }}</span>
</div>
```
```css title="calendar.css"
.calendar {
width: 200px;
background: pink;
}
```
```js title="calendar.js"
document.querySelector(".calendar").onclick = function () {
alert("Clicked calendar!");
};
```
```py title="calendar.py"
from django_components import Component
class Calendar(Component):
template_name = "calendar.html"
js_file = "calendar.js"
css_file = "calendar.css"
```
Alternatively, you can "inline" HTML, JS, and CSS right into the component class:
```py
from django_components import Component
@ -44,14 +76,9 @@ class Calendar(Component):
!!! note
With django-components, you can "inline" the HTML, JS and CSS code into the Python class,
as seen above.
You can set up [syntax highlighting](../../guides/setup/syntax_highlight.md),
but autocompletion / intellisense does not yet work.
So, in the example below we define the Django template in a separate file, `calendar.html`,
to allow our IDEs to interpret the file as HTML / Django file.
If you "inline" the HTML, JS and CSS code into the Python class, you can set up
[syntax highlighting](../../guides/setup/syntax_highlight.md) for better experience.
However, autocompletion / intellisense does not work with syntax highlighting.
We'll start by creating a component that defines only a Django template:
@ -154,4 +181,6 @@ It will output
And voilá!! We've created our first component.
---
Next, [let's add JS and CSS to this component ➡️](./adding_js_and_css.md).

View file

@ -4,11 +4,43 @@ title: Syntax highlighting
## VSCode
Note, in the above example, that the `t.django_html`, `t.css`, and `t.js` types are used to specify the type of the template, CSS, and JS files, respectively. This is not necessary, but if you're using VSCode with the [Python Inline Source Syntax Highlighting](https://marketplace.visualstudio.com/items?itemName=samwillis.python-inline-source) extension, it will give you syntax highlighting for the template, CSS, and JS.
1. First install [Python Inline Source Syntax Highlighting](https://marketplace.visualstudio.com/items?itemName=samwillis.python-inline-source) extension, it will give you syntax highlighting for the template, CSS, and JS.
2. Next, in your component, set typings of `Component.template/css/js` to `types.django_html`, `types.css`, and `types.js` respectively. The extension will recognize these and will activate syntax highlighting.
```python title="[project root]/components/calendar.py"
# In a file called [project root]/components/calendar.py
from django_components import Component, register, types
@register("calendar")
class Calendar(Component):
def get_context_data(self, date):
return {
"date": date,
}
template: types.django_html = """
<div class="calendar-component">Today's date is <span>{{ date }}</span></div>
"""
css: types.css = """
.calendar-component { width: 200px; background: pink; }
.calendar-component span { font-weight: bold; }
"""
js: types.js = """
(function(){
if (document.querySelector(".calendar-component")) {
document.querySelector(".calendar-component").onclick = function(){ alert("Clicked calendar!"); };
}
})()
"""
```
## Pycharm (or other Jetbrains IDEs)
If you're a Pycharm user (or any other editor from Jetbrains), you can have coding assistance as well:
With PyCharm (or any other editor from Jetbrains), you don't need to use `types.django_html`, `types.css`, `types.js` since Pycharm uses [language injections](https://www.jetbrains.com/help/pycharm/using-language-injections.html).
You only need to write the comments `# language=<lang>` above the variables.
```python
from django_components import Component, register
@ -40,6 +72,3 @@ class Calendar(Component):
})()
"""
```
You don't need to use `types.django_html`, `types.css`, `types.js` since Pycharm uses [language injections](https://www.jetbrains.com/help/pycharm/using-language-injections.html).
You only need to write the comments `# language=<lang>` above the variables.

View file

@ -10,6 +10,77 @@ weight: 1
django-components introduces component-based architecture to Django's server-side rendering.
It combines Django's templating system with the modularity seen in modern frontend frameworks like Vue or React.
A component in django-components can be as simple as a Django template and Python code to declare the component:
```htmldjango title="calendar.html"
<div class="calendar">
Today's date is <span>{{ date }}</span>
</div>
```
```py title="calendar.py"
from django_components import Component
class Calendar(Component):
template_name = "calendar.html"
```
Or a combination of Django template, Python, CSS, and Javascript:
```htmldjango title="calendar.html"
<div class="calendar">
Today's date is <span>{{ date }}</span>
</div>
```
```css title="calendar.css"
.calendar {
width: 200px;
background: pink;
}
```
```js title="calendar.js"
document.querySelector(".calendar").onclick = function () {
alert("Clicked calendar!");
};
```
```py title="calendar.py"
from django_components import Component
class Calendar(Component):
template_name = "calendar.html"
js_file = "calendar.js"
css_file = "calendar.css"
```
Alternatively, you can "inline" HTML, JS, and CSS right into the component class:
```py
from django_components import Component
class Calendar(Component):
template = """
<div class="calendar">
Today's date is <span>{{ date }}</span>
</div>
"""
css = """
.calendar {
width: 200px;
background: pink;
}
"""
js = """
document.querySelector(".calendar").onclick = function () {
alert("Clicked calendar!");
};
"""
```
## Features
1. 🧩 **Reusability:** Allows creation of self-contained, reusable UI elements.

View file

@ -8,9 +8,9 @@ class Calendar(Component):
#
# `template_name` can be relative to dir where `calendar.py` is, or relative to COMPONENTS.dirs
template_name = "calendar/calendar.html"
# Or
# def get_template_name(context):
# return f"template-{context['name']}.html"
css_file = "calendar/calendar.css"
js_file = "calendar/calendar.js"
# This component takes one parameter, a date string to show in the template
def get_context_data(self, date):
@ -25,10 +25,6 @@ class Calendar(Component):
},
)
class Media:
css = "calendar/calendar.css"
js = "calendar/calendar.js"
@register("calendar_relative")
class CalendarRelative(Component):
@ -37,9 +33,9 @@ class CalendarRelative(Component):
#
# `template_name` can be relative to dir where `calendar.py` is, or relative to COMPONENTS.dirs
template_name = "calendar.html"
# Or
# def get_template_name(context):
# return f"template-{context['name']}.html"
css_file = "calendar.css"
js_file = "calendar.js"
# This component takes one parameter, a date string to show in the template
def get_context_data(self, date):
@ -53,7 +49,3 @@ class CalendarRelative(Component):
"date": request.GET.get("date", ""),
},
)
class Media:
css = "calendar.css"
js = "calendar.js"

View file

@ -8,9 +8,9 @@ class CalendarNested(Component):
#
# `template_name` can be relative to dir where `calendar.py` is, or relative to COMPONENTS.dirs
template_name = "calendar.html"
# Or
# def get_template_name(context):
# return f"template-{context['name']}.html"
css_file = "calendar.css"
js_file = "calendar.js"
# This component takes one parameter, a date string to show in the template
def get_context_data(self, date):
@ -24,7 +24,3 @@ class CalendarNested(Component):
"date": request.GET.get("date", ""),
},
)
class Media:
css = "calendar.css"
js = "calendar.js"

View file

@ -25,7 +25,7 @@ from typing import (
)
from django.core.exceptions import ImproperlyConfigured
from django.forms.widgets import Media
from django.forms.widgets import Media as MediaCls
from django.http import HttpRequest, HttpResponse
from django.template.base import NodeList, Template, TextNode
from django.template.context import Context, RequestContext
@ -35,7 +35,7 @@ from django.utils.html import conditional_escape
from django.views import View
from django_components.app_settings import ContextBehavior
from django_components.component_media import ComponentMediaInput, MediaMeta
from django_components.component_media import ComponentMediaInput, ComponentMediaMeta
from django_components.component_registry import ComponentRegistry
from django_components.component_registry import registry as registry_
from django_components.context import (
@ -93,9 +93,6 @@ DataType = TypeVar("DataType", bound=Mapping[str, Any], covariant=True)
JsDataType = TypeVar("JsDataType", bound=Mapping[str, Any])
CssDataType = TypeVar("CssDataType", bound=Mapping[str, Any])
# Rename, so we can use `type()` inside functions with kwrags of the same name
_type = type
@dataclass(frozen=True)
class RenderInput(Generic[ArgsType, KwargsType, SlotsType]):
@ -161,14 +158,8 @@ class ComponentVars(NamedTuple):
"""
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)
return super().__new__(mcs, name, bases, attrs)
class ComponentMeta(ComponentMediaMeta):
pass
# NOTE: We use metaclass to automatically define the HTTP methods as defined
@ -254,18 +245,71 @@ class Component(
return cast(DataType, {})
js: Optional[str] = None
"""Inlined JS associated with this component."""
"""Main JS associated with this component inlined as string."""
js_file: Optional[str] = None
"""
Main JS associated with this component as file path.
When you create a Component subclass, these will happen:
1. The filepath is resolved, in case it is relative to the component Python file.
2. The file is read and its contents will be available under `MyComponent.js`.
"""
css: Optional[str] = None
"""Inlined CSS associated with this component."""
media: Media
"""Main CSS associated with this component inlined as string."""
css_file: Optional[str] = None
"""
Main CSS associated with this component as file path.
When you create a Component subclass, these will happen:
1. The filepath is resolved, in case it is relative to the component Python file.
2. The file is read and its contents will be available under `MyComponent.css`.
"""
media: Optional[MediaCls] = None
"""
Normalized definition of JS and CSS media files associated with this component.
`None` if `Media` is not defined.
NOTE: This field is generated from Component.Media class.
NOTE: This field is generated from `Component.media_class`.
"""
media_class: Type[MediaCls] = MediaCls
Media: Optional[Type[ComponentMediaInput]] = None
"""
Defines JS and CSS media files associated with this component.
This `Media` class behaves similarly to
[Django's Media class](https://docs.djangoproject.com/en/5.1/topics/forms/media/#assets-as-a-static-definition),
with a few differences:
1. Our Media class accepts various formats for the JS and CSS files: either a single file, a list,
or (CSS-only) a dictonary (See below)
2. Individual JS / CSS files can be any of `str`, `bytes`, `Path`,
[`SafeString`](https://dev.to/doridoro/django-safestring-afj), or a function.
3. Our Media class does NOT support
[Django's `extend` keyword](https://docs.djangoproject.com/en/5.1/topics/forms/media/#extend)
**Example:**
```py
class MyTable(Component):
class Media:
js = [
"path/to/script.js",
"https://unpkg.com/alpinejs@3.14.7/dist/cdn.min.js", # AlpineJS
]
css = {
"all": [
"path/to/style.css",
"https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css", # TailwindCSS
],
"print": ["path/to/style2.css"],
}
```
"""
media_class: Media = Media
Media = ComponentMediaInput
"""Defines JS and CSS media files associated with this component."""
response_class = HttpResponse
"""This allows to configure what class is used to generate response from `render_to_response`"""

View file

@ -1,36 +1,282 @@
import os
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Dict, List, MutableMapping, Optional, Tuple, Type, Union
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Protocol, Tuple, Type, Union, cast
from django.forms.widgets import Media, MediaDefiningClass
from django.contrib.staticfiles import finders
from django.core.exceptions import ImproperlyConfigured
from django.forms.widgets import Media as MediaCls
from django.utils.safestring import SafeData
from django_components.util.loader import get_component_dirs
from django_components.util.loader import get_component_dirs, resolve_file
from django_components.util.logger import logger
from django_components.util.misc import get_import_path
if TYPE_CHECKING:
from django_components.component import Component
class ComponentMediaInput:
"""Defines JS and CSS media files associated with this component."""
# These are all the attributes that are handled by ComponentMedia and lazily-resolved
COMP_MEDIA_LAZY_ATTRS = ("media", "template", "template_name", "js", "js_file", "css", "css_file")
# This is the interface of the class that user is expected to define on the component class, e.g.:
# ```py
# class MyComponent(Component):
# class Media:
# js = "path/to/script.js"
# css = "path/to/style.css"
# ```
class ComponentMediaInput(Protocol):
"""
Defines JS and CSS media files associated with this component.
```py
class MyTable(Component):
class Media:
js = [
"path/to/script.js",
"https://unpkg.com/alpinejs@3.14.7/dist/cdn.min.js", # AlpineJS
]
css = {
"all": [
"path/to/style.css",
"https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css", # TailwindCSS
],
"print": ["path/to/style2.css"],
}
```
"""
css: Optional[Union[str, List[str], Dict[str, str], Dict[str, List[str]]]] = None
js: Optional[Union[str, List[str]]] = None
class MediaMeta(MediaDefiningClass):
@dataclass
class ComponentMedia:
resolved: bool = False
Media: Optional[Type[ComponentMediaInput]] = None
media: Optional[MediaCls] = None
media_class: Type[MediaCls] = MediaCls
template: Optional[str] = None
template_name: Optional[str] = None
js: Optional[str] = None
js_file: Optional[str] = None
css: Optional[str] = None
css_file: Optional[str] = None
# This metaclass is all about one thing - lazily resolving the media files.
#
# All the CSS/JS/HTML associated with a component - e.g. the `js`, `js_file`, `template_name` or `Media` class,
# are all class attributes. And some of these attributes need to be resolved, e.g. to find the files
# that `js_file`, `css_file` and `template_name` point to.
#
# Some of the resolutions we need to do is:
# - Component's HTML/JS/CSS files can be defined as relative to the component class file. So for each file,
# we check the relative path points to an actual file, and if so, we use that path.
# - If the component defines `js_file` or `css_file`, we load the content of the file and set it to `js` or `css`.
# - Note 1: These file paths still may be relative to the component, so the paths are resolved as above,
# before we load the content.
# - Note 2: We don't support both `js` and `js_file` being set at the same time.
#
# At the same time, we need to do so lazily, otherwise we hit a problem with circular imports when trying to
# use Django settings. This is because the settings are not available at the time when the component class is defined
# (Assuming that components are defined at the top-level of modules).
#
# We achieve this by:
# 1. At class creation, we define a private `ComponentMedia` object that holds all the media-related attributes.
# 2. At the same time, we replace the actual media-related attributes (like `js`) with descriptors that intercept
# the access to them.
# 3. When the user tries to access the media-related attributes, we resolve the media files if they haven't been
# resolved yet.
# 4. Any further access to the media-related attributes will return the resolved values.
class ComponentMediaMeta(type):
def __new__(mcs, name: str, bases: Tuple[Type, ...], attrs: Dict[str, Any]) -> Type:
# Normalize the various forms of Media inputs we allow
if "Media" in attrs:
normalize_media(attrs["Media"])
cls = super().__new__(mcs, name, bases, attrs)
comp_cls = cast(Type["Component"], cls)
_setup_lazy_media_resolve(comp_cls, attrs)
return comp_cls
# `__setattr__` on metaclass allows to intercept when user tries to set an attribute on the class.
#
# NOTE: All of attributes likes `Media`, `js`, `js_file`, etc, they are all class attributes.
# If they were instance attributes, we could use `@property` decorator.
#
# Because we lazily resolve the media, there's a possibility that the user may try to set some media fields
# after the media fields were already resolved. This is currently not supported, and we do the resolution
# only once.
#
# Thus, we print a warning when user sets the media fields after they were resolved.
def __setattr__(cls, name: str, value: Any) -> None:
if name in COMP_MEDIA_LAZY_ATTRS:
comp_media: Optional[ComponentMedia] = getattr(cls, "_component_media", None)
if comp_media is not None and comp_media.resolved:
print(
f"WARNING: Setting attribute '{name}' on component '{cls.__name__}' after the media files were"
" already resolved. This may lead to unexpected behavior."
)
super().__setattr__(name, value)
# This sets up the lazy resolution of the media attributes.
def _setup_lazy_media_resolve(comp_cls: Type["Component"], attrs: Dict[str, Any]) -> None:
# Collect all the original values of the lazy attributes, so we can access them from the getter
comp_cls._component_media = ComponentMedia(
resolved=False,
# NOTE: We take the values from `attrs` so we consider only the values that were set on THIS class,
# and not the values that were inherited from the parent classes.
Media=attrs.get("Media", None),
media=attrs.get("media", None),
media_class=attrs.get("media_class", None),
template=attrs.get("template", None),
template_name=attrs.get("template_name", None),
js=attrs.get("js", None),
js_file=attrs.get("js_file", None),
css=attrs.get("css", None),
css_file=attrs.get("css_file", None),
)
# Because the media values are not defined directly on the instance, but held in `_component_media`,
# then simply accessing `_component_media.js` will NOT get the values from parent classes.
#
# So this function is like `getattr`, but for searching for values inside `_component_media`.
def get_comp_media_attr(attr: str) -> Any:
for base in comp_cls.mro():
comp_media: Optional[ComponentMedia] = getattr(base, "_component_media", None)
if comp_media is None:
continue
if not comp_media.resolved:
resolve_media(base, comp_media)
value = getattr(comp_media, attr, None)
# For each of the pairs of inlined_content + file (e.g. `js` + `js_file`), if at least one of the two
# is defined, we interpret it such that this (sub)class has overriden what was set by the parent class(es),
# and we won't search further up the MRO.
def check_pair_empty(inline_attr: str, file_attr: str) -> bool:
inline_attr_empty = getattr(comp_media, inline_attr, None) is None
file_attr_empty = getattr(comp_media, file_attr, None) is None
return inline_attr_empty and file_attr_empty
if attr in ("js", "js_file"):
if check_pair_empty("js", "js_file"):
continue
else:
return value
if attr in ("css", "css_file"):
if check_pair_empty("css", "css_file"):
continue
else:
return value
if attr in ("template", "template_name"):
if check_pair_empty("template", "template_name"):
continue
else:
return value
# For the other attributes, simply search for the closest non-null
if value is not None:
return value
return None
# Because of the lazy resolution, we want to know when the user tries to access the media attributes.
# And because these fields are class attributes, we can't use `@property` decorator.
#
# Instead, we define a descriptor for each of the media attributes, and set it on the class.
# Read more on descriptors https://docs.python.org/3/howto/descriptor.html
class InterceptDescriptor:
def __init__(self, name: str) -> None:
self._attr_name = name
# `__get__` runs when a class/instance attribute is being accessed
def __get__(self, instance: Optional["Component"], cls: Type["Component"]) -> Any:
return get_comp_media_attr(self._attr_name)
for attr in COMP_MEDIA_LAZY_ATTRS:
setattr(comp_cls, attr, InterceptDescriptor(attr))
def resolve_media(comp_cls: Type["Component"], comp_media: ComponentMedia) -> None:
"""
Metaclass for handling media files for components.
Resolve the media files associated with the component.
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.
### 1. Media are resolved relative to class definition file
This subclass has following changes:
E.g. if in a directory `my_comp` you have `script.js` and `my_comp.py`,
and `my_comp.py` looks like this:
### 1. Support for multiple interfaces of JS/CSS
```py
class MyComponent(Component):
class Media:
js = "script.js"
```
Then `script.js` will be understood as relative to the component file.
To obtain the final path, we make it relative to a component directory (as set in `COMPONENTS.dirs`
and `COMPONENTS.app_dirs`; and `STATICFILES_DIRS` for JS and CSS). So if the parent directory is `components/`,
and the component file is inside `components/my_comp/my_comp.py`, then the final path will be relative
to `components/`, thus `./my_comp/script.js`.
If the relative path does not point to an actual file, the path is kept as is.
### 2. Subclass `Media` class with `media_class`
Django's `MediaDefiningClass` creates an instance of `Media` class under the `media` attribute.
We do the same, but we allow to override the class that will be instantiated with `media_class` attribute:
```py
class MyMedia(Media):
def render_js(self):
...
class MyComponent(Component):
media_class = MyMedia
def get_context_data(self):
assert isinstance(self.media, MyMedia)
```
"""
# Do not resolve if this is a base class
if get_import_path(comp_cls) == "django_components.component.Component" or comp_media.resolved:
comp_media.resolved = True
return
comp_dirs = get_component_dirs()
# Once the inputs are normalized, attempt to resolve the HTML/JS/CSS filepaths
# as relative to the directory where the component class is defined.
_resolve_component_relative_files(comp_cls, comp_media, comp_dirs=comp_dirs)
# If the component defined `js_file` or `css_file`, instead of `js`/`css` resolve them now.
# Effectively, even if the Component class defined `js_file`, at "runtime" the `js` attribute
# will be set to the content of the file.
comp_media.js = _get_static_asset(
comp_cls, comp_media, inlined_attr="js", file_attr="js_file", comp_dirs=comp_dirs
)
comp_media.css = _get_static_asset(
comp_cls, comp_media, inlined_attr="css", file_attr="css_file", comp_dirs=comp_dirs
)
media_cls = comp_media.media_class or MediaCls
media_js = getattr(comp_media.Media, "js", [])
media_css = getattr(comp_media.Media, "css", {})
comp_media.media = media_cls(js=media_js, css=media_css) if comp_media.Media is not None else None
comp_media.resolved = True
def normalize_media(media: Type[ComponentMediaInput]) -> None:
"""
Resolve the `Media` class associated with the component.
We support following cases:
1. As plain strings
```py
@ -58,32 +304,8 @@ class MediaMeta(MediaDefiningClass):
}
```
4. [CSS ONLY] Dicts of lists
```py
class MyComponent(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):
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.:
Moreover, unlike Django's Media class, here, the JS/CSS files can be defined as str, bytes, PathLike, SafeString,
or function of thereof. E.g.:
```py
def lazy_eval_css():
@ -95,96 +317,50 @@ class MediaMeta(MediaDefiningClass):
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):
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]
media.css = {"all": [media.css]} # type: ignore[assignment]
# Allow: class Media: css = ["style.css"]
if isinstance(media.css, (list, tuple)):
elif 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
# class Media: css = {"all": ["style.css"]}
elif isinstance(media.css, dict):
for media_type, path_or_list in media.css.items():
# {"all": "style.css"}
if _is_media_filepath(path_or_list):
media.css[media_type] = [path_or_list] # type: ignore
# {"all": ["style.css"]}
else:
media.css[media_type] = path_or_list # type: ignore
else:
raise ValueError(f"Media.css must be str, list, or dict, got {type(media.css)}")
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]
media.js = [media.js] # type: ignore
# Allow: class Media: js = ["script.js"]
else:
# JS is already a list, no action needed
pass
# Now that the Media class has a predicatable shape, 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, _normalize_media_filepath)
def _map_media_filepaths(media: ComponentMediaInput, map_fn: Callable[[Any], Any]) -> None:
def _map_media_filepaths(media: Type[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)}")
@ -240,28 +416,33 @@ def _normalize_media_filepath(filepath: Any) -> Union[str, SafeData]:
)
def _resolve_component_relative_files(attrs: MutableMapping) -> None:
def _resolve_component_relative_files(
comp_cls: Type["Component"], comp_media: ComponentMedia, comp_dirs: List[Path]
) -> 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.
# HTML/JS/CSS files, just skip.
will_resolve_files = False
if attrs.get("template_name", None):
if (
getattr(comp_media, "template_name", None)
or getattr(comp_media, "js_file", None)
or getattr(comp_media, "css_file", 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):
elif not will_resolve_files and getattr(comp_media, "Media", None):
if getattr(comp_media.Media, "css", None) or getattr(comp_media.Media, "js", None):
will_resolve_files = True
if not will_resolve_files:
return
component_name = attrs["__qualname__"]
component_name = comp_cls.__qualname__
# Derive the full path of the file where the component was defined
module_name = attrs["__module__"]
module_name = comp_cls.__module__
module_obj = sys.modules[module_name]
file_path = module_obj.__file__
@ -272,13 +453,9 @@ def _resolve_component_relative_files(attrs: MutableMapping) -> None:
)
return
# Prepare all possible directories we need to check when searching for
# component's template and media files
components_dirs = get_component_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)
comp_dir_abs, comp_dir_rel = _get_dir_path_from_component_path(file_path, comp_dirs)
except RuntimeError:
# If no dir was found, we assume that the path is NOT relative to the component dir
logger.debug(
@ -292,7 +469,7 @@ def _resolve_component_relative_files(attrs: MutableMapping) -> None:
# 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]:
def resolve_media_file(filepath: Union[str, SafeData]) -> Union[str, SafeData]:
if isinstance(filepath, str):
filepath_abs = os.path.join(comp_dir_abs, filepath)
# NOTE: The paths to resources need to use POSIX (forward slashes) for Django to wor
@ -316,12 +493,15 @@ def _resolve_component_relative_files(attrs: MutableMapping) -> None:
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 getattr(comp_media, "template_name", None):
comp_media.template_name = resolve_media_file(comp_media.template_name)
if getattr(comp_media, "js_file", None):
comp_media.js_file = resolve_media_file(comp_media.js_file)
if getattr(comp_media, "css_file", None):
comp_media.css_file = resolve_media_file(comp_media.css_file)
if "Media" in attrs:
media = attrs["Media"]
_map_media_filepaths(media, resolve_file)
if hasattr(comp_media, "Media") and comp_media.Media:
_map_media_filepaths(comp_media.Media, resolve_media_file)
def _get_dir_path_from_component_path(
@ -351,3 +531,57 @@ def _get_dir_path_from_component_path(
# - 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
def _get_static_asset(
comp_cls: Type["Component"],
comp_media: ComponentMedia,
inlined_attr: str,
file_attr: str,
comp_dirs: List[Path],
) -> Optional[str]:
"""
In case of Component's JS or CSS, one can either define that as "inlined" or as a file.
E.g.
```python
class MyComp(Component):
js = '''
console.log('Hello, world!');
'''
```
or
```python
class MyComp(Component):
js_file = "my_comp.js"
```
This method resolves the content like above.
- `inlined_attr` - The attribute name for the inlined content.
- `file_attr` - The attribute name for the file name.
These are mutually exclusive, so only one of the two can be set at class creation.
"""
asset_content = getattr(comp_media, inlined_attr, None)
asset_file = getattr(comp_media, file_attr, None)
if asset_file is not None and asset_content is not None:
raise ImproperlyConfigured(
f"Received non-null value from both '{inlined_attr}' and '{file_attr}' in"
f" Component {comp_cls.__name__}. Only one of the two must be set."
)
if asset_file is not None:
# Check if the file is in one of the components' directories
full_path = resolve_file(asset_file, comp_dirs)
# If not, check if it's in the static files
if full_path is None:
full_path = finders.find(asset_file)
if full_path is None:
# NOTE: The short name, e.g. `js` or `css` is used in the error message for convenience
raise ValueError(f"Could not find {inlined_attr} file {asset_file}")
asset_content = Path(full_path).read_text()
return asset_content

View file

@ -642,8 +642,18 @@ def _process_dep_declarations(content: bytes, type: RenderType) -> Tuple[bytes,
# Once we have ALL JS and CSS URLs that we want to fetch, we can convert them to
# <script> and <link> tags. Note that this is done by the user-provided Media classes.
to_load_css_tags = [tag for media in all_medias for tag in media.render_css()]
to_load_js_tags = [tag for media in all_medias for tag in media.render_js()]
# fmt: off
to_load_css_tags = [
tag
for media in all_medias if media is not None
for tag in media.render_css()
]
to_load_js_tags = [
tag
for media in all_medias if media is not None
for tag in media.render_js()
]
# fmt: on
# Postprocess all <script> and <link> tags to 1) dedupe, and 2) extract URLs.
# For the deduplication, if multiple components link to the same JS/CSS, but they

View file

@ -220,7 +220,7 @@ def _filepath_to_python_module(
# Combine with the base module path
full_module_name = f"{root_module_path}.{module_name}" if root_module_path else module_name
if full_module_name.endswith(".__init__"):
full_module_name = full_module_name[:-9] # Remove the trailing `.__init__
full_module_name = full_module_name[:-9] # Remove the trailing `.__init__`
return full_module_name
@ -236,3 +236,12 @@ def _search_dirs(dirs: List[Path], search_glob: str) -> List[Path]:
matched_files.append(Path(path))
return matched_files
def resolve_file(filepath: str, dirs: Optional[List[Path]] = None) -> Optional[Path]:
dirs = dirs if dirs is not None else get_component_dirs()
for directory in dirs:
full_path = Path(directory) / filepath
if full_path.exists():
return full_path
return None

View file

@ -0,0 +1,2 @@
/* Used in `MainMediaTest` tests in `test_component_media.py` */
console.log("HTML and JS only");

View file

@ -0,0 +1,4 @@
/* Used in `MainMediaTest` tests in `test_component_media.py` */
.html-css-only {
color: blue;
}

View file

@ -1,4 +1,4 @@
from typing import Any, Dict
from typing import Any, Dict, Optional
from django_components import Component, register
@ -6,7 +6,9 @@ from django_components import Component, register
# Used for testing the template_loader
@register("app_lvl_comp")
class AppLvlCompComponent(Component):
template_name = "app_lvl_comp.html"
template_name: Optional[str] = "app_lvl_comp.html"
js_file = "app_lvl_comp.js"
css_file = "app_lvl_comp.css"
class Media:
js = "app_lvl_comp.js"

View file

@ -17,8 +17,10 @@ from .testutils import BaseTestCase, autodiscover_with_cleanup
setup_test_config({"autodiscover": False})
class InlineComponentTest(BaseTestCase):
def test_html(self):
# "Main media" refer to the HTML, JS, and CSS set on the Component class itself
# (as opposed via the `Media` class). These have special handling in the Component.
class MainMediaTest(BaseTestCase):
def test_html_inlined(self):
class InlineHTMLComponent(Component):
template = "<div class='inline'>Hello Inline</div>"
@ -27,7 +29,20 @@ class InlineComponentTest(BaseTestCase):
'<div class="inline" data-djc-id-a1bc3e>Hello Inline</div>',
)
def test_inlined_js_and_css(self):
def test_html_filepath(self):
class Test(Component):
template_name = "simple_template.html"
rendered = Test.render(context={"variable": "test"})
self.assertHTMLEqual(
rendered,
"""
Variable: <strong data-djc-id-a1bc3e>test</strong>
""",
)
def test_js_css_inlined(self):
class TestComponent(Component):
template = """
{% load component_tags %}
@ -38,6 +53,15 @@ class InlineComponentTest(BaseTestCase):
css = ".html-css-only { color: blue; }"
js = "console.log('HTML and JS only');"
self.assertEqual(
TestComponent.css,
".html-css-only { color: blue; }",
)
self.assertEqual(
TestComponent.js,
"console.log('HTML and JS only');",
)
rendered = TestComponent.render()
self.assertInHTML(
@ -53,6 +77,127 @@ class InlineComponentTest(BaseTestCase):
rendered,
)
@override_settings(
STATICFILES_DIRS=[
os.path.join(Path(__file__).resolve().parent, "static_root"),
],
)
def test_js_css_filepath_rel_to_component(self):
from tests.test_app.components.app_lvl_comp.app_lvl_comp import AppLvlCompComponent
class TestComponent(AppLvlCompComponent):
template_name = None
template = """
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
<div class='html-css-only'>Content</div>
"""
self.assertIn(
".html-css-only {\n color: blue;\n}",
TestComponent.css,
)
self.assertIn(
'console.log("JS file");',
TestComponent.js,
)
rendered = TestComponent.render(kwargs={"variable": "test"})
self.assertInHTML(
'<div class="html-css-only" data-djc-id-a1bc3e>Content</div>',
rendered,
)
self.assertInHTML(
"<style>.html-css-only { color: blue; }</style>",
rendered,
)
self.assertInHTML(
'<script>console.log("JS file");</script>',
rendered,
)
@override_settings(
STATICFILES_DIRS=[
os.path.join(Path(__file__).resolve().parent, "static_root"),
],
)
def test_js_css_filepath_from_static(self):
class TestComponent(Component):
template = """
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
<div class='html-css-only'>Content</div>
"""
css_file = "style.css"
js_file = "script.js"
self.assertIn(
".html-css-only {\n color: blue;\n}",
TestComponent.css,
)
self.assertIn(
'console.log("HTML and JS only");',
TestComponent.js,
)
rendered = TestComponent.render()
self.assertInHTML(
'<div class="html-css-only" data-djc-id-a1bc3e>Content</div>',
rendered,
)
self.assertInHTML(
"<style>/* Used in `MainMediaTest` tests in `test_component_media.py` */\n.html-css-only {\n color: blue;\n}</style>",
rendered,
)
self.assertInHTML(
'<script>/* Used in `MainMediaTest` tests in `test_component_media.py` */\nconsole.log("HTML and JS only");</script>',
rendered,
)
@override_settings(
STATICFILES_DIRS=[
os.path.join(Path(__file__).resolve().parent, "static_root"),
],
)
def test_js_css_filepath_lazy_loaded(self):
from tests.test_app.components.app_lvl_comp.app_lvl_comp import AppLvlCompComponent
class TestComponent(AppLvlCompComponent):
template_name = None
template = """
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
<div class='html-css-only'>Content</div>
"""
# NOTE: Since this is a subclass, actual CSS is defined on the parent class, and thus
# the corresponding ComponentMedia instance is also on the parent class.
self.assertEqual(
AppLvlCompComponent._component_media.css, # type: ignore[attr-defined]
None,
)
self.assertEqual(
AppLvlCompComponent._component_media.css_file, # type: ignore[attr-defined]
"app_lvl_comp.css",
)
# Access the property to load the CSS
_ = TestComponent.css
self.assertHTMLEqual(
AppLvlCompComponent._component_media.css, # type: ignore[attr-defined]
".html-css-only { color: blue; }",
)
self.assertEqual(
AppLvlCompComponent._component_media.css_file, # type: ignore[attr-defined]
"app_lvl_comp/app_lvl_comp.css",
)
def test_html_variable(self):
class VariableHTMLComponent(Component):
def get_template(self, context):