mirror of
https://github.com/django-components/django-components.git
synced 2025-08-17 20:50:14 +00:00
feat: Add support for HTML fragments (#845)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
6681fc0085
commit
4dab940db8
26 changed files with 1225 additions and 246 deletions
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: Authoring component libraries
|
||||
weight: 7
|
||||
weight: 8
|
||||
---
|
||||
|
||||
You can publish and share your components for others to use. Here are the steps to do so:
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: Registering components
|
||||
weight: 4
|
||||
weight: 5
|
||||
---
|
||||
|
||||
In previous examples you could repeatedly see us using `@register()` to "register"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: Lifecycle hooks
|
||||
weight: 3
|
||||
weight: 4
|
||||
---
|
||||
|
||||
_New in version 0.96_
|
||||
|
|
361
docs/concepts/advanced/html_tragments.md
Normal file
361
docs/concepts/advanced/html_tragments.md
Normal file
|
@ -0,0 +1,361 @@
|
|||
---
|
||||
title: HTML fragments
|
||||
weight: 2
|
||||
---
|
||||
|
||||
Django-components provides a seamless integration with HTML fragments ([HTML over the wire](https://hotwired.dev/)),
|
||||
whether you're using HTMX, AlpineJS, or vanilla JavaScript.
|
||||
|
||||
When you define a component that has extra JS or CSS, and you use django-components
|
||||
to render the fragment, django-components will:
|
||||
|
||||
- Automatically load the associated JS and CSS
|
||||
- Ensure that JS is loaded and executed only once even if the fragment is inserted multiple times
|
||||
|
||||
!!! info
|
||||
|
||||
**What are HTML fragments and "HTML over the wire"?**
|
||||
|
||||
It is one of the methods for updating the state in the browser UI upon user interaction.
|
||||
|
||||
How it works is that:
|
||||
|
||||
1. User makes an action - clicks a button or submits a form
|
||||
2. The action causes a request to be made from the client to the server.
|
||||
3. Server processes the request (e.g. form submission), and responds with HTML
|
||||
of some part of the UI (e.g. a new entry in a table).
|
||||
4. A library like HTMX, AlpineJS, or custom function inserts the new HTML into
|
||||
the correct place.
|
||||
|
||||
## Document and fragment types
|
||||
|
||||
Components support two modes of rendering - As a "document" or as a "fragment".
|
||||
|
||||
What's the difference?
|
||||
|
||||
### Document mode
|
||||
|
||||
Document mode assumes that the rendered components will be embedded into the HTML
|
||||
of the initial page load. This means that:
|
||||
|
||||
- The JS and CSS is embedded into the HTML as `<script>` and `<style>` tags
|
||||
(see [JS and CSS output locations](./rendering_js_css.md#js-and-css-output-locations))
|
||||
- Django-components injects a JS script for managing JS and CSS assets
|
||||
|
||||
A component is rendered as a "document" when:
|
||||
|
||||
- It is embedded inside a template as [`{% component %}`](../../reference/template_tags.md#component)
|
||||
- It is rendered with [`Component.render()`](../../../reference/api#django_components.Component.render)
|
||||
or [`Component.render_to_response()`](../../../reference/api#django_components.Component.render_to_response)
|
||||
with the `type` kwarg set to `"document"` (default)
|
||||
|
||||
Example:
|
||||
|
||||
```py
|
||||
MyTable.render(
|
||||
kwargs={...},
|
||||
)
|
||||
|
||||
# or
|
||||
|
||||
MyTable.render(
|
||||
kwargs={...},
|
||||
type="document",
|
||||
)
|
||||
```
|
||||
|
||||
### Fragment mode
|
||||
|
||||
Fragment mode assumes that the main HTML has already been rendered and loaded on the page.
|
||||
The component renders HTML that will be inserted into the page as a fragment, at a LATER time:
|
||||
|
||||
- JS and CSS is not directly embedded to avoid duplicately executing the same JS scripts.
|
||||
So template tags like [`{% component_js_dependencies %}`](../../reference/template_tags.md#component_js_dependencies)
|
||||
inside of fragments are ignored.
|
||||
- Instead, django-components appends the fragment's content with a JSON `<script>` to trigger a call
|
||||
to its asset manager JS script, which will load the JS and CSS smartly.
|
||||
- The asset manager JS script is assumed to be already loaded on the page.
|
||||
|
||||
A component is rendered as "fragment" when:
|
||||
|
||||
- It is rendered with [`Component.render()`](../../../reference/api#django_components.Component.render)
|
||||
or [`Component.render_to_response()`](../../../reference/api#django_components.Component.render_to_response)
|
||||
with the `type` kwarg set to `"fragment"`
|
||||
|
||||
Example:
|
||||
|
||||
```py
|
||||
MyTable.render(
|
||||
kwargs={...},
|
||||
type="fragment",
|
||||
)
|
||||
```
|
||||
|
||||
## Live examples
|
||||
|
||||
For live interactive examples, [start our demo project](../../overview/development.md#developing-against-live-django-app)
|
||||
(`sampleproject`).
|
||||
|
||||
Then navigate to these URLs:
|
||||
|
||||
- `/fragment/base/alpine`
|
||||
- `/fragment/base/htmx`
|
||||
- `/fragment/base/js`
|
||||
|
||||
## Example - HTMX
|
||||
|
||||
### 1. Define document HTML
|
||||
|
||||
```py title="[root]/components/demo.py"
|
||||
from django_components import Component, types
|
||||
|
||||
# HTML into which a fragment will be loaded using HTMX
|
||||
class MyPage(Component):
|
||||
def get(self, request):
|
||||
return self.render_to_response()
|
||||
|
||||
template = """
|
||||
{% load component_tags %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
{% component_css_dependencies %}
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="target">OLD</div>
|
||||
|
||||
<button
|
||||
hx-get="/mypage/frag"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="#target"
|
||||
>
|
||||
Click me!
|
||||
</button>
|
||||
|
||||
{% component_js_dependencies %}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
```
|
||||
|
||||
### 2. Define fragment HTML
|
||||
|
||||
```py title="[root]/components/demo.py"
|
||||
class Frag(Component):
|
||||
def get(self, request):
|
||||
return self.render_to_response(
|
||||
# IMPORTANT: Don't forget `type="fragment"`
|
||||
type="fragment",
|
||||
)
|
||||
|
||||
template = """
|
||||
<div class="frag">
|
||||
123
|
||||
<span id="frag-text"></span>
|
||||
</div>
|
||||
"""
|
||||
|
||||
js = """
|
||||
document.querySelector('#frag-text').textContent = 'xxx';
|
||||
"""
|
||||
|
||||
css = """
|
||||
.frag {
|
||||
background: blue;
|
||||
}
|
||||
"""
|
||||
```
|
||||
|
||||
### 3. Create view and URLs
|
||||
|
||||
```py title="[app]/urls.py"
|
||||
from django.urls import path
|
||||
|
||||
from components.demo import MyPage, Frag
|
||||
|
||||
urlpatterns = [
|
||||
path("mypage/", MyPage.as_view())
|
||||
path("mypage/frag", Frag.as_view()),
|
||||
]
|
||||
```
|
||||
|
||||
## Example - AlpineJS
|
||||
|
||||
### 1. Define document HTML
|
||||
|
||||
```py title="[root]/components/demo.py"
|
||||
from django_components import Component, types
|
||||
|
||||
# HTML into which a fragment will be loaded using AlpineJS
|
||||
class MyPage(Component):
|
||||
def get(self, request):
|
||||
return self.render_to_response()
|
||||
|
||||
template = """
|
||||
{% load component_tags %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
{% component_css_dependencies %}
|
||||
<script defer src="https://unpkg.com/alpinejs"></script>
|
||||
</head>
|
||||
<body x-data="{
|
||||
htmlVar: 'OLD',
|
||||
loadFragment: function () {
|
||||
const url = '/mypage/frag';
|
||||
fetch(url)
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
this.htmlVar = html;
|
||||
});
|
||||
}
|
||||
}">
|
||||
<div id="target" x-html="htmlVar">OLD</div>
|
||||
|
||||
<button @click="loadFragment">
|
||||
Click me!
|
||||
</button>
|
||||
|
||||
{% component_js_dependencies %}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
```
|
||||
|
||||
### 2. Define fragment HTML
|
||||
|
||||
```py title="[root]/components/demo.py"
|
||||
class Frag(Component):
|
||||
def get(self, request):
|
||||
# IMPORTANT: Don't forget `type="fragment"`
|
||||
return self.render_to_response(
|
||||
type="fragment",
|
||||
)
|
||||
|
||||
# NOTE: We wrap the actual fragment in a template tag with x-if="false" to prevent it
|
||||
# from being rendered until we have registered the component with AlpineJS.
|
||||
template = """
|
||||
<template x-if="false" data-name="frag">
|
||||
<div class="frag">
|
||||
123
|
||||
<span x-data="frag" x-text="fragVal">
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
"""
|
||||
|
||||
js = """
|
||||
Alpine.data('frag', () => ({
|
||||
fragVal: 'xxx',
|
||||
}));
|
||||
|
||||
// Now that the component has been defined in AlpineJS, we can "activate"
|
||||
// all instances where we use the `x-data="frag"` directive.
|
||||
document.querySelectorAll('[data-name="frag"]').forEach((el) => {
|
||||
el.setAttribute('x-if', 'true');
|
||||
});
|
||||
"""
|
||||
|
||||
css = """
|
||||
.frag {
|
||||
background: blue;
|
||||
}
|
||||
"""
|
||||
```
|
||||
|
||||
### 3. Create view and URLs
|
||||
|
||||
```py title="[app]/urls.py"
|
||||
from django.urls import path
|
||||
|
||||
from components.demo import MyPage, Frag
|
||||
|
||||
urlpatterns = [
|
||||
path("mypage/", MyPage.as_view())
|
||||
path("mypage/frag", Frag.as_view()),
|
||||
]
|
||||
```
|
||||
|
||||
## Example - Vanilla JS
|
||||
|
||||
### 1. Define document HTML
|
||||
|
||||
```py title="[root]/components/demo.py"
|
||||
from django_components import Component, types
|
||||
|
||||
# HTML into which a fragment will be loaded using JS
|
||||
class MyPage(Component):
|
||||
def get(self, request):
|
||||
return self.render_to_response()
|
||||
|
||||
template = """
|
||||
{% load component_tags %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
{% component_css_dependencies %}
|
||||
</head>
|
||||
<body>
|
||||
<div id="target">OLD</div>
|
||||
|
||||
<button>
|
||||
Click me!
|
||||
</button>
|
||||
<script>
|
||||
const url = `/mypage/frag`;
|
||||
document.querySelector('#loader').addEventListener('click', function () {
|
||||
fetch(url)
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
document.querySelector('#target').outerHTML = html;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% component_js_dependencies %}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
```
|
||||
|
||||
### 2. Define fragment HTML
|
||||
|
||||
```py title="[root]/components/demo.py"
|
||||
class Frag(Component):
|
||||
def get(self, request):
|
||||
return self.render_to_response(
|
||||
# IMPORTANT: Don't forget `type="fragment"`
|
||||
type="fragment",
|
||||
)
|
||||
|
||||
template = """
|
||||
<div class="frag">
|
||||
123
|
||||
<span id="frag-text"></span>
|
||||
</div>
|
||||
"""
|
||||
|
||||
js = """
|
||||
document.querySelector('#frag-text').textContent = 'xxx';
|
||||
"""
|
||||
|
||||
css = """
|
||||
.frag {
|
||||
background: blue;
|
||||
}
|
||||
"""
|
||||
```
|
||||
|
||||
### 3. Create view and URLs
|
||||
|
||||
```py title="[app]/urls.py"
|
||||
from django.urls import path
|
||||
|
||||
from components.demo import MyPage, Frag
|
||||
|
||||
urlpatterns = [
|
||||
path("mypage/", MyPage.as_view())
|
||||
path("mypage/frag", Frag.as_view()),
|
||||
]
|
||||
```
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: Prop drilling and provide / inject
|
||||
weight: 2
|
||||
weight: 3
|
||||
---
|
||||
|
||||
_New in version 0.80_:
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: Tag formatters
|
||||
weight: 6
|
||||
weight: 7
|
||||
---
|
||||
|
||||
## Customizing component tags with TagFormatter
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: Typing and validation
|
||||
weight: 5
|
||||
weight: 6
|
||||
---
|
||||
|
||||
## Adding type hints with Generics
|
||||
|
|
159
sampleproject/components/fragment.py
Normal file
159
sampleproject/components/fragment.py
Normal file
|
@ -0,0 +1,159 @@
|
|||
from django_components import Component, types
|
||||
|
||||
|
||||
# HTML into which a fragment will be loaded using vanilla JS
|
||||
class FragmentBaseJs(Component):
|
||||
def get(self, request):
|
||||
return self.render_to_response()
|
||||
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
{% component_css_dependencies %}
|
||||
</head>
|
||||
<body>
|
||||
<div id="target">OLD</div>
|
||||
|
||||
<button id="loader">
|
||||
Click me!
|
||||
</button>
|
||||
<script>
|
||||
const url = `/fragment/frag/js`;
|
||||
document.querySelector('#loader').addEventListener('click', function () {
|
||||
fetch(url)
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
console.log({ fragment: html })
|
||||
document.querySelector('#target').outerHTML = html;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% component_js_dependencies %}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
# HTML into which a fragment will be loaded using AlpineJs
|
||||
class FragmentBaseAlpine(Component):
|
||||
def get(self, request):
|
||||
return self.render_to_response()
|
||||
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
{% component_css_dependencies %}
|
||||
<script defer src="https://unpkg.com/alpinejs"></script>
|
||||
</head>
|
||||
<body x-data="{
|
||||
htmlVar: 'OLD',
|
||||
loadFragment: function () {
|
||||
const url = '/fragment/frag/alpine';
|
||||
fetch(url)
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
console.log({ fragment: html });
|
||||
this.htmlVar = html;
|
||||
});
|
||||
}
|
||||
}">
|
||||
<div id="target" x-html="htmlVar">OLD</div>
|
||||
|
||||
<button id="loader" @click="loadFragment">
|
||||
Click me!
|
||||
</button>
|
||||
|
||||
{% component_js_dependencies %}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
# HTML into which a fragment will be loaded using HTMX
|
||||
class FragmentBaseHtmx(Component):
|
||||
def get(self, request):
|
||||
return self.render_to_response()
|
||||
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
{% component_css_dependencies %}
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="target">OLD</div>
|
||||
|
||||
<button id="loader" hx-get="/fragment/frag/js" hx-swap="outerHTML" hx-target="#target">
|
||||
Click me!
|
||||
</button>
|
||||
|
||||
{% component_js_dependencies %}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
# Fragment where the JS and CSS are defined on the Component
|
||||
class FragJs(Component):
|
||||
def get(self, request):
|
||||
return self.render_to_response(type="fragment")
|
||||
|
||||
template: types.django_html = """
|
||||
<div class="frag">
|
||||
123
|
||||
<span id="frag-text"></span>
|
||||
</div>
|
||||
"""
|
||||
|
||||
js: types.js = """
|
||||
document.querySelector('#frag-text').textContent = 'xxx';
|
||||
"""
|
||||
|
||||
css: types.css = """
|
||||
.frag {
|
||||
background: blue;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
# Fragment that defines an AlpineJS component
|
||||
class FragAlpine(Component):
|
||||
def get(self, request):
|
||||
return self.render_to_response(type="fragment")
|
||||
|
||||
# NOTE: We wrap the actual fragment in a template tag with x-if="false" to prevent it
|
||||
# from being rendered until we have registered the component with AlpineJS.
|
||||
template: types.django_html = """
|
||||
<template x-if="false" data-name="frag">
|
||||
<div class="frag">
|
||||
123
|
||||
<span x-data="frag" x-text="fragVal">
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
"""
|
||||
|
||||
js: types.js = """
|
||||
Alpine.data('frag', () => ({
|
||||
fragVal: 'xxx',
|
||||
}));
|
||||
|
||||
// Now that the component has been defined in AlpineJS, we can "activate" all instances
|
||||
// where we use the `x-data="frag"` directive.
|
||||
document.querySelectorAll('[data-name="frag"]').forEach((el) => {
|
||||
el.setAttribute('x-if', 'true');
|
||||
});
|
||||
"""
|
||||
|
||||
css: types.css = """
|
||||
.frag {
|
||||
background: blue;
|
||||
}
|
||||
"""
|
|
@ -1,4 +1,5 @@
|
|||
from components.calendar.calendar import Calendar, CalendarRelative
|
||||
from components.fragment import FragAlpine, FragJs, FragmentBaseAlpine, FragmentBaseHtmx, FragmentBaseJs
|
||||
from components.greeting import Greeting
|
||||
from components.nested.calendar.calendar import CalendarNested
|
||||
from django.urls import path
|
||||
|
@ -8,4 +9,9 @@ urlpatterns = [
|
|||
path("calendar/", Calendar.as_view(), name="calendar"),
|
||||
path("calendar-relative/", CalendarRelative.as_view(), name="calendar-relative"),
|
||||
path("calendar-nested/", CalendarNested.as_view(), name="calendar-nested"),
|
||||
path("fragment/base/alpine", FragmentBaseAlpine.as_view()),
|
||||
path("fragment/base/htmx", FragmentBaseHtmx.as_view()),
|
||||
path("fragment/base/js", FragmentBaseJs.as_view()),
|
||||
path("fragment/frag/alpine", FragAlpine.as_view()),
|
||||
path("fragment/frag/js", FragJs.as_view()),
|
||||
]
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
"""All code related to management of component dependencies (JS and CSS scripts)"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from functools import lru_cache
|
||||
from hashlib import md5
|
||||
from textwrap import dedent
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Callable,
|
||||
|
@ -34,9 +34,8 @@ from django.urls import path, reverse
|
|||
from django.utils.decorators import sync_and_async_middleware
|
||||
from django.utils.safestring import SafeString, mark_safe
|
||||
|
||||
import django_components.types as types
|
||||
from django_components.util.html import SoupNode
|
||||
from django_components.util.misc import _escape_js, get_import_path
|
||||
from django_components.util.misc import get_import_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django_components.component import Component
|
||||
|
@ -325,6 +324,9 @@ def render_dependencies(content: TContent, type: RenderType = "document") -> TCo
|
|||
return HttpResponse(processed_html)
|
||||
```
|
||||
"""
|
||||
if type not in ("document", "fragment"):
|
||||
raise ValueError(f"Invalid type '{type}'")
|
||||
|
||||
is_safestring = isinstance(content, SafeString)
|
||||
|
||||
if isinstance(content, str):
|
||||
|
@ -335,18 +337,24 @@ def render_dependencies(content: TContent, type: RenderType = "document") -> TCo
|
|||
content_, js_dependencies, css_dependencies = _process_dep_declarations(content_, type)
|
||||
|
||||
# Replace the placeholders with the actual content
|
||||
# If type == `document`, we insert the JS and CSS directly into the HTML,
|
||||
# where the placeholders were.
|
||||
# If type == `fragment`, we let the client-side manager load the JS and CSS,
|
||||
# and remove the placeholders.
|
||||
did_find_js_placeholder = False
|
||||
did_find_css_placeholder = False
|
||||
css_replacement = css_dependencies if type == "document" else b""
|
||||
js_replacement = js_dependencies if type == "document" else b""
|
||||
|
||||
def on_replace_match(match: "re.Match[bytes]") -> bytes:
|
||||
nonlocal did_find_css_placeholder
|
||||
nonlocal did_find_js_placeholder
|
||||
|
||||
if match[0] == CSS_PLACEHOLDER_BYTES:
|
||||
replacement = css_dependencies
|
||||
replacement = css_replacement
|
||||
did_find_css_placeholder = True
|
||||
elif match[0] == JS_PLACEHOLDER_BYTES:
|
||||
replacement = js_dependencies
|
||||
replacement = js_replacement
|
||||
did_find_js_placeholder = True
|
||||
else:
|
||||
raise RuntimeError(
|
||||
|
@ -370,6 +378,10 @@ def render_dependencies(content: TContent, type: RenderType = "document") -> TCo
|
|||
if maybe_transformed is not None:
|
||||
content_ = maybe_transformed.encode()
|
||||
|
||||
# In case of a fragment, we only append the JS (actually JSON) to trigger the call of dependency-manager
|
||||
if type == "fragment":
|
||||
content_ += js_dependencies
|
||||
|
||||
# Return the same type as we were given
|
||||
output = content_.decode() if isinstance(content, str) else content_
|
||||
output = mark_safe(output) if is_safestring else output
|
||||
|
@ -505,7 +517,8 @@ def _process_dep_declarations(content: bytes, type: RenderType) -> Tuple[bytes,
|
|||
|
||||
# Core scripts without which the rest wouldn't work
|
||||
core_script_tags = Media(
|
||||
js=[static("django_components/django_components.min.js")],
|
||||
# NOTE: When rendering a document, the initial JS is inserted directly into the HTML
|
||||
js=[static("django_components/django_components.min.js")] if type == "document" else [],
|
||||
).render_js()
|
||||
|
||||
final_script_tags = "".join(
|
||||
|
@ -514,7 +527,7 @@ def _process_dep_declarations(content: bytes, type: RenderType) -> Tuple[bytes,
|
|||
*[tag for tag in core_script_tags],
|
||||
# Make calls to the JS dependency manager
|
||||
# Loads JS from `Media.js` and `Component.js` if fragment
|
||||
exec_script,
|
||||
*([exec_script] if exec_script else []),
|
||||
# JS from `Media.js`
|
||||
# NOTE: When rendering a document, the initial JS is inserted directly into the HTML
|
||||
# so the scripts are executed at proper order. In the dependency manager, we only mark those
|
||||
|
@ -620,7 +633,7 @@ def _prepare_tags_and_urls(
|
|||
to_load_js_urls.append(get_script_url("js", comp_cls))
|
||||
|
||||
if _is_nonempty_str(comp_cls.css):
|
||||
loaded_css_urls.append(get_script_url("css", comp_cls))
|
||||
to_load_css_urls.append(get_script_url("css", comp_cls))
|
||||
|
||||
return (
|
||||
to_load_js_urls,
|
||||
|
@ -650,9 +663,20 @@ def _get_script_tag(
|
|||
script = get_script_content(script_type, comp_cls)
|
||||
|
||||
if script_type == "js":
|
||||
return f"<script>{_escape_js(script)}</script>"
|
||||
if "</script" in script:
|
||||
raise RuntimeError(
|
||||
f"Content of `Component.js` for component '{comp_cls.__name__}' contains '</script>' end tag. "
|
||||
"This is not allowed, as it would break the HTML."
|
||||
)
|
||||
return f"<script>{script}</script>"
|
||||
|
||||
elif script_type == "css":
|
||||
if "</style" in script:
|
||||
raise RuntimeError(
|
||||
f"Content of `Component.css` for component '{comp_cls.__name__}' contains '</style>' end tag. "
|
||||
"This is not allowed, as it would break the HTML."
|
||||
)
|
||||
|
||||
return f"<style>{script}</style>"
|
||||
|
||||
return script
|
||||
|
@ -678,51 +702,33 @@ def _gen_exec_script(
|
|||
to_load_css_tags: List[str],
|
||||
loaded_js_urls: List[str],
|
||||
loaded_css_urls: List[str],
|
||||
) -> str:
|
||||
# Generate JS expression like so:
|
||||
# ```js
|
||||
# Promise.all([
|
||||
# Components.manager.loadJs('<script src="/abc/def1">...</script>'),
|
||||
# Components.manager.loadJs('<script src="/abc/def2">...</script>'),
|
||||
# Components.manager.loadCss('<link href="/abc/def3">'),
|
||||
# ]);
|
||||
# ```
|
||||
) -> Optional[str]:
|
||||
if not to_load_js_tags and not to_load_css_tags and not loaded_css_urls and not loaded_js_urls:
|
||||
return None
|
||||
|
||||
def map_to_base64(lst: List[str]) -> List[str]:
|
||||
return [base64.b64encode(tag.encode()).decode() for tag in lst]
|
||||
|
||||
# Generate JSON that will tell the JS dependency manager which JS and CSS to load
|
||||
#
|
||||
# or
|
||||
#
|
||||
# ```js
|
||||
# Components.manager.markScriptLoaded("css", "/abc/def1.css"),
|
||||
# Components.manager.markScriptLoaded("css", "/abc/def2.css"),
|
||||
# Components.manager.markScriptLoaded("js", "/abc/def3.js"),
|
||||
# ```
|
||||
#
|
||||
# NOTE: It would be better to pass only the URL itself for `loadJs/loadCss`, instead of a whole tag.
|
||||
# NOTE: It would be simpler to pass only the URL itself for `loadJs/loadCss`, instead of a whole tag.
|
||||
# But because we allow users to specify the Media class, and thus users can
|
||||
# configure how the `<link>` or `<script>` tags are rendered, we need pass the whole tag.
|
||||
escaped_to_load_js_tags = [_escape_js(tag, eval=False) for tag in to_load_js_tags]
|
||||
escaped_to_load_css_tags = [_escape_js(tag, eval=False) for tag in to_load_css_tags]
|
||||
#
|
||||
# NOTE 2: Convert to Base64 to avoid any issues with `</script>` tags in the content
|
||||
exec_script_data = {
|
||||
"loadedCssUrls": map_to_base64(loaded_css_urls),
|
||||
"loadedJsUrls": map_to_base64(loaded_js_urls),
|
||||
"toLoadCssTags": map_to_base64(to_load_css_tags),
|
||||
"toLoadJsTags": map_to_base64(to_load_js_tags),
|
||||
}
|
||||
|
||||
# Make JS array whose items are interpreted as JS statements (e.g. functions)
|
||||
def js_arr(lst: List) -> str:
|
||||
return "[" + ", ".join(lst) + "]"
|
||||
|
||||
# NOTE: Wrap the body in self-executing function to avoid polluting the global scope.
|
||||
exec_script: types.js = f"""
|
||||
(() => {{
|
||||
Components.manager._loadComponentScripts({{
|
||||
loadedCssUrls: {json.dumps(loaded_css_urls)},
|
||||
loadedJsUrls: {json.dumps(loaded_js_urls)},
|
||||
toLoadCssTags: {js_arr(escaped_to_load_css_tags)},
|
||||
toLoadJsTags: {js_arr(escaped_to_load_js_tags)},
|
||||
}});
|
||||
document.currentScript.remove();
|
||||
}})();
|
||||
"""
|
||||
|
||||
# NOTE: The exec script MUST be executed SYNC, so we MUST NOT put `type="module"`,
|
||||
# `async`, nor `defer` on it.
|
||||
# See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#notes
|
||||
exec_script = f"<script>{_escape_js(dedent(exec_script))}</script>"
|
||||
# NOTE: This data is embedded into the HTML as JSON. It is the responsibility of
|
||||
# the client-side code to detect that this script was inserted, and to load the
|
||||
# corresponding assets
|
||||
# See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#embedding_data_in_html
|
||||
exec_script = json.dumps(exec_script_data)
|
||||
exec_script = f'<script type="application/json" data-djc>{exec_script}</script>'
|
||||
return exec_script
|
||||
|
||||
|
||||
|
@ -807,8 +813,8 @@ def cached_script_view(
|
|||
|
||||
|
||||
urlpatterns = [
|
||||
# E.g. `/components/cache/table.js/`
|
||||
path("cache/<str:comp_cls_hash>.<str:script_type>/", cached_script_view, name=CACHE_ENDPOINT_NAME),
|
||||
# E.g. `/components/cache/table.js`
|
||||
path("cache/<str:comp_cls_hash>.<str:script_type>", cached_script_view, name=CACHE_ENDPOINT_NAME),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -1 +1 @@
|
|||
(()=>{var x=Array.isArray,l=n=>typeof n=="function",w=n=>n!==null&&typeof n=="object",E=n=>(w(n)||l(n))&&l(n.then)&&l(n.catch);function j(n,a){try{return a?n.apply(null,a):n()}catch(r){S(r)}}function g(n,a){if(l(n)){let r=j(n,a);return r&&E(r)&&r.catch(i=>{S(i)}),[r]}if(x(n)){let r=[];for(let i=0;i<n.length;i++)r.push(g(n[i],a));return r}else console.warn(`[Components] Invalid value type passed to callWithAsyncErrorHandling(): ${typeof n}`)}function S(n){console.error(n)}var u=()=>{let n=new Set,a=new Set,r={},i={},b=t=>{let e=new DOMParser().parseFromString(t,"text/html").querySelector("script");if(!e)throw Error("[Components] Failed to extract <script> tag. Make sure that the string contains <script><\/script> and is a valid HTML");return e},M=t=>{let e=new DOMParser().parseFromString(t,"text/html").querySelector("link");if(!e)throw Error("[Components] Failed to extract <link> tag. Make sure that the string contains <link></link> and is a valid HTML");return e},y=t=>{let e=document.createElement(t.tagName);e.innerHTML=t.innerHTML;for(let o of t.attributes)e.setAttributeNode(o.cloneNode());return e},h=t=>{let e=b(t),o=e.getAttribute("src");if(!o||T("js",o))return;c("js",o);let s=y(e),p=e.getAttribute("async")!=null||e.getAttribute("defer")!=null||e.getAttribute("type")==="module";s.async=p;let m=new Promise((d,f)=>{s.onload=()=>{d()},globalThis.document.body.append(s)});return{el:s,promise:m}},C=t=>{let e=M(t),o=e.getAttribute("href");if(!o||T("css",o))return;let s=y(e);return globalThis.document.head.append(s),c("css",o),{el:s,promise:Promise.resolve()}},c=(t,e)=>{if(t!=="js"&&t!=="css")throw Error(`[Components] markScriptLoaded received invalid script type '${t}'. Must be one of 'js', 'css'`);(t==="js"?n:a).add(e)},T=(t,e)=>{if(t!=="js"&&t!=="css")throw Error(`[Components] isScriptLoaded received invalid script type '${t}'. Must be one of 'js', 'css'`);return(t==="js"?n:a).has(e)};return{callComponent:(t,e,o)=>{let s=r[t];if(!s)throw Error(`[Components] '${t}': No component registered for that name`);let p=Array.from(document.querySelectorAll(`[data-comp-id-${e}]`));if(!p.length)throw Error(`[Components] '${t}': No elements with component ID '${e}' found`);let m=`${t}:${o}`,d=i[m];if(!d)throw Error(`[Components] '${t}': Cannot find input for hash '${o}'`);let f=d(),F={name:t,id:e,els:p},[L]=g(s,[f,F]);return L},registerComponent:(t,e)=>{r[t]=e},registerComponentData:(t,e,o)=>{let s=`${t}:${e}`;i[s]=o},loadJs:h,loadCss:C,markScriptLoaded:c,_loadComponentScripts:async t=>{t.loadedCssUrls.forEach(o=>c("css",o)),t.loadedJsUrls.forEach(o=>c("js",o)),Promise.all(t.toLoadCssTags.map(o=>C(o))).catch(console.error);let e=Promise.all(t.toLoadJsTags.map(o=>h(o))).catch(console.error)}}};var k={manager:u(),createComponentsManager:u,unescapeJs:r=>new DOMParser().parseFromString(r,"text/html").documentElement.textContent};globalThis.Components=k;})();
|
||||
(()=>{var x=o=>new DOMParser().parseFromString(o,"text/html").documentElement.textContent,E=Array.isArray,m=o=>typeof o=="function",H=o=>o!==null&&typeof o=="object",S=o=>(H(o)||m(o))&&m(o.then)&&m(o.catch);function N(o,i){try{return i?o.apply(null,i):o()}catch(s){L(s)}}function g(o,i){if(m(o)){let s=N(o,i);return s&&S(s)&&s.catch(c=>{L(c)}),[s]}if(E(o)){let s=[];for(let c=0;c<o.length;c++)s.push(g(o[c],i));return s}else console.warn(`[Components] Invalid value type passed to callWithAsyncErrorHandling(): ${typeof o}`)}function L(o){console.error(o)}var M=o=>{let i=new MutationObserver(s=>{for(let c of s)c.type==="childList"&&c.addedNodes.forEach(p=>{p.nodeName==="SCRIPT"&&p.hasAttribute("data-djc")&&o(p)})});return i.observe(document,{childList:!0,subtree:!0}),i};var y=()=>{let o=new Set,i=new Set,s={},c={},p=t=>{let e=new DOMParser().parseFromString(t,"text/html").querySelector("script");if(!e)throw Error("[Components] Failed to extract <script> tag. Make sure that the string contains <script><\/script> and is a valid HTML");return e},F=t=>{let e=new DOMParser().parseFromString(t,"text/html").querySelector("link");if(!e)throw Error("[Components] Failed to extract <link> tag. Make sure that the string contains <link></link> and is a valid HTML");return e},T=t=>{let e=document.createElement(t.tagName);e.innerHTML=t.innerHTML;for(let r of t.attributes)e.setAttributeNode(r.cloneNode());return e},f=t=>{let e=p(t),r=e.getAttribute("src");if(!r||C("js",r))return;d("js",r);let a=T(e),l=e.getAttribute("async")!=null||e.getAttribute("defer")!=null||e.getAttribute("type")==="module";a.async=l;let u=new Promise((n,b)=>{a.onload=()=>{n()},globalThis.document.body.append(a)});return{el:a,promise:u}},h=t=>{let e=F(t),r=e.getAttribute("href");if(!r||C("css",r))return;let a=T(e);return globalThis.document.head.append(a),d("css",r),{el:a,promise:Promise.resolve()}},d=(t,e)=>{if(t!=="js"&&t!=="css")throw Error(`[Components] markScriptLoaded received invalid script type '${t}'. Must be one of 'js', 'css'`);(t==="js"?o:i).add(e)},C=(t,e)=>{if(t!=="js"&&t!=="css")throw Error(`[Components] isScriptLoaded received invalid script type '${t}'. Must be one of 'js', 'css'`);return(t==="js"?o:i).has(e)},w=(t,e)=>{s[t]=e},j=(t,e,r)=>{let a=`${t}:${e}`;c[a]=r},A=(t,e,r)=>{let a=s[t];if(!a)throw Error(`[Components] '${t}': No component registered for that name`);let l=Array.from(document.querySelectorAll(`[data-comp-id-${e}]`));if(!l.length)throw Error(`[Components] '${t}': No elements with component ID '${e}' found`);let u=`${t}:${r}`,n=c[u];if(!n)throw Error(`[Components] '${t}': Cannot find input for hash '${r}'`);let b=n(),v={name:t,id:e,els:l},[P]=g(a,[b,v]);return P},k=async t=>{let e=t.loadedCssUrls.map(n=>atob(n)),r=t.loadedJsUrls.map(n=>atob(n)),a=t.toLoadCssTags.map(n=>atob(n)),l=t.toLoadJsTags.map(n=>atob(n));e.forEach(n=>d("css",n)),r.forEach(n=>d("js",n)),Promise.all(a.map(n=>h(n))).catch(console.error);let u=Promise.all(l.map(n=>f(n))).catch(console.error)};return M(t=>{let e=JSON.parse(t.text);k(e)}),{callComponent:A,registerComponent:w,registerComponentData:j,loadJs:f,loadCss:h,markScriptLoaded:d}};var $={manager:y(),createComponentsManager:y,unescapeJs:x};globalThis.Components=$;})();
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
import re
|
||||
from typing import Any, Callable, List, Optional, Type, TypeVar
|
||||
|
||||
from django.template.defaultfilters import escape
|
||||
|
||||
from django_components.util.nanoid import generate
|
||||
|
||||
T = TypeVar("T")
|
||||
|
@ -52,22 +50,6 @@ def get_import_path(cls_or_fn: Type[Any]) -> str:
|
|||
return module + "." + cls_or_fn.__qualname__
|
||||
|
||||
|
||||
# See https://stackoverflow.com/a/58800331/9788634
|
||||
# str.replace(/\\|`|\$/g, '\\$&');
|
||||
JS_STRING_LITERAL_SPECIAL_CHARS_REGEX = re.compile(r"\\|`|\$")
|
||||
|
||||
|
||||
# See https://stackoverflow.com/a/34064434/9788634
|
||||
def escape_js_string_literal(js: str) -> str:
|
||||
escaped_js = escape(js)
|
||||
|
||||
def on_replace_match(match: "re.Match[str]") -> str:
|
||||
return f"\\{match[0]}"
|
||||
|
||||
escaped_js = JS_STRING_LITERAL_SPECIAL_CHARS_REGEX.sub(on_replace_match, escaped_js)
|
||||
return escaped_js
|
||||
|
||||
|
||||
def default(val: Optional[T], default: T) -> T:
|
||||
return val if val is not None else default
|
||||
|
||||
|
@ -77,10 +59,3 @@ def get_last_index(lst: List, key: Callable[[Any], bool]) -> Optional[int]:
|
|||
if key(item):
|
||||
return len(lst) - 1 - index
|
||||
return None
|
||||
|
||||
|
||||
def _escape_js(js: str, eval: bool = True) -> str:
|
||||
escaped_js = escape_js_string_literal(js)
|
||||
# `unescapeJs` is the function we call in the browser to parse the escaped JS
|
||||
escaped_js = f"Components.unescapeJs(`{escaped_js}`)"
|
||||
return f"eval({escaped_js})" if eval else escaped_js
|
||||
|
|
|
@ -1,22 +1,14 @@
|
|||
/** This file defines the API of the JS code. */
|
||||
import { createComponentsManager } from './manager';
|
||||
import { unescapeJs } from './utils';
|
||||
|
||||
export type * from './manager';
|
||||
|
||||
export const Components = (() => {
|
||||
const manager = createComponentsManager();
|
||||
|
||||
/** Unescape JS that was escaped in Django side with `escape_js` */
|
||||
const unescapeJs = (escapedJs: string) => {
|
||||
return new DOMParser().parseFromString(escapedJs, 'text/html').documentElement.textContent;
|
||||
};
|
||||
|
||||
return {
|
||||
manager,
|
||||
createComponentsManager,
|
||||
unescapeJs,
|
||||
};
|
||||
})();
|
||||
export const Components = {
|
||||
manager: createComponentsManager(),
|
||||
createComponentsManager,
|
||||
unescapeJs,
|
||||
};
|
||||
|
||||
// In browser, this is accessed as `Components.manager`, etc
|
||||
globalThis.Components = Components;
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
/** The actual code of the JS dependency manager */
|
||||
import { callWithAsyncErrorHandling } from './errorHandling';
|
||||
import { observeScriptTag } from './mutationObserver';
|
||||
import { unescapeJs } from './utils';
|
||||
|
||||
type MaybePromise<T> = Promise<T> | T;
|
||||
|
||||
|
@ -233,24 +235,35 @@ export const createComponentsManager = () => {
|
|||
toLoadCssTags: string[];
|
||||
toLoadJsTags: string[];
|
||||
}) => {
|
||||
const loadedCssUrls = inputs.loadedCssUrls.map((s) => atob(s));
|
||||
const loadedJsUrls = inputs.loadedJsUrls.map((s) => atob(s));
|
||||
const toLoadCssTags = inputs.toLoadCssTags.map((s) => atob(s));
|
||||
const toLoadJsTags = inputs.toLoadJsTags.map((s) => atob(s));
|
||||
|
||||
// Mark as loaded the CSS that WAS inlined into the HTML.
|
||||
inputs.loadedCssUrls.forEach((s) => markScriptLoaded("css", s));
|
||||
inputs.loadedJsUrls.forEach((s) => markScriptLoaded("js", s));
|
||||
loadedCssUrls.forEach((s) => markScriptLoaded("css", s));
|
||||
loadedJsUrls.forEach((s) => markScriptLoaded("js", s));
|
||||
|
||||
// Load CSS that was not inlined into the HTML
|
||||
// NOTE: We don't need to wait for CSS to load
|
||||
Promise
|
||||
.all(inputs.toLoadCssTags.map((s) => loadCss(s)))
|
||||
.all(toLoadCssTags.map((s) => loadCss(s)))
|
||||
.catch(console.error);
|
||||
|
||||
// Load JS that was not inlined into the HTML
|
||||
const jsScriptsPromise = Promise
|
||||
// NOTE: Interestingly enough, when we insert scripts into the DOM programmatically,
|
||||
// the order of execution is the same as the order of insertion.
|
||||
.all(inputs.toLoadJsTags.map((s) => loadJs(s)))
|
||||
.all(toLoadJsTags.map((s) => loadJs(s)))
|
||||
.catch(console.error);
|
||||
};
|
||||
|
||||
// Initialise the MutationObserver that watches for `<script>` tags with `data-djc` attribute
|
||||
observeScriptTag((script) => {
|
||||
const data = JSON.parse(script.text);
|
||||
_loadComponentScripts(data);
|
||||
});
|
||||
|
||||
return {
|
||||
callComponent,
|
||||
registerComponent,
|
||||
|
@ -258,6 +271,5 @@ export const createComponentsManager = () => {
|
|||
loadJs,
|
||||
loadCss,
|
||||
markScriptLoaded,
|
||||
_loadComponentScripts,
|
||||
};
|
||||
};
|
||||
|
|
27
src/django_components_js/src/mutationObserver.ts
Normal file
27
src/django_components_js/src/mutationObserver.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
/** Set up MutationObserver that watches for `<script>` tags with `data-djc` attribute */
|
||||
export const observeScriptTag = (onScriptTag: (node: HTMLScriptElement) => void) => {
|
||||
const observer = new MutationObserver((mutationsList) => {
|
||||
for (const mutation of mutationsList) {
|
||||
if (mutation.type === "childList") {
|
||||
// Check added nodes
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (
|
||||
node.nodeName === "SCRIPT" &&
|
||||
(node as HTMLElement).hasAttribute("data-djc")
|
||||
) {
|
||||
onScriptTag(node as HTMLScriptElement);
|
||||
}
|
||||
});
|
||||
2;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Observe the entire document
|
||||
observer.observe(document, {
|
||||
childList: true,
|
||||
subtree: true, // To detect nodes added anywhere in the DOM
|
||||
});
|
||||
|
||||
return observer;
|
||||
};
|
|
@ -1,10 +1,19 @@
|
|||
// Helper functions taken from @vue/shared
|
||||
/** Unescape JS that was escaped in Django side with `escape_js` */
|
||||
export const unescapeJs = (escapedJs: string) => {
|
||||
const doc = new DOMParser().parseFromString(escapedJs, "text/html")
|
||||
return doc.documentElement.textContent as string;
|
||||
};
|
||||
|
||||
// ////////////////////////////////////////////////////////
|
||||
// Helper functions below were taken from @vue/shared
|
||||
// See https://github.com/vuejs/core/blob/91112520427ff55941a1c759d7d60a0811ff4a61/packages/shared/src/general.ts#L105
|
||||
// ////////////////////////////////////////////////////////
|
||||
|
||||
export const isArray = Array.isArray;
|
||||
export const isFunction = (val: unknown): val is Function => typeof val === 'function';
|
||||
export const isFunction = (val: unknown): val is Function =>
|
||||
typeof val === "function";
|
||||
export const isObject = (val: unknown): val is Record<any, any> => {
|
||||
return val !== null && typeof val === 'object';
|
||||
return val !== null && typeof val === "object";
|
||||
};
|
||||
export const isPromise = <T = any>(val: unknown): val is Promise<T> => {
|
||||
return (
|
||||
|
|
|
@ -111,6 +111,74 @@ class CheckScriptOrderInMedia(Component):
|
|||
js = "check_script_order.js"
|
||||
|
||||
|
||||
# Fragment where the JS and CSS are defined on the Component
|
||||
@register("frag_comp")
|
||||
class FragComp(Component):
|
||||
template: types.django_html = """
|
||||
<div class="frag">
|
||||
123
|
||||
<span id="frag-text"></span>
|
||||
</div>
|
||||
"""
|
||||
|
||||
js = """
|
||||
document.querySelector('#frag-text').textContent = 'xxx';
|
||||
"""
|
||||
|
||||
css = """
|
||||
.frag {
|
||||
background: blue;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
# Fragment where the JS and CSS are defined on the Media class
|
||||
@register("frag_media")
|
||||
class FragMedia(Component):
|
||||
template = """
|
||||
<div class="frag">
|
||||
123
|
||||
<span id="frag-text"></span>
|
||||
</div>
|
||||
"""
|
||||
|
||||
class Media:
|
||||
js = "fragment.js"
|
||||
css = "fragment.css"
|
||||
|
||||
|
||||
# Fragment that defines an AlpineJS component
|
||||
@register("frag_alpine")
|
||||
class FragAlpine(Component):
|
||||
template = """
|
||||
<template x-if="false" data-name="frag">
|
||||
<div class="frag">
|
||||
123
|
||||
<span x-data="frag" x-text="fragVal">
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
"""
|
||||
|
||||
js = """
|
||||
Alpine.data('frag', () => ({
|
||||
fragVal: 'xxx',
|
||||
}));
|
||||
|
||||
// Now that the component has been defined in AlpineJS, we can "activate" all instances
|
||||
// where we use the `x-data="frag"` directive.
|
||||
document.querySelectorAll('[data-name="frag"]').forEach((el) => {
|
||||
el.setAttribute('x-if', 'true');
|
||||
});
|
||||
"""
|
||||
|
||||
css = """
|
||||
.frag {
|
||||
background: blue;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
@register("alpine_test_in_media")
|
||||
class AlpineCompInMedia(Component):
|
||||
template: types.django_html = """
|
||||
|
|
3
tests/e2e/testserver/testserver/static/fragment.css
Normal file
3
tests/e2e/testserver/testserver/static/fragment.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
.frag {
|
||||
background: blue;
|
||||
}
|
1
tests/e2e/testserver/testserver/static/fragment.js
Normal file
1
tests/e2e/testserver/testserver/static/fragment.js
Normal file
|
@ -0,0 +1 @@
|
|||
document.querySelector('#frag-text').textContent = 'xxx';
|
|
@ -8,6 +8,10 @@ from testserver.views import (
|
|||
check_js_order_in_js_view,
|
||||
check_js_order_in_media_view,
|
||||
check_js_order_vars_not_available_before_view,
|
||||
fragment_base_alpine_view,
|
||||
fragment_base_htmx_view,
|
||||
fragment_base_js_view,
|
||||
fragment_view,
|
||||
multiple_components_view,
|
||||
single_component_view,
|
||||
)
|
||||
|
@ -22,6 +26,10 @@ urlpatterns = [
|
|||
path("js-order/js", check_js_order_in_js_view),
|
||||
path("js-order/media", check_js_order_in_media_view),
|
||||
path("js-order/invalid", check_js_order_vars_not_available_before_view),
|
||||
path("fragment/base/alpine", fragment_base_alpine_view),
|
||||
path("fragment/base/htmx", fragment_base_htmx_view),
|
||||
path("fragment/base/js", fragment_base_js_view),
|
||||
path("fragment/frag", fragment_view),
|
||||
path("alpine/head", alpine_in_head_view),
|
||||
path("alpine/body", alpine_in_body_view),
|
||||
path("alpine/body2", alpine_in_body_view_2),
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
from django.http import HttpResponse
|
||||
from django.template import Context, Template
|
||||
from testserver.components import FragComp, FragMedia
|
||||
|
||||
from django_components import render_dependencies, types
|
||||
|
||||
|
@ -126,6 +127,136 @@ def check_js_order_vars_not_available_before_view(request):
|
|||
return HttpResponse(rendered)
|
||||
|
||||
|
||||
# HTML into which a fragment will be loaded using vanilla JS
|
||||
def fragment_base_js_view(request):
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
{% component_css_dependencies %}
|
||||
</head>
|
||||
<body>
|
||||
{% component 'inner' variable='foo' / %}
|
||||
|
||||
<div id="target">OLD</div>
|
||||
|
||||
<button id="loader">
|
||||
Click me!
|
||||
</button>
|
||||
<script>
|
||||
const frag = "{{ frag }}";
|
||||
document.querySelector('#loader').addEventListener('click', function () {
|
||||
fetch(`/fragment/frag?frag=${frag}`)
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
console.log({ fragment: html })
|
||||
document.querySelector('#target').outerHTML = html;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% component_js_dependencies %}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
template = Template(template_str)
|
||||
|
||||
frag = request.GET["frag"]
|
||||
rendered_raw = template.render(
|
||||
Context(
|
||||
{
|
||||
"frag": frag,
|
||||
}
|
||||
)
|
||||
)
|
||||
rendered = render_dependencies(rendered_raw)
|
||||
return HttpResponse(rendered)
|
||||
|
||||
|
||||
# HTML into which a fragment will be loaded using AlpineJS
|
||||
def fragment_base_alpine_view(request):
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
{% component_css_dependencies %}
|
||||
<script defer src="https://unpkg.com/alpinejs"></script>
|
||||
</head>
|
||||
<body x-data="{
|
||||
htmlVar: 'OLD',
|
||||
loadFragment: function () {
|
||||
const frag = '{{ frag }}';
|
||||
fetch(`/fragment/frag?frag=${frag}`)
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
console.log({ fragment: html });
|
||||
this.htmlVar = html;
|
||||
});
|
||||
}
|
||||
}">
|
||||
{% component 'inner' variable='foo' / %}
|
||||
|
||||
<div id="target" x-html="htmlVar">OLD</div>
|
||||
|
||||
<button id="loader" @click="loadFragment">
|
||||
Click me!
|
||||
</button>
|
||||
|
||||
{% component_js_dependencies %}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
template = Template(template_str)
|
||||
|
||||
frag = request.GET["frag"]
|
||||
rendered_raw = template.render(Context({"frag": frag}))
|
||||
rendered = render_dependencies(rendered_raw)
|
||||
return HttpResponse(rendered)
|
||||
|
||||
|
||||
# HTML into which a fragment will be loaded using HTMX
|
||||
def fragment_base_htmx_view(request):
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
{% component_css_dependencies %}
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
</head>
|
||||
<body>
|
||||
{% component 'inner' variable='foo' / %}
|
||||
|
||||
<div id="target">OLD</div>
|
||||
|
||||
<button id="loader" hx-get="/fragment/frag?frag={{ frag }}" hx-swap="outerHTML" hx-target="#target">
|
||||
Click me!
|
||||
</button>
|
||||
|
||||
{% component_js_dependencies %}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
template = Template(template_str)
|
||||
|
||||
frag = request.GET["frag"]
|
||||
rendered_raw = template.render(Context({"frag": frag}))
|
||||
rendered = render_dependencies(rendered_raw)
|
||||
return HttpResponse(rendered)
|
||||
|
||||
|
||||
def fragment_view(request):
|
||||
fragment_type = request.GET["frag"]
|
||||
if fragment_type == "comp":
|
||||
return FragComp.render_to_response(type="fragment")
|
||||
elif fragment_type == "media":
|
||||
return FragMedia.render_to_response(type="fragment")
|
||||
else:
|
||||
raise ValueError("Invalid fragment type")
|
||||
|
||||
|
||||
def alpine_in_head_view(request):
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
|
|
|
@ -49,7 +49,7 @@ class InlineComponentTest(BaseTestCase):
|
|||
rendered,
|
||||
)
|
||||
self.assertInHTML(
|
||||
"<script>eval(Components.unescapeJs(`console.log('HTML and JS only');`))</script>",
|
||||
"<script>console.log('HTML and JS only');</script>",
|
||||
rendered,
|
||||
)
|
||||
|
||||
|
@ -106,7 +106,7 @@ class ComponentMediaTests(BaseTestCase):
|
|||
self.assertEqual(rendered.count("<style"), 0)
|
||||
self.assertEqual(rendered.count("<link"), 0)
|
||||
|
||||
self.assertEqual(rendered.count("<script"), 2) # 2 Boilerplate scripts
|
||||
self.assertEqual(rendered.count("<script"), 1) # 1 Boilerplate script
|
||||
|
||||
def test_css_js_as_lists(self):
|
||||
class SimpleComponent(Component):
|
||||
|
|
|
@ -68,9 +68,7 @@ class RenderDependenciesTests(BaseTestCase):
|
|||
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
|
||||
|
||||
self.assertInHTML("<style>.xyz { color: red; }</style>", rendered, count=1) # Inlined CSS
|
||||
self.assertInHTML(
|
||||
"<script>eval(Components.unescapeJs(`console.log("xyz");`))</script>", rendered, count=1
|
||||
) # Inlined JS
|
||||
self.assertInHTML('<script>console.log("xyz");</script>', rendered, count=1) # Inlined JS
|
||||
|
||||
self.assertInHTML('<link href="style.css" media="all" rel="stylesheet">', rendered, count=1) # Media.css
|
||||
|
||||
|
@ -90,9 +88,7 @@ class RenderDependenciesTests(BaseTestCase):
|
|||
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
|
||||
|
||||
self.assertInHTML("<style>.xyz { color: red; }</style>", rendered, count=1) # Inlined CSS
|
||||
self.assertInHTML(
|
||||
"<script>eval(Components.unescapeJs(`console.log("xyz");`))</script>", rendered, count=1
|
||||
) # Inlined JS
|
||||
self.assertInHTML('<script>console.log("xyz");</script>', rendered, count=1) # Inlined JS
|
||||
|
||||
self.assertInHTML('<link href="style.css" media="all" rel="stylesheet">', rendered, count=1) # Media.css
|
||||
self.assertEqual(rendered.count("<link"), 1)
|
||||
|
@ -119,9 +115,7 @@ class RenderDependenciesTests(BaseTestCase):
|
|||
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
|
||||
|
||||
self.assertInHTML("<style>.xyz { color: red; }</style>", rendered, count=1) # Inlined CSS
|
||||
self.assertInHTML(
|
||||
"<script>eval(Components.unescapeJs(`console.log("xyz");`))</script>", rendered, count=1
|
||||
) # Inlined JS
|
||||
self.assertInHTML('<script>console.log("xyz");</script>', rendered, count=1) # Inlined JS
|
||||
|
||||
self.assertInHTML('<link href="style.css" media="all" rel="stylesheet">', rendered, count=1) # Media.css
|
||||
self.assertEqual(rendered.count("<link"), 1)
|
||||
|
@ -157,7 +151,7 @@ class RenderDependenciesTests(BaseTestCase):
|
|||
self.assertInHTML('<link href="style.css" media="all" rel="stylesheet">', rendered_raw, count=0) # Media.css
|
||||
|
||||
self.assertInHTML(
|
||||
"<script>eval(Components.unescapeJs(`console.log("xyz");`))</script>",
|
||||
'<script>console.log("xyz");</script>',
|
||||
rendered_raw,
|
||||
count=0,
|
||||
) # Inlined JS
|
||||
|
@ -184,9 +178,7 @@ class RenderDependenciesTests(BaseTestCase):
|
|||
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
|
||||
|
||||
self.assertInHTML("<style>.xyz { color: red; }</style>", rendered, count=1) # Inlined CSS
|
||||
self.assertInHTML(
|
||||
"<script>eval(Components.unescapeJs(`console.log("xyz");`))</script>", rendered, count=1
|
||||
) # Inlined JS
|
||||
self.assertInHTML('<script>console.log("xyz");</script>', rendered, count=1) # Inlined JS
|
||||
|
||||
self.assertEqual(rendered.count('<link href="style.css" media="all" rel="stylesheet">'), 1) # Media.css
|
||||
self.assertEqual(rendered.count("<link"), 1)
|
||||
|
@ -234,7 +226,7 @@ class RenderDependenciesTests(BaseTestCase):
|
|||
count=1,
|
||||
)
|
||||
self.assertInHTML(
|
||||
"""<script>eval(Components.unescapeJs(`console.log("xyz");`))</script>""",
|
||||
'<script>console.log("xyz");</script>',
|
||||
rendered_body,
|
||||
count=1,
|
||||
)
|
||||
|
@ -286,7 +278,7 @@ class RenderDependenciesTests(BaseTestCase):
|
|||
count=1,
|
||||
)
|
||||
self.assertInHTML(
|
||||
"""<script>eval(Components.unescapeJs(`console.log("xyz");`))</script>""",
|
||||
'<script>console.log("xyz");</script>',
|
||||
rendered_head,
|
||||
count=1,
|
||||
)
|
||||
|
@ -401,6 +393,11 @@ class RenderDependenciesTests(BaseTestCase):
|
|||
rendered_raw = Template(template_str).render(Context({"formset": [1]}))
|
||||
rendered = render_dependencies(rendered_raw, type="fragment")
|
||||
|
||||
# Base64 encodings:
|
||||
# `PGxpbmsgaHJlZj0ic3R5bGUuY3NzIiBtZWRpYT0iYWxsIiByZWw9InN0eWxlc2hlZXQiPg==` -> `<link href="style.css" media="all" rel="stylesheet">` # noqa: E501
|
||||
# `PGxpbmsgaHJlZj0iL2NvbXBvbmVudHMvY2FjaGUvU2ltcGxlQ29tcG9uZW50XzMxMTA5Ny5jc3MiIG1lZGlhPSJhbGwiIHJlbD0ic3R5bGVzaGVldCI+` -> `<link href="/components/cache/SimpleComponent_311097.css" media="all" rel="stylesheet">` # noqa: E501
|
||||
# `PHNjcmlwdCBzcmM9InNjcmlwdC5qcyI+PC9zY3JpcHQ+` -> `<script src="script.js"></script>`
|
||||
# `PHNjcmlwdCBzcmM9Ii9jb21wb25lbnRzL2NhY2hlL1NpbXBsZUNvbXBvbmVudF8zMTEwOTcuanMiPjwvc2NyaXB0Pg==` -> `<script src="/components/cache/SimpleComponent_311097.js"></script>` # noqa: E501
|
||||
expected = """
|
||||
<table class="table-auto border-collapse divide-y divide-x divide-slate-300 w-full">
|
||||
<!-- Table head -->
|
||||
|
@ -423,10 +420,49 @@ class RenderDependenciesTests(BaseTestCase):
|
|||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
"""
|
||||
<script type="application/json" data-djc>
|
||||
{"loadedCssUrls": [],
|
||||
"loadedJsUrls": [],
|
||||
"toLoadCssTags": ["PGxpbmsgaHJlZj0ic3R5bGUuY3NzIiBtZWRpYT0iYWxsIiByZWw9InN0eWxlc2hlZXQiPg==",
|
||||
"PGxpbmsgaHJlZj0iL2NvbXBvbmVudHMvY2FjaGUvU2ltcGxlQ29tcG9uZW50XzMxMTA5Ny5jc3MiIG1lZGlhPSJhbGwiIHJlbD0ic3R5bGVzaGVldCI+"],
|
||||
"toLoadJsTags": ["PHNjcmlwdCBzcmM9InNjcmlwdC5qcyI+PC9zY3JpcHQ+",
|
||||
"PHNjcmlwdCBzcmM9Ii9jb21wb25lbnRzL2NhY2hlL1NpbXBsZUNvbXBvbmVudF8zMTEwOTcuanMiPjwvc2NyaXB0Pg=="]}
|
||||
</script>
|
||||
""" # noqa: E501
|
||||
|
||||
self.assertHTMLEqual(expected, rendered)
|
||||
|
||||
def test_raises_if_script_end_tag_inside_component_js(self):
|
||||
class ComponentWithScript(SimpleComponent):
|
||||
js: types.js = """
|
||||
console.log("</script >");
|
||||
"""
|
||||
|
||||
registry.register(name="test", component=ComponentWithScript)
|
||||
|
||||
with self.assertRaisesMessage(
|
||||
RuntimeError,
|
||||
"Content of `Component.js` for component 'ComponentWithScript' contains '</script>' end tag.",
|
||||
):
|
||||
ComponentWithScript.render(kwargs={"variable": "foo"})
|
||||
|
||||
def test_raises_if_script_end_tag_inside_component_css(self):
|
||||
class ComponentWithScript(SimpleComponent):
|
||||
css: types.css = """
|
||||
/* </style > */
|
||||
.xyz {
|
||||
color: red;
|
||||
}
|
||||
"""
|
||||
|
||||
registry.register(name="test", component=ComponentWithScript)
|
||||
|
||||
with self.assertRaisesMessage(
|
||||
RuntimeError,
|
||||
"Content of `Component.css` for component 'ComponentWithScript' contains '</style>' end tag.",
|
||||
):
|
||||
ComponentWithScript.render(kwargs={"variable": "foo"})
|
||||
|
||||
|
||||
class MiddlewareTests(BaseTestCase):
|
||||
def test_middleware_response_without_content_type(self):
|
||||
|
@ -462,9 +498,7 @@ class MiddlewareTests(BaseTestCase):
|
|||
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
|
||||
|
||||
# Inlined JS
|
||||
self.assertInHTML(
|
||||
"<script>eval(Components.unescapeJs(`console.log("xyz");`))</script>", rendered, count=1
|
||||
)
|
||||
self.assertInHTML('<script>console.log("xyz");</script>', rendered, count=1)
|
||||
# Inlined CSS
|
||||
self.assertInHTML("<style>.xyz { color: red; }</style>", rendered, count=1)
|
||||
# Media.css
|
||||
|
|
|
@ -69,7 +69,6 @@ class DependencyManagerTests(_BaseDepManagerTestCase):
|
|||
"loadJs",
|
||||
"loadCss",
|
||||
"markScriptLoaded",
|
||||
"_loadComponentScripts",
|
||||
],
|
||||
)
|
||||
|
||||
|
|
|
@ -123,23 +123,14 @@ class DependencyRenderingTests(BaseTestCase):
|
|||
# Dependency manager script
|
||||
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
|
||||
|
||||
self.assertEqual(rendered.count("<script"), 2) # Two 2 scripts belong to the boilerplate
|
||||
self.assertEqual(rendered.count("<script"), 1) # 1 boilerplate script
|
||||
self.assertEqual(rendered.count("<link"), 0) # No CSS
|
||||
self.assertEqual(rendered.count("<style"), 0)
|
||||
|
||||
# We expect to find this:
|
||||
# ```js
|
||||
# Components.manager._loadComponentScripts({
|
||||
# loadedCssUrls: [],
|
||||
# loadedJsUrls: [],
|
||||
# toLoadCssTags: [],
|
||||
# toLoadJsTags: [],
|
||||
# });
|
||||
# ```
|
||||
self.assertEqual(rendered.count("loadedJsUrls: [],"), 1)
|
||||
self.assertEqual(rendered.count("loadedCssUrls: [],"), 1)
|
||||
self.assertEqual(rendered.count("toLoadJsTags: [],"), 1)
|
||||
self.assertEqual(rendered.count("toLoadCssTags: [],"), 1)
|
||||
self.assertNotIn("loadedJsUrls", rendered)
|
||||
self.assertNotIn("loadedCssUrls", rendered)
|
||||
self.assertNotIn("toLoadJsTags", rendered)
|
||||
self.assertNotIn("toLoadCssTags", rendered)
|
||||
|
||||
def test_no_js_dependencies_when_no_components_used(self):
|
||||
registry.register(name="test", component=SimpleComponent)
|
||||
|
@ -153,23 +144,14 @@ class DependencyRenderingTests(BaseTestCase):
|
|||
# Dependency manager script
|
||||
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
|
||||
|
||||
self.assertEqual(rendered.count("<script"), 2) # Two 2 scripts belong to the boilerplate
|
||||
self.assertEqual(rendered.count("<script"), 1) # 1 boilerplate script
|
||||
self.assertEqual(rendered.count("<link"), 0) # No CSS
|
||||
self.assertEqual(rendered.count("<style"), 0)
|
||||
|
||||
# We expect to find this:
|
||||
# ```js
|
||||
# Components.manager._loadComponentScripts({
|
||||
# loadedCssUrls: [],
|
||||
# loadedJsUrls: [],
|
||||
# toLoadCssTags: [],
|
||||
# toLoadJsTags: [],
|
||||
# });
|
||||
# ```
|
||||
self.assertEqual(rendered.count("loadedJsUrls: [],"), 1)
|
||||
self.assertEqual(rendered.count("loadedCssUrls: [],"), 1)
|
||||
self.assertEqual(rendered.count("toLoadJsTags: [],"), 1)
|
||||
self.assertEqual(rendered.count("toLoadCssTags: [],"), 1)
|
||||
self.assertNotIn("loadedJsUrls", rendered)
|
||||
self.assertNotIn("loadedCssUrls", rendered)
|
||||
self.assertNotIn("toLoadJsTags", rendered)
|
||||
self.assertNotIn("toLoadCssTags", rendered)
|
||||
|
||||
def test_no_css_dependencies_when_no_components_used(self):
|
||||
registry.register(name="test", component=SimpleComponent)
|
||||
|
@ -204,19 +186,20 @@ class DependencyRenderingTests(BaseTestCase):
|
|||
self.assertEqual(rendered.count("<style"), 0)
|
||||
self.assertEqual(rendered.count("<script"), 3)
|
||||
|
||||
# We expect to find this:
|
||||
# ```js
|
||||
# Components.manager._loadComponentScripts({
|
||||
# loadedCssUrls: ["style.css"],
|
||||
# loadedJsUrls: ["script.js"],
|
||||
# toLoadCssTags: [],
|
||||
# toLoadJsTags: [],
|
||||
# });
|
||||
# ```
|
||||
self.assertEqual(rendered.count("loadedJsUrls: ["script.js"],"), 1)
|
||||
self.assertEqual(rendered.count("loadedCssUrls: ["style.css"],"), 1)
|
||||
self.assertEqual(rendered.count("toLoadJsTags: [],"), 1)
|
||||
self.assertEqual(rendered.count("toLoadCssTags: [],"), 1)
|
||||
# `c3R5bGUuY3Nz` is base64 encoded `style.css`
|
||||
# `c2NyaXB0Lmpz` is base64 encoded `style.js`
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<script type="application/json" data-djc>
|
||||
{"loadedCssUrls": ["c3R5bGUuY3Nz"],
|
||||
"loadedJsUrls": ["c2NyaXB0Lmpz"],
|
||||
"toLoadCssTags": [],
|
||||
"toLoadJsTags": []}
|
||||
</script>
|
||||
""",
|
||||
rendered,
|
||||
count=1,
|
||||
)
|
||||
|
||||
def test_single_component_with_dash_or_slash_in_name(self):
|
||||
registry.register(name="te-s/t", component=SimpleComponent)
|
||||
|
@ -238,19 +221,20 @@ class DependencyRenderingTests(BaseTestCase):
|
|||
self.assertEqual(rendered.count("<style"), 0)
|
||||
self.assertEqual(rendered.count("<script"), 3)
|
||||
|
||||
# We expect to find this:
|
||||
# ```js
|
||||
# Components.manager._loadComponentScripts({
|
||||
# loadedCssUrls: ["style.css"],
|
||||
# loadedJsUrls: ["script.js"],
|
||||
# toLoadCssTags: [],
|
||||
# toLoadJsTags: [],
|
||||
# });
|
||||
# ```
|
||||
self.assertEqual(rendered.count("loadedJsUrls: ["script.js"],"), 1)
|
||||
self.assertEqual(rendered.count("loadedCssUrls: ["style.css"],"), 1)
|
||||
self.assertEqual(rendered.count("toLoadJsTags: [],"), 1)
|
||||
self.assertEqual(rendered.count("toLoadCssTags: [],"), 1)
|
||||
# `c3R5bGUuY3Nz` is base64 encoded `style.css`
|
||||
# `c2NyaXB0Lmpz` is base64 encoded `style.js`
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<script type="application/json" data-djc>
|
||||
{"loadedCssUrls": ["c3R5bGUuY3Nz"],
|
||||
"loadedJsUrls": ["c2NyaXB0Lmpz"],
|
||||
"toLoadCssTags": [],
|
||||
"toLoadJsTags": []}
|
||||
</script>
|
||||
""",
|
||||
rendered,
|
||||
count=1,
|
||||
)
|
||||
|
||||
def test_single_component_placeholder_removed(self):
|
||||
registry.register(name="test", component=SimpleComponent)
|
||||
|
@ -303,19 +287,20 @@ class DependencyRenderingTests(BaseTestCase):
|
|||
self.assertEqual(rendered.count("<style"), 0)
|
||||
self.assertEqual(rendered.count("<script"), 3)
|
||||
|
||||
# We expect to find this:
|
||||
# ```js
|
||||
# Components.manager._loadComponentScripts({
|
||||
# loadedCssUrls: ["style.css"],
|
||||
# loadedJsUrls: ["script.js"],
|
||||
# toLoadCssTags: [],
|
||||
# toLoadJsTags: [],
|
||||
# });
|
||||
# ```
|
||||
self.assertEqual(rendered.count("loadedJsUrls: ["script.js"],"), 1)
|
||||
self.assertEqual(rendered.count("loadedCssUrls: ["style.css"],"), 1)
|
||||
self.assertEqual(rendered.count("toLoadJsTags: [],"), 1)
|
||||
self.assertEqual(rendered.count("toLoadCssTags: [],"), 1)
|
||||
# `c3R5bGUuY3Nz` is base64 encoded `style.css`
|
||||
# `c2NyaXB0Lmpz` is base64 encoded `style.js`
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<script type="application/json" data-djc>
|
||||
{"loadedCssUrls": ["c3R5bGUuY3Nz"],
|
||||
"loadedJsUrls": ["c2NyaXB0Lmpz"],
|
||||
"toLoadCssTags": [],
|
||||
"toLoadJsTags": []}
|
||||
</script>
|
||||
""",
|
||||
rendered,
|
||||
count=1,
|
||||
)
|
||||
|
||||
def test_all_dependencies_are_rendered_for_component_with_multiple_dependencies(
|
||||
self,
|
||||
|
@ -357,19 +342,23 @@ class DependencyRenderingTests(BaseTestCase):
|
|||
count=1,
|
||||
)
|
||||
|
||||
# We expect to find this:
|
||||
# ```js
|
||||
# Components.manager._loadComponentScripts({
|
||||
# loadedCssUrls: ["style.css", "style2.css"],
|
||||
# loadedJsUrls: ["script.js", "script2.js"],
|
||||
# toLoadCssTags: [],
|
||||
# toLoadJsTags: [],
|
||||
# });
|
||||
# ```
|
||||
self.assertEqual(rendered.count("loadedCssUrls: ["style.css", "style2.css"],"), 1)
|
||||
self.assertEqual(rendered.count("loadedJsUrls: ["script.js", "script2.js""), 1)
|
||||
self.assertEqual(rendered.count("toLoadCssTags: [],"), 1)
|
||||
self.assertEqual(rendered.count("toLoadJsTags: [],"), 1)
|
||||
# Base64 encoding:
|
||||
# `c3R5bGUuY3Nz` -> `style.css`
|
||||
# `c3R5bGUyLmNzcw==` -> `style2.css`
|
||||
# `c2NyaXB0Lmpz` -> `script.js`
|
||||
# `c2NyaXB0Mi5qcw==` -> `script2.js`
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<script type="application/json" data-djc>
|
||||
{"loadedCssUrls": ["c3R5bGUuY3Nz", "c3R5bGUyLmNzcw=="],
|
||||
"loadedJsUrls": ["c2NyaXB0Lmpz", "c2NyaXB0Mi5qcw=="],
|
||||
"toLoadCssTags": [],
|
||||
"toLoadJsTags": []}
|
||||
</script>
|
||||
""",
|
||||
rendered,
|
||||
count=1,
|
||||
)
|
||||
|
||||
def test_no_dependencies_with_multiple_unused_components(self):
|
||||
registry.register(name="inner", component=SimpleComponent)
|
||||
|
@ -386,23 +375,14 @@ class DependencyRenderingTests(BaseTestCase):
|
|||
# Dependency manager script
|
||||
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
|
||||
|
||||
self.assertEqual(rendered.count("<script"), 2) # 2 scripts belong to the boilerplate
|
||||
self.assertEqual(rendered.count("<script"), 1) # 1 boilerplate script
|
||||
self.assertEqual(rendered.count("<link"), 0) # No CSS
|
||||
self.assertEqual(rendered.count("<style"), 0)
|
||||
|
||||
# We expect to find this:
|
||||
# ```js
|
||||
# Components.manager._loadComponentScripts({
|
||||
# loadedCssUrls: [],
|
||||
# loadedJsUrls: [],
|
||||
# toLoadCssTags: [],
|
||||
# toLoadJsTags: [],
|
||||
# });
|
||||
# ```
|
||||
self.assertEqual(rendered.count("loadedJsUrls: [],"), 1)
|
||||
self.assertEqual(rendered.count("loadedCssUrls: [],"), 1)
|
||||
self.assertEqual(rendered.count("toLoadJsTags: [],"), 1)
|
||||
self.assertEqual(rendered.count("toLoadCssTags: [],"), 1)
|
||||
self.assertNotIn("loadedJsUrls", rendered)
|
||||
self.assertNotIn("loadedCssUrls", rendered)
|
||||
self.assertNotIn("toLoadJsTags", rendered)
|
||||
self.assertNotIn("toLoadCssTags", rendered)
|
||||
|
||||
def test_multiple_components_dependencies(self):
|
||||
registry.register(name="inner", component=SimpleComponent)
|
||||
|
@ -464,36 +444,36 @@ class DependencyRenderingTests(BaseTestCase):
|
|||
<script src="script2.js"></script>
|
||||
<script src="script.js"></script>
|
||||
<script src="xyz1.js"></script>
|
||||
<script>eval(Components.unescapeJs(`console.log("Hello");`))</script>
|
||||
<script>eval(Components.unescapeJs(`console.log("xyz");`))</script>
|
||||
<script>console.log("Hello");</script>
|
||||
<script>console.log("xyz");</script>
|
||||
""",
|
||||
rendered,
|
||||
count=1,
|
||||
)
|
||||
|
||||
# We expect to find this:
|
||||
# ```js
|
||||
# Components.manager._loadComponentScripts({
|
||||
# loadedCssUrls: ["/components/cache/OtherComponent_6329ae.css/", "/components/cache/SimpleComponentNested_f02d32.css/", "style.css", "style2.css", "xyz1.css"],
|
||||
# loadedJsUrls: ["/components/cache/OtherComponent_6329ae.js/", "/components/cache/SimpleComponentNested_f02d32.js/", "script.js", "script2.js", "xyz1.js"],
|
||||
# toLoadCssTags: [],
|
||||
# toLoadJsTags: [],
|
||||
# });
|
||||
# ```
|
||||
self.assertEqual(
|
||||
rendered.count(
|
||||
"loadedJsUrls: ["/components/cache/OtherComponent_6329ae.js/", "/components/cache/SimpleComponentNested_f02d32.js/", "script.js", "script2.js", "xyz1.js"],"
|
||||
),
|
||||
1,
|
||||
# Base64 encoding:
|
||||
# `c3R5bGUuY3Nz` -> `style.css`
|
||||
# `c3R5bGUyLmNzcw==` -> `style2.css`
|
||||
# `eHl6MS5jc3M=` -> `xyz1.css`
|
||||
# `L2NvbXBvbmVudHMvY2FjaGUvT3RoZXJDb21wb25lbnRfNjMyOWFlLmNzcw==` -> `/components/cache/OtherComponent_6329ae.css`
|
||||
# `L2NvbXBvbmVudHMvY2FjaGUvU2ltcGxlQ29tcG9uZW50TmVzdGVkX2YwMmQzMi5jc3M=` -> `/components/cache/SimpleComponentNested_f02d32.css`
|
||||
# `L2NvbXBvbmVudHMvY2FjaGUvT3RoZXJDb21wb25lbnRfNjMyOWFlLmpz` -> `/components/cache/OtherComponent_6329ae.js`
|
||||
# `L2NvbXBvbmVudHMvY2FjaGUvU2ltcGxlQ29tcG9uZW50TmVzdGVkX2YwMmQzMi5qcw==` -> `/components/cache/SimpleComponentNested_f02d32.js`
|
||||
# `c2NyaXB0Lmpz` -> `script.js`
|
||||
# `c2NyaXB0Mi5qcw==` -> `script2.js`
|
||||
# `eHl6MS5qcw==` -> `xyz1.js`
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<script type="application/json" data-djc>
|
||||
{"loadedCssUrls": ["L2NvbXBvbmVudHMvY2FjaGUvT3RoZXJDb21wb25lbnRfNjMyOWFlLmNzcw==", "L2NvbXBvbmVudHMvY2FjaGUvU2ltcGxlQ29tcG9uZW50TmVzdGVkX2YwMmQzMi5jc3M=", "c3R5bGUuY3Nz", "c3R5bGUyLmNzcw==", "eHl6MS5jc3M="],
|
||||
"loadedJsUrls": ["L2NvbXBvbmVudHMvY2FjaGUvT3RoZXJDb21wb25lbnRfNjMyOWFlLmpz", "L2NvbXBvbmVudHMvY2FjaGUvU2ltcGxlQ29tcG9uZW50TmVzdGVkX2YwMmQzMi5qcw==", "c2NyaXB0Lmpz", "c2NyaXB0Mi5qcw==", "eHl6MS5qcw=="],
|
||||
"toLoadCssTags": [],
|
||||
"toLoadJsTags": []}
|
||||
</script>
|
||||
""",
|
||||
rendered,
|
||||
count=1,
|
||||
)
|
||||
self.assertEqual(
|
||||
rendered.count(
|
||||
"loadedCssUrls: ["/components/cache/OtherComponent_6329ae.css/", "/components/cache/SimpleComponentNested_f02d32.css/", "style.css", "style2.css", "xyz1.css"],"
|
||||
),
|
||||
1,
|
||||
)
|
||||
self.assertEqual(rendered.count("toLoadJsTags: [],"), 1)
|
||||
self.assertEqual(rendered.count("toLoadCssTags: [],"), 1)
|
||||
|
||||
def test_multiple_components_all_placeholders_removed(self):
|
||||
registry.register(name="inner", component=SimpleComponent)
|
||||
|
|
|
@ -297,6 +297,214 @@ class E2eDependencyRenderingTests(BaseTestCase):
|
|||
|
||||
await page.close()
|
||||
|
||||
# Fragment where JS and CSS is defined on Component class
|
||||
@with_playwright
|
||||
async def test_fragment_comp(self):
|
||||
page: Page = await self.browser.new_page()
|
||||
await page.goto(f"{TEST_SERVER_URL}/fragment/base/js?frag=comp")
|
||||
|
||||
test_before_js: types.js = """() => {
|
||||
const targetEl = document.querySelector("#target");
|
||||
const targetHtml = targetEl ? targetEl.outerHTML : null;
|
||||
const fragEl = document.querySelector(".frag");
|
||||
const fragHtml = fragEl ? fragEl.outerHTML : null;
|
||||
|
||||
return { targetHtml, fragHtml };
|
||||
}"""
|
||||
|
||||
data_before = await page.evaluate(test_before_js)
|
||||
|
||||
self.assertEqual(data_before["targetHtml"], '<div id="target">OLD</div>')
|
||||
self.assertEqual(data_before["fragHtml"], None)
|
||||
|
||||
# Clicking button should load and insert the fragment
|
||||
await page.locator("button").click()
|
||||
|
||||
# Wait until both JS and CSS are loaded
|
||||
await page.locator(".frag").wait_for(state="visible")
|
||||
await page.wait_for_function(
|
||||
"() => document.head.innerHTML.includes('<link href=\"/components/cache/FragComp_')"
|
||||
)
|
||||
await page.wait_for_timeout(100) # NOTE: For CI we need to wait a bit longer
|
||||
|
||||
test_js: types.js = """() => {
|
||||
const targetEl = document.querySelector("#target");
|
||||
const targetHtml = targetEl ? targetEl.outerHTML : null;
|
||||
const fragEl = document.querySelector(".frag");
|
||||
const fragHtml = fragEl ? fragEl.outerHTML : null;
|
||||
|
||||
// Get the stylings defined via CSS
|
||||
const fragBg = fragEl ? globalThis.getComputedStyle(fragEl).getPropertyValue('background') : null;
|
||||
|
||||
return { targetHtml, fragHtml, fragBg };
|
||||
}"""
|
||||
|
||||
data = await page.evaluate(test_js)
|
||||
|
||||
self.assertEqual(data["targetHtml"], None)
|
||||
self.assertHTMLEqual('<div class="frag"> 123 <span id="frag-text">xxx</span></div>', data["fragHtml"])
|
||||
self.assertIn("rgb(0, 0, 255)", data["fragBg"]) # AKA 'background: blue'
|
||||
|
||||
await page.close()
|
||||
|
||||
# Fragment where JS and CSS is defined on Media class
|
||||
@with_playwright
|
||||
async def test_fragment_media(self):
|
||||
page: Page = await self.browser.new_page()
|
||||
await page.goto(f"{TEST_SERVER_URL}/fragment/base/js?frag=media")
|
||||
|
||||
test_before_js: types.js = """() => {
|
||||
const targetEl = document.querySelector("#target");
|
||||
const targetHtml = targetEl ? targetEl.outerHTML : null;
|
||||
const fragEl = document.querySelector(".frag");
|
||||
const fragHtml = fragEl ? fragEl.outerHTML : null;
|
||||
|
||||
return { targetHtml, fragHtml };
|
||||
}"""
|
||||
|
||||
data_before = await page.evaluate(test_before_js)
|
||||
|
||||
self.assertEqual(data_before["targetHtml"], '<div id="target">OLD</div>')
|
||||
self.assertEqual(data_before["fragHtml"], None)
|
||||
|
||||
# Clicking button should load and insert the fragment
|
||||
await page.locator("button").click()
|
||||
|
||||
# Wait until both JS and CSS are loaded
|
||||
await page.locator(".frag").wait_for(state="visible")
|
||||
await page.wait_for_function("() => document.head.innerHTML.includes('<link href=\"/static/fragment.css\"')")
|
||||
await page.wait_for_timeout(100) # NOTE: For CI we need to wait a bit longer
|
||||
|
||||
test_js: types.js = """() => {
|
||||
const targetEl = document.querySelector("#target");
|
||||
const targetHtml = targetEl ? targetEl.outerHTML : null;
|
||||
const fragEl = document.querySelector(".frag");
|
||||
const fragHtml = fragEl ? fragEl.outerHTML : null;
|
||||
|
||||
// Get the stylings defined via CSS
|
||||
const fragBg = fragEl ? globalThis.getComputedStyle(fragEl).getPropertyValue('background') : null;
|
||||
|
||||
return { targetHtml, fragHtml, fragBg };
|
||||
}"""
|
||||
|
||||
data = await page.evaluate(test_js)
|
||||
|
||||
self.assertEqual(data["targetHtml"], None)
|
||||
self.assertHTMLEqual('<div class="frag"> 123 <span id="frag-text">xxx</span></div>', data["fragHtml"])
|
||||
self.assertIn("rgb(0, 0, 255)", data["fragBg"]) # AKA 'background: blue'
|
||||
|
||||
await page.close()
|
||||
|
||||
# Fragment loaded by AlpineJS
|
||||
@with_playwright
|
||||
async def test_fragment_alpine(self):
|
||||
page: Page = await self.browser.new_page()
|
||||
await page.goto(f"{TEST_SERVER_URL}/fragment/base/alpine?frag=comp")
|
||||
|
||||
test_before_js: types.js = """() => {
|
||||
const targetEl = document.querySelector("#target");
|
||||
const targetHtml = targetEl ? targetEl.outerHTML : null;
|
||||
const fragEl = document.querySelector(".frag");
|
||||
const fragHtml = fragEl ? fragEl.outerHTML : null;
|
||||
|
||||
return { targetHtml, fragHtml };
|
||||
}"""
|
||||
|
||||
data_before = await page.evaluate(test_before_js)
|
||||
|
||||
self.assertEqual(data_before["targetHtml"], '<div id="target" x-html="htmlVar">OLD</div>')
|
||||
self.assertEqual(data_before["fragHtml"], None)
|
||||
|
||||
# Clicking button should load and insert the fragment
|
||||
await page.locator("button").click()
|
||||
|
||||
# Wait until both JS and CSS are loaded
|
||||
await page.locator(".frag").wait_for(state="visible")
|
||||
await page.wait_for_function(
|
||||
"() => document.head.innerHTML.includes('<link href=\"/components/cache/FragComp_')"
|
||||
)
|
||||
await page.wait_for_timeout(100) # NOTE: For CI we need to wait a bit longer
|
||||
|
||||
test_js: types.js = """() => {
|
||||
const targetEl = document.querySelector("#target");
|
||||
const targetHtml = targetEl ? targetEl.outerHTML : null;
|
||||
const fragEl = document.querySelector(".frag");
|
||||
const fragHtml = fragEl ? fragEl.outerHTML : null;
|
||||
|
||||
// Get the stylings defined via CSS
|
||||
const fragBg = fragEl ? globalThis.getComputedStyle(fragEl).getPropertyValue('background') : null;
|
||||
|
||||
return { targetHtml, fragHtml, fragBg };
|
||||
}"""
|
||||
|
||||
data = await page.evaluate(test_js)
|
||||
|
||||
# NOTE: Unlike the vanilla JS tests, for the Alpine test we don't remove the targetHtml,
|
||||
# but only change its contents.
|
||||
self.assertInHTML(
|
||||
'<div class="frag"> 123 <span id="frag-text">xxx</span></div>',
|
||||
data["targetHtml"],
|
||||
)
|
||||
self.assertHTMLEqual(data["fragHtml"], '<div class="frag"> 123 <span id="frag-text">xxx</span></div>')
|
||||
self.assertIn("rgb(0, 0, 255)", data["fragBg"]) # AKA 'background: blue'
|
||||
|
||||
await page.close()
|
||||
|
||||
# Fragment loaded by HTMX
|
||||
@with_playwright
|
||||
async def test_fragment_htmx(self):
|
||||
page: Page = await self.browser.new_page()
|
||||
await page.goto(f"{TEST_SERVER_URL}/fragment/base/htmx?frag=comp")
|
||||
|
||||
test_before_js: types.js = """() => {
|
||||
const targetEl = document.querySelector("#target");
|
||||
const targetHtml = targetEl ? targetEl.outerHTML : null;
|
||||
const fragEl = document.querySelector(".frag");
|
||||
const fragHtml = fragEl ? fragEl.outerHTML : null;
|
||||
|
||||
return { targetHtml, fragHtml };
|
||||
}"""
|
||||
|
||||
data_before = await page.evaluate(test_before_js)
|
||||
|
||||
self.assertEqual(data_before["targetHtml"], '<div id="target">OLD</div>')
|
||||
self.assertEqual(data_before["fragHtml"], None)
|
||||
|
||||
# Clicking button should load and insert the fragment
|
||||
await page.locator("button").click()
|
||||
|
||||
# Wait until both JS and CSS are loaded
|
||||
await page.locator(".frag").wait_for(state="visible")
|
||||
await page.wait_for_function(
|
||||
"() => document.head.innerHTML.includes('<link href=\"/components/cache/FragComp_')"
|
||||
)
|
||||
await page.wait_for_timeout(100) # NOTE: For CI we need to wait a bit longer
|
||||
|
||||
test_js: types.js = """() => {
|
||||
const targetEl = document.querySelector("#target");
|
||||
const targetHtml = targetEl ? targetEl.outerHTML : null;
|
||||
const fragEl = document.querySelector(".frag");
|
||||
const fragInnerHtml = fragEl ? fragEl.innerHTML : null;
|
||||
|
||||
// Get the stylings defined via CSS
|
||||
const fragBg = fragEl ? globalThis.getComputedStyle(fragEl).getPropertyValue('background') : null;
|
||||
|
||||
return { targetHtml, fragInnerHtml, fragBg };
|
||||
}"""
|
||||
|
||||
data = await page.evaluate(test_js)
|
||||
|
||||
self.assertEqual(data["targetHtml"], None)
|
||||
# NOTE: We test only the inner HTML, because the element itself may or may not have
|
||||
# extra CSS classes added by HTMX, which results in flaky tests.
|
||||
self.assertHTMLEqual(
|
||||
data["fragInnerHtml"],
|
||||
'123 <span id="frag-text">xxx</span>',
|
||||
)
|
||||
self.assertIn("rgb(0, 0, 255)", data["fragBg"]) # AKA 'background: blue'
|
||||
|
||||
await page.close()
|
||||
|
||||
@with_playwright
|
||||
async def test_alpine__head(self):
|
||||
single_comp_url = TEST_SERVER_URL + "/alpine/head"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue