mirror of
https://github.com/django-components/django-components.git
synced 2025-08-04 14:28:18 +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.
|
||||
|
||||
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.
|
||||
|
|
|
@ -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>"""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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`"""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
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
|
||||
|
||||
|
@ -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"
|
||||
|
|
|
@ -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):
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue