diff --git a/docs/concepts/advanced/authoring_component_libraries.md b/docs/concepts/advanced/authoring_component_libraries.md index 6fc9257e..f10693d7 100644 --- a/docs/concepts/advanced/authoring_component_libraries.md +++ b/docs/concepts/advanced/authoring_component_libraries.md @@ -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: diff --git a/docs/concepts/advanced/component_registry.md b/docs/concepts/advanced/component_registry.md index da8c0c9c..be481912 100644 --- a/docs/concepts/advanced/component_registry.md +++ b/docs/concepts/advanced/component_registry.md @@ -1,6 +1,6 @@ --- title: Registering components -weight: 4 +weight: 5 --- In previous examples you could repeatedly see us using `@register()` to "register" diff --git a/docs/concepts/advanced/hooks.md b/docs/concepts/advanced/hooks.md index 9e0e47d2..13574763 100644 --- a/docs/concepts/advanced/hooks.md +++ b/docs/concepts/advanced/hooks.md @@ -1,6 +1,6 @@ --- title: Lifecycle hooks -weight: 3 +weight: 4 --- _New in version 0.96_ diff --git a/docs/concepts/advanced/html_tragments.md b/docs/concepts/advanced/html_tragments.md new file mode 100644 index 00000000..1b93c167 --- /dev/null +++ b/docs/concepts/advanced/html_tragments.md @@ -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 ` + + +
OLD
+ + + + {% component_js_dependencies %} + + + """ +``` + +### 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 = """ +
+ 123 + +
+ """ + + 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 %} + + + + {% component_css_dependencies %} + + + +
OLD
+ + + + {% component_js_dependencies %} + + + """ +``` + +### 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 = """ + + """ + + 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 %} + + + + {% component_css_dependencies %} + + +
OLD
+ + + + + {% component_js_dependencies %} + + + """ +``` + +### 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 = """ +
+ 123 + +
+ """ + + 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()), +] +``` diff --git a/docs/concepts/advanced/provide_inject.md b/docs/concepts/advanced/provide_inject.md index e6df9ed4..0ba9b1c1 100644 --- a/docs/concepts/advanced/provide_inject.md +++ b/docs/concepts/advanced/provide_inject.md @@ -1,6 +1,6 @@ --- title: Prop drilling and provide / inject -weight: 2 +weight: 3 --- _New in version 0.80_: diff --git a/docs/concepts/advanced/tag_formatter.md b/docs/concepts/advanced/tag_formatter.md index 79ca5893..a5af24d6 100644 --- a/docs/concepts/advanced/tag_formatter.md +++ b/docs/concepts/advanced/tag_formatter.md @@ -1,6 +1,6 @@ --- title: Tag formatters -weight: 6 +weight: 7 --- ## Customizing component tags with TagFormatter diff --git a/docs/concepts/advanced/typing_and_validation.md b/docs/concepts/advanced/typing_and_validation.md index 63231650..e6c0842b 100644 --- a/docs/concepts/advanced/typing_and_validation.md +++ b/docs/concepts/advanced/typing_and_validation.md @@ -1,6 +1,6 @@ --- title: Typing and validation -weight: 5 +weight: 6 --- ## Adding type hints with Generics diff --git a/sampleproject/components/fragment.py b/sampleproject/components/fragment.py new file mode 100644 index 00000000..649f0368 --- /dev/null +++ b/sampleproject/components/fragment.py @@ -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 %} + + + + {% component_css_dependencies %} + + +
OLD
+ + + + + {% component_js_dependencies %} + + + """ + + +# 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 %} + + + + {% component_css_dependencies %} + + + +
OLD
+ + + + {% component_js_dependencies %} + + + """ + + +# 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 %} + + + + {% component_css_dependencies %} + + + +
OLD
+ + + + {% component_js_dependencies %} + + + """ + + +# 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 = """ +
+ 123 + +
+ """ + + 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 = """ + + """ + + 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; + } + """ diff --git a/sampleproject/components/urls.py b/sampleproject/components/urls.py index bd6c2ee4..1d54820a 100644 --- a/sampleproject/components/urls.py +++ b/sampleproject/components/urls.py @@ -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 @@ 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()), ] diff --git a/src/django_components/dependencies.py b/src/django_components/dependencies.py index 2e0cc42b..4710c478 100644 --- a/src/django_components/dependencies.py +++ b/src/django_components/dependencies.py @@ -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.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 my_view(request): 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 my_view(request): 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 on_replace_match(match: "re.Match[bytes]") -> bytes: 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 get_component_media(comp_cls_hash: str) -> Media: # 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 get_component_media(comp_cls_hash: str) -> Media: *[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"" + if "' end tag. " + "This is not allowed, as it would break the HTML." + ) + return f"" elif script_type == "css": + if "' end tag. " + "This is not allowed, as it would break the HTML." + ) + return f"" 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(''), - # Components.manager.loadJs(''), - # Components.manager.loadCss(''), - # ]); - # ``` - # - # or - # - # ```js - # Components.manager.markScriptLoaded("css", "/abc/def1.css"), - # Components.manager.markScriptLoaded("css", "/abc/def2.css"), - # Components.manager.markScriptLoaded("js", "/abc/def3.js"), - # ``` +) -> 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 # - # 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 `` or `" + # + # NOTE 2: Convert to Base64 to avoid any issues with `` 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), + } + + # 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'' return exec_script @@ -807,8 +813,8 @@ def cached_script_view( urlpatterns = [ - # E.g. `/components/cache/table.js/` - path("cache/./", cached_script_view, name=CACHE_ENDPOINT_NAME), + # E.g. `/components/cache/table.js` + path("cache/.", cached_script_view, name=CACHE_ENDPOINT_NAME), ] diff --git a/src/django_components/static/django_components/django_components.min.js b/src/django_components/static/django_components/django_components.min.js index 876e51ab..2b944672 100644 --- a/src/django_components/static/django_components/django_components.min.js +++ b/src/django_components/static/django_components/django_components.min.js @@ -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{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 + + {% component_js_dependencies %} + + + """ + 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 %} + + + + {% component_css_dependencies %} + + + + {% component 'inner' variable='foo' / %} + +
OLD
+ + + + {% component_js_dependencies %} + + + """ + 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 %} + + + + {% component_css_dependencies %} + + + + {% component 'inner' variable='foo' / %} + +
OLD
+ + + + {% component_js_dependencies %} + + + """ + 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 %} diff --git a/tests/test_component_media.py b/tests/test_component_media.py index 8121ece4..23a35b1a 100644 --- a/tests/test_component_media.py +++ b/tests/test_component_media.py @@ -49,7 +49,7 @@ class TestComponent(Component): rendered, ) self.assertInHTML( - "", + "", rendered, ) @@ -106,7 +106,7 @@ class Media: self.assertEqual(rendered.count("', rendered, count=1) self.assertInHTML("", rendered, count=1) # Inlined CSS - self.assertInHTML( - "", rendered, count=1 - ) # Inlined JS + self.assertInHTML('', rendered, count=1) # Inlined JS self.assertInHTML('', rendered, count=1) # Media.css @@ -90,9 +88,7 @@ def test_middleware_renders_dependencies(self): self.assertInHTML('', rendered, count=1) self.assertInHTML("", rendered, count=1) # Inlined CSS - self.assertInHTML( - "", rendered, count=1 - ) # Inlined JS + self.assertInHTML('', rendered, count=1) # Inlined JS self.assertInHTML('', rendered, count=1) # Media.css self.assertEqual(rendered.count("', rendered, count=1) self.assertInHTML("", rendered, count=1) # Inlined CSS - self.assertInHTML( - "", rendered, count=1 - ) # Inlined JS + self.assertInHTML('', rendered, count=1) # Inlined JS self.assertInHTML('', rendered, count=1) # Media.css self.assertEqual(rendered.count("', rendered_raw, count=0) # Media.css self.assertInHTML( - "", + '', rendered_raw, count=0, ) # Inlined JS @@ -184,9 +178,7 @@ class SimpleComponentWithDeps(SimpleComponent): self.assertInHTML('', rendered, count=1) self.assertInHTML("", rendered, count=1) # Inlined CSS - self.assertInHTML( - "", rendered, count=1 - ) # Inlined JS + self.assertInHTML('', rendered, count=1) # Inlined JS self.assertEqual(rendered.count(''), 1) # Media.css self.assertEqual(rendered.count("eval(Components.unescapeJs(`console.log("xyz");`))""", + '', rendered_body, count=1, ) @@ -286,7 +278,7 @@ def test_does_not_insert_styles_and_script_to_default_places_if_overriden(self): count=1, ) self.assertInHTML( - """""", + '', rendered_head, count=1, ) @@ -401,6 +393,11 @@ def test_does_not_modify_html_when_component_used_but_nowhere_to_insert(self): rendered_raw = Template(template_str).render(Context({"formset": [1]})) rendered = render_dependencies(rendered_raw, type="fragment") + # Base64 encodings: + # `PGxpbmsgaHJlZj0ic3R5bGUuY3NzIiBtZWRpYT0iYWxsIiByZWw9InN0eWxlc2hlZXQiPg==` -> `` # noqa: E501 + # `PGxpbmsgaHJlZj0iL2NvbXBvbmVudHMvY2FjaGUvU2ltcGxlQ29tcG9uZW50XzMxMTA5Ny5jc3MiIG1lZGlhPSJhbGwiIHJlbD0ic3R5bGVzaGVldCI+` -> `` # noqa: E501 + # `PHNjcmlwdCBzcmM9InNjcmlwdC5qcyI+PC9zY3JpcHQ+` -> `` + # `PHNjcmlwdCBzcmM9Ii9jb21wb25lbnRzL2NhY2hlL1NpbXBsZUNvbXBvbmVudF8zMTEwOTcuanMiPjwvc2NyaXB0Pg==` -> `` # noqa: E501 expected = """ @@ -423,10 +420,49 @@ def test_does_not_modify_html_when_component_used_but_nowhere_to_insert(self):
- """ + + """ # 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(""); + """ + + registry.register(name="test", component=ComponentWithScript) + + with self.assertRaisesMessage( + RuntimeError, + "Content of `Component.js` for component 'ComponentWithScript' contains '' end tag.", + ): + ComponentWithScript.render(kwargs={"variable": "foo"}) + + def test_raises_if_script_end_tag_inside_component_css(self): + class ComponentWithScript(SimpleComponent): + css: types.css = """ + /* */ + .xyz { + color: red; + } + """ + + registry.register(name="test", component=ComponentWithScript) + + with self.assertRaisesMessage( + RuntimeError, + "Content of `Component.css` for component 'ComponentWithScript' contains '' end tag.", + ): + ComponentWithScript.render(kwargs={"variable": "foo"}) + class MiddlewareTests(BaseTestCase): def test_middleware_response_without_content_type(self): @@ -462,9 +498,7 @@ def test_middleware_response_with_components_with_slash_dash_and_underscore( self.assertInHTML('', rendered, count=1) # Inlined JS - self.assertInHTML( - "", rendered, count=1 - ) + self.assertInHTML('', rendered, count=1) # Inlined CSS self.assertInHTML("", rendered, count=1) # Media.css diff --git a/tests/test_dependency_manager.py b/tests/test_dependency_manager.py index 880270bf..21980718 100644 --- a/tests/test_dependency_manager.py +++ b/tests/test_dependency_manager.py @@ -69,7 +69,6 @@ async def test_script_loads(self): "loadJs", "loadCss", "markScriptLoaded", - "_loadComponentScripts", ], ) diff --git a/tests/test_dependency_rendering.py b/tests/test_dependency_rendering.py index 694080b9..9875d8b1 100644 --- a/tests/test_dependency_rendering.py +++ b/tests/test_dependency_rendering.py @@ -123,23 +123,14 @@ def test_no_dependencies_when_no_components_used(self): # Dependency manager script self.assertInHTML('', rendered, count=1) - self.assertEqual(rendered.count("', rendered, count=1) - self.assertEqual(rendered.count(" + {"loadedCssUrls": ["c3R5bGUuY3Nz"], + "loadedJsUrls": ["c2NyaXB0Lmpz"], + "toLoadCssTags": [], + "toLoadJsTags": []} + + """, + 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 @@ def test_single_component_with_dash_or_slash_in_name(self): self.assertEqual(rendered.count(" + {"loadedCssUrls": ["c3R5bGUuY3Nz"], + "loadedJsUrls": ["c2NyaXB0Lmpz"], + "toLoadCssTags": [], + "toLoadJsTags": []} + + """, + rendered, + count=1, + ) def test_single_component_placeholder_removed(self): registry.register(name="test", component=SimpleComponent) @@ -303,19 +287,20 @@ def test_single_component_js_dependencies(self): self.assertEqual(rendered.count(" + {"loadedCssUrls": ["c3R5bGUuY3Nz"], + "loadedJsUrls": ["c2NyaXB0Lmpz"], + "toLoadCssTags": [], + "toLoadJsTags": []} + + """, + rendered, + count=1, + ) def test_all_dependencies_are_rendered_for_component_with_multiple_dependencies( self, @@ -357,19 +342,23 @@ def test_all_dependencies_are_rendered_for_component_with_multiple_dependencies( 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( + """ + + """, + rendered, + count=1, + ) def test_no_dependencies_with_multiple_unused_components(self): registry.register(name="inner", component=SimpleComponent) @@ -386,23 +375,14 @@ def test_no_dependencies_with_multiple_unused_components(self): # Dependency manager script self.assertInHTML('', rendered, count=1) - self.assertEqual(rendered.count(" - - + + """, 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, - ) - self.assertEqual( - rendered.count( - "loadedCssUrls: ["/components/cache/OtherComponent_6329ae.css/", "/components/cache/SimpleComponentNested_f02d32.css/", "style.css", "style2.css", "xyz1.css"]," - ), - 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( + """ + + """, + rendered, + count=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) diff --git a/tests/test_dependency_rendering_e2e.py b/tests/test_dependency_rendering_e2e.py index 075633a9..d016a7c1 100644 --- a/tests/test_dependency_rendering_e2e.py +++ b/tests/test_dependency_rendering_e2e.py @@ -297,6 +297,214 @@ async def test_js_executed_in_order__invalid(self): 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"], '
OLD
') + 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(' { + 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('
123 xxx
', 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"], '
OLD
') + 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(' { + 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('
123 xxx
', 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"], '
OLD
') + 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(' { + 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( + '
123 xxx
', + data["targetHtml"], + ) + self.assertHTMLEqual(data["fragHtml"], '
123 xxx
') + 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"], '
OLD
') + 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(' { + 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 xxx', + ) + 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"