mirror of
https://github.com/django-components/django-components.git
synced 2025-08-18 13:10:13 +00:00
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:
parent
8fcb84c002
commit
715bf7d447
20 changed files with 1014 additions and 248 deletions
72
README.md
72
README.md
|
@ -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.
|
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
|
## Features
|
||||||
|
|
||||||
1. 🧩 **Reusability:** Allows creation of self-contained, reusable UI elements.
|
1. 🧩 **Reusability:** Allows creation of self-contained, reusable UI elements.
|
||||||
|
|
|
@ -24,16 +24,15 @@ class SimpleComponent(Component):
|
||||||
Variable: <strong>{{ variable }}</strong>
|
Variable: <strong>{{ variable }}</strong>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
css_file = "style.css"
|
||||||
|
js_file = "script.js"
|
||||||
|
|
||||||
def get_context_data(self, variable, variable2="default"):
|
def get_context_data(self, variable, variable2="default"):
|
||||||
return {
|
return {
|
||||||
"variable": variable,
|
"variable": variable,
|
||||||
"variable2": variable2,
|
"variable2": variable2,
|
||||||
}
|
}
|
||||||
|
|
||||||
class Media:
|
|
||||||
css = {"all": ["style.css"]}
|
|
||||||
js = ["script.js"]
|
|
||||||
|
|
||||||
|
|
||||||
class BreadcrumbComponent(Component):
|
class BreadcrumbComponent(Component):
|
||||||
template: types.django_html = """
|
template: types.django_html = """
|
||||||
|
@ -53,6 +52,9 @@ class BreadcrumbComponent(Component):
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
css_file = "test.css"
|
||||||
|
js_file = "test.js"
|
||||||
|
|
||||||
LINKS = [
|
LINKS = [
|
||||||
(
|
(
|
||||||
"https://developer.mozilla.org/en-US/docs/Learn",
|
"https://developer.mozilla.org/en-US/docs/Learn",
|
||||||
|
@ -79,10 +81,6 @@ class BreadcrumbComponent(Component):
|
||||||
items = 0
|
items = 0
|
||||||
return {"links": self.LINKS[: items - 1]}
|
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_CSS = """<link href="test.css" media="all" rel="stylesheet">"""
|
||||||
EXPECTED_JS = """<script src="test.js"></script>"""
|
EXPECTED_JS = """<script src="test.js"></script>"""
|
||||||
|
|
|
@ -3,7 +3,7 @@ title: Defining HTML / JS / CSS files
|
||||||
weight: 8
|
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:
|
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
|
## 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
|
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
|
```py
|
||||||
# In a file [project root]/components/calendar/calendar.py
|
# In a file [project root]/components/calendar/calendar.py
|
||||||
|
@ -21,10 +21,8 @@ from django_components import Component, register
|
||||||
@register("calendar")
|
@register("calendar")
|
||||||
class Calendar(Component):
|
class Calendar(Component):
|
||||||
template_name = "template.html"
|
template_name = "template.html"
|
||||||
|
css_file = "style.css"
|
||||||
class Media:
|
js_file = "script.js"
|
||||||
css = "style.css"
|
|
||||||
js = "script.js"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
In the example above, the files are defined relative to the directory where `component.py` is.
|
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")
|
@register("calendar")
|
||||||
class Calendar(Component):
|
class Calendar(Component):
|
||||||
template_name = "calendar/template.html"
|
template_name = "calendar/template.html"
|
||||||
|
css_file = "calendar/style.css"
|
||||||
class Media:
|
js_file = "calendar/script.js"
|
||||||
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.
|
NOTE: In case of conflict, the preference goes to resolving the files relative to the component's directory.
|
||||||
|
|
||||||
## Defining multiple paths
|
## 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
|
```py
|
||||||
class MyComponent(Component):
|
class MyComponent(Component):
|
||||||
|
@ -106,14 +111,14 @@ from django.utils.safestring import mark_safe
|
||||||
class SimpleComponent(Component):
|
class SimpleComponent(Component):
|
||||||
class Media:
|
class Media:
|
||||||
css = [
|
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"),
|
Path("calendar/style1.css"),
|
||||||
"calendar/style2.css",
|
"calendar/style2.css",
|
||||||
b"calendar/style3.css",
|
b"calendar/style3.css",
|
||||||
lambda: "calendar/style4.css",
|
lambda: "calendar/style4.css",
|
||||||
]
|
]
|
||||||
js = [
|
js = [
|
||||||
mark_safe('<script src="/static/calendar/script.js"></script>'),
|
mark_safe('<script src="/static/calendar/script1.js"></script>'),
|
||||||
Path("calendar/script1.js"),
|
Path("calendar/script1.js"),
|
||||||
"calendar/script2.js",
|
"calendar/script2.js",
|
||||||
b"calendar/script3.js",
|
b"calendar/script3.js",
|
||||||
|
@ -152,7 +157,7 @@ class Calendar(Component):
|
||||||
}
|
}
|
||||||
|
|
||||||
class Media:
|
class Media:
|
||||||
css = "calendar/style.css"
|
css = "calendar/style1.css"
|
||||||
js = [
|
js = [
|
||||||
# <script> tag constructed by Media class
|
# <script> tag constructed by Media class
|
||||||
"calendar/script1.js",
|
"calendar/script1.js",
|
||||||
|
@ -191,10 +196,12 @@ class MyMedia(Media):
|
||||||
@register("calendar")
|
@register("calendar")
|
||||||
class Calendar(Component):
|
class Calendar(Component):
|
||||||
template_name = "calendar/template.html"
|
template_name = "calendar/template.html"
|
||||||
|
css_file = "calendar/style.css"
|
||||||
|
js_file = "calendar/script.js"
|
||||||
|
|
||||||
class Media:
|
class Media:
|
||||||
css = "calendar/style.css"
|
css = "calendar/style1.css"
|
||||||
js = "calendar/script.js"
|
js = "calendar/script2.js"
|
||||||
|
|
||||||
# Override the behavior of Media class
|
# Override the behavior of Media class
|
||||||
media_class = MyMedia
|
media_class = MyMedia
|
||||||
|
|
|
@ -3,7 +3,7 @@ title: Single-file components
|
||||||
weight: 1
|
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"
|
```python title="[project root]/components/calendar.py"
|
||||||
# In a file called [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.
|
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).
|
||||||
|
|
|
@ -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.
|
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
|
To link JS and CSS defined in other files, use [`js_file`](../../../reference/api#django_components.Component.js_file)
|
||||||
([Learn more about using Media](../fundamentals/defining_js_css_html_files.md)).
|
and [`css_file`](../../../reference/api#django_components.Component.css_file) attributes:
|
||||||
|
|
||||||
```python title="[project root]/components/calendar/calendar.py"
|
```python title="[project root]/components/calendar/calendar.py"
|
||||||
from django_components import Component
|
from django_components import Component
|
||||||
|
|
||||||
class Calendar(Component):
|
class Calendar(Component):
|
||||||
template_name = "calendar.html"
|
template_name = "calendar.html"
|
||||||
|
js_file = "calendar.js" # <--- new
|
||||||
class Media: # <--- new
|
css_file = "calendar.css" # <--- new
|
||||||
js = "calendar.js"
|
|
||||||
css = "calendar.css"
|
|
||||||
|
|
||||||
def get_context_data(self):
|
def get_context_data(self):
|
||||||
return {
|
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
|
!!! info
|
||||||
|
|
||||||
The `Media` nested class is shaped based on [Django's Media class](https://docs.djangoproject.com/en/5.1/topics/forms/media/).
|
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.
|
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),
|
1. JS is executed in the order in which the components are found in the HTML.
|
||||||
2. Relative to any of the component directories as defined by
|
2. JS will be executed only once, even if there is multiple instances of the same component.
|
||||||
[`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`)
|
|
||||||
|
|
||||||
|
Additionally to `Media.js` applies that:
|
||||||
|
|
||||||
And that's it! If you were to embed this component in an HTML, django-components will
|
1. JS in `Media.js` is executed **before** the component's primary JS.
|
||||||
automatically embed the associated JS and CSS.
|
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).
|
Now that we have a fully-defined component, [next let's use it in a Django template ➡️](./components_in_templates.md).
|
||||||
|
|
|
@ -32,10 +32,8 @@ from django_components import Component, register # <--- new
|
||||||
@register("calendar") # <--- new
|
@register("calendar") # <--- new
|
||||||
class Calendar(Component):
|
class Calendar(Component):
|
||||||
template_name = "calendar.html"
|
template_name = "calendar.html"
|
||||||
|
js_file = "calendar.js"
|
||||||
class Media:
|
css_file = "calendar.css"
|
||||||
js = "calendar.js"
|
|
||||||
css = "calendar.css"
|
|
||||||
|
|
||||||
def get_context_data(self):
|
def get_context_data(self):
|
||||||
return {
|
return {
|
||||||
|
@ -48,7 +46,7 @@ by calling `{% load component_tags %}` inside the template.
|
||||||
|
|
||||||
!!! info
|
!!! 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.
|
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
|
!!! 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.
|
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.
|
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
|
You can now render the components in templates!
|
||||||
and how many times we use it. [Let's parametrise some of its state, so that our Calendar component
|
|
||||||
|
---
|
||||||
|
|
||||||
|
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)
|
is configurable from within the template ➡️](./parametrising_components.md)
|
||||||
|
|
|
@ -222,4 +222,6 @@ the parametrized version of the component:
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
Next, you will learn [how to use slots give your components even more flexibility ➡️](./adding_slots.md)
|
Next, you will learn [how to use slots give your components even more flexibility ➡️](./adding_slots.md)
|
||||||
|
|
|
@ -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:
|
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
|
from django_components import Component
|
||||||
|
|
||||||
class Calendar(Component):
|
class Calendar(Component):
|
||||||
template = """
|
template_name = "calendar.html"
|
||||||
<div class="calendar">
|
|
||||||
Today's date is <span>{{ date }}</span>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Or a combination of Django template, Python, CSS, and Javascript:
|
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
|
```py
|
||||||
from django_components import Component
|
from django_components import Component
|
||||||
|
|
||||||
|
@ -44,14 +76,9 @@ class Calendar(Component):
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
With django-components, you can "inline" the HTML, JS and CSS code into the Python class,
|
If you "inline" the HTML, JS and CSS code into the Python class, you can set up
|
||||||
as seen above.
|
[syntax highlighting](../../guides/setup/syntax_highlight.md) for better experience.
|
||||||
|
However, autocompletion / intellisense does not work with syntax highlighting.
|
||||||
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.
|
|
||||||
|
|
||||||
|
|
||||||
We'll start by creating a component that defines only a Django template:
|
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.
|
And voilá!! We've created our first component.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
Next, [let's add JS and CSS to this component ➡️](./adding_js_and_css.md).
|
Next, [let's add JS and CSS to this component ➡️](./adding_js_and_css.md).
|
||||||
|
|
|
@ -4,11 +4,43 @@ title: Syntax highlighting
|
||||||
|
|
||||||
## VSCode
|
## 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)
|
## 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
|
```python
|
||||||
from django_components import Component, register
|
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.
|
|
||||||
|
|
|
@ -10,6 +10,77 @@ weight: 1
|
||||||
django-components introduces component-based architecture to Django's server-side rendering.
|
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.
|
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
|
## Features
|
||||||
|
|
||||||
1. 🧩 **Reusability:** Allows creation of self-contained, reusable UI elements.
|
1. 🧩 **Reusability:** Allows creation of self-contained, reusable UI elements.
|
||||||
|
|
|
@ -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` can be relative to dir where `calendar.py` is, or relative to COMPONENTS.dirs
|
||||||
template_name = "calendar/calendar.html"
|
template_name = "calendar/calendar.html"
|
||||||
# Or
|
|
||||||
# def get_template_name(context):
|
css_file = "calendar/calendar.css"
|
||||||
# return f"template-{context['name']}.html"
|
js_file = "calendar/calendar.js"
|
||||||
|
|
||||||
# This component takes one parameter, a date string to show in the template
|
# This component takes one parameter, a date string to show in the template
|
||||||
def get_context_data(self, date):
|
def get_context_data(self, date):
|
||||||
|
@ -25,10 +25,6 @@ class Calendar(Component):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
class Media:
|
|
||||||
css = "calendar/calendar.css"
|
|
||||||
js = "calendar/calendar.js"
|
|
||||||
|
|
||||||
|
|
||||||
@register("calendar_relative")
|
@register("calendar_relative")
|
||||||
class CalendarRelative(Component):
|
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` can be relative to dir where `calendar.py` is, or relative to COMPONENTS.dirs
|
||||||
template_name = "calendar.html"
|
template_name = "calendar.html"
|
||||||
# Or
|
|
||||||
# def get_template_name(context):
|
css_file = "calendar.css"
|
||||||
# return f"template-{context['name']}.html"
|
js_file = "calendar.js"
|
||||||
|
|
||||||
# This component takes one parameter, a date string to show in the template
|
# This component takes one parameter, a date string to show in the template
|
||||||
def get_context_data(self, date):
|
def get_context_data(self, date):
|
||||||
|
@ -53,7 +49,3 @@ class CalendarRelative(Component):
|
||||||
"date": request.GET.get("date", ""),
|
"date": request.GET.get("date", ""),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
class Media:
|
|
||||||
css = "calendar.css"
|
|
||||||
js = "calendar.js"
|
|
||||||
|
|
|
@ -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` can be relative to dir where `calendar.py` is, or relative to COMPONENTS.dirs
|
||||||
template_name = "calendar.html"
|
template_name = "calendar.html"
|
||||||
# Or
|
|
||||||
# def get_template_name(context):
|
css_file = "calendar.css"
|
||||||
# return f"template-{context['name']}.html"
|
js_file = "calendar.js"
|
||||||
|
|
||||||
# This component takes one parameter, a date string to show in the template
|
# This component takes one parameter, a date string to show in the template
|
||||||
def get_context_data(self, date):
|
def get_context_data(self, date):
|
||||||
|
@ -24,7 +24,3 @@ class CalendarNested(Component):
|
||||||
"date": request.GET.get("date", ""),
|
"date": request.GET.get("date", ""),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
class Media:
|
|
||||||
css = "calendar.css"
|
|
||||||
js = "calendar.js"
|
|
||||||
|
|
|
@ -25,7 +25,7 @@ from typing import (
|
||||||
)
|
)
|
||||||
|
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
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.http import HttpRequest, HttpResponse
|
||||||
from django.template.base import NodeList, Template, TextNode
|
from django.template.base import NodeList, Template, TextNode
|
||||||
from django.template.context import Context, RequestContext
|
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.views import View
|
||||||
|
|
||||||
from django_components.app_settings import ContextBehavior
|
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 ComponentRegistry
|
||||||
from django_components.component_registry import registry as registry_
|
from django_components.component_registry import registry as registry_
|
||||||
from django_components.context import (
|
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])
|
JsDataType = TypeVar("JsDataType", bound=Mapping[str, Any])
|
||||||
CssDataType = TypeVar("CssDataType", 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)
|
@dataclass(frozen=True)
|
||||||
class RenderInput(Generic[ArgsType, KwargsType, SlotsType]):
|
class RenderInput(Generic[ArgsType, KwargsType, SlotsType]):
|
||||||
|
@ -161,14 +158,8 @@ class ComponentVars(NamedTuple):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class ComponentMeta(MediaMeta):
|
class ComponentMeta(ComponentMediaMeta):
|
||||||
def __new__(mcs, name: str, bases: Tuple[Type, ...], attrs: Dict[str, Any]) -> Type:
|
pass
|
||||||
# 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)
|
|
||||||
|
|
||||||
|
|
||||||
# NOTE: We use metaclass to automatically define the HTTP methods as defined
|
# NOTE: We use metaclass to automatically define the HTTP methods as defined
|
||||||
|
@ -254,18 +245,71 @@ class Component(
|
||||||
return cast(DataType, {})
|
return cast(DataType, {})
|
||||||
|
|
||||||
js: Optional[str] = None
|
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
|
css: Optional[str] = None
|
||||||
"""Inlined CSS associated with this component."""
|
"""Main CSS associated with this component inlined as string."""
|
||||||
media: Media
|
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.
|
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
|
response_class = HttpResponse
|
||||||
"""This allows to configure what class is used to generate response from `render_to_response`"""
|
"""This allows to configure what class is used to generate response from `render_to_response`"""
|
||||||
|
|
|
@ -1,36 +1,282 @@
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
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.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.logger import logger
|
||||||
|
from django_components.util.misc import get_import_path
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django_components.component import Component
|
from django_components.component import Component
|
||||||
|
|
||||||
|
|
||||||
class ComponentMediaInput:
|
# These are all the attributes that are handled by ComponentMedia and lazily-resolved
|
||||||
"""Defines JS and CSS media files associated with this component."""
|
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
|
css: Optional[Union[str, List[str], Dict[str, str], Dict[str, List[str]]]] = None
|
||||||
js: Optional[Union[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
|
### 1. Media are resolved relative to class definition file
|
||||||
to define associated JS/CSS files, which are then available under `media`
|
|
||||||
attribute as a instance of `Media` class.
|
|
||||||
|
|
||||||
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
|
1. As plain strings
|
||||||
```py
|
```py
|
||||||
|
@ -58,32 +304,8 @@ class MediaMeta(MediaDefiningClass):
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
4. [CSS ONLY] Dicts of lists
|
Moreover, unlike Django's Media class, here, the JS/CSS files can be defined as str, bytes, PathLike, SafeString,
|
||||||
```py
|
or function of thereof. E.g.:
|
||||||
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.:
|
|
||||||
|
|
||||||
```py
|
```py
|
||||||
def lazy_eval_css():
|
def lazy_eval_css():
|
||||||
|
@ -95,96 +317,50 @@ class MediaMeta(MediaDefiningClass):
|
||||||
js = b"script.js"
|
js = b"script.js"
|
||||||
css = lazy_eval_css
|
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:
|
if hasattr(media, "css") and media.css:
|
||||||
# Allow: class Media: css = "style.css"
|
# Allow: class Media: css = "style.css"
|
||||||
if _is_media_filepath(media.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"]
|
# Allow: class Media: css = ["style.css"]
|
||||||
if isinstance(media.css, (list, tuple)):
|
elif isinstance(media.css, (list, tuple)):
|
||||||
media.css = {"all": media.css}
|
media.css = {"all": media.css}
|
||||||
|
|
||||||
# Allow: class Media: css = {"all": "style.css"}
|
# Allow: class Media: css = {"all": "style.css"}
|
||||||
if isinstance(media.css, dict):
|
# class Media: css = {"all": ["style.css"]}
|
||||||
for media_type, path_list in media.css.items():
|
elif isinstance(media.css, dict):
|
||||||
if _is_media_filepath(path_list):
|
for media_type, path_or_list in media.css.items():
|
||||||
media.css[media_type] = [path_list] # type: ignore
|
# {"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:
|
if hasattr(media, "js") and media.js:
|
||||||
# Allow: class Media: js = "script.js"
|
# Allow: class Media: js = "script.js"
|
||||||
if _is_media_filepath(media.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 hasattr(media, "css") and media.css:
|
||||||
if not isinstance(media.css, dict):
|
if not isinstance(media.css, dict):
|
||||||
raise ValueError(f"Media.css must be a dict, got {type(media.css)}")
|
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
|
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
|
as the component class. If so, modify the attributes so the class Django's rendering
|
||||||
will pick up these files correctly.
|
will pick up these files correctly.
|
||||||
"""
|
"""
|
||||||
# First check if we even need to resolve anything. If the class doesn't define any
|
# 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
|
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
|
will_resolve_files = True
|
||||||
if not will_resolve_files and "Media" in attrs:
|
elif not will_resolve_files and getattr(comp_media, "Media", None):
|
||||||
media: ComponentMediaInput = attrs["Media"]
|
if getattr(comp_media.Media, "css", None) or getattr(comp_media.Media, "js", None):
|
||||||
if getattr(media, "css", None) or getattr(media, "js", None):
|
|
||||||
will_resolve_files = True
|
will_resolve_files = True
|
||||||
|
|
||||||
if not will_resolve_files:
|
if not will_resolve_files:
|
||||||
return
|
return
|
||||||
|
|
||||||
component_name = attrs["__qualname__"]
|
component_name = comp_cls.__qualname__
|
||||||
# Derive the full path of the file where the component was defined
|
# 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]
|
module_obj = sys.modules[module_name]
|
||||||
file_path = module_obj.__file__
|
file_path = module_obj.__file__
|
||||||
|
|
||||||
|
@ -272,13 +453,9 @@ def _resolve_component_relative_files(attrs: MutableMapping) -> None:
|
||||||
)
|
)
|
||||||
return
|
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
|
# Get the directory where the component class is defined
|
||||||
try:
|
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:
|
except RuntimeError:
|
||||||
# If no dir was found, we assume that the path is NOT relative to the component dir
|
# If no dir was found, we assume that the path is NOT relative to the component dir
|
||||||
logger.debug(
|
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.
|
# 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 yes, modify the path to refer to the relative file.
|
||||||
# If not, don't modify anything.
|
# 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):
|
if isinstance(filepath, str):
|
||||||
filepath_abs = os.path.join(comp_dir_abs, filepath)
|
filepath_abs = os.path.join(comp_dir_abs, filepath)
|
||||||
# NOTE: The paths to resources need to use POSIX (forward slashes) for Django to wor
|
# 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
|
return filepath
|
||||||
|
|
||||||
# Check if template name is a local file or not
|
# Check if template name is a local file or not
|
||||||
if "template_name" in attrs and attrs["template_name"]:
|
if getattr(comp_media, "template_name", None):
|
||||||
attrs["template_name"] = resolve_file(attrs["template_name"])
|
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:
|
if hasattr(comp_media, "Media") and comp_media.Media:
|
||||||
media = attrs["Media"]
|
_map_media_filepaths(comp_media.Media, resolve_media_file)
|
||||||
_map_media_filepaths(media, resolve_file)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_dir_path_from_component_path(
|
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
|
# - Absolute path is used to check if the file exists
|
||||||
# - Relative path is used for defining the import on the component class
|
# - Relative path is used for defining the import on the component class
|
||||||
return comp_dir_path_abs, comp_dir_path_rel
|
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
|
||||||
|
|
|
@ -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
|
# 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.
|
# <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()]
|
# fmt: off
|
||||||
to_load_js_tags = [tag for media in all_medias for tag in media.render_js()]
|
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.
|
# 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
|
# For the deduplication, if multiple components link to the same JS/CSS, but they
|
||||||
|
|
|
@ -220,7 +220,7 @@ def _filepath_to_python_module(
|
||||||
# Combine with the base module path
|
# Combine with the base module path
|
||||||
full_module_name = f"{root_module_path}.{module_name}" if root_module_path else module_name
|
full_module_name = f"{root_module_path}.{module_name}" if root_module_path else module_name
|
||||||
if full_module_name.endswith(".__init__"):
|
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
|
return full_module_name
|
||||||
|
|
||||||
|
@ -236,3 +236,12 @@ def _search_dirs(dirs: List[Path], search_glob: str) -> List[Path]:
|
||||||
matched_files.append(Path(path))
|
matched_files.append(Path(path))
|
||||||
|
|
||||||
return matched_files
|
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
|
||||||
|
|
2
tests/static_root/script.js
Normal file
2
tests/static_root/script.js
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
/* Used in `MainMediaTest` tests in `test_component_media.py` */
|
||||||
|
console.log("HTML and JS only");
|
4
tests/static_root/style.css
Normal file
4
tests/static_root/style.css
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
/* Used in `MainMediaTest` tests in `test_component_media.py` */
|
||||||
|
.html-css-only {
|
||||||
|
color: blue;
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from django_components import Component, register
|
from django_components import Component, register
|
||||||
|
|
||||||
|
@ -6,7 +6,9 @@ from django_components import Component, register
|
||||||
# Used for testing the template_loader
|
# Used for testing the template_loader
|
||||||
@register("app_lvl_comp")
|
@register("app_lvl_comp")
|
||||||
class AppLvlCompComponent(Component):
|
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:
|
class Media:
|
||||||
js = "app_lvl_comp.js"
|
js = "app_lvl_comp.js"
|
||||||
|
|
|
@ -17,8 +17,10 @@ from .testutils import BaseTestCase, autodiscover_with_cleanup
|
||||||
setup_test_config({"autodiscover": False})
|
setup_test_config({"autodiscover": False})
|
||||||
|
|
||||||
|
|
||||||
class InlineComponentTest(BaseTestCase):
|
# "Main media" refer to the HTML, JS, and CSS set on the Component class itself
|
||||||
def test_html(self):
|
# (as opposed via the `Media` class). These have special handling in the Component.
|
||||||
|
class MainMediaTest(BaseTestCase):
|
||||||
|
def test_html_inlined(self):
|
||||||
class InlineHTMLComponent(Component):
|
class InlineHTMLComponent(Component):
|
||||||
template = "<div class='inline'>Hello Inline</div>"
|
template = "<div class='inline'>Hello Inline</div>"
|
||||||
|
|
||||||
|
@ -27,7 +29,20 @@ class InlineComponentTest(BaseTestCase):
|
||||||
'<div class="inline" data-djc-id-a1bc3e>Hello Inline</div>',
|
'<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):
|
class TestComponent(Component):
|
||||||
template = """
|
template = """
|
||||||
{% load component_tags %}
|
{% load component_tags %}
|
||||||
|
@ -38,6 +53,15 @@ class InlineComponentTest(BaseTestCase):
|
||||||
css = ".html-css-only { color: blue; }"
|
css = ".html-css-only { color: blue; }"
|
||||||
js = "console.log('HTML and JS only');"
|
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()
|
rendered = TestComponent.render()
|
||||||
|
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
|
@ -53,6 +77,127 @@ class InlineComponentTest(BaseTestCase):
|
||||||
rendered,
|
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):
|
def test_html_variable(self):
|
||||||
class VariableHTMLComponent(Component):
|
class VariableHTMLComponent(Component):
|
||||||
def get_template(self, context):
|
def get_template(self, context):
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue