From 5fd45ab424908f7c3565139709c8eae86bdd9533 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Mon, 25 Nov 2024 09:41:57 +0100 Subject: [PATCH] chore: Push dev to master to release v0.110 (#767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: skeleton of dependency manager backend (#688) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * refactor: selectolax update and tests cleanup (#702) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * refactor: move release notes to own file (#704) * chore: merge changes from master (#705) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Yassin Rakha Co-authored-by: Emil StenstrΓΆm fix for nested slots (#698) (#699) * refactor: remove joint {% component_dependencies %} tag (#706) Co-authored-by: Emil StenstrΓΆm Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * refactor: split up utils file and move utils to util dir (#707) * docs: Move docs inside src/ to allow imports in python scripts (#708) * refactor: Docs prep 1 (#715) * refactor: Document template tags (#716) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * refactor: pass slot fills in template via slots param (#719) * chore: Merge master to dev (#729) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Yassin Rakha Co-authored-by: Emil StenstrΓΆm Co-authored-by: Tom Larsen fix for nested slots (#698) (#699) * fix: Do not raise error if multiple slots with same name are flagged as default (#727) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * refactor: tag formatter - allow fwd slash in end tag (#730) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * refactor: Use lowercase names for registry settings (#731) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * docs: add docstrings (#732) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * feat: define settings as a data class for type hints, intellisense, and docs (#733) * refactor: fix reload-on-change logic, expose autodiscover's dirs-getting logic, rename settings (#734) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * docs: document settings (#743) * docs: document settings * refactor: fix linter errors * feat: passthrough slots and more (#758) * feat: passthrough slots and more * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * refactor: remove ComponentSlotContext.slots * refactor: update comment * docs: update changelog * refactor: update docstrings * refactor: document and test-cover more changes * refactor: revert fill without name * docs: update README --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * fix: apostrophes in tags (#765) * refactor: fix merge error - duplicate code --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Emil StenstrΓΆm --- .github/workflows/tests.yml | 1 + .gitignore | 1 - CHANGELOG.md | 585 +++++++++++ README.md | 566 ++++++---- benchmarks/component_rendering.py | 8 +- docs/CHANGELOG.md | 13 - docs/slot_rendering.md | 238 ----- mkdocs.yml | 15 +- pyproject.toml | 5 + requirements-dev.in | 3 +- requirements-dev.txt | 2 + sampleproject/sampleproject/settings.py | 19 +- sampleproject/sampleproject/urls.py | 1 + src/django_components/__init__.py | 98 +- src/django_components/app_settings.py | 648 ++++++++++-- src/django_components/apps.py | 31 +- src/django_components/autodiscover.py | 149 --- src/django_components/autodiscovery.py | 95 ++ src/django_components/component.py | 356 ++++--- src/django_components/component_media.py | 6 +- src/django_components/component_registry.py | 309 ++++-- src/django_components/components/__init__.py | 5 +- src/django_components/components/dynamic.py | 93 +- src/django_components/context.py | 27 +- src/django_components/dependencies.py | 793 ++++++++++++++ src/django_components/finders.py | 8 +- src/django_components/library.py | 27 +- .../management/commands/startcomponent.py | 88 +- src/django_components/middleware.py | 111 +- src/django_components/node.py | 105 +- src/django_components/provide.py | 4 +- src/django_components/slots.py | 968 +++++++++--------- src/django_components/tag_formatter.py | 159 ++- src/django_components/template.py | 36 +- src/django_components/template_loader.py | 81 +- .../templatetags/component_tags.py | 892 ++++++++++++---- src/django_components/types.py | 38 +- src/django_components/urls.py | 7 + src/django_components/util/__init__.py | 0 src/django_components/util/cache.py | 45 + src/django_components/util/html.py | 100 ++ src/django_components/util/loader.py | 240 +++++ src/django_components/{ => util}/logger.py | 8 +- src/django_components/util/misc.py | 79 ++ src/django_components/util/nanoid.py | 29 + src/django_components/util/tag_parser.py | 198 ++++ src/django_components/util/types.py | 143 +++ .../{utils.py => util/validation.py} | 94 +- src/docs/.gitignore | 1 + src/docs/CHANGELOG.md | 6 + {docs => src/docs}/CODE_OF_CONDUCT.md | 0 {docs => src/docs}/README.md | 0 {docs => src/docs}/SUMMARY.md | 0 src/docs/__init__.py | 0 src/docs/devguides/dependency_mgmt.md | 223 ++++ src/docs/devguides/slot_rendering.md | 253 +++++ .../docs/devguides}/slots_and_blocks.md | 0 {docs => src/docs}/license.md | 0 .../docs}/migrating_from_safer_staticfiles.md | 0 {docs => src/docs}/overrides/main.html | 0 src/docs/scripts/__init__.py | 0 .../docs/scripts/reference.py | 2 +- .../relative_file_pathobj.html | 3 + .../relative_file_pathobj.py | 5 +- tests/django_test_setup.py | 1 + .../testserver/components/__init__.py | 80 ++ tests/e2e/testserver/testserver/settings.py | 1 + .../testserver/testserver/static/script.js | 1 + .../testserver/testserver/static/script2.js | 1 + .../testserver/testserver/static/style.css | 3 + .../testserver/testserver/static/style2.css | 3 + tests/e2e/testserver/testserver/urls.py | 12 +- tests/e2e/testserver/testserver/views.py | 51 + tests/e2e/utils.py | 2 +- tests/static_root/staticfiles.json | 3 +- tests/test_autodiscover.py | 52 +- tests/test_component.py | 38 +- tests/test_component_as_view.py | 3 +- tests/test_component_media.py | 614 +++++------ tests/test_context.py | 95 +- tests/test_dependencies.py | 333 ++++++ tests/test_dependency_rendering.py | 568 +++++----- tests/test_dependency_rendering_e2e.py | 217 ++++ tests/test_expression.py | 12 +- tests/test_finders.py | 6 +- tests/test_html.py | 267 +++++ ...test_template_loader.py => test_loader.py} | 166 ++- tests/test_registry.py | 9 +- tests/test_settings.py | 11 +- tests/test_tag_formatter.py | 45 + tests/test_tag_parser.py | 94 ++ tests/test_template_parser.py | 18 +- tests/test_templatetags.py | 81 +- tests/test_templatetags_component.py | 49 +- tests/test_templatetags_slot_fill.py | 842 +++++++++++++-- tests/test_utils.py | 2 +- tests/testutils.py | 38 +- 97 files changed, 8727 insertions(+), 3011 deletions(-) create mode 100644 CHANGELOG.md delete mode 100644 docs/CHANGELOG.md delete mode 100644 docs/slot_rendering.md delete mode 100644 src/django_components/autodiscover.py create mode 100644 src/django_components/autodiscovery.py create mode 100644 src/django_components/dependencies.py create mode 100644 src/django_components/urls.py create mode 100644 src/django_components/util/__init__.py create mode 100644 src/django_components/util/cache.py create mode 100644 src/django_components/util/html.py create mode 100644 src/django_components/util/loader.py rename src/django_components/{ => util}/logger.py (89%) create mode 100644 src/django_components/util/misc.py create mode 100644 src/django_components/util/nanoid.py create mode 100644 src/django_components/util/tag_parser.py create mode 100644 src/django_components/util/types.py rename src/django_components/{utils.py => util/validation.py} (67%) create mode 100644 src/docs/.gitignore create mode 100644 src/docs/CHANGELOG.md rename {docs => src/docs}/CODE_OF_CONDUCT.md (100%) rename {docs => src/docs}/README.md (100%) rename {docs => src/docs}/SUMMARY.md (100%) create mode 100644 src/docs/__init__.py create mode 100644 src/docs/devguides/dependency_mgmt.md create mode 100644 src/docs/devguides/slot_rendering.md rename {docs => src/docs/devguides}/slots_and_blocks.md (100%) rename {docs => src/docs}/license.md (100%) rename {docs => src/docs}/migrating_from_safer_staticfiles.md (100%) rename {docs => src/docs}/overrides/main.html (100%) create mode 100644 src/docs/scripts/__init__.py rename scripts/gen_ref_nav.py => src/docs/scripts/reference.py (96%) create mode 100644 tests/e2e/testserver/testserver/components/__init__.py create mode 100644 tests/e2e/testserver/testserver/static/script.js create mode 100644 tests/e2e/testserver/testserver/static/script2.js create mode 100644 tests/e2e/testserver/testserver/static/style.css create mode 100644 tests/e2e/testserver/testserver/static/style2.css create mode 100644 tests/e2e/testserver/testserver/views.py create mode 100644 tests/test_dependencies.py create mode 100644 tests/test_dependency_rendering_e2e.py create mode 100644 tests/test_html.py rename tests/{test_template_loader.py => test_loader.py} (53%) create mode 100644 tests/test_tag_parser.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c217fe95..451e5cc9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,6 +4,7 @@ on: push: branches: - 'master' + - 'dev' pull_request: workflow_dispatch: diff --git a/.gitignore b/.gitignore index 909e7fd2..1d20d106 100644 --- a/.gitignore +++ b/.gitignore @@ -74,7 +74,6 @@ poetry.lock .DS_Store .python-version site -docs/reference # JS, NPM Dependency directories node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..7c2a8ab0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,585 @@ +# Release notes + +## πŸš¨πŸ“’ v0.110 + +### General + +#### πŸš¨πŸ“’ BREAKING CHANGES + +- Installation changes: + + - If your components include JS or CSS, you now must use the middleware and add django-components' URLs to your `urlpatterns` + (See "[Adding support for JS and CSS](https://github.com/EmilStenstrom/django-components#adding-support-for-js-and-css)") + +- Component typing signature changed from + + ```py + Component[Args, Kwargs, Data, Slots] + ``` + + to + + ```py + Component[Args, Kwargs, Slots, Data, JsData, CssData] + ``` + +- If you rendered a component A with `Component.render()` and then inserted that into another component B, now you must pass `render_dependencies=False` to component A: + + ```py + prerendered_a = CompA.render( + args=[...], + kwargs={...}, + render_dependencies=False, + ) + + html = CompB.render( + kwargs={ + content=prerendered_a, + }, + ) + ``` + +#### Feat + +- Intellisense and mypy validation for settings: + + Instead of defining the `COMPONENTS` settings as a plain dict, you can use `ComponentsSettings`: + + ```py + # settings.py + from django_components import ComponentsSettings + + COMPONENTS = ComponentsSettings( + autodiscover=True, + ... + ) + ``` + +- Use `get_component_dirs()` and `get_component_files()` to get the same list of dirs / files that would be imported by `autodiscover()`, but without actually +importing them. + +#### Refactor + +- For advanced use cases, use can omit the middleware and instead manage component JS and CSS dependencies yourself with [`render_dependencies`](https://github.com/EmilStenstrom/django-components#render_dependencies-and-deep-dive-into-rendering-js--css-without-the-middleware) + +- The [`ComponentRegistry`](../api#django_components.ComponentRegistry) settings [`RegistrySettings`](../api#django_components.RegistrySettings) + were lowercased to align with the global settings: + - `RegistrySettings.CONTEXT_BEHAVIOR` -> `RegistrySettings.context_behavior` + - `RegistrySettings.TAG_FORMATTER` -> `RegistrySettings.tag_formatter` + + The old uppercase settings `CONTEXT_BEHAVIOR` and `TAG_FORMATTER` are deprecated and will be removed in v1. + +- The setting `reload_on_template_change` was renamed to + [`reload_on_file_change`](../settings#django_components.app_settings.ComponentsSettings#reload_on_file_change). + And now it properly triggers server reload when any file in the component dirs change. The old name `reload_on_template_change` + is deprecated and will be removed in v1. + +- The setting `forbidden_static_files` was renamed to + [`static_files_forbidden`](../settings#django_components.app_settings.ComponentsSettings#static_files_forbidden) + to align with [`static_files_allowed`](../settings#django_components.app_settings.ComponentsSettings#static_files_allowed) + The old name `forbidden_static_files` is deprecated and will be removed in v1. + +### Tags + +#### πŸš¨πŸ“’ BREAKING CHANGES + +- `{% component_dependencies %}` tag was removed. Instead, use `{% component_js_dependencies %}` and `{% component_css_dependencies %}` + + - The combined tag was removed to encourage the best practice of putting JS scripts at the end of ``, and CSS styles inside ``. + + On the other hand, co-locating JS script and CSS styles can lead to + a [flash of unstyled content](https://en.wikipedia.org/wiki/Flash_of_unstyled_content), + as either JS scripts will block the rendering, or CSS will load too late. + +- The undocumented keyword arg `preload` of `{% component_js_dependencies %}` and `{% component_css_dependencies %}` tags was removed. + This will be replaced with HTML fragment support. + +#### Fix + +- Allow using forward slash (`/`) when defining custom TagFormatter, + e.g. `{% MyComp %}..{% /MyComp %}`. + +#### Refactor + +- `{% component_dependencies %}` tags are now OPTIONAL - If your components use JS and CSS, but you don't use `{% component_dependencies %}` tags, the JS and CSS will now be, by default, inserted at the end of `` and at the end of `` respectively. + +### Slots + +#### Feat + +- Fills can now be defined within loops (`{% for %}`) or other tags (like `{% with %}`), + or even other templates using `{% include %}`. + + Following is now possible + + ```django + {% component "table" %} + {% for slot_name in slots %} + {% fill name=slot_name %} + {% endfill %} + {% endfor %} + {% endcomponent %} + ``` + +- If you need to access the data or the default content of a default fill, you can + set the `name` kwarg to `"default"`. + + Previously, a default fill would be defined simply by omitting the `{% fill %}` tags: + + ```django + {% component "child" %} + Hello world + {% endcomponent %} + ``` + + But in that case you could not access the slot data or the default content, like it's possible + for named fills: + + ```django + {% component "child" %} + {% fill name="header" data="data" %} + Hello {{ data.user.name }} + {% endfill %} + {% endcomponent %} + ``` + + Now, you can specify default tag by using `name="default"`: + + ```django + {% component "child" %} + {% fill name="default" data="data" %} + Hello {{ data.user.name }} + {% endfill %} + {% endcomponent %} + ``` + +- When inside `get_context_data()` or other component methods, the default fill + can now be accessed as `Component.input.slots["default"]`, e.g.: + + ```py + class MyTable(Component): + def get_context_data(self, *args, **kwargs): + default_slot = self.input.slots["default"] + ... + ``` + +- You can now dynamically pass all slots to a child component. This is similar to + [passing all slots in Vue](https://vue-land.github.io/faq/forwarding-slots#passing-all-slots): + + ```py + class MyTable(Component): + def get_context_data(self, *args, **kwargs): + return { + "slots": self.input.slots, + } + + template: """ +
+ {% component "child" %} + {% for slot_name in slots %} + {% fill name=slot_name data="data" %} + {% slot name=slot_name ...data / %} + {% endfill %} + {% endfor %} + {% endcomponent %} +
+ """ + ``` + +#### Fix + +- Slots defined with `{% fill %}` tags are now properly accessible via `self.input.slots` in `get_context_data()` + +- Do not raise error if multiple slots with same name are flagged as default + +- Slots can now be defined within loops (`{% for %}`) or other tags (like `{% with %}`), + or even other templates using `{% include %}`. + + Previously, following would cause the kwarg `name` to be an empty string: + + ```django + {% for slot_name in slots %} + {% slot name=slot_name %} + {% endfor %} + ``` + +#### Refactor + +- When you define multiple slots with the same name inside a template, + you now have to set the `default` and `required` flags individually. + + ```htmldjango +
+
+ {% slot "image" default required %}Image here{% endslot %} +
+
+ {% slot "image" default required %}Image here{% endslot %} +
+
+ ``` + + This means you can also have multiple slots with the same name but + different conditions. + + E.g. in this example, we have a component that renders a user avatar + - a small circular image with a profile picture of name initials. + + If the component is given `image_src` or `name_initials` variables, + the `image` slot is optional. But if neither of those are provided, + you MUST fill the `image` slot. + + ```htmldjango +
+ {% if image_src %} + {% slot "image" default %} + + {% endslot %} + {% elif name_initials %} + {% slot "image" default required %} +
+ {{ name_initials }} +
+ {% endslot %} + {% else %} + {% slot "image" default required / %} + {% endif %} +
+ ``` + +- The slot fills that were passed to a component and which can be accessed as `Component.input.slots` + can now be passed through the Django template, e.g. as inputs to other tags. + + Internally, django-components handles slot fills as functions. + + Previously, if you tried to pass a slot fill within a template, Django would try to call it as a function. + + Now, something like this is possible: + + ```py + class MyTable(Component): + def get_context_data(self, *args, **kwargs): + return { + "child_slot": self.input.slots["child_slot"], + } + + template: """ +
+ {% component "child" content=child_slot / %} +
+ """ + ``` + + NOTE: Using `{% slot %}` and `{% fill %}` tags is still the preferred method, but the approach above + may be necessary in some complex or edge cases. + +- The `is_filled` variable (and the `{{ component_vars.is_filled }}` context variable) now returns + `False` when you try to access a slot name which has not been defined: + + Before: + + ```django + {{ component_vars.is_filled.header }} -> True + {{ component_vars.is_filled.footer }} -> False + {{ component_vars.is_filled.nonexist }} -> "" (empty string) + ``` + + After: + ```django + {{ component_vars.is_filled.header }} -> True + {{ component_vars.is_filled.footer }} -> False + {{ component_vars.is_filled.nonexist }} -> False + ``` + +- Components no longer raise an error if there are extra slot fills + +- Components will raise error when a slot is doubly-filled. + + E.g. if we have a component with a default slot: + + ```django + {% slot name="content" default / %} + ``` + + Now there is two ways how we can target this slot: Either using `name="default"` + or `name="content"`. + + In case you specify BOTH, the component will raise an error: + + ```django + {% component "child" %} + {% fill slot="default" %} + Hello from default slot + {% endfill %} + {% fill slot="content" data="data" %} + Hello from content slot + {% endfill %} + {% endcomponent %} + ``` + +## πŸš¨πŸ“’ v0.100 + +#### BREAKING CHANGES + +- `django_components.safer_staticfiles` app was removed. It is no longer needed. + +- Installation changes: + + - Instead of defining component directories in `STATICFILES_DIRS`, set them to [`COMPONENTS.dirs`](https://github.com/EmilStenstrom/django-components#dirs). + - You now must define `STATICFILES_FINDERS` + + - [See here how to migrate your settings.py](https://github.com/EmilStenstrom/django-components/blob/master/docs/migrating_from_safer_staticfiles.md) + +#### Feat + +- Beside the top-level `/components` directory, you can now define also app-level components dirs, e.g. `[app]/components` + (See [`COMPONENTS.app_dirs`](https://github.com/EmilStenstrom/django-components#app_dirs)). + +#### Refactor + +- When you call `as_view()` on a component instance, that instance will be passed to `View.as_view()` + +## v0.97 + +#### Fix + +- Fixed template caching. You can now also manually create cached templates with [`cached_template()`](https://github.com/EmilStenstrom/django-components#template_cache_size---tune-the-template-cache) + +#### Refactor + +- The previously undocumented `get_template` was made private. + +- In it's place, there's a new `get_template`, which supersedes `get_template_string` (will be removed in v1). The new `get_template` is the same as `get_template_string`, except + it allows to return either a string or a Template instance. + +- You now must use only one of `template`, `get_template`, `template_name`, or `get_template_name`. + +## v0.96 + +#### Feat + +- Run-time type validation for Python 3.11+ - If the `Component` class is typed, e.g. `Component[Args, Kwargs, ...]`, the args, kwargs, slots, and data are validated against the given types. (See [Runtime input validation with types](https://github.com/EmilStenstrom/django-components#runtime-input-validation-with-types)) + +- Render hooks - Set `on_render_before` and `on_render_after` methods on `Component` to intercept or modify the template or context before rendering, or the rendered result afterwards. (See [Component hooks](https://github.com/EmilStenstrom/django-components#component-hooks)) + +- `component_vars.is_filled` context variable can be accessed from within `on_render_before` and `on_render_after` hooks as `self.is_filled.my_slot` + +## 0.95 + +#### Feat + +- Added support for dynamic components, where the component name is passed as a variable. (See [Dynamic components](https://github.com/EmilStenstrom/django-components#dynamic-components)) + +#### Refactor + +- Changed `Component.input` to raise `RuntimeError` if accessed outside of render context. Previously it returned `None` if unset. + +## v0.94 + +#### Feat + +- django_components now automatically configures Django to support multi-line tags. (See [Multi-line tags](https://github.com/EmilStenstrom/django-components#multi-line-tags)) + +- New setting `reload_on_template_change`. Set this to `True` to reload the dev server on changes to component template files. (See [Reload dev server on component file changes](https://github.com/EmilStenstrom/django-components#reload-dev-server-on-component-file-changes)) + +## v0.93 + +#### Feat + +- Spread operator `...dict` inside template tags. (See [Spread operator](https://github.com/EmilStenstrom/django-components#spread-operator)) + +- Use template tags inside string literals in component inputs. (See [Use template tags inside component inputs](https://github.com/EmilStenstrom/django-components#use-template-tags-inside-component-inputs)) + +- Dynamic slots, fills and provides - The `name` argument for these can now be a variable, a template expression, or via spread operator + +- Component library authors can now configure `CONTEXT_BEHAVIOR` and `TAG_FORMATTER` settings independently from user settings. + +## πŸš¨πŸ“’ v0.92 + +#### BREAKING CHANGES + +- `Component` class is no longer a subclass of `View`. To configure the `View` class, set the `Component.View` nested class. HTTP methods like `get` or `post` can still be defined directly on `Component` class, and `Component.as_view()` internally calls `Component.View.as_view()`. (See [Modifying the View class](https://github.com/EmilStenstrom/django-components#modifying-the-view-class)) + +#### Feat + +- The inputs (args, kwargs, slots, context, ...) that you pass to `Component.render()` can be accessed from within `get_context_data`, `get_template` and `get_template_name` via `self.input`. (See [Accessing data passed to the component](https://github.com/EmilStenstrom/django-components#accessing-data-passed-to-the-component)) + +- Typing: `Component` class supports generics that specify types for `Component.render` (See [Adding type hints with Generics](https://github.com/EmilStenstrom/django-components#adding-type-hints-with-generics)) + +## v0.90 + +#### Feat + +- All tags (`component`, `slot`, `fill`, ...) now support "self-closing" or "inline" form, where you can omit the closing tag: + + ```django + {# Before #} + {% component "button" %}{% endcomponent %} + {# After #} + {% component "button" / %} + ``` + +- All tags now support the "dictionary key" or "aggregate" syntax (`kwarg:key=val`): + + ```django + {% component "button" attrs:class="hidden" %} + ``` + +- You can change how the components are written in the template with [TagFormatter](https://github.com/EmilStenstrom/django-components#customizing-component-tags-with-tagformatter). + + The default is `django_components.component_formatter`: + + ```django + {% component "button" href="..." disabled %} + Click me! + {% endcomponent %} + ``` + + While `django_components.shorthand_component_formatter` allows you to write components like so: + + ```django + {% button href="..." disabled %} + Click me! + {% endbutton %} + ``` + +## πŸš¨πŸ“’ v0.85 + +#### BREAKING CHANGES + +- Autodiscovery module resolution changed. Following undocumented behavior was removed: + + - Previously, autodiscovery also imported any `[app]/components.py` files, and used `SETTINGS_MODULE` to search for component dirs. + + To migrate from: + + - `[app]/components.py` - Define each module in `COMPONENTS.libraries` setting, + or import each module inside the `AppConfig.ready()` hook in respective `apps.py` files. + + - `SETTINGS_MODULE` - Define component dirs using `STATICFILES_DIRS` + + - Previously, autodiscovery handled relative files in `STATICFILES_DIRS`. To align with Django, `STATICFILES_DIRS` now must be full paths ([Django docs](https://docs.djangoproject.com/en/5.0/ref/settings/#std-setting-STATICFILES_DIRS)). + +## πŸš¨πŸ“’ v0.81 + +#### BREAKING CHANGES + +- The order of arguments to `render_to_response` has changed, to align with the (now public) `render` method of `Component` class. + +#### Feat + +- `Component.render()` is public and documented + +- Slots passed `render_to_response` and `render` can now be rendered also as functions. + +## v0.80 + +#### Feat + +- Vue-like provide/inject with the `{% provide %}` tag and `inject()` method. + +## πŸš¨πŸ“’ v0.79 + +#### BREAKING CHANGES + +- Default value for the `COMPONENTS.context_behavior` setting was changes from `"isolated"` to `"django"`. If you did not set this value explicitly before, this may be a breaking change. See the rationale for change [here](https://github.com/EmilStenstrom/django-components/issues/498). + +## πŸš¨πŸ“’ v0.77 + +#### BREAKING + +- The syntax for accessing default slot content has changed from + + ```django + {% fill "my_slot" as "alias" %} + {{ alias.default }} + {% endfill %} + + ``` + + to + + ```django + {% fill "my_slot" default="alias" %} + {{ alias }} + {% endfill %} + ``` + +## v0.74 + +#### Feat + +- `{% html_attrs %}` tag for formatting data as HTML attributes + +- `prefix:key=val` construct for passing dicts to components + +## πŸš¨πŸ“’ v0.70 + +#### BREAKING CHANGES + +- `{% if_filled "my_slot" %}` tags were replaced with `{{ component_vars.is_filled.my_slot }}` variables. + +- Simplified settings - `slot_context_behavior` and `context_behavior` were merged. See the [documentation](https://github.com/EmilStenstrom/django-components#context-behavior) for more details. + +## v0.67 + +#### Refactor + +- Changed the default way how context variables are resolved in slots. See the [documentation](https://github.com/EmilStenstrom/django-components/tree/0.67#isolate-components-slots) for more details. + +## πŸš¨πŸ“’ v0.50 + +#### BREAKING CHANGES + +- `{% component_block %}` is now `{% component %}`, and `{% component %}` blocks need an ending `{% endcomponent %}` tag. + + The new `python manage.py upgradecomponent` command can be used to upgrade a directory (use `--path` argument to point to each dir) of templates that use components to the new syntax automatically. + + This change is done to simplify the API in anticipation of a 1.0 release of django_components. After 1.0 we intend to be stricter with big changes like this in point releases. + +## v0.34 + +#### Feat + +- Components as views, which allows you to handle requests and render responses from within a component. See the [documentation](https://github.com/EmilStenstrom/django-components#use-components-as-views) for more details. + +## v0.28 + +#### Feat + +- 'implicit' slot filling and the `default` option for `slot` tags. + +## v0.27 + +#### Feat + +- A second installable app `django_components.safer_staticfiles`. It provides the same behavior as `django.contrib.staticfiles` but with extra security guarantees (more info below in [Security Notes](https://github.com/EmilStenstrom/django-components#security-notes)). + +## πŸš¨πŸ“’ v0.26 + +#### BREAKING CHANGES + +- Changed the syntax for `{% slot %}` tags. From now on, we separate defining a slot (`{% slot %}`) from filling a slot with content (`{% fill %}`). This means you will likely need to change a lot of slot tags to fill. + + We understand this is annoying, but it's the only way we can get support for nested slots that fill in other slots, which is a very nice feature to have access to. Hoping that this will feel worth it! + +## v0.22 + +#### Feat + +- All files inside components subdirectores are autoimported to simplify setup. + + An existing project might start to get `AlreadyRegistered` errors because of this. To solve this, either remove your custom loading of components, or set `"autodiscover": False` in `settings.COMPONENTS`. + +## v0.17 + +#### BREAKING CHANGES + +- Renamed `Component.context` and `Component.template` to `get_context_data` and `get_template_name`. The old methods still work, but emit a deprecation warning. + + This change was done to sync naming with Django's class based views, and make using django-components more familiar to Django users. `Component.context` and `Component.template` will be removed when version 1.0 is released. diff --git a/README.md b/README.md index 62b9daa6..1e49cfa3 100644 --- a/README.md +++ b/README.md @@ -96,128 +96,8 @@ Read on to learn about all the exciting details and configuration possibilities! ## Release notes -πŸš¨πŸ“’ **Version 0.100** -- BREAKING CHANGE: - - `django_components.safer_staticfiles` app was removed. It is no longer needed. - - Installation changes: - - Instead of defining component directories in `STATICFILES_DIRS`, set them to [`COMPONENTS.dirs`](#dirs). - - You now must define `STATICFILES_FINDERS` - - [See here how to migrate your settings.py](https://github.com/EmilStenstrom/django-components/blob/master/docs/migrating_from_safer_staticfiles.md) -- Beside the top-level `/components` directory, you can now define also app-level components dirs, e.g. `[app]/components` - (See [`COMPONENTS.app_dirs`](#app_dirs)). -- When you call `as_view()` on a component instance, that instance will be passed to `View.as_view()` - -**Version 0.97** -- Fixed template caching. You can now also manually create cached templates with [`cached_template()`](#template_cache_size---tune-the-template-cache) -- The previously undocumented `get_template` was made private. -- In it's place, there's a new `get_template`, which supersedes `get_template_string` (will be removed in v1). The new `get_template` is the same as `get_template_string`, except -it allows to return either a string or a Template instance. -- You now must use only one of `template`, `get_template`, `template_name`, or `get_template_name`. - -**Version 0.96** -- Run-time type validation for Python 3.11+ - If the `Component` class is typed, e.g. `Component[Args, Kwargs, ...]`, the args, kwargs, slots, and data are validated against the given types. (See [Runtime input validation with types](#runtime-input-validation-with-types)) -- Render hooks - Set `on_render_before` and `on_render_after` methods on `Component` to intercept or modify the template or context before rendering, or the rendered result afterwards. (See [Component hooks](#component-hooks)) -- `component_vars.is_filled` context variable can be accessed from within `on_render_before` and `on_render_after` hooks as `self.is_filled.my_slot` - -**Version 0.95** -- Added support for dynamic components, where the component name is passed as a variable. (See [Dynamic components](#dynamic-components)) -- Changed `Component.input` to raise `RuntimeError` if accessed outside of render context. Previously it returned `None` if unset. - -**Version 0.94** -- django_components now automatically configures Django to support multi-line tags. (See [Multi-line tags](#multi-line-tags)) -- New setting `reload_on_template_change`. Set this to `True` to reload the dev server on changes to component template files. (See [Reload dev server on component file changes](#reload-dev-server-on-component-file-changes)) - -**Version 0.93** -- Spread operator `...dict` inside template tags. (See [Spread operator](#spread-operator)) -- Use template tags inside string literals in component inputs. (See [Use template tags inside component inputs](#use-template-tags-inside-component-inputs)) -- Dynamic slots, fills and provides - The `name` argument for these can now be a variable, a template expression, or via spread operator -- Component library authors can now configure `CONTEXT_BEHAVIOR` and `TAG_FORMATTER` settings independently from user settings. - -πŸš¨πŸ“’ **Version 0.92** -- BREAKING CHANGE: `Component` class is no longer a subclass of `View`. To configure the `View` class, set the `Component.View` nested class. HTTP methods like `get` or `post` can still be defined directly on `Component` class, and `Component.as_view()` internally calls `Component.View.as_view()`. (See [Modifying the View class](#modifying-the-view-class)) - -- The inputs (args, kwargs, slots, context, ...) that you pass to `Component.render()` can be accessed from within `get_context_data`, `get_template` and `get_template_name` via `self.input`. (See [Accessing data passed to the component](#accessing-data-passed-to-the-component)) - -- Typing: `Component` class supports generics that specify types for `Component.render` (See [Adding type hints with Generics](#adding-type-hints-with-generics)) - -**Version 0.90** -- All tags (`component`, `slot`, `fill`, ...) now support "self-closing" or "inline" form, where you can omit the closing tag: - ```django - {# Before #} - {% component "button" %}{% endcomponent %} - {# After #} - {% component "button" / %} - ``` -- All tags now support the "dictionary key" or "aggregate" syntax (`kwarg:key=val`): - ```django - {% component "button" attrs:class="hidden" %} - ``` -- You can change how the components are written in the template with [TagFormatter](#customizing-component-tags-with-tagformatter). - - The default is `django_components.component_formatter`: - ```django - {% component "button" href="..." disabled %} - Click me! - {% endcomponent %} - ``` - - While `django_components.component_shorthand_formatter` allows you to write components like so: - - ```django - {% button href="..." disabled %} - Click me! - {% endbutton %} - -πŸš¨πŸ“’ **Version 0.85** Autodiscovery module resolution changed. Following undocumented behavior was removed: - -- Previously, autodiscovery also imported any `[app]/components.py` files, and used `SETTINGS_MODULE` to search for component dirs. - - To migrate from: - - `[app]/components.py` - Define each module in `COMPONENTS.libraries` setting, - or import each module inside the `AppConfig.ready()` hook in respective `apps.py` files. - - `SETTINGS_MODULE` - Define component dirs using `STATICFILES_DIRS` -- Previously, autodiscovery handled relative files in `STATICFILES_DIRS`. To align with Django, `STATICFILES_DIRS` now must be full paths ([Django docs](https://docs.djangoproject.com/en/5.0/ref/settings/#std-setting-STATICFILES_DIRS)). - -πŸš¨πŸ“’ **Version 0.81** Aligned the `render_to_response` method with the (now public) `render` method of `Component` class. Moreover, slots passed to these can now be rendered also as functions. - -- BREAKING CHANGE: The order of arguments to `render_to_response` has changed. - -**Version 0.80** introduces dependency injection with the `{% provide %}` tag and `inject()` method. - -πŸš¨πŸ“’ **Version 0.79** - -- BREAKING CHANGE: Default value for the `COMPONENTS.context_behavior` setting was changes from `"isolated"` to `"django"`. If you did not set this value explicitly before, this may be a breaking change. See the rationale for change [here](https://github.com/EmilStenstrom/django-components/issues/498). - -πŸš¨πŸ“’ **Version 0.77** CHANGED the syntax for accessing default slot content. - -- Previously, the syntax was - `{% fill "my_slot" as "alias" %}` and `{{ alias.default }}`. -- Now, the syntax is - `{% fill "my_slot" default="alias" %}` and `{{ alias }}`. - -**Version 0.74** introduces `html_attrs` tag and `prefix:key=val` construct for passing dicts to components. - -πŸš¨πŸ“’ **Version 0.70** - -- `{% if_filled "my_slot" %}` tags were replaced with `{{ component_vars.is_filled.my_slot }}` variables. -- Simplified settings - `slot_context_behavior` and `context_behavior` were merged. See the [documentation](#context-behavior) for more details. - -**Version 0.67** CHANGED the default way how context variables are resolved in slots. See the [documentation](https://github.com/EmilStenstrom/django-components/tree/0.67#isolate-components-slots) for more details. - -πŸš¨πŸ“’ **Version 0.5** CHANGES THE SYNTAX for components. `component_block` is now `component`, and `component` blocks need an ending `endcomponent` tag. The new `python manage.py upgradecomponent` command can be used to upgrade a directory (use --path argument to point to each dir) of templates that use components to the new syntax automatically. - -This change is done to simplify the API in anticipation of a 1.0 release of django_components. After 1.0 we intend to be stricter with big changes like this in point releases. - -**Version 0.34** adds components as views, which allows you to handle requests and render responses from within a component. See the [documentation](#use-components-as-views) for more details. - -**Version 0.28** introduces 'implicit' slot filling and the `default` option for `slot` tags. - -**Version 0.27** adds a second installable app: _django_components.safer_staticfiles_. It provides the same behavior as _django.contrib.staticfiles_ but with extra security guarantees (more info below in Security Notes). - -**Version 0.26** changes the syntax for `{% slot %}` tags. From now on, we separate defining a slot (`{% slot %}`) from filling a slot with content (`{% fill %}`). This means you will likely need to change a lot of slot tags to fill. We understand this is annoying, but it's the only way we can get support for nested slots that fill in other slots, which is a very nice featuPpre to have access to. Hoping that this will feel worth it! - -**Version 0.22** starts autoimporting all files inside components subdirectores, to simplify setup. An existing project might start to get AlreadyRegistered-errors because of this. To solve this, either remove your custom loading of components, or set "autodiscover": False in settings.COMPONENTS. - -**Version 0.17** renames `Component.context` and `Component.template` to `get_context_data` and `get_template_name`. The old methods still work, but emit a deprecation warning. This change was done to sync naming with Django's class based views, and make using django-components more familiar to Django users. `Component.context` and `Component.template` will be removed when version 1.0 is released. +Read the [Release Notes](https://github.com/EmilStenstrom/django-components/tree/master/CHANGELOG.md) +to see the latest features and fixes. ## Security notes 🚨 @@ -357,6 +237,34 @@ STATICFILES_FINDERS = [ ] ``` +### Adding support for JS and CSS + +If you want to use JS or CSS with components, you will need to: + +1. Add [`ComponentDependencyMiddleware`](#setting-up-componentdependencymiddleware) to `MIDDLEWARE` setting. + +The middleware searches the outgoing HTML for all components that were rendered +to generate the HTML, and adds the JS and CSS associated with those components. + +```py +MIDDLEWARE = [ + ... + "django_components.middleware.ComponentDependencyMiddleware", +] +``` + +Read more in [Rendering JS/CSS dependencies](#rendering-jscss-dependencies). + +2. Add django-component's URL paths to your `urlpatterns`: + +```py +from django.urls import include, path + +urlpatterns = [ + ... + path("", include("django_components.urls")), +] +``` ### Optional @@ -897,7 +805,7 @@ that allow you to specify the types of args, kwargs, slots, and data: ```py -class Button(Component[Args, Kwargs, Data, Slots]): +class Button(Component[Args, Kwargs, Slots, Data, JsData, CssData]): ... ``` @@ -936,7 +844,7 @@ class Slots(TypedDict): # SlotContent == Union[str, SafeString] another_slot: SlotContent -class Button(Component[Args, Kwargs, Data, Slots]): +class Button(Component[Args, Kwargs, Slots, Data, JsData, CssData]): def get_context_data(self, variable, another): return { "variable": variable, @@ -1014,7 +922,7 @@ from django_components import Component, EmptyDict, EmptyTuple Args = EmptyTuple Kwargs = Data = Slots = EmptyDict -class Button(Component[Args, Kwargs, Data, Slots]): +class Button(Component[Args, Kwargs, Slots, Data, JsData, CssData]): ... ``` @@ -1056,7 +964,7 @@ Or you can replace `Args` with `Any` altogether, to skip the validation of args: ```py # Replaced `Args` with `Any` -class Button(Component[Any, Kwargs, Data, Slots]): +class Button(Component[Any, Kwargs, Slots, Data, JsData, CssData]): ... ``` @@ -1250,8 +1158,8 @@ NOTE: The Library instance can be accessed under `library` attribute of `Compone When you are creating an instance of `ComponentRegistry`, you can define the components' behavior within the template. The registry accepts these settings: -- `CONTEXT_BEHAVIOR` -- `TAG_FORMATTER` +- `context_behavior` +- `tag_formatter` ```py from django.template import Library @@ -1261,8 +1169,8 @@ register = library = django.template.Library() comp_registry = ComponentRegistry( library=library, settings=RegistrySettings( - CONTEXT_BEHAVIOR="isolated", - TAG_FORMATTER="django_components.component_formatter", + context_behavior="isolated", + tag_formatter="django_components.component_formatter", ), ) ``` @@ -1346,7 +1254,7 @@ This behavior is similar to [slots in Vue](https://vuejs.org/guide/components/sl In the example below we introduce two block tags that work hand in hand to make this work. These are... - `{% slot %}`/`{% endslot %}`: Declares a new slot in the component template. -- `{% fill %}`/`{% endfill %}`: (Used inside a `component` tag pair.) Fills a declared slot with the specified content. +- `{% fill %}`/`{% endfill %}`: (Used inside a `{% component %}` tag pair.) Fills a declared slot with the specified content. Let's update our calendar component to support more customization. We'll add `slot` tag pairs to its template, _template.html_. @@ -1365,7 +1273,9 @@ When using the component, you specify which slots you want to fill and where you ```htmldjango {% component "calendar" date="2020-06-06" %} - {% fill "body" %}Can you believe it's already {{ date }}??{% endfill %} + {% fill "body" %} + Can you believe it's already {{ date }}?? + {% endfill %} {% endcomponent %} ``` @@ -1382,13 +1292,53 @@ Since the 'header' fill is unspecified, it's taken from the base template. If yo ``` +### Named slots + +As seen in the previouse section, you can use `{% fill slot_name %}` to insert content into a specific +slot. + +You can define fills for multiple slot simply by defining them all within the `{% component %} {% endcomponent %}` +tags: + +```htmldjango +{% component "calendar" date="2020-06-06" %} + {% fill "header" %} + Hi this is header! + {% endfill %} + {% fill "body" %} + Can you believe it's already {{ date }}?? + {% endfill %} +{% endcomponent %} +``` + +You can also use `{% for %}`, `{% with %}`, or other tags (even `{% include %}`) +to construct the `{% fill %}` tags, **as long as these other tags do not leave any text behind!** + +```django +{% component "table" %} + {% for slot_name in slots %} + {% fill name=slot_name %} + {{ slot_name }} + {% endfill %} + {% endfor %} + + {% with slot_name="abc" %} + {% fill name=slot_name %} + {{ slot_name }} + {% endfill %} + {% endwith %} +{% endcomponent %} +``` + ### Default slot _Added in version 0.28_ As you can see, component slots lets you write reusable containers that you fill in when you use a component. This makes for highly reusable components that can be used in different circumstances. -It can become tedious to use `fill` tags everywhere, especially when you're using a component that declares only one slot. To make things easier, `slot` tags can be marked with an optional keyword: `default`. When added to the end of the tag (as shown below), this option lets you pass filling content directly in the body of a `component` tag pair – without using a `fill` tag. Choose carefully, though: a component template may contain at most one slot that is marked as `default`. The `default` option can be combined with other slot options, e.g. `required`. +It can become tedious to use `fill` tags everywhere, especially when you're using a component that declares only one slot. To make things easier, `slot` tags can be marked with an optional keyword: `default`. + +When added to the tag (as shown below), this option lets you pass filling content directly in the body of a `component` tag pair – without using a `fill` tag. Choose carefully, though: a component template may contain at most one slot that is marked as `default`. The `default` option can be combined with other slot options, e.g. `required`. Here's the same example as before, except with default slots and implicit filling. @@ -1422,7 +1372,7 @@ The rendered result (exactly the same as before): ``` -You may be tempted to combine implicit fills with explicit `fill` tags. This will not work. The following component template will raise an error when compiled. +You may be tempted to combine implicit fills with explicit `fill` tags. This will not work. The following component template will raise an error when rendered. ```htmldjango {# DON'T DO THIS #} @@ -1432,26 +1382,33 @@ You may be tempted to combine implicit fills with explicit `fill` tags. This wil {% endcomponent %} ``` -By contrast, it is permitted to use `fill` tags in nested components, e.g.: +Instead, you can use a named fill with name `default` to target the default fill: ```htmldjango +{# THIS WORKS #} {% component "calendar" date="2020-06-06" %} - {% component "beautiful-box" %} - {% fill "content" %} Can you believe it's already {{ date }}?? {% endfill %} - {% endcomponent %} + {% fill "header" %}Totally new header!{% endfill %} + {% fill "default" %} + Can you believe it's already {{ date }}?? + {% endfill %} {% endcomponent %} ``` -This is fine too: +NOTE: If you doubly-fill a slot, that is, that both `{% fill "default" %}` and `{% fill "header" %}` +would point to the same slot, this will raise an error when rendered. -```htmldjango -{% component "calendar" date="2020-06-06" %} - {% fill "header" %} - {% component "calendar-header" %} - Super Special Calendar Header - {% endcomponent %} - {% endfill %} -{% endcomponent %} +#### Accessing default slot in Python + +Since the default slot is stored under the slot name `default`, you can access the default slot +like so: + +```py +class MyTable(Component): + def get_context_data(self, *args, **kwargs): + default_slot = self.input.slots["default"] + return { + "default_slot": default_slot, + } ``` ### Render fill in multiple places @@ -1498,9 +1455,7 @@ This renders: #### Default and required slots If you use a slot multiple times, you can still mark the slot as `default` or `required`. -For that, you must mark ONLY ONE of the identical slots. - -We recommend to mark the first occurence for consistency, e.g.: +For that, you must mark each slot individually, e.g.: ```htmldjango
@@ -1508,12 +1463,12 @@ We recommend to mark the first occurence for consistency, e.g.: {% slot "image" default required %}Image here{% endslot %}
- {% slot "image" %}Image here{% endslot %} + {% slot "image" default required %}Image here{% endslot %}
``` -Which you can then use are regular default slot: +Which you can then use as regular default slot: ```htmldjango {% component "calendar" date="2020-06-06" %} @@ -1521,6 +1476,39 @@ Which you can then use are regular default slot: {% endcomponent %} ``` +Since each slot is tagged individually, you can have multiple slots +with the same name but different conditions. + +E.g. in this example, we have a component that renders a user avatar +- a small circular image with a profile picture of name initials. + +If the component is given `image_src` or `name_initials` variables, +the `image` slot is optional. But if neither of those are provided, +you MUST fill the `image` slot. + +```htmldjango +
+ {% if image_src %} + {% slot "image" default %} + + {% endslot %} + {% elif name_initials %} + {% slot "image" default %} +
+ {{ name_initials }} +
+ {% endslot %} + {% else %} + {% slot "image" default required / %} + {% endif %} +
+``` + ### Accessing original content of slots _Added in version 0.26_ @@ -1564,6 +1552,16 @@ This produces: ``` +To access the original content of a default slot, set the name to `default`: + +```htmldjango +{% component "calendar" date="2020-06-06" %} + {% fill "default" default="slot_default" %} + {{ slot_default }}. Have a great day! + {% endfill %} +{% endcomponent %} +``` + ### Conditional slots _Added in version 0.26._ @@ -1615,6 +1613,7 @@ This is what our example looks like with `component_vars.is_filled`. {% endif %} +``` Here's our example with more complex branching. @@ -1654,6 +1653,20 @@ However, you can still define slots with other special characters. In such case, So a slot named `"my super-slot :)"` will be available as `component_vars.is_filled.my_super_slot___`. +Same applies when you are accessing `is_filled` from within the Python, e.g.: + +```py +class MyTable(Component): + def on_render_before(self, context, template) -> None: + # βœ… Works + if self.is_filled["my_super_slot___"]: + # Do something + + # ❌ Does not work + if self.is_filled["my super-slot :)"]: + # Do something +``` + ### Scoped slots _Added in version 0.76_: @@ -1715,8 +1728,8 @@ the slot data. In the example below, we set it to `data`: ```django {% component "my_comp" %} - {% fill "content" data="data" %} - {{ data.input }} + {% fill "content" data="slot_data" %} + {{ slot_data.input }} {% endfill %} {% endcomponent %} ``` @@ -1727,8 +1740,8 @@ So this works: ```django {% component "my_comp" %} - {% fill "content" data="data" %} - {{ data.input }} + {% fill "default" data="slot_data" %} + {{ slot_data.input }} {% endfill %} {% endcomponent %} ``` @@ -1823,6 +1836,31 @@ So it's possible to define a `name` key on a dictionary, and then spread that on {% slot ...slot_props / %} ``` +### Pass through all the slots + +You can dynamically pass all slots to a child component. This is similar to +[passing all slots in Vue](https://vue-land.github.io/faq/forwarding-slots#passing-all-slots): + +```py +class MyTable(Component): + def get_context_data(self, *args, **kwargs): + return { + "slots": self.input.slots, + } + + template: """ +
+ {% component "child" %} + {% for slot_name in slots %} + {% fill name=slot_name data="data" %} + {% slot name=slot_name ...data / %} + {% endfill %} + {% endfor %} + {% endcomponent %} +
+ """ +``` + ## Accessing data passed to the component When you call `Component.render` or `Component.render_to_response`, the inputs to these methods can be accessed from within the instance under `self.input`. @@ -1831,6 +1869,8 @@ This means that you can use `self.input` inside: - `get_context_data` - `get_template_name` - `get_template` +- `on_render_before` +- `on_render_after` `self.input` is only defined during the execution of `Component.render`, and raises a `RuntimeError` when called outside of this context. @@ -1841,7 +1881,7 @@ class TestComponent(Component): def get_context_data(self, var1, var2, variable, another, **attrs): assert self.input.args == (123, "str") assert self.input.kwargs == {"variable": "test", "another": 1} - assert self.input.slots == {"my_slot": "MY_SLOT"} + assert self.input.slots == {"my_slot": ...} assert isinstance(self.input.context, Context) return { @@ -1855,6 +1895,8 @@ rendered = TestComponent.render( ) ``` +NOTE: The slots in `self.input.slots` are normalized to slot functions. + ## Rendering HTML attributes _New in version 0.74_: @@ -2794,6 +2836,16 @@ Here is a list of all variables that are automatically available from within the {% endif %} ``` + This is equivalent to checking if a given key is among the slot fills: + + ```py + class MyTable(Component): + def get_context_data(self, *args, **kwargs): + return { + "my_slot_filled": "my_slot" in self.input.slots + } + ``` + ## Customizing component tags with TagFormatter _New in version 0.89_ @@ -3146,15 +3198,72 @@ NOTE: The instance of the `Media` class (or it's subclass) is available under `C ## Rendering JS/CSS dependencies -The JS and CSS files included in components are not automatically rendered. -Instead, use the following tags to specify where to render the dependencies: +If: +1. Your components use JS and CSS, whether inlined via `Component.js/css` or via `Component.Media.js/css`, +2. And you use the `ComponentDependencyMiddleware` middleware -- `component_dependencies` - Renders both JS and CSS -- `component_js_dependencies` - Renders only JS -- `component_css_dependencies` - Reneders only CSS +Then, by default, the components' JS and CSS will be automatically inserted into the HTML: +- CSS styles will be inserted at the end of the `` +- JS scripts will be inserted at the end of the `` -JS files are rendered as `""" -@override_settings(COMPONENTS={"RENDER_DEPENDENCIES": True}) class RenderBenchmarks(BaseTestCase): def setUp(self): registry.clear() @@ -122,7 +120,9 @@ class RenderBenchmarks(BaseTestCase): def test_middleware_time_with_dependency_for_small_page(self): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} {% component 'test_component' %} {% slot "header" %} {% component 'inner_component' variable='foo' %}{% endcomponent %} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md deleted file mode 100644 index 2e473032..00000000 --- a/docs/CHANGELOG.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -hide: - - toc ---- - -# Release notes - -{! - include-markdown "../README.md" - start="## Release notes" - end='## ' - heading-offset=1 -!} \ No newline at end of file diff --git a/docs/slot_rendering.md b/docs/slot_rendering.md deleted file mode 100644 index ecde6127..00000000 --- a/docs/slot_rendering.md +++ /dev/null @@ -1,238 +0,0 @@ -# Slot rendering - -This doc serves as a primer on how component slots and fills are resolved. - -## Flow - -1. Imagine you have a template. Some kind of text, maybe HTML: - ```django - | ------ - | --------- - | ---- - | ------- - ``` - -2. The template may contain some vars, tags, etc - ```django - | -- {{ my_var }} -- - | --------- - | ---- - | ------- - ``` - -3. The template also contains some slots, etc - ```django - | -- {{ my_var }} -- - | --------- - | -- {% slot "myslot" %} --- - | -- {% endslot %} --- - | ---- - | -- {% slot "myslot2" %} --- - | -- {% endslot %} --- - | ------- - ``` - -4. Slots may be nested - ```django - | -- {{ my_var }} -- - | -- ABC - | -- {% slot "myslot" %} --- - | ----- DEF {{ my_var }} - | ----- {% slot "myslot_inner" %} - | -------- GHI {{ my_var }} - | ----- {% endslot %} - | -- {% endslot %} --- - | ---- - | -- {% slot "myslot2" %} --- - | ---- JKL {{ my_var }} - | -- {% endslot %} --- - | ------- - ``` - -5. Some slots may be inside fills for other components - ```django - | -- {{ my_var }} -- - | -- ABC - | -- {% slot "myslot" %}--- - | ----- DEF {{ my_var }} - | ----- {% slot "myslot_inner" %} - | -------- GHI {{ my_var }} - | ----- {% endslot %} - | -- {% endslot %} --- - | ------ - | -- {% component "mycomp" %} --- - | ---- {% slot "myslot" %} --- - | ------- JKL {{ my_var }} - | ------- {% slot "myslot_inner" %} - | ---------- MNO {{ my_var }} - | ------- {% endslot %} - | ---- {% endslot %} --- - | -- {% endcomponent %} --- - | ---- - | -- {% slot "myslot2" %} --- - | ---- PQR {{ my_var }} - | -- {% endslot %} --- - | ------- - ``` - -5. I want to render the slots with `{% fill %}` tag that were defined OUTSIDE of this template. How do I do that? - - 1. Traverse the template to collect ALL slots - - NOTE: I will also look inside `{% slot %}` and `{% fill %}` tags, since they are all still - defined within the same TEMPLATE. - - I should end up with a list like this: - ```txt - - Name: "myslot" - ID 0001 - Content: - | ----- DEF {{ my_var }} - | ----- {% slot "myslot_inner" %} - | -------- GHI {{ my_var }} - | ----- {% endslot %} - - Name: "myslot_inner" - ID 0002 - Content: - | -------- GHI {{ my_var }} - - Name: "myslot" - ID 0003 - Content: - | ------- JKL {{ my_var }} - | ------- {% slot "myslot_inner" %} - | ---------- MNO {{ my_var }} - | ------- {% endslot %} - - Name: "myslot_inner" - ID 0004 - Content: - | ---------- MNO {{ my_var }} - - Name: "myslot2" - ID 0005 - Content: - | ---- PQR {{ my_var }} - ``` - - 2. Note the relationships - which slot is nested in which one - - I should end up with a graph-like data like: - ```txt - - 0001: [0002] - - 0002: [] - - 0003: [0004] - - 0004: [] - - 0005: [] - ``` - - In other words, the data tells us that slot ID `0001` is PARENT of slot `0002`. - - This is important, because, IF parent template provides slot fill for slot 0001, - then we DON'T NEED TO render it's children, AKA slot 0002. - - 3. Find roots of the slot relationships - - The data from previous step can be understood also as a collection of - directled acyclig graphs (DAG), e.g.: - - ```txt - 0001 --> 0002 - 0003 --> 0004 - 0005 - ``` - - So we find the roots (`0001`, `0003`, `0005`), AKA slots that are NOT nested in other slots. - We do so by going over ALL entries from previous step. Those IDs which are NOT - mentioned in ANY of the lists are the roots. - - Because of the nature of nested structures, there cannot be any cycles. - - 4. Recursively render slots, starting from roots. - 1. First we take each of the roots. - - 2. Then we check if there is a slot fill for given slot name. - - 3. If YES we replace the slot node with the fill node. - - Note: We assume slot fills are ALREADY RENDERED! - ```django - | ----- {% slot "myslot_inner" %} - | -------- GHI {{ my_var }} - | ----- {% endslot %} - ``` - becomes - ```django - | ----- Bla bla - | -------- Some Other Content - | ----- ... - ``` - We don't continue further, because inner slots have been overriden! - - 4. If NO, then we will replace slot nodes with their children, e.g.: - ```django - | ---- {% slot "myslot" %} --- - | ------- JKL {{ my_var }} - | ------- {% slot "myslot_inner" %} - | ---------- MNO {{ my_var }} - | ------- {% endslot %} - | ---- {% endslot %} --- - ``` - Becomes - ```django - | ------- JKL {{ my_var }} - | ------- {% slot "myslot_inner" %} - | ---------- MNO {{ my_var }} - | ------- {% endslot %} - ``` - - 5. We check if the slot includes any children `{% slot %}` tags. If YES, then continue with step 4. for them, and wait until they finish. - - 5. At this point, ALL slots should be rendered and we should have something like this: - ```django - | -- {{ my_var }} -- - | -- ABC - | ----- DEF {{ my_var }} - | -------- GHI {{ my_var }} - | ------ - | -- {% component "mycomp" %} --- - | ------- JKL {{ my_var }} - | ---- {% component "mycomp" %} --- - | ---------- MNO {{ my_var }} - | ---- {% endcomponent %} --- - | -- {% endcomponent %} --- - | ---- - | -- {% component "mycomp2" %} --- - | ---- PQR {{ my_var }} - | -- {% endcomponent %} --- - | ---- - ``` - - NOTE: Inserting fills into {% slots %} should NOT introduce new {% slots %}, as the fills should be already rendered! - -## Using the correct context in {% slot/fill %} tags - -In previous section, we said that the `{% fill %}` tags should be already rendered by the time they are inserted into the `{% slot %}` tags. - -This is not quite true. To help you understand, consider this complex case: - -```django -| -- {% for var in [1, 2, 3] %} --- -| ---- {% component "mycomp2" %} --- -| ------ {% fill "first" %} -| ------- STU {{ my_var }} -| ------- {{ var }} -| ------ {% endfill %} -| ------ {% fill "second" %} -| -------- {% component var=var my_var=my_var %} -| ---------- VWX {{ my_var }} -| -------- {% endcomponent %} -| ------ {% endfill %} -| ---- {% endcomponent %} --- -| -- {% endfor %} --- -| ------- -``` - -We want the forloop variables to be available inside the `{% fill %}` tags. Because of that, however, we CANNOT render the fills/slots in advance. - -Instead, our solution is closer to [how Vue handles slots](https://vuejs.org/guide/components/slots.html#scoped-slots). In Vue, slots are effectively functions that accept a context variables and render some content. - -While we do not wrap the logic in a function, we do PREPARE IN ADVANCE: -1. The content that should be rendered for each slot -2. The context variables from `get_context_data()` - -Thus, once we reach the `{% slot %}` node, in it's `render()` method, we access the data above, and, depending on the `context_behavior` setting, include the current context or not. For more info, see `SlotNode.render()`. diff --git a/mkdocs.yml b/mkdocs.yml index 622a51c7..58a0a6a9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -5,15 +5,14 @@ site_url: https://emilstenstrom.github.io/django-components/ repo_url: https://github.com/EmilStenstrom/django-components repo_name: EmilStenstrom/django-components -edit_uri: https://github.com/EmilStenstrom/django-components/edit/master/docs/ +edit_uri: https://github.com/EmilStenstrom/django-components/edit/master/src/docs/ dev_addr: "127.0.0.1:9000" site_dir: site -docs_dir: docs +docs_dir: src/docs watch: - src - - docs - mkdocs.yml - README.md - scripts @@ -26,7 +25,7 @@ validation: theme: name: "material" - custom_dir: docs/overrides + custom_dir: src/docs/overrides features: - content.action.edit - content.action.view @@ -117,7 +116,7 @@ plugins: closing_tag: "!}" - gen-files: scripts: - - scripts/gen_ref_nav.py + - src/docs/scripts/reference.py - literate-nav: nav_file: SUMMARY.md tab_length: 2 @@ -175,13 +174,7 @@ plugins: signature_crossrefs: true summary: true unwrap_annotated: true - # show_root_heading: true - # show_signature_annotations: true show_if_no_docstring: false - # separate_signature: true line_length: 140 - # merge_init_into_class: true show_submodules: true docstring_style: google - # docstring_options: - # ignore_init_summary: true diff --git a/pyproject.toml b/pyproject.toml index 7fac6063..52bb24f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ classifiers = [ ] dependencies = [ 'Django>=4.2', + 'selectolax>=0.3.24', ] license = {text = "MIT"} @@ -79,6 +80,10 @@ exclude = [ '.tox', 'build', ] +per-file-ignores = [ + 'tests/test_component_media.py:E501', + 'tests/test_dependency_rendering.py:E501', +] [tool.mypy] check_untyped_defs = true diff --git a/requirements-dev.in b/requirements-dev.in index c8f01700..6238e9f1 100644 --- a/requirements-dev.in +++ b/requirements-dev.in @@ -10,4 +10,5 @@ mypy playwright requests types-requests -whitenoise \ No newline at end of file +whitenoise +selectolax \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index 1cca60f1..18464dd5 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -91,6 +91,8 @@ pyyaml==6.0.1 # via pre-commit requests==2.32.3 # via -r requirements-dev.in +selectolax==0.3.21 + # via -r requirements-dev.in sqlparse==0.5.0 # via django tox==4.23.2 diff --git a/sampleproject/sampleproject/settings.py b/sampleproject/sampleproject/settings.py index f77b9c1e..c8bbb313 100644 --- a/sampleproject/sampleproject/settings.py +++ b/sampleproject/sampleproject/settings.py @@ -5,6 +5,8 @@ import sys from pathlib import Path from typing import List +from django_components import ComponentsSettings + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -44,6 +46,7 @@ MIDDLEWARE = [ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "django_components.middleware.ComponentDependencyMiddleware", ] ROOT_URLCONF = "sampleproject.urls" @@ -87,14 +90,14 @@ STATICFILES_FINDERS = [ WSGI_APPLICATION = "sampleproject.wsgi.application" -COMPONENTS = { - # "autodiscover": True, - "dirs": [BASE_DIR / "components"], - # "app_dirs": ["components"], - # "libraries": [], - # "template_cache_size": 128, - # "context_behavior": "isolated", # "django" | "isolated" -} +COMPONENTS = ComponentsSettings( + # autodiscover=True, + dirs=[BASE_DIR / "components"], + # app_dirs=["components"], + # libraries=[], + # template_cache_size=128, + # context_behavior="isolated", # "django" | "isolated" +) # Database diff --git a/sampleproject/sampleproject/urls.py b/sampleproject/sampleproject/urls.py index ff2faa2c..966f02c6 100644 --- a/sampleproject/sampleproject/urls.py +++ b/sampleproject/sampleproject/urls.py @@ -3,4 +3,5 @@ from django.urls import include, path urlpatterns = [ path("", include("calendarapp.urls")), path("", include("components.urls")), + path("", include("django_components.urls")), ] diff --git a/src/django_components/__init__.py b/src/django_components/__init__.py index 65f53b05..b076b6cf 100644 --- a/src/django_components/__init__.py +++ b/src/django_components/__init__.py @@ -1,49 +1,73 @@ -# flake8: noqa F401 """Main package for Django Components.""" -import django - # Public API +# NOTE: Middleware is exposed via django_components.middleware +# NOTE: Some of the documentation is generated based on these exports # isort: off -from django_components.app_settings import ContextBehavior as ContextBehavior -from django_components.autodiscover import ( - autodiscover as autodiscover, - import_libraries as import_libraries, -) -from django_components.component import ( - Component as Component, - ComponentView as ComponentView, -) +from django_components.app_settings import ContextBehavior, ComponentsSettings +from django_components.autodiscovery import autodiscover, import_libraries +from django_components.component import Component, ComponentVars, ComponentView from django_components.component_registry import ( - AlreadyRegistered as AlreadyRegistered, - ComponentRegistry as ComponentRegistry, - NotRegistered as NotRegistered, - RegistrySettings as RegistrySettings, - register as register, - registry as registry, -) -from django_components.components import DynamicComponent as DynamicComponent -from django_components.library import TagProtectedError as TagProtectedError -from django_components.slots import ( - SlotContent as SlotContent, - SlotFunc as SlotFunc, + AlreadyRegistered, + ComponentRegistry, + NotRegistered, + RegistrySettings, + register, + registry, ) +from django_components.components import DynamicComponent +from django_components.dependencies import render_dependencies +from django_components.library import TagProtectedError +from django_components.slots import SlotContent, Slot, SlotFunc, SlotRef, SlotResult from django_components.tag_formatter import ( - ComponentFormatter as ComponentFormatter, - ShorthandComponentFormatter as ShorthandComponentFormatter, - TagFormatterABC as TagFormatterABC, - TagResult as TagResult, - component_formatter as component_formatter, - component_shorthand_formatter as component_shorthand_formatter, + ComponentFormatter, + ShorthandComponentFormatter, + TagFormatterABC, + TagResult, + component_formatter, + component_shorthand_formatter, ) -from django_components.template import cached_template as cached_template +from django_components.template import cached_template import django_components.types as types -from django_components.types import ( - EmptyTuple as EmptyTuple, - EmptyDict as EmptyDict, -) +from django_components.util.loader import ComponentFileEntry, get_component_dirs, get_component_files +from django_components.util.types import EmptyTuple, EmptyDict # isort: on -if django.VERSION < (3, 2): - default_app_config = "django_components.apps.ComponentsConfig" + +__all__ = [ + "AlreadyRegistered", + "autodiscover", + "cached_template", + "ContextBehavior", + "ComponentsSettings", + "Component", + "ComponentFileEntry", + "ComponentFormatter", + "ComponentRegistry", + "ComponentVars", + "ComponentView", + "component_formatter", + "component_shorthand_formatter", + "DynamicComponent", + "EmptyTuple", + "EmptyDict", + "get_component_dirs", + "get_component_files", + "import_libraries", + "NotRegistered", + "register", + "registry", + "RegistrySettings", + "render_dependencies", + "ShorthandComponentFormatter", + "SlotContent", + "Slot", + "SlotFunc", + "SlotRef", + "SlotResult", + "TagFormatterABC", + "TagProtectedError", + "TagResult", + "types", +] diff --git a/src/django_components/app_settings.py b/src/django_components/app_settings.py index 29885759..948db6ac 100644 --- a/src/django_components/app_settings.py +++ b/src/django_components/app_settings.py @@ -1,24 +1,60 @@ import re +from dataclasses import dataclass from enum import Enum +from os import PathLike from pathlib import Path -from typing import TYPE_CHECKING, Dict, List, Tuple, Union +from typing import ( + TYPE_CHECKING, + Callable, + Generic, + List, + Literal, + NamedTuple, + Optional, + Sequence, + Tuple, + TypeVar, + Union, + cast, +) from django.conf import settings +from django_components.util.misc import default + if TYPE_CHECKING: from django_components.tag_formatter import TagFormatterABC +T = TypeVar("T") + + +ContextBehaviorType = Literal["django", "isolated"] + + class ContextBehavior(str, Enum): + """ + Configure how (and whether) the context is passed to the component fills + and what variables are available inside the [`{% fill %}`](../template_tags#fill) tags. + + Also see [Component context and scope](../../concepts/fundamentals/component_context_scope#context-behavior). + + **Options:** + + - `django`: With this setting, component fills behave as usual Django tags. + - `isolated`: This setting makes the component fills behave similar to Vue or React. + """ + DJANGO = "django" """ With this setting, component fills behave as usual Django tags. That is, they enrich the context, and pass it along. 1. Component fills use the context of the component they are within. - 2. Variables from `get_context_data` are available to the component fill. + 2. Variables from [`Component.get_context_data()`](../api#django_components.Component.get_context_data) + are available to the component fill. - Example: + **Example:** Given this template ```django @@ -30,13 +66,13 @@ class ContextBehavior(str, Enum): {% endwith %} ``` - and this context returned from the `get_context_data()` method - ```py + and this context returned from the `Component.get_context_data()` method + ```python { "my_var": 123 } ``` Then if component "my_comp" defines context - ```py + ```python { "my_var": 456 } ``` @@ -56,9 +92,10 @@ class ContextBehavior(str, Enum): ISOLATED = "isolated" """ This setting makes the component fills behave similar to Vue or React, where - the fills use EXCLUSIVELY the context variables defined in `get_context_data`. + the fills use EXCLUSIVELY the context variables defined in + [`Component.get_context_data()`](../api#django_components.Component.get_context_data). - Example: + **Example:** Given this template ```django @@ -71,12 +108,12 @@ class ContextBehavior(str, Enum): ``` and this context returned from the `get_context_data()` method - ```py + ```python { "my_var": 123 } ``` Then if component "my_comp" defines context - ```py + ```python { "my_var": 456 } ``` @@ -91,95 +128,565 @@ class ContextBehavior(str, Enum): """ -class AppSettings: +# This is the source of truth for the settings that are available. If the documentation +# or the defaults do NOT match this, they should be updated. +class ComponentsSettings(NamedTuple): + """ + Settings available for django_components. + + **Example:** + + ```python + COMPONENTS = ComponentsSettings( + autodiscover=False, + dirs = [BASE_DIR / "components"], + ) + ``` + """ + + autodiscover: Optional[bool] = None + """ + Toggle whether to run [autodiscovery](../../concepts/fundamentals/autodiscovery) at the Django server startup. + + Defaults to `True` + + ```python + COMPONENTS = ComponentsSettings( + autodiscover=False, + ) + ``` + """ + + dirs: Optional[Sequence[Union[str, PathLike, Tuple[str, str], Tuple[str, PathLike]]]] = None + """ + Specify the directories that contain your components. + + Defaults to `[Path(settings.BASE_DIR) / "components"]`. That is, the root `components/` app. + + Directories must be full paths, same as with + [STATICFILES_DIRS](https://docs.djangoproject.com/en/5.1/ref/settings/#std-setting-STATICFILES_DIRS). + + These locations are searched during [autodiscovery](../../concepts/fundamentals/autodiscovery), + or when you [define HTML, JS, or CSS as separate files](../../concepts/fundamentals/defining_js_css_html_files). + + ```python + COMPONENTS = ComponentsSettings( + dirs=[BASE_DIR / "components"], + ) + ``` + + Set to empty list to disable global components directories: + + ```python + COMPONENTS = ComponentsSettings( + dirs=[], + ) + ``` + """ + + app_dirs: Optional[Sequence[str]] = None + """ + Specify the app-level directories that contain your components. + + Defaults to `["components"]`. That is, for each Django app, we search `/components/` for components. + + The paths must be relative to app, e.g.: + + ```python + COMPONENTS = ComponentsSettings( + app_dirs=["my_comps"], + ) + ``` + + To search for `/my_comps/`. + + These locations are searched during [autodiscovery](../../concepts/fundamentals/autodiscovery), + or when you [define HTML, JS, or CSS as separate files](../../concepts/fundamentals/defining_js_css_html_files). + + Set to empty list to disable app-level components: + + ```python + COMPONENTS = ComponentsSettings( + app_dirs=[], + ) + ``` + """ + + context_behavior: Optional[ContextBehaviorType] = None + """ + Configure whether, inside a component template, you can use variables from the outside + ([`"django"`](../api#django_components.ContextBehavior.DJANGO)) + or not ([`"isolated"`](../api#django_components.ContextBehavior.ISOLATED)). + This also affects what variables are available inside the [`{% fill %}`](../template_tags#fill) + tags. + + Also see [Component context and scope](../../concepts/fundamentals/component_context_scope#context-behavior). + + Defaults to `"django"`. + + ```python + COMPONENTS = ComponentsSettings( + context_behavior="isolated", + ) + ``` + + > NOTE: `context_behavior` and `slot_context_behavior` options were merged in v0.70. + > + > If you are migrating from BEFORE v0.67, set `context_behavior` to `"django"`. + > From v0.67 to v0.78 (incl) the default value was `"isolated"`. + > + > For v0.79 and later, the default is again `"django"`. See the rationale for change + > [here](https://github.com/EmilStenstrom/django-components/issues/498). + """ + + dynamic_component_name: Optional[str] = None + """ + By default, the [dynamic component](../components#django_components.components.dynamic.DynamicComponent) + is registered under the name `"dynamic"`. + + In case of a conflict, you can use this setting to change the component name used for + the dynamic components. + + ```python + # settings.py + COMPONENTS = ComponentsSettings( + dynamic_component_name="my_dynamic", + ) + ``` + + After which you will be able to use the dynamic component with the new name: + + ```django + {% component "my_dynamic" is=table_comp data=table_data headers=table_headers %} + {% fill "pagination" %} + {% component "pagination" / %} + {% endfill %} + {% endcomponent %} + ``` + """ + + libraries: Optional[List[str]] = None + """ + Configure extra python modules that should be loaded. + + This may be useful if you are not using the [autodiscovery feature](../../concepts/fundamentals/autodiscovery), + or you need to load components from non-standard locations. Thus you can have + a structure of components that is independent from your apps. + + Expects a list of python module paths. Defaults to empty list. + + **Example:** + + ```python + COMPONENTS = ComponentsSettings( + libraries=[ + "mysite.components.forms", + "mysite.components.buttons", + "mysite.components.cards", + ], + ) + ``` + + This would be the equivalent of importing these modules from within Django's + [`AppConfig.ready()`](https://docs.djangoproject.com/en/5.1/ref/applications/#django.apps.AppConfig.ready): + + ```python + class MyAppConfig(AppConfig): + def ready(self): + import "mysite.components.forms" + import "mysite.components.buttons" + import "mysite.components.cards" + ``` + + # Manually loading libraries + + In the rare case that you need to manually trigger the import of libraries, you can use + the [`import_libraries()`](../api/#django_components.import_libraries) function: + + ```python + from django_components import import_libraries + + import_libraries() + ``` + """ + + multiline_tags: Optional[bool] = None + """ + Enable / disable + [multiline support for template tags](../../concepts/fundamentals/template_tag_syntax#multiline-tags). + If `True`, template tags like `{% component %}` or `{{ my_var }}` can span multiple lines. + + Defaults to `True`. + + Disable this setting if you are making custom modifications to Django's + regular expression for parsing templates at `django.template.base.tag_re`. + + ```python + COMPONENTS = ComponentsSettings( + multiline_tags=False, + ) + ``` + """ + + # TODO_REMOVE_IN_V1 + reload_on_template_change: Optional[bool] = None + """Deprecated. Use + [`COMPONENTS.reload_on_file_change`](../settings/#django_components.app_settings.ComponentsSettings.reload_on_file_change) + instead.""" # noqa: E501 + + reload_on_file_change: Optional[bool] = None + """ + This is relevant if you are using the project structure where + HTML, JS, CSS and Python are in separate files and nested in a directory. + + In this case you may notice that when you are running a development server, + the server sometimes does not reload when you change component files. + + Django's native [live reload](https://stackoverflow.com/a/66023029/9788634) logic + handles only Python files and HTML template files. It does NOT reload when other + file types change or when template files are nested more than one level deep. + + The setting `reload_on_file_change` fixes this, reloading the dev server even when your component's + HTML, JS, or CSS changes. + + If `True`, django_components configures Django to reload when files inside + [`COMPONENTS.dirs`](../settings/#django_components.app_settings.ComponentsSettings.dirs) + or + [`COMPONENTS.app_dirs`](../settings/#django_components.app_settings.ComponentsSettings.app_dirs) + change. + + See [Reload dev server on component file changes](../../guides/setup/dev_server_setup/#reload-dev-server-on-component-file-changes). + + Defaults to `False`. + + !!! warning + + This setting should be enabled only for the dev environment! + """ # noqa: E501 + + static_files_allowed: Optional[List[Union[str, re.Pattern]]] = None + """ + A list of file extensions (including the leading dot) that define which files within + [`COMPONENTS.dirs`](../settings/#django_components.app_settings.ComponentsSettings.dirs) + or + [`COMPONENTS.app_dirs`](../settings/#django_components.app_settings.ComponentsSettings.app_dirs) + are treated as [static files](https://docs.djangoproject.com/en/5.1/howto/static-files/). + + If a file is matched against any of the patterns, it's considered a static file. Such files are collected + when running [`collectstatic`](https://docs.djangoproject.com/en/5.1/ref/contrib/staticfiles/#collectstatic), + and can be accessed under the + [static file endpoint](https://docs.djangoproject.com/en/5.1/ref/settings/#static-url). + + You can also pass in compiled regexes ([`re.Pattern`](https://docs.python.org/3/library/re.html#re.Pattern)) + for more advanced patterns. + + By default, JS, CSS, and common image and font file formats are considered static files: + + ```python + COMPONENTS = ComponentsSettings( + static_files_allowed=[ + ".css", + ".js", ".jsx", ".ts", ".tsx", + # Images + ".apng", ".png", ".avif", ".gif", ".jpg", + ".jpeg", ".jfif", ".pjpeg", ".pjp", ".svg", + ".webp", ".bmp", ".ico", ".cur", ".tif", ".tiff", + # Fonts + ".eot", ".ttf", ".woff", ".otf", ".svg", + ], + ) + ``` + + !!! warning + + Exposing your Python files can be a security vulnerability. + See [Security notes](../../overview/security_notes). + """ + + # TODO_REMOVE_IN_V1 + forbidden_static_files: Optional[List[Union[str, re.Pattern]]] = None + """Deprecated. Use + [`COMPONENTS.static_files_forbidden`](../settings/#django_components.app_settings.ComponentsSettings.static_files_forbidden) + instead.""" # noqa: E501 + + static_files_forbidden: Optional[List[Union[str, re.Pattern]]] = None + """ + A list of file extensions (including the leading dot) that define which files within + [`COMPONENTS.dirs`](../settings/#django_components.app_settings.ComponentsSettings.dirs) + or + [`COMPONENTS.app_dirs`](../settings/#django_components.app_settings.ComponentsSettings.app_dirs) + will NEVER be treated as [static files](https://docs.djangoproject.com/en/5.1/howto/static-files/). + + If a file is matched against any of the patterns, it will never be considered a static file, + even if the file matches a pattern in + [`static_files_allowed`](../settings/#django_components.app_settings.ComponentsSettings.static_files_allowed). + + Use this setting together with + [`static_files_allowed`](../settings/#django_components.app_settings.ComponentsSettings.static_files_allowed) + for a fine control over what file types will be exposed. + + You can also pass in compiled regexes ([`re.Pattern`](https://docs.python.org/3/library/re.html#re.Pattern)) + for more advanced patterns. + + By default, any HTML and Python are considered NOT static files: + + ```python + COMPONENTS = ComponentsSettings( + static_files_forbidden=[ + ".html", ".django", ".dj", ".tpl", + # Python files + ".py", ".pyc", + ], + ) + ``` + + !!! warning + + Exposing your Python files can be a security vulnerability. + See [Security notes](../../overview/security_notes). + """ + + tag_formatter: Optional[Union["TagFormatterABC", str]] = None + """ + Configure what syntax is used inside Django templates to render components. + See the [available tag formatters](../tag_formatters). + + Defaults to `"django_components.component_formatter"`. + + Learn more about [Customizing component tags with TagFormatter](../../concepts/advanced/tag_formatter). + + Can be set either as direct reference: + + ```python + from django_components import component_formatter + + COMPONENTS = ComponentsSettings( + "tag_formatter": component_formatter + ) + ``` + + Or as an import string; + + ```python + COMPONENTS = ComponentsSettings( + "tag_formatter": "django_components.component_formatter" + ) + ``` + + **Examples:** + + - `"django_components.component_formatter"` + + Set + + ```python + COMPONENTS = ComponentsSettings( + "tag_formatter": "django_components.component_formatter" + ) + ``` + + To write components like this: + + ```django + {% component "button" href="..." %} + Click me! + {% endcomponent %} + ``` + + - `django_components.component_shorthand_formatter` + + Set + + ```python + COMPONENTS = ComponentsSettings( + "tag_formatter": "django_components.component_shorthand_formatter" + ) + ``` + + To write components like this: + + ```django + {% button href="..." %} + Click me! + {% endbutton %} + ``` + """ + + template_cache_size: Optional[int] = None + """ + Configure the maximum amount of Django templates to be cached. + + Defaults to `128`. + + Each time a [Django template](https://docs.djangoproject.com/en/5.1/ref/templates/api/#django.template.Template) + is rendered, it is cached to a global in-memory cache (using Python's + [`lru_cache`](https://docs.python.org/3/library/functools.html#functools.lru_cache) + decorator). This speeds up the next render of the component. + As the same component is often used many times on the same page, these savings add up. + + By default the cache holds 128 component templates in memory, which should be enough for most sites. + But if you have a lot of components, or if you are overriding + [`Component.get_template()`](../api#django_components.Component.get_template) + to render many dynamic templates, you can increase this number. + + ```python + COMPONENTS = ComponentsSettings( + template_cache_size=256, + ) + ``` + + To remove the cache limit altogether and cache everything, set `template_cache_size` to `None`. + + ```python + COMPONENTS = ComponentsSettings( + template_cache_size=None, + ) + ``` + + If you want to add templates to the cache yourself, you can use + [`cached_template()`](../api/#django_components.cached_template): + + ```python + from django_components import cached_template + + cached_template("Variable: {{ variable }}") + + # You can optionally specify Template class, and other Template inputs: + class MyTemplate(Template): + pass + + cached_template( + "Variable: {{ variable }}", + template_cls=MyTemplate, + name=... + origin=... + engine=... + ) + ``` + """ + + +# NOTE: Some defaults depend on the Django settings, which may not yet be +# initialized at the time that these settings are generated. For such cases +# we define the defaults as a factory function, and use the `Dynamic` class to +# mark such fields. +@dataclass(frozen=True) +class Dynamic(Generic[T]): + getter: Callable[[], T] + + +# This is the source of truth for the settings defaults. If the documentation +# does NOT match it, the documentation should be updated. +# +# NOTE: Because we need to access Django settings to generate default dirs +# for `COMPONENTS.dirs`, we do it lazily. +# NOTE 2: We show the defaults in the documentation, together with the comments +# (except for the `Dynamic` instances and comments like `type: ignore`). +# So `fmt: off` turns off Black formatting and `snippet:defaults` allows +# us to extract the snippet from the file. +# +# fmt: off +# --snippet:defaults-- +defaults = ComponentsSettings( + autodiscover=True, + context_behavior=ContextBehavior.DJANGO.value, # "django" | "isolated" + # Root-level "components" dirs, e.g. `/path/to/proj/components/` + dirs=Dynamic(lambda: [Path(settings.BASE_DIR) / "components"]), # type: ignore[arg-type] + # App-level "components" dirs, e.g. `[app]/components/` + app_dirs=["components"], + dynamic_component_name="dynamic", + libraries=[], # E.g. ["mysite.components.forms", ...] + multiline_tags=True, + reload_on_file_change=False, + static_files_allowed=[ + ".css", + ".js", ".jsx", ".ts", ".tsx", + # Images + ".apng", ".png", ".avif", ".gif", ".jpg", + ".jpeg", ".jfif", ".pjpeg", ".pjp", ".svg", + ".webp", ".bmp", ".ico", ".cur", ".tif", ".tiff", + # Fonts + ".eot", ".ttf", ".woff", ".otf", ".svg", + ], + static_files_forbidden=[ + # See https://marketplace.visualstudio.com/items?itemName=junstyle.vscode-django-support + ".html", ".django", ".dj", ".tpl", + # Python files + ".py", ".pyc", + ], + tag_formatter="django_components.component_formatter", + template_cache_size=128, +) +# --endsnippet:defaults-- +# fmt: on + + +class InternalSettings: @property - def settings(self) -> Dict: - return getattr(settings, "COMPONENTS", {}) + def _settings(self) -> ComponentsSettings: + data = getattr(settings, "COMPONENTS", {}) + return ComponentsSettings(**data) if not isinstance(data, ComponentsSettings) else data @property def AUTODISCOVER(self) -> bool: - return self.settings.get("autodiscover", True) + return default(self._settings.autodiscover, cast(bool, defaults.autodiscover)) @property - def DIRS(self) -> List[Union[str, Tuple[str, str]]]: - base_dir_path = Path(settings.BASE_DIR) - return self.settings.get("dirs", [base_dir_path / "components"]) + def DIRS(self) -> Sequence[Union[str, PathLike, Tuple[str, str], Tuple[str, PathLike]]]: + # For DIRS we use a getter, because default values uses Django settings, + # which may not yet be initialized at the time these settings are generated. + default_fn = cast(Dynamic[Sequence[Union[str, Tuple[str, str]]]], defaults.dirs) + default_dirs = default_fn.getter() + return default(self._settings.dirs, default_dirs) @property - def APP_DIRS(self) -> List[str]: - return self.settings.get("app_dirs", ["components"]) + def APP_DIRS(self) -> Sequence[str]: + return default(self._settings.app_dirs, cast(List[str], defaults.app_dirs)) @property def DYNAMIC_COMPONENT_NAME(self) -> str: - return self.settings.get("dynamic_component_name", "dynamic") + return default(self._settings.dynamic_component_name, cast(str, defaults.dynamic_component_name)) @property def LIBRARIES(self) -> List[str]: - return self.settings.get("libraries", []) + return default(self._settings.libraries, cast(List[str], defaults.libraries)) @property def MULTILINE_TAGS(self) -> bool: - return self.settings.get("multiline_tags", True) + return default(self._settings.multiline_tags, cast(bool, defaults.multiline_tags)) @property - def RELOAD_ON_TEMPLATE_CHANGE(self) -> bool: - return self.settings.get("reload_on_template_change", False) + def RELOAD_ON_FILE_CHANGE(self) -> bool: + val = self._settings.reload_on_file_change + # TODO_REMOVE_IN_V1 + if val is None: + val = self._settings.reload_on_template_change + + return default(val, cast(bool, defaults.reload_on_file_change)) @property def TEMPLATE_CACHE_SIZE(self) -> int: - return self.settings.get("template_cache_size", 128) + return default(self._settings.template_cache_size, cast(int, defaults.template_cache_size)) @property - def STATIC_FILES_ALLOWED(self) -> List[Union[str, re.Pattern]]: - default_static_files = [ - ".css", - ".js", - # Images - See https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types#common_image_file_types # noqa: E501 - ".apng", - ".png", - ".avif", - ".gif", - ".jpg", - ".jpeg", - ".jfif", - ".pjpeg", - ".pjp", - ".svg", - ".webp", - ".bmp", - ".ico", - ".cur", - ".tif", - ".tiff", - # Fonts - See https://stackoverflow.com/q/30572159/9788634 - ".eot", - ".ttf", - ".woff", - ".otf", - ".svg", - ] - return self.settings.get("static_files_allowed", default_static_files) + def STATIC_FILES_ALLOWED(self) -> Sequence[Union[str, re.Pattern]]: + return default(self._settings.static_files_allowed, cast(List[str], defaults.static_files_allowed)) @property - def STATIC_FILES_FORBIDDEN(self) -> List[Union[str, re.Pattern]]: - default_forbidden_static_files = [ - ".html", - # See https://marketplace.visualstudio.com/items?itemName=junstyle.vscode-django-support - ".django", - ".dj", - ".tpl", - # Python files - ".py", - ".pyc", - ] - return self.settings.get("forbidden_static_files", default_forbidden_static_files) + def STATIC_FILES_FORBIDDEN(self) -> Sequence[Union[str, re.Pattern]]: + val = self._settings.static_files_forbidden + # TODO_REMOVE_IN_V1 + if val is None: + val = self._settings.forbidden_static_files + + return default(val, cast(List[str], defaults.static_files_forbidden)) @property def CONTEXT_BEHAVIOR(self) -> ContextBehavior: - raw_value = self.settings.get("context_behavior", ContextBehavior.DJANGO.value) + raw_value = cast(str, default(self._settings.context_behavior, defaults.context_behavior)) return self._validate_context_behavior(raw_value) - def _validate_context_behavior(self, raw_value: ContextBehavior) -> ContextBehavior: + def _validate_context_behavior(self, raw_value: Union[ContextBehavior, str]) -> ContextBehavior: try: return ContextBehavior(raw_value) except ValueError: @@ -188,7 +695,8 @@ class AppSettings: @property def TAG_FORMATTER(self) -> Union["TagFormatterABC", str]: - return self.settings.get("tag_formatter", "django_components.component_formatter") + tag_formatter = default(self._settings.tag_formatter, cast(str, defaults.tag_formatter)) + return cast(Union["TagFormatterABC", str], tag_formatter) -app_settings = AppSettings() +app_settings = InternalSettings() diff --git a/src/django_components/apps.py b/src/django_components/apps.py index 3fe26e34..f2fa6677 100644 --- a/src/django_components/apps.py +++ b/src/django_components/apps.py @@ -1,6 +1,9 @@ import re +from pathlib import Path +from typing import Any from django.apps import AppConfig +from django.utils.autoreload import file_changed, trigger_reload class ComponentsConfig(AppConfig): @@ -10,10 +13,9 @@ class ComponentsConfig(AppConfig): # to Django's INSTALLED_APPS def ready(self) -> None: from django_components.app_settings import app_settings - from django_components.autodiscover import autodiscover, get_dirs, import_libraries, search_dirs + from django_components.autodiscovery import autodiscover, import_libraries from django_components.component_registry import registry from django_components.components.dynamic import DynamicComponent - from django_components.utils import watch_files_for_autoreload # Import modules set in `COMPONENTS.libraries` setting import_libraries() @@ -21,13 +23,10 @@ class ComponentsConfig(AppConfig): if app_settings.AUTODISCOVER: autodiscover() - # Watch template files for changes, so Django dev server auto-reloads + # Auto-reload Django dev server when any component files changes # See https://github.com/EmilStenstrom/django-components/discussions/567#discussioncomment-10273632 - # And https://stackoverflow.com/questions/42907285/66673186#66673186 - if app_settings.RELOAD_ON_TEMPLATE_CHANGE: - dirs = get_dirs(include_apps=False) - component_filepaths = search_dirs(dirs, "**/*") - watch_files_for_autoreload(component_filepaths) + if app_settings.RELOAD_ON_FILE_CHANGE: + _watch_component_files_for_autoreload() # Allow tags to span multiple lines. This makes it easier to work with # components inside Django templates, allowing us syntax like: @@ -48,3 +47,19 @@ class ComponentsConfig(AppConfig): # Register the dynamic component under the name as given in settings registry.register(app_settings.DYNAMIC_COMPONENT_NAME, DynamicComponent) + + +# See https://github.com/EmilStenstrom/django-components/issues/586#issue-2472678136 +def _watch_component_files_for_autoreload() -> None: + from django_components.util.loader import get_component_dirs + + component_dirs = set(get_component_dirs()) + + def template_changed(sender: Any, file_path: Path, **kwargs: Any) -> None: + # Reload dev server if any of the files within `COMPONENTS.dirs` or `COMPONENTS.app_dirs` changed + for dir_path in file_path.parents: + if dir_path in component_dirs: + trigger_reload(file_path) + return + + file_changed.connect(template_changed) diff --git a/src/django_components/autodiscover.py b/src/django_components/autodiscover.py deleted file mode 100644 index e7e80c02..00000000 --- a/src/django_components/autodiscover.py +++ /dev/null @@ -1,149 +0,0 @@ -import glob -import importlib -import os -from pathlib import Path -from typing import Callable, List, Optional, Union - -from django.apps import apps -from django.conf import settings - -from django_components.app_settings import app_settings -from django_components.logger import logger -from django_components.template_loader import get_dirs - - -def autodiscover( - map_module: Optional[Callable[[str], str]] = None, -) -> List[str]: - """ - Search for component files and import them. Returns a list of module - paths of imported files. - - Autodiscover searches in the locations as defined by `Loader.get_dirs`. - - You can map the module paths with `map_module` function. This serves - as an escape hatch for when you need to use this function in tests. - """ - dirs = get_dirs(include_apps=False) - component_filepaths = search_dirs(dirs, "**/*.py") - logger.debug(f"Autodiscover found {len(component_filepaths)} files in component directories.") - - if hasattr(settings, "BASE_DIR") and settings.BASE_DIR: - project_root = str(settings.BASE_DIR) - else: - # Fallback for getting the root dir, see https://stackoverflow.com/a/16413955/9788634 - project_root = os.path.abspath(os.path.dirname(__name__)) - - modules: List[str] = [] - - # We handle dirs from `COMPONENTS.dirs` and from individual apps separately. - # - # Because for dirs in `COMPONENTS.dirs`, we assume they will be nested under `BASE_DIR`, - # and that `BASE_DIR` is the current working dir (CWD). So the path relatively to `BASE_DIR` - # is ALSO the python import path. - for filepath in component_filepaths: - module_path = _filepath_to_python_module(filepath, project_root, None) - # Ignore files starting with dot `.` or files in dirs that start with dot. - # - # If any of the parts of the path start with a dot, e.g. the filesystem path - # is `./abc/.def`, then this gets converted to python module as `abc..def` - # - # NOTE: This approach also ignores files: - # - with two dots in the middle (ab..cd.py) - # - an extra dot at the end (abcd..py) - # - files outside of the parent component (../abcd.py). - # But all these are NOT valid python modules so that's fine. - if ".." in module_path: - continue - - modules.append(module_path) - - # For for apps, the directories may be outside of the project, e.g. in case of third party - # apps. So we have to resolve the python import path relative to the package name / the root - # import path for the app. - # See https://github.com/EmilStenstrom/django-components/issues/669 - for conf in apps.get_app_configs(): - for app_dir in app_settings.APP_DIRS: - comps_path = Path(conf.path).joinpath(app_dir) - if not comps_path.exists(): - continue - app_component_filepaths = search_dirs([comps_path], "**/*.py") - for filepath in app_component_filepaths: - app_component_module = _filepath_to_python_module(filepath, conf.path, conf.name) - modules.append(app_component_module) - - return _import_modules(modules, map_module) - - -def import_libraries( - map_module: Optional[Callable[[str], str]] = None, -) -> List[str]: - """ - Import modules set in `COMPONENTS.libraries` setting. - - You can map the module paths with `map_module` function. This serves - as an escape hatch for when you need to use this function in tests. - """ - from django_components.app_settings import app_settings - - return _import_modules(app_settings.LIBRARIES, map_module) - - -def _import_modules( - modules: List[str], - map_module: Optional[Callable[[str], str]] = None, -) -> List[str]: - imported_modules: List[str] = [] - for module_name in modules: - if map_module: - module_name = map_module(module_name) - - # This imports the file and runs it's code. So if the file defines any - # django components, they will be registered. - logger.debug(f'Importing module "{module_name}"') - importlib.import_module(module_name) - imported_modules.append(module_name) - return imported_modules - - -def _filepath_to_python_module( - file_path: Union[Path, str], - root_fs_path: Union[str, Path], - root_module_path: Optional[str], -) -> str: - """ - Derive python import path from the filesystem path. - - Example: - - If project root is `/path/to/project` - - And file_path is `/path/to/project/app/components/mycomp.py` - - Then the path relative to project root is `app/components/mycomp.py` - - Which we then turn into python import path `app.components.mycomp` - """ - rel_path = os.path.relpath(file_path, start=root_fs_path) - rel_path_without_suffix = str(Path(rel_path).with_suffix("")) - - # NOTE: `Path` normalizes paths to use `/` as separator, while `os.path` - # uses `os.path.sep`. - sep = os.path.sep if os.path.sep in rel_path_without_suffix else "/" - module_name = rel_path_without_suffix.replace(sep, ".") - - # Combine with the base module path - full_module_name = f"{root_module_path}.{module_name}" if root_module_path else module_name - if full_module_name.endswith(".__init__"): - full_module_name = full_module_name[:-9] # Remove the trailing `.__init__ - - return full_module_name - - -def search_dirs(dirs: List[Path], search_glob: str) -> List[Path]: - """ - Search the directories for the given glob pattern. Glob search results are returned - as a flattened list. - """ - matched_files: List[Path] = [] - for directory in dirs: - for path in glob.iglob(str(Path(directory) / search_glob), recursive=True): - matched_files.append(Path(path)) - - return matched_files diff --git a/src/django_components/autodiscovery.py b/src/django_components/autodiscovery.py new file mode 100644 index 00000000..08b53316 --- /dev/null +++ b/src/django_components/autodiscovery.py @@ -0,0 +1,95 @@ +import importlib +from typing import Callable, List, Optional + +from django_components.util.loader import get_component_files +from django_components.util.logger import logger + + +def autodiscover( + map_module: Optional[Callable[[str], str]] = None, +) -> List[str]: + """ + Search for all python files in + [`COMPONENTS.dirs`](../settings#django_components.app_settings.ComponentsSettings.dirs) + and + [`COMPONENTS.app_dirs`](../settings#django_components.app_settings.ComponentsSettings.app_dirs) + and import them. + + See [Autodiscovery](../../concepts/fundamentals/autodiscovery). + + Args: + map_module (Callable[[str], str], optional): Map the module paths with `map_module` function.\ + This serves as an escape hatch for when you need to use this function in tests. + + Returns: + List[str]: A list of module paths of imported files. + + To get the same list of modules that `autodiscover()` would return, but without importing them, use + [`get_component_files()`](../api#django_components.get_component_files): + + ```python + from django_components import get_component_files + + modules = get_component_files(".py") + ``` + """ + modules = get_component_files(".py") + logger.debug(f"Autodiscover found {len(modules)} files in component directories.") + return _import_modules([entry.dot_path for entry in modules], map_module) + + +def import_libraries( + map_module: Optional[Callable[[str], str]] = None, +) -> List[str]: + """ + Import modules set in + [`COMPONENTS.libraries`](../settings#django_components.app_settings.ComponentsSettings.libraries) + setting. + + See [Autodiscovery](../../concepts/fundamentals/autodiscovery). + + Args: + map_module (Callable[[str], str], optional): Map the module paths with `map_module` function.\ + This serves as an escape hatch for when you need to use this function in tests. + + Returns: + List[str]: A list of module paths of imported files. + + **Examples:** + + Normal usage - load libraries after Django has loaded + ```python + from django_components import import_libraries + + class MyAppConfig(AppConfig): + def ready(self): + import_libraries() + ``` + + Potential usage in tests + ```python + from django_components import import_libraries + + import_libraries(lambda path: path.replace("tests.", "myapp.")) + ``` + """ + from django_components.app_settings import app_settings + + return _import_modules(app_settings.LIBRARIES, map_module) + + +def _import_modules( + modules: List[str], + map_module: Optional[Callable[[str], str]] = None, +) -> List[str]: + imported_modules: List[str] = [] + for module_name in modules: + if map_module: + module_name = map_module(module_name) + + # This imports the file and runs it's code. So if the file defines any + # django components, they will be registered. + logger.debug(f'Importing module "{module_name}"') + importlib.import_module(module_name) + imported_modules.append(module_name) + return imported_modules diff --git a/src/django_components/component.py b/src/django_components/component.py index 27b60dfa..46b5f52c 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -14,6 +14,7 @@ from typing import ( List, Literal, Mapping, + NamedTuple, Optional, Protocol, Tuple, @@ -31,7 +32,6 @@ from django.template.context import Context from django.template.loader import get_template from django.template.loader_tags import BLOCK_CONTEXT_KEY from django.utils.html import conditional_escape -from django.utils.safestring import SafeString, mark_safe from django.views import View from django_components.app_settings import ContextBehavior @@ -39,31 +39,31 @@ from django_components.component_media import ComponentMediaInput, MediaMeta from django_components.component_registry import ComponentRegistry from django_components.component_registry import registry as registry_ from django_components.context import ( - _FILLED_SLOTS_CONTENT_CONTEXT_KEY, + _COMPONENT_SLOT_CTX_CONTEXT_KEY, _REGISTRY_CONTEXT_KEY, _ROOT_CTX_CONTEXT_KEY, get_injected_context_var, make_isolated_context_copy, - prepare_context, ) +from django_components.dependencies import RenderType, cache_inlined_css, cache_inlined_js, postprocess_component_html from django_components.expression import Expression, RuntimeKwargs, safe_resolve_list -from django_components.logger import trace_msg -from django_components.middleware import is_dependency_middleware_active from django_components.node import BaseNode from django_components.slots import ( - DEFAULT_SLOT_KEY, - FillContent, - FillNode, + ComponentSlotContext, + Slot, SlotContent, + SlotFunc, + SlotIsFilled, SlotName, SlotRef, SlotResult, _nodelist_to_slot_render_func, - resolve_fill_nodes, - resolve_slots, + resolve_fills, ) from django_components.template import cached_template -from django_components.utils import gen_id, validate_typed_dict, validate_typed_tuple +from django_components.util.logger import trace_msg +from django_components.util.misc import gen_id +from django_components.util.validation import validate_typed_dict, validate_typed_tuple # TODO_REMOVE_IN_V1 - Users should use top-level import instead # isort: off @@ -75,14 +75,18 @@ from django_components.component_registry import registry as registry # NOQA # isort: on -RENDERED_COMMENT_TEMPLATE = "" COMP_ONLY_FLAG = "only" # Define TypeVars for args and kwargs ArgsType = TypeVar("ArgsType", bound=tuple, contravariant=True) KwargsType = TypeVar("KwargsType", bound=Mapping[str, Any], contravariant=True) -DataType = TypeVar("DataType", bound=Mapping[str, Any], covariant=True) SlotsType = TypeVar("SlotsType", bound=Mapping[SlotName, SlotContent]) +DataType = TypeVar("DataType", bound=Mapping[str, Any], covariant=True) +JsDataType = TypeVar("JsDataType", bound=Mapping[str, Any]) +CssDataType = TypeVar("CssDataType", bound=Mapping[str, Any]) + +# Rename, so we can use `type()` inside functions with kwrags of the same name +_type = type @dataclass(frozen=True) @@ -91,19 +95,53 @@ class RenderInput(Generic[ArgsType, KwargsType, SlotsType]): args: ArgsType kwargs: KwargsType slots: SlotsType - escape_slots_content: bool + type: RenderType + render_dependencies: bool @dataclass() class RenderStackItem(Generic[ArgsType, KwargsType, SlotsType]): input: RenderInput[ArgsType, KwargsType, SlotsType] - is_filled: Optional[Dict[str, bool]] + is_filled: Optional[SlotIsFilled] class ViewFn(Protocol): def __call__(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Any: ... # noqa: E704 +class ComponentVars(NamedTuple): + """ + Type for the variables available inside the component templates. + + All variables here are scoped under `component_vars.`, so e.g. attribute + `is_filled` on this class is accessible inside the template as: + + ```django + {{ component_vars.is_filled }} + ``` + """ + + is_filled: Dict[str, bool] + """ + Dictonary describing which component slots are filled (`True`) or are not (`False`). + + New in version 0.70 + + Use as `{{ component_vars.is_filled }}` + + Example: + + ```django + {# Render wrapping HTML only if the slot is defined #} + {% if component_vars.is_filled.my_slot %} +
+ {% slot "my_slot" / %} +
+ {% endif %} + ``` + """ + + class ComponentMeta(MediaMeta): def __new__(mcs, name: str, bases: Tuple[Type, ...], attrs: Dict[str, Any]) -> Type: # NOTE: Skip template/media file resolution when then Component class ITSELF @@ -149,10 +187,13 @@ class ComponentView(View, metaclass=ComponentViewMeta): self.component = component -class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=ComponentMeta): - # Either template_name or template must be set on subclass OR subclass must implement get_template() with - # non-null return. - _class_hash: ClassVar[int] +class Component( + Generic[ArgsType, KwargsType, SlotsType, DataType, JsDataType, CssDataType], + metaclass=ComponentMeta, +): + # ##################################### + # PUBLIC API (Configurable by users) + # ##################################### template_name: Optional[str] = None """ @@ -190,6 +231,9 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co """ return None + def get_context_data(self, *args: Any, **kwargs: Any) -> DataType: + return cast(DataType, {}) + js: Optional[str] = None """Inlined JS associated with this component.""" css: Optional[str] = None @@ -201,14 +245,17 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co NOTE: This field is generated from Component.Media class. """ media_class: Media = Media - response_class = HttpResponse - """This allows to configure what class is used to generate response from `render_to_response`""" - Media = ComponentMediaInput """Defines JS and CSS media files associated with this component.""" + response_class = HttpResponse + """This allows to configure what class is used to generate response from `render_to_response`""" View = ComponentView + # ##################################### + # PUBLIC API - HOOKS + # ##################################### + def on_render_before(self, context: Context, template: Template) -> None: """ Hook that runs just before the component's template is rendered. @@ -230,12 +277,17 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co """ pass + # ##################################### + # MISC + # ##################################### + + _class_hash: ClassVar[int] + def __init__( self, registered_name: Optional[str] = None, component_id: Optional[str] = None, outer_context: Optional[Context] = None, - fill_content: Optional[Dict[str, FillContent]] = None, registry: Optional[ComponentRegistry] = None, # noqa F811 ): # When user first instantiates the component class before calling @@ -255,12 +307,11 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co self.registered_name: Optional[str] = registered_name self.outer_context: Context = outer_context or Context() - self.fill_content = fill_content or {} self.component_id = component_id or gen_id() self.registry = registry or registry_ self._render_stack: Deque[RenderStackItem[ArgsType, KwargsType, SlotsType]] = deque() # None == uninitialized, False == No types, Tuple == types - self._types: Optional[Union[Tuple[Any, Any, Any, Any], Literal[False]]] = None + self._types: Optional[Union[Tuple[Any, Any, Any, Any, Any, Any], Literal[False]]] = None def __init_subclass__(cls, **kwargs: Any) -> None: cls._class_hash = hash(inspect.getfile(cls) + cls.__name__) @@ -283,7 +334,7 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co return self._render_stack[-1].input @property - def is_filled(self) -> Dict[str, bool]: + def is_filled(self) -> SlotIsFilled: """ Dictionary describing which slots have or have not been filled. @@ -304,9 +355,6 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co return ctx.is_filled - def get_context_data(self, *args: Any, **kwargs: Any) -> DataType: - return cast(DataType, {}) - # NOTE: When the template is taken from a file (AKA specified via `template_name`), # then we leverage Django's template caching. This means that the same instance # of Template is reused. This is important to keep in mind, because the implication @@ -358,32 +406,6 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co f"Either 'template_name' or 'template' must be set for Component {type(self).__name__}." ) - def render_dependencies(self) -> SafeString: - """Helper function to render all dependencies for a component.""" - dependencies = [] - - css_deps = self.render_css_dependencies() - if css_deps: - dependencies.append(css_deps) - - js_deps = self.render_js_dependencies() - if js_deps: - dependencies.append(js_deps) - - return mark_safe("\n".join(dependencies)) - - def render_css_dependencies(self) -> SafeString: - """Render only CSS dependencies available in the media class or provided as a string.""" - if self.css is not None: - return mark_safe(f"") - return mark_safe("\n".join(self.media.render_css())) - - def render_js_dependencies(self) -> SafeString: - """Render only JS dependencies available in the media class or provided as a string.""" - if self.js is not None: - return mark_safe(f"") - return mark_safe("\n".join(self.media.render_js())) - def inject(self, key: str, default: Optional[Any] = None) -> Any: """ Use this method to retrieve the data that was passed to a `{% provide %}` tag @@ -449,6 +471,10 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co # Allow the View class to access this component via `self.component` return comp.View.as_view(**initkwargs, component=comp) + # ##################################### + # RENDERING + # ##################################### + @classmethod def render_to_response( cls, @@ -457,6 +483,7 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co escape_slots_content: bool = True, args: Optional[ArgsType] = None, kwargs: Optional[KwargsType] = None, + type: RenderType = "document", *response_args: Any, **response_kwargs: Any, ) -> HttpResponse: @@ -481,6 +508,10 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co is rendered. The keys on the context can be accessed from within the template. - NOTE: In "isolated" mode, context is NOT accessible, and data MUST be passed via component's args and kwargs. + - `type` - Configure how to handle JS and CSS dependencies. + - `"document"` (default) - JS dependencies are inserted into `{% component_js_dependencies %}`, + or to the end of the `` tag. CSS dependencies are inserted into + `{% component_css_dependencies %}`, or the end of the `` tag. Any additional args and kwargs are passed to the `response_class`. @@ -509,6 +540,8 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co context=context, slots=slots, escape_slots_content=escape_slots_content, + type=type, + render_dependencies=True, ) return cls.response_class(content, *response_args, **response_kwargs) @@ -520,6 +553,8 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co kwargs: Optional[KwargsType] = None, slots: Optional[SlotsType] = None, escape_slots_content: bool = True, + type: RenderType = "document", + render_dependencies: bool = True, ) -> str: """ Render the component into a string. @@ -537,6 +572,11 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co is rendered. The keys on the context can be accessed from within the template. - NOTE: In "isolated" mode, context is NOT accessible, and data MUST be passed via component's args and kwargs. + - `type` - Configure how to handle JS and CSS dependencies. + - `"document"` (default) - JS dependencies are inserted into `{% component_js_dependencies %}`, + or to the end of the `` tag. CSS dependencies are inserted into + `{% component_css_dependencies %}`, or the end of the `` tag. + - `render_dependencies` - Set this to `False` if you want to insert the resulting HTML into another component. Example: ```py @@ -560,7 +600,7 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co else: comp = cls() - return comp._render(context, args, kwargs, slots, escape_slots_content) + return comp._render(context, args, kwargs, slots, escape_slots_content, type, render_dependencies) # This is the internal entrypoint for the render function def _render( @@ -570,11 +610,13 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co kwargs: Optional[KwargsType] = None, slots: Optional[SlotsType] = None, escape_slots_content: bool = True, + type: RenderType = "document", + render_dependencies: bool = True, ) -> str: try: - return self._render_impl(context, args, kwargs, slots, escape_slots_content) + return self._render_impl(context, args, kwargs, slots, escape_slots_content, type, render_dependencies) except Exception as err: - raise type(err)(f"An error occured while rendering component '{self.name}':\n{repr(err)}") from err + raise _type(err)(f"An error occured while rendering component '{self.name}':\n{repr(err)}") from err def _render_impl( self, @@ -583,20 +625,24 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co kwargs: Optional[KwargsType] = None, slots: Optional[SlotsType] = None, escape_slots_content: bool = True, + type: RenderType = "document", + render_dependencies: bool = True, ) -> str: - has_slots = slots is not None + # NOTE: We must run validation before we normalize the slots, because the normalization + # wraps them in functions. + self._validate_inputs(args or (), kwargs or {}, slots or {}) # Allow to provide no args/kwargs/slots/context args = cast(ArgsType, args or ()) kwargs = cast(KwargsType, kwargs or {}) - slots = cast(SlotsType, slots or {}) + slots_untyped = self._normalize_slot_fills(slots or {}, escape_slots_content) + slots = cast(SlotsType, slots_untyped) context = context or Context() # Allow to provide a dict instead of Context # NOTE: This if/else is important to avoid nested Contexts, # See https://github.com/EmilStenstrom/django-components/issues/414 context = context if isinstance(context, Context) else Context(context) - prepare_context(context, self.component_id) # By adding the current input to the stack, we temporarily allow users # to access the provided context, slots, etc. Also required so users can @@ -608,71 +654,72 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co slots=slots, args=args, kwargs=kwargs, - escape_slots_content=escape_slots_content, + type=type, + render_dependencies=render_dependencies, ), is_filled=None, ), ) - self._validate_inputs() - context_data = self.get_context_data(*args, **kwargs) - self._validate_outputs(context_data) + self._validate_outputs(data=context_data) + + # Process JS and CSS files + cache_inlined_js(self.__class__, self.js or "") + cache_inlined_css(self.__class__, self.css or "") with _prepare_template(self, context, context_data) as template: - # Support passing slots explicitly to `render` method - if has_slots: - fill_content = self._fills_from_slots_data( - slots, - escape_slots_content, - ) - else: - fill_content = self.fill_content - - _, resolved_fills = resolve_slots( - context, - template, - component_name=self.name, - fill_content=fill_content, - # Dynamic component has a special mark do it doesn't raise certain errors - is_dynamic_component=getattr(self, "_is_dynamic_component", False), - ) - - # Available slot fills - this is internal to us - updated_slots = { - **context.get(_FILLED_SLOTS_CONTENT_CONTEXT_KEY, {}), - **resolved_fills, - } - # For users, we expose boolean variables that they may check # to see if given slot was filled, e.g.: # `{% if variable > 8 and component_vars.is_filled.header %}` - slot_bools = {slot_fill.escaped_name: slot_fill.is_filled for slot_fill in resolved_fills.values()} - self._render_stack[-1].is_filled = slot_bools + is_filled = SlotIsFilled(slots_untyped) + self._render_stack[-1].is_filled = is_filled + + component_slot_ctx = ComponentSlotContext( + component_name=self.name, + template_name=template.name, + fills=slots_untyped, + is_dynamic_component=getattr(self, "_is_dynamic_component", False), + # This field will be modified from within `SlotNodes.render()`: + # - The `default_slot` will be set to the first slot that has the `default` attribute set. + # If multiple slots have the `default` attribute set, yet have different name, then + # we will raise an error. + default_slot=None, + ) with context.update( { # Private context fields _ROOT_CTX_CONTEXT_KEY: self.outer_context, - _FILLED_SLOTS_CONTENT_CONTEXT_KEY: updated_slots, + _COMPONENT_SLOT_CTX_CONTEXT_KEY: component_slot_ctx, _REGISTRY_CONTEXT_KEY: self.registry, # NOTE: Public API for variables accessible from within a component's template # See https://github.com/EmilStenstrom/django-components/issues/280#issuecomment-2081180940 - "component_vars": { - "is_filled": slot_bools, - }, + "component_vars": ComponentVars( + is_filled=is_filled, + ), } ): self.on_render_before(context, template) - rendered_component = template.render(context) - new_output = self.on_render_after(context, template, rendered_component) - rendered_component = new_output if new_output is not None else rendered_component + # Get the component's HTML + html_content = template.render(context) - if is_dependency_middleware_active(): - output = RENDERED_COMMENT_TEMPLATE.format(name=self.name) + rendered_component - else: - output = rendered_component + # After we've rendered the contents, we now know what slots were there, + # and thus we can validate that. + component_slot_ctx.post_render_validation() + + # Allow to optionally override/modify the rendered content + new_output = self.on_render_after(context, template, html_content) + html_content = new_output if new_output is not None else html_content + + output = postprocess_component_html( + component_cls=self.__class__, + component_id=self.component_id, + html_content=html_content, + type=type, + render_dependencies=render_dependencies, + ) # After rendering is done, remove the current state from the stack, which means # properties like `self.context` will no longer return the current state. @@ -680,51 +727,57 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co return output - def _fills_from_slots_data( + def _normalize_slot_fills( self, - slots_data: Mapping[SlotName, SlotContent], + fills: Mapping[SlotName, SlotContent], escape_content: bool = True, - ) -> Dict[SlotName, FillContent]: - """Fill component slots outside of template rendering.""" - slot_fills = {} - for slot_name, content in slots_data.items(): - if not callable(content): - content_func = _nodelist_to_slot_render_func( - NodeList([TextNode(conditional_escape(content) if escape_content else content)]) + ) -> Dict[SlotName, Slot]: + # Preprocess slots to escape content if `escape_content=True` + norm_fills = {} + + # NOTE: `gen_escaped_content_func` is defined as a separate function, instead of being inlined within + # the forloop, because the value the forloop variable points to changes with each loop iteration. + def gen_escaped_content_func(content: SlotFunc) -> Slot: + def content_fn(ctx: Context, slot_data: Dict, slot_ref: SlotRef) -> SlotResult: + rendered = content(ctx, slot_data, slot_ref) + return conditional_escape(rendered) if escape_content else rendered + + slot = Slot(content_func=cast(SlotFunc, content_fn)) + return slot + + for slot_name, content in fills.items(): + if content is None: + continue + elif not callable(content): + slot = _nodelist_to_slot_render_func( + slot_name, + NodeList([TextNode(conditional_escape(content) if escape_content else content)]), + data_var=None, + default_var=None, ) else: + slot = gen_escaped_content_func(content) - def content_func( # type: ignore[misc] - ctx: Context, - kwargs: Dict[str, Any], - slot_ref: SlotRef, - ) -> SlotResult: - rendered = content(ctx, kwargs, slot_ref) - return conditional_escape(rendered) if escape_content else rendered + norm_fills[slot_name] = slot - slot_fills[slot_name] = FillContent( - content_func=content_func, - slot_default_var=None, - slot_data_var=None, - ) - return slot_fills + return norm_fills - ###################### + # ##################################### # VALIDATION - ###################### + # ##################################### - def _get_types(self) -> Optional[Tuple[Any, Any, Any, Any]]: + def _get_types(self) -> Optional[Tuple[Any, Any, Any, Any, Any, Any]]: """ Extract the types passed to the Component class. So if a component subclasses Component class like so ```py - class MyComp(Component[MyArgs, MyKwargs, Any, MySlots]): + class MyComp(Component[MyArgs, MyKwargs, MySlots, MyData, MyJsData, MyCssData]): ... ``` - Then we want to extract the tuple (MyArgs, MyKwargs, Any, MySlots). + Then we want to extract the tuple (MyArgs, MyKwargs, MySlots, MyData, MyJsData, MyCssData). Returns `None` if types were not provided. That is, the class was subclassed as: @@ -770,35 +823,34 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co # If we got here, then we've found ourselves the typed Component class, e.g. # - # `Component(Tuple[int], MyKwargs, MySlots, Any)` + # `Component(Tuple[int], MyKwargs, MySlots, Any, Any, Any)` # # By accessing the __args__, we access individual types between the brackets, so # - # (Tuple[int], MyKwargs, MySlots, Any) - args_type, kwargs_type, data_type, slots_type = component_generics_base.__args__ + # (Tuple[int], MyKwargs, MySlots, Any, Any, Any) + args_type, kwargs_type, slots_type, data_type, js_data_type, css_data_type = component_generics_base.__args__ - self._types = args_type, kwargs_type, data_type, slots_type + self._types = args_type, kwargs_type, slots_type, data_type, js_data_type, css_data_type return self._types - def _validate_inputs(self) -> None: - + def _validate_inputs(self, args: Tuple, kwargs: Any, slots: Any) -> None: maybe_inputs = self._get_types() if maybe_inputs is None: return - args_type, kwargs_type, data_type, slots_type = maybe_inputs + args_type, kwargs_type, slots_type, data_type, js_data_type, css_data_type = maybe_inputs # Validate args - validate_typed_tuple(self.input.args, args_type, f"Component '{self.name}'", "positional argument") + validate_typed_tuple(args, args_type, f"Component '{self.name}'", "positional argument") # Validate kwargs - validate_typed_dict(self.input.kwargs, kwargs_type, f"Component '{self.name}'", "keyword argument") + validate_typed_dict(kwargs, kwargs_type, f"Component '{self.name}'", "keyword argument") # Validate slots - validate_typed_dict(self.input.slots, slots_type, f"Component '{self.name}'", "slot") + validate_typed_dict(slots, slots_type, f"Component '{self.name}'", "slot") def _validate_outputs(self, data: Any) -> None: maybe_inputs = self._get_types() if maybe_inputs is None: return - args_type, kwargs_type, data_type, slots_type = maybe_inputs + args_type, kwargs_type, slots_type, data_type, js_data_type, css_data_type = maybe_inputs # Validate data validate_typed_dict(data, data_type, f"Component '{self.name}'", "data") @@ -814,14 +866,13 @@ class ComponentNode(BaseNode): kwargs: RuntimeKwargs, registry: ComponentRegistry, # noqa F811 isolated_context: bool = False, - fill_nodes: Optional[List[FillNode]] = None, + nodelist: Optional[NodeList] = None, node_id: Optional[str] = None, ) -> None: - super().__init__(nodelist=NodeList(fill_nodes), args=args, kwargs=kwargs, node_id=node_id) + super().__init__(nodelist=nodelist or NodeList(), args=args, kwargs=kwargs, node_id=node_id) self.name = name self.isolated_context = isolated_context - self.fill_nodes = fill_nodes or [] self.registry = registry def __repr__(self) -> str: @@ -841,34 +892,27 @@ class ComponentNode(BaseNode): args = safe_resolve_list(context, self.args) kwargs = self.kwargs.resolve(context) - is_default_slot = len(self.fill_nodes) == 1 and self.fill_nodes[0].is_implicit - if is_default_slot: - fill_content: Dict[str, FillContent] = { - DEFAULT_SLOT_KEY: FillContent( - content_func=_nodelist_to_slot_render_func(self.fill_nodes[0].nodelist), - slot_data_var=None, - slot_default_var=None, - ), - } - else: - fill_content = resolve_fill_nodes(context, self.fill_nodes, self.name) + slot_fills = resolve_fills(context, self.nodelist, self.name) component: Component = component_cls( registered_name=self.name, outer_context=context, - fill_content=fill_content, component_id=self.node_id, registry=self.registry, ) # Prevent outer context from leaking into the template of the component - if self.isolated_context or self.registry.settings.CONTEXT_BEHAVIOR == ContextBehavior.ISOLATED: + if self.isolated_context or self.registry.settings.context_behavior == ContextBehavior.ISOLATED: context = make_isolated_context_copy(context) output = component._render( context=context, args=args, kwargs=kwargs, + slots=slot_fills, + # NOTE: When we render components inside the template via template tags, + # do NOT render deps, because this may be decided by outer component + render_dependencies=False, ) trace_msg("RENDR", "COMP", self.name, self.node_id, "...Done!") diff --git a/src/django_components/component_media.py b/src/django_components/component_media.py index 164e5f61..dc6d7091 100644 --- a/src/django_components/component_media.py +++ b/src/django_components/component_media.py @@ -6,8 +6,8 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, List, MutableMapping, Opt from django.forms.widgets import Media, MediaDefiningClass from django.utils.safestring import SafeData -from django_components.autodiscover import get_dirs -from django_components.logger import logger +from django_components.util.loader import get_component_dirs +from django_components.util.logger import logger if TYPE_CHECKING: from django_components.component import Component @@ -273,7 +273,7 @@ def _resolve_component_relative_files(attrs: MutableMapping) -> None: # Prepare all possible directories we need to check when searching for # component's template and media files - components_dirs = get_dirs() + components_dirs = get_component_dirs() # Get the directory where the component class is defined try: diff --git a/src/django_components/component_registry.py b/src/django_components/component_registry.py index c9c72768..027bca97 100644 --- a/src/django_components/component_registry.py +++ b/src/django_components/component_registry.py @@ -1,22 +1,40 @@ -from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Type, TypeVar, Union +from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Type, Union from django.template import Library -from django_components.app_settings import ContextBehavior, app_settings +from django_components.app_settings import ContextBehaviorType, app_settings from django_components.library import is_tag_protected, mark_protected_tags, register_tag_from_formatter from django_components.tag_formatter import TagFormatterABC, get_tag_formatter if TYPE_CHECKING: - from django_components.component import Component - -_TComp = TypeVar("_TComp", bound=Type["Component"]) + from django_components.component import ( + ArgsType, + Component, + CssDataType, + DataType, + JsDataType, + KwargsType, + SlotsType, + ) class AlreadyRegistered(Exception): + """ + Raised when you try to register a [Component](../api#django_components#Component), + but it's already registered with given + [ComponentRegistry](../api#django_components.ComponentRegistry). + """ + pass class NotRegistered(Exception): + """ + Raised when you try to access a [Component](../api#django_components#Component), + but it's NOT registered with given + [ComponentRegistry](../api#django_components.ComponentRegistry). + """ + pass @@ -35,13 +53,77 @@ class ComponentRegistryEntry(NamedTuple): class RegistrySettings(NamedTuple): - CONTEXT_BEHAVIOR: Optional[ContextBehavior] = None + """ + Configuration for a [`ComponentRegistry`](../api#django_components.ComponentRegistry). + + These settings define how the components registered with this registry will behave when rendered. + + ```python + from django_components import ComponentRegistry, RegistrySettings + + registry_settings = RegistrySettings( + context_behavior="django", + tag_formatter="django_components.component_shorthand_formatter", + ) + + registry = ComponentRegistry(settings=registry_settings) + ``` + """ + + context_behavior: Optional[ContextBehaviorType] = None + """ + Same as the global + [`COMPONENTS.context_behavior`](../settings#django_components.app_settings.ComponentsSettings.context_behavior) + setting, but for this registry. + + If omitted, defaults to the global + [`COMPONENTS.context_behavior`](../settings#django_components.app_settings.ComponentsSettings.context_behavior) + setting. + """ + + # TODO_REMOVE_IN_V1 + CONTEXT_BEHAVIOR: Optional[ContextBehaviorType] = None + """ + _Deprecated. Use `context_behavior` instead. Will be removed in v1._ + + Same as the global + [`COMPONENTS.context_behavior`](../settings#django_components.app_settings.ComponentsSettings.context_behavior) + setting, but for this registry. + + If omitted, defaults to the global + [`COMPONENTS.context_behavior`](../settings#django_components.app_settings.ComponentsSettings.context_behavior) + setting. + """ + + tag_formatter: Optional[Union["TagFormatterABC", str]] = None + """ + Same as the global + [`COMPONENTS.tag_formatter`](../settings#django_components.app_settings.ComponentsSettings.tag_formatter) + setting, but for this registry. + + If omitted, defaults to the global + [`COMPONENTS.tag_formatter`](../settings#django_components.app_settings.ComponentsSettings.tag_formatter) + setting. + """ + + # TODO_REMOVE_IN_V1 TAG_FORMATTER: Optional[Union["TagFormatterABC", str]] = None + """ + _Deprecated. Use `tag_formatter` instead. Will be removed in v1._ + + Same as the global + [`COMPONENTS.tag_formatter`](../settings#django_components.app_settings.ComponentsSettings.tag_formatter) + setting, but for this registry. + + If omitted, defaults to the global + [`COMPONENTS.tag_formatter`](../settings#django_components.app_settings.ComponentsSettings.tag_formatter) + setting. + """ class InternalRegistrySettings(NamedTuple): - CONTEXT_BEHAVIOR: ContextBehavior - TAG_FORMATTER: Union["TagFormatterABC", str] + context_behavior: ContextBehaviorType + tag_formatter: Union["TagFormatterABC", str] # We keep track of all registries that exist so that, when users want to @@ -52,21 +134,43 @@ all_registries: List["ComponentRegistry"] = [] class ComponentRegistry: """ - Manages which components can be used in the template tags. + Manages [components](../api#django_components.Component) and makes them available + in the template, by default as [`{% component %}`](../template_tags#component) + tags. - Each ComponentRegistry instance is associated with an instance - of Django's Library. So when you register or unregister a component - to/from a component registry, behind the scenes the registry - automatically adds/removes the component's template tag to/from - the Library. + ```django + {% component "my_comp" key=value %} + {% endcomponent %} + ``` - The Library instance can be set at instantiation. If omitted, then - the default Library instance from django_components is used. The - Library instance can be accessed under `library` attribute. + To enable a component to be used in a template, the component must be registered with a component registry. - Example: + When you register a component to a registry, behind the scenes the registry + automatically adds the component's template tag (e.g. `{% component %}` to + the [`Library`](https://docs.djangoproject.com/en/5.1/howto/custom-template-tags/#code-layout). + And the opposite happens when you unregister a component - the tag is removed. - ```py + See [Registering components](../../concepts/advanced/component_registry). + + Args: + library (Library, optional): Django\ + [`Library`](https://docs.djangoproject.com/en/5.1/howto/custom-template-tags/#code-layout)\ + associated with this registry. If omitted, the default Library instance from django_components is used. + settings (Union[RegistrySettings, Callable[[ComponentRegistry], RegistrySettings]], optional): Configure\ + how the components registered with this registry will behave when rendered.\ + See [`RegistrySettings`](../api#django_components.RegistrySettings). Can be either\ + a static value or a callable that returns the settings. If omitted, the settings from\ + [`COMPONENTS`](../settings#django_components.app_settings.ComponentsSettings) are used. + + **Notes:** + + - The default registry is available as [`django_components.registry`](../api#django_components.registry). + - The default registry is used when registering components with [`@register`](../api#django_components.register) + decorator. + + **Example:** + + ```python # Use with default Library registry = ComponentRegistry() @@ -81,6 +185,33 @@ class ComponentRegistry: registry.clear() registry.get() ``` + + # Using registry to share components + + You can use component registry for isolating or "packaging" components: + + 1. Create new instance of `ComponentRegistry` and Library: + ```django + my_comps = Library() + my_comps_reg = ComponentRegistry(library=my_comps) + ``` + + 2. Register components to the registry: + ```django + my_comps_reg.register("my_button", ButtonComponent) + my_comps_reg.register("my_card", CardComponent) + ``` + + 3. In your target project, load the Library associated with the registry: + ```django + {% load my_comps %} + ``` + + 4. Use the registered components in your templates: + ```django + {% component "button" %} + {% endcomponent %} + ``` """ def __init__( @@ -99,7 +230,8 @@ class ComponentRegistry: @property def library(self) -> Library: """ - The template tag library with which the component registry is associated. + The template tag [`Library`](https://docs.djangoproject.com/en/5.1/howto/custom-template-tags/#code-layout) + that is associated with the registry. """ # Lazily use the default library if none was passed if self._library is not None: @@ -118,6 +250,9 @@ class ComponentRegistry: @property def settings(self) -> InternalRegistrySettings: + """ + [Registry settings](../api#django_components.RegistrySettings) configured for this registry. + """ # This is run on subsequent calls if self._settings is not None: # NOTE: Registry's settings can be a function, so we always take @@ -136,10 +271,16 @@ class ComponentRegistry: else: settings_input = self._settings_input + if settings_input: + context_behavior = settings_input.context_behavior or settings_input.CONTEXT_BEHAVIOR + tag_formatter = settings_input.tag_formatter or settings_input.TAG_FORMATTER + else: + context_behavior = None + tag_formatter = None + return InternalRegistrySettings( - CONTEXT_BEHAVIOR=(settings_input and settings_input.CONTEXT_BEHAVIOR) - or app_settings.CONTEXT_BEHAVIOR, - TAG_FORMATTER=(settings_input and settings_input.TAG_FORMATTER) or app_settings.TAG_FORMATTER, + context_behavior=context_behavior or app_settings.CONTEXT_BEHAVIOR.value, + tag_formatter=tag_formatter or app_settings.TAG_FORMATTER, ) self._settings = get_settings @@ -149,19 +290,27 @@ class ComponentRegistry: def register(self, name: str, component: Type["Component"]) -> None: """ - Register a component with this registry under the given name. + Register a [`Component`](../api#django_components.Component) class + with this registry under the given name. A component MUST be registered before it can be used in a template such as: ```django - {% component "my_comp" %}{% endcomponent %} + {% component "my_comp" %} + {% endcomponent %} ``` - Raises `AlreadyRegistered` if a different component was already registered - under the same name. + Args: + name (str): The name under which the component will be registered. Required. + component (Type[Component]): The component class to register. Required. - Example: + **Raises:** - ```py + - [`AlreadyRegistered`](../exceptions#django_components.AlreadyRegistered) + if a different component was already registered under the same name. + + **Example:** + + ```python registry.register("button", ButtonComponent) ``` """ @@ -182,19 +331,22 @@ class ComponentRegistry: def unregister(self, name: str) -> None: """ - Unlinks a previously-registered component from the registry under the given name. + Unregister the [`Component`](../api#django_components.Component) class + that was registered under the given name. - Once a component is unregistered, it CANNOT be used in a template anymore. - Following would raise an error: - ```django - {% component "my_comp" %}{% endcomponent %} - ``` + Once a component is unregistered, it is no longer available in the templates. - Raises `NotRegistered` if the given name is not registered. + Args: + name (str): The name under which the component is registered. Required. - Example: + **Raises:** - ```py + - [`NotRegistered`](../exceptions#django_components.NotRegistered) + if the given name is not registered. + + **Example:** + + ```python # First register component registry.register("button", ButtonComponent) # Then unregister @@ -227,13 +379,23 @@ class ComponentRegistry: def get(self, name: str) -> Type["Component"]: """ - Retrieve a component class registered under the given name. + Retrieve a [`Component`](../api#django_components.Component) + class registered under the given name. - Raises `NotRegistered` if the given name is not registered. + Args: + name (str): The name under which the component was registered. Required. - Example: + Returns: + Type[Component]: The component class registered under the given name. - ```py + **Raises:** + + - [`NotRegistered`](../exceptions#django_components.NotRegistered) + if the given name is not registered. + + **Example:** + + ```python # First register component registry.register("button", ButtonComponent) # Then get @@ -248,11 +410,14 @@ class ComponentRegistry: def all(self) -> Dict[str, Type["Component"]]: """ - Retrieve all registered component classes. + Retrieve all registered [`Component`](../api#django_components.Component) classes. - Example: + Returns: + Dict[str, Type[Component]]: A dictionary of component names to component classes - ```py + **Example:** + + ```python # First register components registry.register("button", ButtonComponent) registry.register("card", CardComponent) @@ -273,7 +438,7 @@ class ComponentRegistry: Example: - ```py + ```python # First register components registry.register("button", ButtonComponent) registry.register("card", CardComponent) @@ -308,19 +473,25 @@ class ComponentRegistry: # This variable represents the global component registry registry: ComponentRegistry = ComponentRegistry() """ -The default and global component registry. Use this instance to directly -register or remove components: +The default and global [component registry](./#django_components.ComponentRegistry). +Use this instance to directly register or remove components: -```py +See [Registering components](../../concepts/advanced/component_registry). + +```python # Register components registry.register("button", ButtonComponent) registry.register("card", CardComponent) + # Get single registry.get("button") + # Get all registry.all() + # Unregister single registry.unregister("button") + # Unregister all registry.clear() ``` @@ -330,23 +501,43 @@ registry.clear() _the_registry = registry -def register(name: str, registry: Optional[ComponentRegistry] = None) -> Callable[[_TComp], _TComp]: +def register(name: str, registry: Optional[ComponentRegistry] = None) -> Callable[ + [Type["Component[ArgsType, KwargsType, SlotsType, DataType, JsDataType, CssDataType]"]], + Type["Component[ArgsType, KwargsType, SlotsType, DataType, JsDataType, CssDataType]"], +]: """ - Class decorator to register a component. + Class decorator for registering a [component](./#django_components.Component) + to a [component registry](./#django_components.ComponentRegistry). - Usage: + See [Registering components](../../concepts/advanced/component_registry). + + Args: + name (str): Registered name. This is the name by which the component will be accessed\ + from within a template when using the [`{% component %}`](../template_tags#component) tag. Required. + registry (ComponentRegistry, optional): Specify the [registry](./#django_components.ComponentRegistry)\ + to which to register this component. If omitted, component is registered to the default registry. + + Raises: + AlreadyRegistered: If there is already a component registered under the same name. + + **Examples**: + + ```python + from django_components import Component, register - ```py @register("my_component") class MyComponent(Component): ... ``` - Optionally specify which `ComponentRegistry` the component should be registered to by - setting the `registry` kwarg: + Specifing [`ComponentRegistry`](./#django_components.ComponentRegistry) the component + should be registered to by setting the `registry` kwarg: - ```py - my_lib = django.template.Library() + ```python + from django.template import Library + from django_components import Component, ComponentRegistry, register + + my_lib = Library() my_reg = ComponentRegistry(library=my_lib) @register("my_component", registry=my_reg) @@ -357,7 +548,9 @@ def register(name: str, registry: Optional[ComponentRegistry] = None) -> Callabl if registry is None: registry = _the_registry - def decorator(component: _TComp) -> _TComp: + def decorator( + component: Type["Component[ArgsType, KwargsType, SlotsType, DataType, JsDataType, CssDataType]"], + ) -> Type["Component[ArgsType, KwargsType, SlotsType, DataType, JsDataType, CssDataType]"]: registry.register(name=name, component=component) return component diff --git a/src/django_components/components/__init__.py b/src/django_components/components/__init__.py index 34433285..8a7a4e1b 100644 --- a/src/django_components/components/__init__.py +++ b/src/django_components/components/__init__.py @@ -1,3 +1,4 @@ -# flake8: noqa F401 +# NOTE: Components exported here are documented in +from django_components.components.dynamic import DynamicComponent -from django_components.components.dynamic import DynamicComponent as DynamicComponent +__all__ = ["DynamicComponent"] diff --git a/src/django_components/components/dynamic.py b/src/django_components/components/dynamic.py index 4ce37b06..b438676a 100644 --- a/src/django_components/components/dynamic.py +++ b/src/django_components/components/dynamic.py @@ -7,14 +7,82 @@ from django_components.component_registry import all_registries class DynamicComponent(Component): """ - Dynamic component - This component takes inputs and renders the outputs depending on the - `is` and `registry` arguments. + This component is given a registered name or a reference to another component, + and behaves as if the other component was in its place. - - `is` - required - The component class or registered name of the component that will be - rendered in this place. + The args, kwargs, and slot fills are all passed down to the underlying component. - - `registry` - optional - Specify the registry to search for the registered name. If omitted, - all registries are searched. + Args: + is (str | Type[Component]): Component that should be rendered. Either a registered name of a component, + or a [Component](../api#django_components.Component) class directly. Required. + registry (ComponentRegistry, optional): Specify the [registry](../api#django_components.ComponentRegistry)\ + to search for the registered name. If omitted, all registries are searched until the first match. + *args: Additional data passed to the component. + **kwargs: Additional data passed to the component. + + **Slots:** + + * Any slots, depending on the actual component. + + **Examples:** + + Django + ```django + {% component "dynamic" is=table_comp data=table_data headers=table_headers %} + {% fill "pagination" %} + {% component "pagination" / %} + {% endfill %} + {% endcomponent %} + ``` + + Python + ```py + from django_components import DynamicComponent + + DynamicComponent.render( + kwargs={ + "is": table_comp, + "data": table_data, + "headers": table_headers, + }, + slots={ + "pagination": PaginationComponent.render( + render_dependencies=False, + ), + }, + ) + ``` + + # Use cases + + Dynamic components are suitable if you are writing something like a form component. You may design + it such that users give you a list of input types, and you render components depending on the input types. + + While you could handle this with a series of if / else statements, that's not an extensible approach. + Instead, you can use the dynamic component in place of normal components. + + # Component name + + By default, the dynamic component is registered under the name `"dynamic"`. In case of a conflict, + you can set the + [`COMPONENTS.dynamic_component_name`](../settings#django_components.app_settings.ComponentsSettings.dynamic_component_name) + setting to change the name used for the dynamic components. + + ```py + # settings.py + COMPONENTS = ComponentsSettings( + dynamic_component_name="my_dynamic", + ) + ``` + + After which you will be able to use the dynamic component with the new name: + ```django + {% component "my_dynamic" is=table_comp data=table_data headers=table_headers %} + {% fill "pagination" %} + {% component "pagination" / %} + {% endfill %} + {% endcomponent %} + ``` """ _is_dynamic_component = True @@ -32,27 +100,30 @@ class DynamicComponent(Component): comp_class = self._resolve_component(comp_name_or_class, registry) + # NOTE: Slots are passed at component instantiation comp = comp_class( registered_name=self.registered_name, component_id=self.component_id, outer_context=self.outer_context, - fill_content=self.fill_content, registry=self.registry, ) output = comp.render( context=self.input.context, args=args, kwargs=kwargs, - escape_slots_content=self.input.escape_slots_content, + slots=self.input.slots, + # NOTE: Since we're accessing slots as `self.input.slots`, the content of slot functions + # was already escaped (if set so). + escape_slots_content=False, + type=self.input.type, + render_dependencies=self.input.render_dependencies, ) return { "output": output, } - template: types.django_html = """ - {{ output }} - """ + template: types.django_html = """{{ output }}""" def _resolve_component( self, diff --git a/src/django_components/context.py b/src/django_components/context.py index 288b5fe8..4a222569 100644 --- a/src/django_components/context.py +++ b/src/django_components/context.py @@ -10,34 +10,19 @@ from typing import Any, Dict, Optional from django.template import Context, TemplateSyntaxError -from django_components.utils import find_last_index +from django_components.util.misc import find_last_index -_FILLED_SLOTS_CONTENT_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS" +_COMPONENT_SLOT_CTX_CONTEXT_KEY = "_DJANGO_COMPONENTS_COMPONENT_SLOT_CTX" _ROOT_CTX_CONTEXT_KEY = "_DJANGO_COMPONENTS_ROOT_CTX" _REGISTRY_CONTEXT_KEY = "_DJANGO_COMPONENTS_REGISTRY" -_CURRENT_COMP_CONTEXT_KEY = "_DJANGO_COMPONENTS_CURRENT_COMP" _INJECT_CONTEXT_KEY_PREFIX = "_DJANGO_COMPONENTS_INJECT__" -def prepare_context( - context: Context, - component_id: str, -) -> None: - """Initialize the internal context state.""" - # Initialize mapping dicts within this rendering run. - # This is shared across the whole render chain, thus we set it only once. - if _FILLED_SLOTS_CONTENT_CONTEXT_KEY not in context: - context[_FILLED_SLOTS_CONTENT_CONTEXT_KEY] = {} - - set_component_id(context, component_id) - - def make_isolated_context_copy(context: Context) -> Context: context_copy = context.new() copy_forloop_context(context, context_copy) # Pass through our internal keys - context_copy[_FILLED_SLOTS_CONTENT_CONTEXT_KEY] = context.get(_FILLED_SLOTS_CONTENT_CONTEXT_KEY, {}) context_copy[_REGISTRY_CONTEXT_KEY] = context.get(_REGISTRY_CONTEXT_KEY, None) if _ROOT_CTX_CONTEXT_KEY in context: context_copy[_ROOT_CTX_CONTEXT_KEY] = context[_ROOT_CTX_CONTEXT_KEY] @@ -51,14 +36,6 @@ def make_isolated_context_copy(context: Context) -> Context: return context_copy -def set_component_id(context: Context, component_id: str) -> None: - """ - We use the Context object to pass down info on inside of which component - we are currently rendering. - """ - context[_CURRENT_COMP_CONTEXT_KEY] = component_id - - def copy_forloop_context(from_context: Context, to_context: Context) -> None: """Forward the info about the current loop""" # Note that the ForNode (which implements for loop behavior) does not diff --git a/src/django_components/dependencies.py b/src/django_components/dependencies.py new file mode 100644 index 00000000..8dcbe469 --- /dev/null +++ b/src/django_components/dependencies.py @@ -0,0 +1,793 @@ +"""All code related to management of component dependencies (JS and CSS scripts)""" + +import json +import re +import sys +from abc import ABC, abstractmethod +from functools import lru_cache +from hashlib import md5 +from typing import ( + TYPE_CHECKING, + Callable, + Dict, + Iterable, + List, + Literal, + NamedTuple, + Optional, + Set, + Tuple, + Type, + TypeVar, + Union, + cast, +) +from weakref import WeakValueDictionary + +from asgiref.sync import iscoroutinefunction, markcoroutinefunction +from django.forms import Media +from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed, HttpResponseNotFound, StreamingHttpResponse +from django.http.response import HttpResponseBase +from django.templatetags.static import static +from django.urls import path, reverse +from django.utils.decorators import sync_and_async_middleware +from django.utils.safestring import SafeString, mark_safe +from selectolax.lexbor import LexborHTMLParser + +import django_components.types as types +from django_components.util.html import parse_document_or_nodes, parse_multiroot_html, parse_node +from django_components.util.misc import escape_js_string_literal, get_import_path + +if TYPE_CHECKING: + from django_components.component import Component + + +ScriptType = Literal["css", "js"] +RenderType = Literal["document", "fragment"] + + +######################################################### +# 1. Cache the inlined component JS and CSS scripts, +# so they can be referenced and retrieved later via +# an ID. +######################################################### + + +class ComponentMediaCacheABC(ABC): + @abstractmethod + def get(self, key: str) -> Optional[str]: ... # noqa: #704 + + @abstractmethod + def has(self, key: str) -> bool: ... # noqa: #704 + + @abstractmethod + def set(self, key: str, value: str) -> None: ... # noqa: #704 + + +class InMemoryComponentMediaCache(ComponentMediaCacheABC): + def __init__(self) -> None: + self._data: Dict[str, str] = {} + + def get(self, key: str) -> Optional[str]: + return self._data.get(key, None) + + def has(self, key: str) -> bool: + return key in self._data + + def set(self, key: str, value: str) -> None: + self._data[key] = value + + +comp_media_cache = InMemoryComponentMediaCache() + + +# NOTE: Initially, we fetched components by their registered name, but that didn't work +# for multiple registries and unregistered components. +# +# To have unique identifiers that works across registries, we rely +# on component class' module import path (e.g. `path.to.my.MyComponent`). +# +# But we also don't want to expose the module import paths to the outside world, as +# that information could be potentially exploited. So, instead, each component is +# associated with a hash that's derived from its module import path, ensuring uniqueness, +# consistency and privacy. +# +# E.g. `path.to.my.secret.MyComponent` -> `MyComponent_ab01f32` +# +# The associations are defined as WeakValue map, so deleted components can be garbage +# collected and automatically deleted from the dict. +if sys.version_info < (3, 9): + comp_hash_mapping: WeakValueDictionary = WeakValueDictionary() +else: + comp_hash_mapping: WeakValueDictionary[str, Type["Component"]] = WeakValueDictionary() + + +# Convert Component class to something like `TableComp_a91d03` +@lru_cache(None) +def _hash_comp_cls(comp_cls: Type["Component"]) -> str: + full_name = get_import_path(comp_cls) + comp_cls_hash = md5(full_name.encode()).hexdigest()[0:6] + return comp_cls.__name__ + "_" + comp_cls_hash + + +def _gen_cache_key( + comp_cls_hash: str, + script_type: ScriptType, +) -> str: + return f"__components:{comp_cls_hash}:{script_type}" + + +def _is_script_in_cache( + comp_cls: Type["Component"], + script_type: ScriptType, +) -> bool: + comp_cls_hash = _hash_comp_cls(comp_cls) + cache_key = _gen_cache_key(comp_cls_hash, script_type) + return comp_media_cache.has(cache_key) + + +def _cache_script( + comp_cls: Type["Component"], + script: str, + script_type: ScriptType, +) -> None: + """ + Given a component and it's inlined JS or CSS, store the JS/CSS in a cache, + so it can be retrieved via URL endpoint. + """ + comp_cls_hash = _hash_comp_cls(comp_cls) + + # E.g. `__components:MyButton:js:df7c6d10` + if script_type in ("js", "css"): + cache_key = _gen_cache_key(comp_cls_hash, script_type) + else: + raise ValueError(f"Unexpected script_type '{script_type}'") + + # NOTE: By setting the script in the cache, we will be able to retrieve it + # via the endpoint, e.g. when we make a request to `/components/cache/MyComp_ab0c2d.js`. + comp_media_cache.set(cache_key, script.strip()) + + +def cache_inlined_js(comp_cls: Type["Component"], content: str) -> None: + if not _is_nonempty_str(comp_cls.js): + return + + # Prepare the script that's common to all instances of the same component + # E.g. `my_table.js` + if not _is_script_in_cache(comp_cls, "js"): + _cache_script( + comp_cls=comp_cls, + script=content, + script_type="js", + ) + + +def cache_inlined_css(comp_cls: Type["Component"], content: str) -> None: + if not _is_nonempty_str(comp_cls.js): + return + + # Prepare the script that's common to all instances of the same component + if not _is_script_in_cache(comp_cls, "css"): + # E.g. `my_table.css` + _cache_script( + comp_cls=comp_cls, + script=content, + script_type="css", + ) + + +######################################################### +# 2. Modify the HTML to use the same IDs defined in previous +# step for the inlined CSS and JS scripts, so the scripts +# can be applied to the correct HTML elements. And embed +# component + JS/CSS relationships as HTML comments. +######################################################### + + +class Dependencies(NamedTuple): + # NOTE: We pass around the component CLASS, so the dependencies logic is not + # dependent on ComponentRegistries + component_cls: Type["Component"] + component_id: str + + +def _insert_component_comment( + content: str, + deps: Dependencies, +) -> str: + """ + Given some textual content, prepend it with a short string that + will be used by the ComponentDependencyMiddleware to collect all + declared JS / CSS scripts. + """ + # Add components to the cache + comp_cls_hash = _hash_comp_cls(deps.component_cls) + comp_hash_mapping[comp_cls_hash] = deps.component_cls + + data = f"{comp_cls_hash},{deps.component_id}" + + # NOTE: It's important that we put the comment BEFORE the content, so we can + # use the order of comments to evaluate components' instance JS code in the correct order. + output = mark_safe(COMPONENT_DEPS_COMMENT.format(data=data)) + content + return output + + +# Anything and everything that needs to be done with a Component's HTML +# script in order to support running JS and CSS per-instance. +def postprocess_component_html( + component_cls: Type["Component"], + component_id: str, + html_content: str, + type: RenderType, + render_dependencies: bool, +) -> str: + # NOTE: To better understand the next section, consider this: + # + # We define and cache the component's JS and CSS at the same time as + # when we render the HTML. However, the resulting HTML MAY OR MAY NOT + # be used in another component. + # + # IF the component's HTML IS used in another component, and the other + # component want to render the JS or CSS dependencies (e.g. inside ), + # then it's only at that point when we want to access the data about + # which JS and CSS scripts is the component's HTML associated with. + # + # This happens AFTER the rendering context, so there's no Context to rely on. + # + # Hence, we store the info about associated JS and CSS right in the HTML itself. + # As an HTML comment ``. Thus, the inner component can be used as many times + # and in different components, and they will all know to fetch also JS and CSS of the + # inner components. + + # Mark the generated HTML so that we will know which JS and CSS + # scripts are associated with it. + output = _insert_component_comment( + html_content, + Dependencies( + component_cls=component_cls, + component_id=component_id, + ), + ) + + if render_dependencies: + output = _render_dependencies(output, type) + return output + + +######################################################### +# 3. Given a FINAL HTML composed of MANY components, +# process all the HTML dependency comments (created in +# previous step), obtaining ALL JS and CSS scripts +# required by this HTML document. And post-process them, +# so the scripts are either inlined into the HTML, or +# fetched when the HTML is loaded in the browser. +######################################################### + + +TContent = TypeVar("TContent", bound=Union[bytes, str]) + + +CSS_DEPENDENCY_PLACEHOLDER = '' +JS_DEPENDENCY_PLACEHOLDER = '' + +CSS_PLACEHOLDER_BYTES = bytes(CSS_DEPENDENCY_PLACEHOLDER, encoding="utf-8") +JS_PLACEHOLDER_BYTES = bytes(JS_DEPENDENCY_PLACEHOLDER, encoding="utf-8") + +COMPONENT_DEPS_COMMENT = "" +# E.g. `` +COMPONENT_COMMENT_REGEX = re.compile(rb"") +# E.g. `table,123` +SCRIPT_NAME_REGEX = re.compile(rb"^(?P[\w\-\./]+?),(?P[\w]+?)$") +PLACEHOLDER_REGEX = re.compile( + r"{css_placeholder}|{js_placeholder}".format( + css_placeholder=CSS_DEPENDENCY_PLACEHOLDER, + js_placeholder=JS_DEPENDENCY_PLACEHOLDER, + ).encode() +) + + +def render_dependencies(content: TContent, type: RenderType = "document") -> TContent: + """ + Given a string that contains parts that were rendered by components, + this function inserts all used JS and CSS. + + By default, the string is parsed as an HTML and: + - CSS is inserted at the end of `` (if present) + - JS is inserted at the end of `` (if present) + + If you used `{% component_js_dependencies %}` or `{% component_css_dependencies %}`, + then the JS and CSS will be inserted only at these locations. + + Example: + ```python + def my_view(request): + template = Template(''' + {% load components %} + + + + +

{{ table_name }}

+ {% component "table" name=table_name / %} + + + ''') + + html = template.render( + Context({ + "table_name": request.GET["name"], + }) + ) + + # This inserts components' JS and CSS + processed_html = render_dependencies(html) + + return HttpResponse(processed_html) + ``` + """ + is_safestring = isinstance(content, SafeString) + + if isinstance(content, str): + content_ = content.encode() + else: + content_ = cast(bytes, content) + + content_, js_dependencies, css_dependencies = _process_dep_declarations(content_, type) + + # Replace the placeholders with the actual content + did_find_js_placeholder = False + did_find_css_placeholder = False + + 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 + did_find_css_placeholder = True + elif match[0] == JS_PLACEHOLDER_BYTES: + replacement = js_dependencies + did_find_js_placeholder = True + else: + raise RuntimeError( + "Unexpected error: Regex for component dependencies processing" + f" matched unknown string '{match[0].decode()}'" + ) + return replacement + + content_ = PLACEHOLDER_REGEX.sub(on_replace_match, content_) + + # By default, if user didn't specify any `{% component_dependencies %}`, + # then try to insert the JS scripts at the end of and CSS sheets at the end + # of + if type == "document" and (not did_find_js_placeholder or not did_find_css_placeholder): + tree = parse_document_or_nodes(content_.decode()) + + if isinstance(tree, LexborHTMLParser): + did_modify_html = False + + if not did_find_css_placeholder and tree.head: + css_elems = parse_multiroot_html(css_dependencies.decode()) + for css_elem in css_elems: + tree.head.insert_child(css_elem) # type: ignore # TODO: Update to selectolax 0.3.25 + did_modify_html = True + + if not did_find_js_placeholder and tree.body: + js_elems = parse_multiroot_html(js_dependencies.decode()) + for js_elem in js_elems: + tree.body.insert_child(js_elem) # type: ignore # TODO: Update to selectolax 0.3.25 + did_modify_html = True + + transformed = cast(str, tree.html) + if did_modify_html: + content_ = transformed.encode() + + # 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 + return cast(TContent, output) + + +# Renamed so we can access use this function where there's kwarg of the same name +_render_dependencies = render_dependencies + + +# Overview of this function: +# 1. We extract all HTML comments like ``. +# 2. We look up the corresponding component classes +# 3. For each component class we get the component's inlined JS and CSS, +# and the JS and CSS from `Media.js/css` +# 4. We add our client-side JS logic into the mix (`django_components/django_components.min.js`) +# - For fragments, we would skip this step. +# 5. For all the above JS and CSS, we figure out which JS / CSS needs to be inserted directly +# into the HTML, and which can be loaded with the client-side manager. +# - Components' inlined JS is inserted directly into the HTML as `") + elif script_type == "css": + script = mark_safe(f"") + return script + + +def _gen_exec_script( + to_load_js_tags: List[str], + 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.loadScript("js", ''), + # Components.manager.loadScript("js", ''), + # Components.manager.loadScript("css", ''), + # ]); + # ``` + # + # 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 `loadScript`, instead of a whole tag. + # But because we allow users to specify the Media class, and thus users can + # configure how the `` or `" + return exec_script + + +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 + + +######################################################### +# 4. Endpoints for fetching the JS / CSS scripts from within +# the browser, as defined from previous steps. +######################################################### + + +CACHE_ENDPOINT_NAME = "components_cached_script" +_CONTENT_TYPES = {"js": "text/javascript", "css": "text/css"} + + +def _get_content_types(script_type: ScriptType) -> str: + if script_type not in _CONTENT_TYPES: + raise ValueError(f"Unknown script_type '{script_type}'") + + return _CONTENT_TYPES[script_type] + + +def cached_script_view( + req: HttpRequest, + comp_cls_hash: str, + script_type: ScriptType, +) -> HttpResponse: + if req.method != "GET": + return HttpResponseNotAllowed(["GET"]) + + # Otherwise check if the file is among the dynamically generated files in the cache + cache_key = _gen_cache_key(comp_cls_hash, script_type) + script = comp_media_cache.get(cache_key) + + if script is None: + return HttpResponseNotFound() + + content_type = _get_content_types(script_type) + return HttpResponse(content=script, content_type=content_type) + + +urlpatterns = [ + # E.g. `/components/cache/table.js/` + path("cache/./", cached_script_view, name=CACHE_ENDPOINT_NAME), +] + + +######################################################### +# 5. Middleware that automatically applies the dependency- +# aggregating logic on all HTML responses. +######################################################### + + +@sync_and_async_middleware +class ComponentDependencyMiddleware: + """ + Middleware that inserts CSS/JS dependencies for all rendered + components at points marked with template tags. + """ + + def __init__(self, get_response: "Callable[[HttpRequest], HttpResponse]") -> None: + self.get_response = get_response + + # NOTE: Required to work with async + if iscoroutinefunction(self.get_response): + markcoroutinefunction(self) + + def __call__(self, request: HttpRequest) -> HttpResponseBase: + if iscoroutinefunction(self): + return self.__acall__(request) + + response = self.get_response(request) + response = self.process_response(response) + return response + + # NOTE: Required to work with async + async def __acall__(self, request: HttpRequest) -> HttpResponseBase: + response = await self.get_response(request) + response = self.process_response(response) + return response + + def process_response(self, response: HttpResponse) -> HttpResponse: + if not isinstance(response, StreamingHttpResponse) and response.get("Content-Type", "").startswith( + "text/html" + ): + response.content = render_dependencies(response.content, type="document") + + return response diff --git a/src/django_components/finders.py b/src/django_components/finders.py index 71262749..06029459 100644 --- a/src/django_components/finders.py +++ b/src/django_components/finders.py @@ -9,8 +9,8 @@ from django.core.files.storage import FileSystemStorage from django.utils._os import safe_join from django_components.app_settings import app_settings -from django_components.template_loader import get_dirs -from django_components.utils import any_regex_match, no_regex_match +from django_components.util.loader import get_component_dirs +from django_components.util.misc import any_regex_match, no_regex_match # To keep track on which directories the finder has searched the static files. searched_locations = [] @@ -29,12 +29,12 @@ class ComponentsFileSystemFinder(BaseFinder): Differences: - This finder uses `COMPONENTS.dirs` setting to locate files instead of `STATICFILES_DIRS`. - Whether a file within `COMPONENTS.dirs` is considered a STATIC file is configured - by `COMPONENTS.static_files_allowed` and `COMPONENTS.forbidden_static_files`. + by `COMPONENTS.static_files_allowed` and `COMPONENTS.static_files_forbidden`. - If `COMPONENTS.dirs` is not set, defaults to `settings.BASE_DIR / "components"` """ def __init__(self, app_names: Any = None, *args: Any, **kwargs: Any) -> None: - component_dirs = [str(p) for p in get_dirs()] + component_dirs = [str(p) for p in get_component_dirs()] # NOTE: The rest of the __init__ is the same as `django.contrib.staticfiles.finders.FileSystemFinder`, # but using our locations instead of STATICFILES_DIRS. diff --git a/src/django_components/library.py b/src/django_components/library.py index 54dc0aac..70ae94eb 100644 --- a/src/django_components/library.py +++ b/src/django_components/library.py @@ -12,11 +12,36 @@ if TYPE_CHECKING: class TagProtectedError(Exception): + """ + The way the [`TagFormatter`](../../concepts/advanced/tag_formatter) works is that, + based on which start and end tags are used for rendering components, + the [`ComponentRegistry`](../api#django_components.ComponentRegistry) behind the scenes + [un-/registers the template tags](https://docs.djangoproject.com/en/5.1/howto/custom-template-tags/#registering-the-tag) + with the associated instance of Django's + [`Library`](https://docs.djangoproject.com/en/5.1/howto/custom-template-tags/#code-layout). + + In other words, if I have registered a component `"table"`, and I use the shorthand + syntax: + + ```django + {% table ... %} + {% endtable %} + ``` + + Then [`ComponentRegistry`](../api#django_components.ComponentRegistry) + registers the tag `table` onto the Django's Library instance. + + However, that means that if we registered a component `"slot"`, then we would overwrite + the [`{% slot %}`](../template_tags#slot) tag from django_components. + + Thus, this exception is raised when a component is attempted to be registered under + a forbidden name, such that it would overwrite one of django_component's own template tags. + """ # noqa: E501 + pass PROTECTED_TAGS = [ - "component_dependencies", "component_css_dependencies", "component_js_dependencies", "fill", diff --git a/src/django_components/management/commands/startcomponent.py b/src/django_components/management/commands/startcomponent.py index fb6d6003..a4a43267 100644 --- a/src/django_components/management/commands/startcomponent.py +++ b/src/django_components/management/commands/startcomponent.py @@ -7,49 +7,117 @@ from django.core.management.base import BaseCommand, CommandError, CommandParser class Command(BaseCommand): - help = "Creates a new component" + """ + ### Management Command Usage + + To use the command, run the following command in your terminal: + + ```bash + python manage.py startcomponent --path --js --css --template --force --verbose --dry-run + ``` + + Replace ``, ``, ``, ``, and `` with your desired values. + + ### Management Command Examples + + Here are some examples of how you can use the command: + + #### Creating a Component with Default Settings + + To create a component with the default settings, you only need to provide the name of the component: + + ```bash + python manage.py startcomponent my_component + ``` + + This will create a new component named `my_component` in the `components` directory of your Django project. The JavaScript, CSS, and template files will be named `script.js`, `style.css`, and `template.html`, respectively. + + #### Creating a Component with Custom Settings + + You can also create a component with custom settings by providing additional arguments: + + ```bash + python manage.py startcomponent new_component --path my_components --js my_script.js --css my_style.css --template my_template.html + ``` + + This will create a new component named `new_component` in the `my_components` directory. The JavaScript, CSS, and template files will be named `my_script.js`, `my_style.css`, and `my_template.html`, respectively. + + #### Overwriting an Existing Component + + If you want to overwrite an existing component, you can use the `--force` option: + + ```bash + python manage.py startcomponent my_component --force + ``` + + This will overwrite the existing `my_component` if it exists. + + #### Simulating Component Creation + + If you want to simulate the creation of a component without actually creating any files, you can use the `--dry-run` option: + + ```bash + python manage.py startcomponent my_component --dry-run + ``` + + This will simulate the creation of `my_component` without creating any files. + """ # noqa: E501 + + help = "Create a new django component." def add_arguments(self, parser: CommandParser) -> None: - parser.add_argument("name", type=str, help="The name of the component to create") + parser.add_argument( + "name", + type=str, + help="The name of the component to create. This is a required argument.", + ) parser.add_argument( "--path", type=str, - help="The path to the components directory", + help=( + "The path to the component's directory. This is an optional argument. If not provided, " + "the command will use the `COMPONENTS.dirs` setting from your Django settings." + ), default=None, ) parser.add_argument( "--js", type=str, - help="The name of the javascript file", + help="The name of the JavaScript file. This is an optional argument. The default value is `script.js`.", default="script.js", ) parser.add_argument( "--css", type=str, - help="The name of the style file", + help="The name of the CSS file. This is an optional argument. The default value is `style.css`.", default="style.css", ) parser.add_argument( "--template", type=str, - help="The name of the template file", + help="The name of the template file. This is an optional argument. The default value is `template.html`.", default="template.html", ) parser.add_argument( "--force", action="store_true", - help="Overwrite existing files if they exist", + help="This option allows you to overwrite existing files if they exist. This is an optional argument.", ) parser.add_argument( "--verbose", action="store_true", - help="Print additional information during component creation", + help=( + "This option allows the command to print additional information during component " + "creation. This is an optional argument." + ), ) parser.add_argument( "--dry-run", action="store_true", - help="Simulate component creation without actually creating any files", - default=False, + help=( + "This option allows you to simulate component creation without actually creating any files. " + "This is an optional argument. The default value is `False`." + ), ) def handle(self, *args: Any, **kwargs: Any) -> None: diff --git a/src/django_components/middleware.py b/src/django_components/middleware.py index 44fc32e8..b18e2e41 100644 --- a/src/django_components/middleware.py +++ b/src/django_components/middleware.py @@ -1,109 +1,4 @@ -import re -from collections.abc import Callable -from typing import TYPE_CHECKING, Iterable +# These middlewares are part of public API +from django_components.dependencies import ComponentDependencyMiddleware -from asgiref.sync import iscoroutinefunction, markcoroutinefunction -from django.conf import settings -from django.forms import Media -from django.http import HttpRequest, HttpResponse, StreamingHttpResponse -from django.http.response import HttpResponseBase -from django.utils.decorators import sync_and_async_middleware - -from django_components.component_registry import registry - -if TYPE_CHECKING: - from django_components.component import Component - -RENDERED_COMPONENTS_CONTEXT_KEY = "_COMPONENT_DEPENDENCIES" -CSS_DEPENDENCY_PLACEHOLDER = '' -JS_DEPENDENCY_PLACEHOLDER = '' - -SCRIPT_TAG_REGEX = re.compile("[\w\-/]+?) -->") -PLACEHOLDER_REGEX = re.compile( - rb"" - rb'|' - rb'|' -) - - -@sync_and_async_middleware -class ComponentDependencyMiddleware: - """Middleware that inserts CSS/JS dependencies for all rendered components at points marked with template tags.""" - - dependency_regex = COMPONENT_COMMENT_REGEX - - def __init__(self, get_response: "Callable[[HttpRequest], HttpResponse]") -> None: - self.get_response = get_response - - if iscoroutinefunction(self.get_response): - markcoroutinefunction(self) - - def __call__(self, request: HttpRequest) -> HttpResponseBase: - - if iscoroutinefunction(self): - return self.__acall__(request) - - response = self.get_response(request) - response = self.process_response(response) - return response - - async def __acall__(self, request: HttpRequest) -> HttpResponseBase: - - response = await self.get_response(request) - response = self.process_response(response) - return response - - def process_response(self, response: HttpResponse) -> HttpResponse: - if ( - getattr(settings, "COMPONENTS", {}).get("RENDER_DEPENDENCIES", False) - and not isinstance(response, StreamingHttpResponse) - and response.get("Content-Type", "").startswith("text/html") - ): - response.content = process_response_content(response.content) - - return response - - -def process_response_content(content: bytes) -> bytes: - component_names_seen = {match.group("name") for match in COMPONENT_COMMENT_REGEX.finditer(content)} - all_components = [registry.get(name.decode("utf-8"))("") for name in component_names_seen] - all_media = join_media(all_components) - js_dependencies = b"".join(media.encode("utf-8") for media in all_media.render_js()) - css_dependencies = b"".join(media.encode("utf-8") for media in all_media.render_css()) - return PLACEHOLDER_REGEX.sub(DependencyReplacer(css_dependencies, js_dependencies), content) - - -def add_module_attribute_to_scripts(scripts: str) -> str: - return re.sub(SCRIPT_TAG_REGEX, ' + + ``` + + But this has a number of issues: + + - The JS scripts would run for each instance of the component. + - Bloating of the HTML file, as each inlined JS or CSS would be included fully for each component. + - While this sound OK, this could really bloat the HTML files if we used a UI component library for the basic building blocks like buttons, lists, cards, etc. + +## Flow + +So the solution should address all the points above. To achieve that, we manage the JS / CSS dependencies ourselves in the browser. So when a full HTML document is loaded, we keep track of which JS and CSS have been loaded. And when an HTML fragment is inserted, we check which JS / CSS dependencies it has, and load only those that have NOT been loaded yet. + +This is how we achieve that: + +1. When a component is rendered, it inserts an HTML comment containing metadata about the rendered component. + + So a template like this + + ```django + {% load component_tags %} +
+ {% component "my_table" / %} +
+ {% component "button" %} + Click me! + {% endcomponent %} + ``` + + May actually render: + + ```html +
+ + + ... +
+
+ + + ``` + + Each `` comment includes comma-separated data - a unique hash for the component class, e.g. `my_table_10bc2c`, and the component ID, e.g. `c020ad`. + + This way, we or the user can freely pass the rendered around or transform it, treating it as a string to add / remove / replace bits. As long as the `` comments remain in the rendered string, we will be able to deduce which JS and CSS dependencies the component needs. + +2. Post-process the rendered HTML, extracting the `` comments, and instead inserting the corresponding JS and CSS dependencies. + + If we dealt only with JS, then we could get away with processing the `` comments on the client (browser). However, the CSS needs to be processed still on the server, so the browser receives CSS styles already inserted as `", + rendered, ) - - def test_html_and_js(self): - class HTMLJSComponent(Component): - template = "
Content
" - js = "console.log('HTML and JS only');" - - comp = HTMLJSComponent("html_js_component") - self.assertHTMLEqual( - comp.render(Context({})), - "
Content
", - ) - self.assertHTMLEqual( - comp.render_js_dependencies(), - "", - ) - - def test_html_inline_and_css_js_files(self): - class HTMLStringFileCSSJSComponent(Component): - template = "
Content
" - - class Media: - css = "path/to/style.css" - js = "path/to/script.js" - - comp = HTMLStringFileCSSJSComponent("html_string_file_css_js_component") - self.assertHTMLEqual( - comp.render(Context({})), - "
Content
", - ) - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - """, - ) - - def test_html_js_inline_and_css_file(self): - class HTMLStringFileCSSJSComponent(Component): - template = "
Content
" - js = "console.log('HTML and JS only');" - - class Media: - css = "path/to/style.css" - - comp = HTMLStringFileCSSJSComponent("html_string_file_css_js_component") - self.assertHTMLEqual( - comp.render(Context({})), - "
Content
", - ) - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - """, - ) - - def test_html_css_inline_and_js_file(self): - class HTMLStringFileCSSJSComponent(Component): - template = "
Content
" - css = ".html-string-file { color: blue; }" - - class Media: - js = "path/to/script.js" - - comp = HTMLStringFileCSSJSComponent("html_string_file_css_js_component") - self.assertHTMLEqual( - comp.render(Context({})), - "
Content
", - ) - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - """, + self.assertInHTML( + "", + rendered, ) def test_html_variable(self): @@ -156,117 +89,78 @@ class InlineComponentTest(BaseTestCase): class ComponentMediaTests(BaseTestCase): - def test_css_and_js(self): - class SimpleComponent(Component): - template: types.django_html = """ - Variable: {{ variable }} - """ - - class Media: - css = "style.css" - js = "script.js" - - comp = SimpleComponent("simple_component") - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - """, - ) - - def test_css_only(self): - class SimpleComponent(Component): - template: types.django_html = """ - Variable: {{ variable }} - """ - - class Media: - css = "style.css" - - comp = SimpleComponent("simple_component") - - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - """, - ) - - def test_js_only(self): - class SimpleComponent(Component): - template: types.django_html = """ - Variable: {{ variable }} - """ - - class Media: - js = "script.js" - - comp = SimpleComponent("simple_component") - - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - """, - ) - def test_empty_media(self): class SimpleComponent(Component): template: types.django_html = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} Variable: {{ variable }} """ class Media: pass - comp = SimpleComponent("simple_component") + rendered = SimpleComponent.render() - self.assertHTMLEqual(comp.render_dependencies(), "") + self.assertEqual(rendered.count("{{ variable }} - """ - - comp = SimpleComponent("simple_component") - - self.assertHTMLEqual(comp.render_dependencies(), "") + self.assertEqual(rendered.count(" - - - """, + rendered = SimpleComponent.render() + + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + + # Command to load the JS from Media.js + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, ) def test_css_js_as_string(self): class SimpleComponent(Component): + template = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + """ + class Media: css = "path/to/style.css" js = "path/to/script.js" - comp = SimpleComponent("") - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - """, + rendered = SimpleComponent.render() + + self.assertInHTML('', rendered) + + # Command to load the JS from Media.js + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, ) def test_css_as_dict(self): class SimpleComponent(Component): + template = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + """ + class Media: css = { "all": "path/to/style.css", @@ -275,15 +169,16 @@ class ComponentMediaTests(BaseTestCase): } js = ["path/to/script.js"] - comp = SimpleComponent("") - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - - - """, + rendered = SimpleComponent.render() + + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + + # Command to load the JS from Media.js + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, ) def test_media_custom_render_js(self): @@ -292,22 +187,31 @@ class ComponentMediaTests(BaseTestCase): tags: list[str] = [] for path in self._js: # type: ignore[attr-defined] abs_path = self.absolute_path(path) # type: ignore[attr-defined] - tags.append(f'') + tags.append(f'') return tags class SimpleComponent(Component): + template = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + """ + media_class = MyMedia class Media: js = ["path/to/script.js", "path/to/script2.js"] - comp = SimpleComponent() - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - """, + rendered = SimpleComponent.render() + + # Command to load the JS from Media.js + self.assertIn( + "Components.unescapeJs(\\`&lt;script defer src=&quot;path/to/script.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, + ) + self.assertIn( + "Components.unescapeJs(\\`&lt;script defer src=&quot;path/to/script2.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, ) def test_media_custom_render_css(self): @@ -317,10 +221,16 @@ class ComponentMediaTests(BaseTestCase): media = sorted(self._css) # type: ignore[attr-defined] for medium in media: for path in self._css[medium]: # type: ignore[attr-defined] - tags.append(f'') + tags.append(f'') return tags class SimpleComponent(Component): + template = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + """ + media_class = MyMedia class Media: @@ -330,15 +240,11 @@ class ComponentMediaTests(BaseTestCase): "screen": "path/to/style3.css", } - comp = SimpleComponent() - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - - """, - ) + rendered = SimpleComponent.render() + + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) class MediaPathAsObjectTests(BaseTestCase): @@ -377,6 +283,12 @@ class MediaPathAsObjectTests(BaseTestCase): return format_html('', static(self.static_path)) class SimpleComponent(Component): + template = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + """ + class Media: css = { "all": [ @@ -395,20 +307,29 @@ class MediaPathAsObjectTests(BaseTestCase): "path/to/script4.js", # Formatted by Media.render_js ] - comp = SimpleComponent() - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - - + rendered = SimpleComponent.render() - - - - - """, + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + + # Command to load the JS from Media.js + self.assertIn( + "Components.unescapeJs(\\`&lt;script js_tag src=&quot;path/to/script.js&quot; type=&quot;module&quot;&gt;&lt;/script&gt;\\`)", + rendered, + ) + self.assertIn( + "Components.unescapeJs(\\`&lt;script hi src=&quot;path/to/script2.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, + ) + self.assertIn( + "Components.unescapeJs(\\`&lt;script type=&quot;module&quot; src=&quot;path/to/script3.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, + ) + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script4.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, ) def test_pathlike(self): @@ -425,6 +346,12 @@ class MediaPathAsObjectTests(BaseTestCase): return self.path class SimpleComponent(Component): + template = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + """ + class Media: css = { "all": [ @@ -442,19 +369,25 @@ class MediaPathAsObjectTests(BaseTestCase): "path/to/script3.js", ] - comp = SimpleComponent() - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - - + rendered = SimpleComponent.render() - - - - """, + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + + # Command to load the JS from Media.js + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, + ) + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script2.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, + ) + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script3.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, ) def test_str(self): @@ -467,6 +400,12 @@ class MediaPathAsObjectTests(BaseTestCase): pass class SimpleComponent(Component): + template = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + """ + class Media: css = { "all": [ @@ -483,18 +422,21 @@ class MediaPathAsObjectTests(BaseTestCase): "path/to/script2.js", ] - comp = SimpleComponent() - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - - + rendered = SimpleComponent.render() - - - """, + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + + # Command to load the JS from Media.js + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, + ) + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script2.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, ) def test_bytes(self): @@ -507,6 +449,12 @@ class MediaPathAsObjectTests(BaseTestCase): pass class SimpleComponent(Component): + template = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + """ + class Media: css = { "all": [ @@ -523,22 +471,31 @@ class MediaPathAsObjectTests(BaseTestCase): "path/to/script2.js", ] - comp = SimpleComponent() - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - - + rendered = SimpleComponent.render() - - - """, + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + + # Command to load the JS from Media.js + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, + ) + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;path/to/script2.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, ) def test_function(self): class SimpleComponent(Component): + template = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + """ + class Media: css = [ lambda: mark_safe(''), # Literal @@ -553,20 +510,29 @@ class MediaPathAsObjectTests(BaseTestCase): lambda: b"calendar/script3.js", ] - comp = SimpleComponent() - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - - + rendered = SimpleComponent.render() - - - - - """, + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + + # Command to load the JS from Media.js + self.assertIn( + "Components.unescapeJs(\\`&lt;script hi src=&quot;calendar/script.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, + ) + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;calendar/script1.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, + ) + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;calendar/script2.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, + ) + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;calendar/script3.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, ) @override_settings(STATIC_URL="static/") @@ -574,6 +540,12 @@ class MediaPathAsObjectTests(BaseTestCase): """Test that all the different ways of defining media files works with Django's staticfiles""" class SimpleComponent(Component): + template = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + """ + class Media: css = [ mark_safe(f''), # Literal @@ -590,22 +562,30 @@ class MediaPathAsObjectTests(BaseTestCase): lambda: "calendar/script4.js", ] - comp = SimpleComponent() - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - - - + rendered = SimpleComponent.render() - - - - - - """, + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + + # Command to load the JS from Media.js + self.assertIn( + "Components.unescapeJs(\\`&lt;script hi src=&quot;/static/calendar/script.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, + ) + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;/static/calendar/script1.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, + ) + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;/static/calendar/script2.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, + ) + self.assertIn( + "Components.unescapeJs(\\`&lt;script src=&quot;/static/calendar/script3.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, ) @@ -632,26 +612,32 @@ class MediaStaticfilesTests(BaseTestCase): tags: list[str] = [] for path in self._js: # type: ignore[attr-defined] abs_path = self.absolute_path(path) # type: ignore[attr-defined] - tags.append(f'') + tags.append(f'') return tags class SimpleComponent(Component): + template = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + """ + media_class = MyMedia class Media: css = "calendar/style.css" js = "calendar/script.js" - comp = SimpleComponent() + rendered = SimpleComponent.render() # NOTE: Since we're using the default storage class for staticfiles, the files should # be searched as specified above (e.g. `calendar/script.js`) inside `static_root` dir. - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - """, + self.assertInHTML('', rendered) + + # Command to load the JS from Media.js + self.assertIn( + "Components.unescapeJs(\\`&lt;script defer src=&quot;/static/calendar/script.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, ) # For context see https://github.com/EmilStenstrom/django-components/issues/522 @@ -688,26 +674,34 @@ class MediaStaticfilesTests(BaseTestCase): tags: list[str] = [] for path in self._js: # type: ignore[attr-defined] abs_path = self.absolute_path(path) # type: ignore[attr-defined] - tags.append(f'') + tags.append(f'') return tags class SimpleComponent(Component): + template = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + """ + media_class = MyMedia class Media: css = "calendar/style.css" js = "calendar/script.js" - comp = SimpleComponent() + rendered = SimpleComponent.render() # NOTE: Since we're using ManifestStaticFilesStorage, we expect the rendered media to link # to the files as defined in staticfiles.json - self.assertHTMLEqual( - comp.render_dependencies(), - """ - - - """, + self.assertInHTML( + '', rendered + ) + + # Command to load the JS from Media.js + self.assertIn( + "Components.unescapeJs(\\`&lt;script defer src=&quot;/static/calendar/script.e1815e23e0ec.js&quot;&gt;&lt;/script&gt;\\`)", + rendered, ) @@ -776,22 +770,30 @@ class MediaRelativePathTests(BaseTestCase): registry.unregister(comp_name) template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} - {% component name='relative_file_component' variable=variable %} - {% endcomponent %} + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + {% component name='relative_file_component' variable=variable / %} """ template = Template(template_str) - rendered = template.render(Context({"variable": "test"})) - self.assertHTMLEqual( - rendered, + rendered = render_dependencies(template.render(Context({"variable": "test"}))) + + self.assertInHTML('', rendered) + + self.assertInHTML( """ - -
""", + rendered, + ) + + # Command to load the JS from Media.js + self.assertIn( + "Components.unescapeJs(\\`&lt;link href=&quot;relative_file/relative_file.css&quot; media=&quot;all&quot; rel=&quot;stylesheet&quot;&gt;\\`)", + rendered, ) # Settings required for autodiscover to work @@ -811,7 +813,9 @@ class MediaRelativePathTests(BaseTestCase): registry.unregister("relative_file_pathobj_component") template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} {% component 'parent_component' %} {% fill 'content' %} {% component name='relative_file_component' variable='hello' %} @@ -848,17 +852,19 @@ class MediaRelativePathTests(BaseTestCase): # Fix the paths, since the "components" dir is nested with autodiscover_with_cleanup(map_module=lambda p: f"tests.{p}" if p.startswith("components") else p): - # Mark the PathObj instances of 'relative_file_pathobj_component' so they won raise - # error PathObj.__str__ is triggered. + # Mark the PathObj instances of 'relative_file_pathobj_component' so they won't raise + # error if PathObj.__str__ is triggered. CompCls = registry.get("relative_file_pathobj_component") CompCls.Media.js[0].throw_on_calling_str = False # type: ignore CompCls.Media.css["all"][0].throw_on_calling_str = False # type: ignore - rendered = CompCls().render_dependencies() - self.assertHTMLEqual( + rendered = CompCls.render(kwargs={"variable": "abc"}) + + self.assertInHTML('', rendered) + self.assertInHTML('', rendered) + + # Command to load the JS from Media.js + self.assertIn( + "Components.unescapeJs(\\`&lt;script type=&quot;module&quot; src=&quot;relative_file_pathobj.js&quot;&gt;&lt;/script&gt;\\`)", rendered, - """ - - - """, ) diff --git a/tests/test_context.py b/tests/test_context.py index 3681a2ac..5c7efda0 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -21,10 +21,6 @@ class SimpleComponent(Component): def get_context_data(self, variable=None): return {"variable": variable} if variable is not None else {} - @staticmethod - def expected_output(variable_value): - return "Variable: < strong > {} < / strong >".format(variable_value) - class VariableDisplay(Component): template: types.django_html = """ @@ -98,7 +94,7 @@ class ContextTests(BaseTestCase): self, ): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component 'parent_component' %}{% endcomponent %} """ template = Template(template_str) @@ -118,7 +114,6 @@ class ContextTests(BaseTestCase): ): template_str: types.django_html = """ {% load component_tags %} - {% component_dependencies %} {% component name='parent_component' %}{% endcomponent %} """ template = Template(template_str) @@ -134,7 +129,7 @@ class ContextTests(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_nested_component_context_shadows_parent_with_filled_slots(self): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component 'parent_component' %} {% fill 'content' %} {% component name='variable_display' shadowing_variable='shadow_from_slot' new_variable='unique_from_slot' %} @@ -157,7 +152,6 @@ class ContextTests(BaseTestCase): def test_nested_component_instances_have_unique_context_with_filled_slots(self): template_str: types.django_html = """ {% load component_tags %} - {% component_dependencies %} {% component 'parent_component' %} {% fill 'content' %} {% component name='variable_display' shadowing_variable='shadow_from_slot' new_variable='unique_from_slot' %} @@ -181,7 +175,6 @@ class ContextTests(BaseTestCase): ): template_str: types.django_html = """ {% load component_tags %} - {% component_dependencies %} {% component name='parent_component' %}{% endcomponent %} """ template = Template(template_str) @@ -200,7 +193,7 @@ class ContextTests(BaseTestCase): self, ): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component 'parent_component' %} {% fill 'content' %} {% component name='variable_display' shadowing_variable='shadow_from_slot' new_variable='unique_from_slot' %} @@ -250,7 +243,7 @@ class ParentArgsTests(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_parent_args_can_be_drawn_from_context(self): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component 'parent_with_args' parent_value=parent_value %} {% endcomponent %} """ @@ -276,7 +269,7 @@ class ParentArgsTests(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_parent_args_available_outside_slots(self): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component 'parent_with_args' parent_value='passed_in' %}{%endcomponent %} """ template = Template(template_str) @@ -297,7 +290,7 @@ class ParentArgsTests(BaseTestCase): first_val, second_val = context_behavior_data template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component 'parent_with_args' parent_value='passed_in' %} {% fill 'content' %} {% component name='variable_display' shadowing_variable='value_from_slot' new_variable=inner_parent_value %} @@ -331,7 +324,7 @@ class ContextCalledOnceTests(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_one_context_call_with_simple_component(self): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component name='incrementer' %}{% endcomponent %} """ template = Template(template_str) @@ -427,7 +420,7 @@ class ComponentsCanAccessOuterContext(BaseTestCase): ) def test_simple_component_can_use_outer_context(self, context_behavior_data): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component 'simple_component' %}{% endcomponent %} """ template = Template(template_str) @@ -448,7 +441,7 @@ class IsolatedContextTests(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_simple_component_can_pass_outer_context_in_args(self): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component 'simple_component' variable only %}{% endcomponent %} """ template = Template(template_str) @@ -458,7 +451,7 @@ class IsolatedContextTests(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_simple_component_cannot_use_outer_context(self): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component 'simple_component' only %}{% endcomponent %} """ template = Template(template_str) @@ -476,7 +469,7 @@ class IsolatedContextSettingTests(BaseTestCase): self, ): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component 'simple_component' variable %}{% endcomponent %} """ template = Template(template_str) @@ -488,7 +481,7 @@ class IsolatedContextSettingTests(BaseTestCase): self, ): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component 'simple_component' %}{% endcomponent %} """ template = Template(template_str) @@ -500,7 +493,7 @@ class IsolatedContextSettingTests(BaseTestCase): self, ): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component 'simple_component' variable %} {% endcomponent %} """ @@ -513,7 +506,7 @@ class IsolatedContextSettingTests(BaseTestCase): self, ): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component 'simple_component' %} {% endcomponent %} """ @@ -538,7 +531,7 @@ class OuterContextPropertyTests(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_outer_context_property_with_component(self): template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} {% component 'outer_context_component' only %}{% endcomponent %} """ template = Template(template_str) @@ -551,12 +544,17 @@ class ContextVarsIsFilledTests(BaseTestCase): template: types.django_html = """ {% load component_tags %}
- {% slot "title" default %}{% endslot %} - {% slot "my_title" %}{% endslot %} - {% slot "my title 1" %}{% endslot %} - {% slot "my-title-2" %}{% endslot %} - {% slot "escape this: #$%^*()" %}{% endslot %} - {{ component_vars.is_filled|safe }} + {% slot "title" default / %} + {% slot "my-title" / %} + {% slot "my-title-1" / %} + {% slot "my-title-2" / %} + {% slot "escape this: #$%^*()" / %} + + title: {{ component_vars.is_filled.title }} + my_title: {{ component_vars.is_filled.my_title }} + my_title_1: {{ component_vars.is_filled.my_title_1 }} + my_title_2: {{ component_vars.is_filled.my_title_2 }} + escape_this_________: {{ component_vars.is_filled.escape_this_________ }}
""" @@ -593,7 +591,6 @@ class ContextVarsIsFilledTests(BaseTestCase): def setUp(self) -> None: super().setUp() - registry.register("is_filled_vars", self.IsFilledVarsComponent) registry.register("conditional_slots", self.ComponentWithConditionalSlots) registry.register( "complex_conditional_slots", @@ -602,28 +599,34 @@ class ContextVarsIsFilledTests(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_is_filled_vars(self): + registry.register("is_filled_vars", self.IsFilledVarsComponent) + template: types.django_html = """ {% load component_tags %} {% component "is_filled_vars" %} - {% fill "title" %}{% endfill %} - {% fill "my-title-2" %}{% endfill %} - {% fill "escape this: #$%^*()" %}{% endfill %} + {% fill "title" / %} + {% fill "my-title-2" / %} + {% fill "escape this: #$%^*()" / %} {% endcomponent %} """ + rendered = Template(template).render(Context()) + expected = """
- {'title': True, - 'my_title': False, - 'my_title_1': False, - 'my_title_2': True, - 'escape_this_________': True} + title: True + my_title: False + my_title_1: False + my_title_2: True + escape_this_________: True
""" self.assertHTMLEqual(rendered, expected) @parametrize_context_behavior(["django", "isolated"]) def test_is_filled_vars_default(self): + registry.register("is_filled_vars", self.IsFilledVarsComponent) + template: types.django_html = """ {% load component_tags %} {% component "is_filled_vars" %} @@ -634,11 +637,11 @@ class ContextVarsIsFilledTests(BaseTestCase): expected = """
bla bla - {'title': True, - 'my_title': False, - 'my_title_1': False, - 'my_title_2': False, - 'escape_this_________': False} + title: False + my_title: False + my_title_1: False + my_title_2: False + escape_this_________: False
""" self.assertHTMLEqual(rendered, expected) @@ -776,12 +779,6 @@ class ContextVarsIsFilledTests(BaseTestCase): """ Template(template).render(Context()) - expected = { - "title": True, - "my_title": False, - "my_title_1": False, - "my_title_2": False, - "escape_this_________": False, - } + expected = {"default": True} self.assertEqual(captured_before, expected) self.assertEqual(captured_after, expected) diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py new file mode 100644 index 00000000..83c8a1b2 --- /dev/null +++ b/tests/test_dependencies.py @@ -0,0 +1,333 @@ +from unittest.mock import Mock + +from django.http import HttpResponseNotModified +from django.template import Context, Template +from selectolax.lexbor import LexborHTMLParser + +from django_components import Component, registry, render_dependencies, types +from django_components.components.dynamic import DynamicComponent +from django_components.middleware import ComponentDependencyMiddleware + +from .django_test_setup import setup_test_config +from .testutils import BaseTestCase, create_and_process_template_response + +setup_test_config({"autodiscover": False}) + + +class SimpleComponent(Component): + template: types.django_html = """ + Variable: {{ variable }} + """ + + css: types.css = """ + .xyz { + color: red; + } + """ + + js: types.js = """ + console.log("xyz"); + """ + + def get_context_data(self, variable, variable2="default"): + return { + "variable": variable, + "variable2": variable2, + } + + class Media: + css = "style.css" + js = "script.js" + + +class RenderDependenciesTests(BaseTestCase): + def test_standalone_render_dependencies(self): + registry.register(name="test", component=SimpleComponent) + + template_str: types.django_html = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + {% component 'test' variable='foo' / %} + """ + template = Template(template_str) + rendered_raw = template.render(Context({})) + + # Placeholders + self.assertEqual(rendered_raw.count(''), 1) + self.assertEqual(rendered_raw.count(''), 1) + + self.assertEqual(rendered_raw.count("', rendered, count=1) + + self.assertInHTML("", rendered, count=1) # Inlined CSS + self.assertInHTML( + "", rendered, count=1 + ) # Inlined JS + + self.assertInHTML('', rendered, count=1) # Media.css + + def test_middleware_renders_dependencies(self): + registry.register(name="test", component=SimpleComponent) + + template_str: types.django_html = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + {% component 'test' variable='foo' / %} + """ + template = Template(template_str) + rendered = create_and_process_template_response(template, use_middleware=True) + + # Dependency manager script + self.assertInHTML('', rendered, count=1) + + self.assertInHTML("", rendered, count=1) # Inlined CSS + 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) # Media.css + self.assertEqual(rendered.count("', rendered_raw, count=0) + + self.assertInHTML("", rendered_raw, count=0) # Inlined CSS + self.assertInHTML('', rendered_raw, count=0) # Media.css + + self.assertInHTML( + "", + rendered_raw, + count=0, + ) # Inlined JS + + def test_component_render_to_response_renders_dependencies(self): + class SimpleComponentWithDeps(SimpleComponent): + template: types.django_html = ( + """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + """ + + SimpleComponent.template + ) + + registry.register(name="test", component=SimpleComponentWithDeps) + + response = SimpleComponentWithDeps.render_to_response( + kwargs={"variable": "foo"}, + ) + rendered = response.content.decode() + + # Dependency manager script + self.assertInHTML('', rendered, count=1) + + self.assertInHTML("", rendered, count=1) # Inlined CSS + self.assertInHTML( + "", rendered, count=1 + ) # Inlined JS + + self.assertEqual(rendered.count(''), 1) # Media.css + self.assertEqual(rendered.count(" + + + + {% component "test" variable="foo" / %} + + + """ + rendered_raw = Template(template_str).render(Context({})) + rendered = render_dependencies(rendered_raw) + + self.assertEqual(rendered.count(" + + + + """, + rendered, + count=1, + ) + + rendered_body = LexborHTMLParser(rendered).body.html # type: ignore[union-attr] + + self.assertInHTML( + """""", + rendered_body, + count=1, + ) + + def test_does_not_insert_styles_and_script_to_default_places_if_overriden(self): + registry.register(name="test", component=SimpleComponent) + + template_str: types.django_html = """ + {% load component_tags %} + + + + {% component_js_dependencies %} + + + {% component "test" variable="foo" / %} + {% component_css_dependencies %} + + + """ + rendered_raw = Template(template_str).render(Context({})) + rendered = render_dependencies(rendered_raw) + + self.assertEqual(rendered.count(" + Variable: foo + + + + + """, + rendered, + count=1, + ) + + rendered_head = LexborHTMLParser(rendered).head.html # type: ignore[union-attr] + + self.assertInHTML( + """""", + rendered_head, + count=1, + ) + + +class MiddlewareTests(BaseTestCase): + def test_middleware_response_without_content_type(self): + response = HttpResponseNotModified() + middleware = ComponentDependencyMiddleware(get_response=lambda _: response) + request = Mock() + self.assertEqual(response, middleware(request=request)) + + def test_middleware_response_with_components_with_slash_dash_and_underscore( + self, + ): + registry.register("dynamic", DynamicComponent) + + component_names = [ + "test-component", + "test/component", + "test_component", + ] + for component_name in component_names: + registry.register(name=component_name, component=SimpleComponent) + template_str: types.django_html = """ + {% load component_tags %} + {% component_css_dependencies %} + {% component_js_dependencies %} + {% component "dynamic" is=component_name variable='value' / %} + """ + template = Template(template_str) + rendered = create_and_process_template_response( + template, context=Context({"component_name": component_name}) + ) + + # Dependency manager script (empty) + self.assertInHTML('', rendered, count=1) + + # Inlined JS + self.assertInHTML( + "", rendered, count=1 + ) + # Inlined CSS + self.assertInHTML("", rendered, count=1) + # Media.css + self.assertInHTML('', rendered, count=1) + + self.assertEqual(rendered.count("Variable: value"), 1) diff --git a/tests/test_dependency_rendering.py b/tests/test_dependency_rendering.py index e23186e3..c9b41d0f 100644 --- a/tests/test_dependency_rendering.py +++ b/tests/test_dependency_rendering.py @@ -1,16 +1,22 @@ -from unittest.mock import Mock +""" +Here we check that the logic around dependency rendering outputs correct HTML. +During actual rendering, the HTML is then picked up by the JS-side dependency manager. +""" + +import re -from django.http import HttpResponseNotModified from django.template import Template -from django.test import override_settings from django_components import Component, registry, types -from django_components.middleware import ComponentDependencyMiddleware from .django_test_setup import setup_test_config from .testutils import BaseTestCase, create_and_process_template_response -setup_test_config() +setup_test_config({"autodiscover": False}) + + +def to_spaces(s: str): + return re.compile(r"\s+").sub(" ", s) class SimpleComponent(Component): @@ -29,19 +35,56 @@ class SimpleComponent(Component): js = "script.js" -class SimpleComponentAlternate(Component): +class SimpleComponentNested(Component): template: types.django_html = """ - Variable: {{ variable }} + {% load component_tags %} +
+ {% component "inner" variable=variable / %} + {% slot "default" default / %} +
+ """ + + css: types.css = """ + .my-class { + color: red; + } + """ + + js: types.js = """ + console.log("Hello"); """ def get_context_data(self, variable): return {} class Media: - css = "style2.css" + css = ["style.css", "style2.css"] js = "script2.js" +class OtherComponent(Component): + template: types.django_html = """ + XYZ: {{ variable }} + """ + + css: types.css = """ + .xyz { + color: red; + } + """ + + js: types.js = """ + console.log("xyz"); + """ + + def get_context_data(self, variable): + return {} + + class Media: + css = "xyz1.css" + js = "xyz1.js" + + class SimpleComponentWithSharedDependency(Component): template: types.django_html = """ Variable: {{ variable }} @@ -65,22 +108,29 @@ class MultistyleComponent(Component): js = ["script.js", "script2.js"] -@override_settings(COMPONENTS={"RENDER_DEPENDENCIES": True}) -class ComponentMediaRenderingTests(BaseTestCase): +class DependencyRenderingTests(BaseTestCase): def test_no_dependencies_when_no_components_used(self): registry.register(name="test", component=SimpleComponent) template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} """ template = Template(template_str) rendered = create_and_process_template_response(template) - self.assertInHTML('', rendered, count=1) + + self.assertEqual(rendered.count("', rendered, count=0) + + # Dependency manager script + self.assertInHTML('', rendered, count=1) + + self.assertEqual(rendered.count("', - rendered, - count=0, - ) - def test_preload_dependencies_render_when_no_components_used(self): + self.assertEqual(rendered.count("', rendered, count=1) - self.assertInHTML( - '', - rendered, - count=1, + + # Dependency manager script + self.assertInHTML('', rendered, count=1) + + self.assertEqual(rendered.count(''), 1) # Media.css + self.assertEqual(rendered.count("', - rendered, - count=1, + self.assertEqual( + rendered.count( + r"const toLoadCssScripts = [Components.unescapeJs(\`&lt;link href=&quot;style.css&quot; media=&quot;all&quot; rel=&quot;stylesheet&quot;&gt;\`)];" + ), + 1, ) - def test_single_component_dependencies_render_when_used(self): - registry.register(name="test", component=SimpleComponent) - - template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies %} - {% component 'test' variable='foo' %}{% endcomponent %} - """ - template = Template(template_str) - rendered = create_and_process_template_response(template) - self.assertInHTML( - '', - rendered, - count=1, - ) - self.assertInHTML('', rendered, count=1) + + self.assertEqual(rendered.count(''), 1) # Media.css + self.assertEqual(rendered.count("', rendered, count=1) - - def test_preload_dependencies_render_once_when_used(self): - registry.register(name="test", component=SimpleComponent) - - template_str: types.django_html = """ - {% load component_tags %}{% component_dependencies preload='test' %} - {% component 'test' variable='foo' %}{% endcomponent %} - """ - template = Template(template_str) - rendered = create_and_process_template_response(template) - self.assertInHTML( - '', - rendered, - count=1, + self.assertEqual( + rendered.count( + r"const toLoadCssScripts = [Components.unescapeJs(\`&lt;link href=&quot;style.css&quot; media=&quot;all&quot; rel=&quot;stylesheet&quot;&gt;\`)];" + ), + 1, ) - self.assertInHTML('', rendered, count=0) + + self.assertEqual(rendered.count("'), 1) # Media.css def test_single_component_js_dependencies(self): registry.register(name="test", component=SimpleComponent) @@ -228,227 +278,167 @@ class ComponentMediaRenderingTests(BaseTestCase): """ template = Template(template_str) rendered = create_and_process_template_response(template) - self.assertInHTML('', rendered, count=1) + + # CSS NOT included + self.assertEqual(rendered.count("', rendered, count=1) - self.assertInHTML('', rendered, count=1) - def test_all_css_dependencies_are_rendered_for_component_with_multiple_dependencies( - self, - ): - registry.register(name="test", component=MultistyleComponent) - template_str: types.django_html = """ - {% load component_tags %}{% component_css_dependencies %} - {% component 'test' %}{% endcomponent %} - """ - template = Template(template_str) - rendered = create_and_process_template_response(template) - self.assertInHTML('', rendered, count=1) + + self.assertEqual(rendered.count("', - rendered, - count=1, + + # Dependency manager script + # NOTE: Should be present only ONCE! + self.assertInHTML('', rendered, count=1) + + self.assertEqual(rendered.count(".xyz { color: red; }", rendered, count=1) + self.assertInHTML("", rendered, count=1) + + # Components' Media.css + # NOTE: Each of these should be present only ONCE! + self.assertInHTML('', rendered, count=1) + self.assertInHTML('', rendered, count=1) + self.assertInHTML('', rendered, count=1) + + self.assertEqual( + rendered.count( + "const loadedJsScripts = ["/components/cache/OtherComponent_6329ae.js/", "/components/cache/SimpleComponentNested_f02d32.js/"];" + ), + 1, ) - self.assertInHTML( - '', - rendered, - count=0, + self.assertEqual( + rendered.count( + "const loadedCssScripts = ["/components/cache/OtherComponent_6329ae.css/", "/components/cache/SimpleComponentNested_f02d32.css/", "style.css", "style2.css", "xyz1.css"];" + ), + 1, + ) + self.assertEqual( + rendered.count( + r"const toLoadJsScripts = [Components.unescapeJs(\`&lt;script src=&quot;script.js&quot;&gt;&lt;/script&gt;\`), Components.unescapeJs(\`&lt;script src=&quot;script2.js&quot;&gt;&lt;/script&gt;\`), Components.unescapeJs(\`&lt;script src=&quot;xyz1.js&quot;&gt;&lt;/script&gt;\`)];" + ), + 1, + ) + self.assertEqual( + rendered.count( + r"const toLoadCssScripts = [Components.unescapeJs(\`&lt;link href=&quot;style.css&quot; media=&quot;all&quot; rel=&quot;stylesheet&quot;&gt;\`), Components.unescapeJs(\`&lt;link href=&quot;style2.css&quot; media=&quot;all&quot; rel=&quot;stylesheet&quot;&gt;\`), Components.unescapeJs(\`&lt;link href=&quot;xyz1.css&quot; media=&quot;all&quot; rel=&quot;stylesheet&quot;&gt;\`)];" + ), + 1, ) - def test_correct_js_dependencies_with_multiple_components(self): - registry.register(name="test1", component=SimpleComponent) - registry.register(name="test2", component=SimpleComponentAlternate) + def test_multiple_components_all_placeholders_removed(self): + registry.register(name="inner", component=SimpleComponent) + registry.register(name="outer", component=SimpleComponentNested) + registry.register(name="test", component=SimpleComponentWithSharedDependency) template_str: types.django_html = """ - {% load component_tags %}{% component_js_dependencies %} - {% component 'test1' 'variable' %}{% endcomponent %} - """ - template = Template(template_str) - rendered = create_and_process_template_response(template) - self.assertInHTML('' - '' - "Variable: value\n" - ), - ) diff --git a/tests/test_dependency_rendering_e2e.py b/tests/test_dependency_rendering_e2e.py new file mode 100644 index 00000000..a81c6fd4 --- /dev/null +++ b/tests/test_dependency_rendering_e2e.py @@ -0,0 +1,217 @@ +""" +Here we check that all parts of managing JS and CSS dependencies work together +in an actual browser. +""" + +from playwright.async_api import Page + +from django_components import types +from tests.django_test_setup import setup_test_config +from tests.e2e.utils import TEST_SERVER_URL, with_playwright +from tests.testutils import BaseTestCase + +setup_test_config({"autodiscover": False}) + + +# NOTE: All views, components, and associated JS and CSS are defined in +# `tests/e2e/testserver/testserver` +class E2eDependencyRenderingTests(BaseTestCase): + @with_playwright + async def test_single_component_dependencies(self): + single_comp_url = TEST_SERVER_URL + "/single" + + page: Page = await self.browser.new_page() + await page.goto(single_comp_url) + + test_js: types.js = """() => { + const bodyHTML = document.body.innerHTML; + + const innerEl = document.querySelector(".inner"); + const innerFontSize = globalThis.getComputedStyle(innerEl).getPropertyValue('font-size'); + + const myStyleEl = document.querySelector(".my-style"); + const myStyleBg = globalThis.getComputedStyle(myStyleEl).getPropertyValue('background'); + + return { + bodyHTML, + componentJsMsg: globalThis.testSimpleComponent, + scriptJsMsg: globalThis.testMsg, + innerFontSize, + myStyleBg, + }; + }""" + + data = await page.evaluate(test_js) + + # Check that the actual HTML content was loaded + self.assertIn('Variable: foo', data["bodyHTML"]) + self.assertInHTML('
123
', data["bodyHTML"], count=1) + self.assertInHTML('
xyz
', data["bodyHTML"], count=1) + + # Check components' inlined JS got loaded + self.assertEqual(data["componentJsMsg"], "kapowww!") + + # Check JS from Media.js got loaded + self.assertEqual(data["scriptJsMsg"], {"hello": "world"}) + + # Check components' inlined CSS got loaded + self.assertEqual(data["innerFontSize"], "4px") + + # Check CSS from Media.css got loaded + self.assertIn("rgb(0, 0, 255)", data["myStyleBg"]) # AKA 'background: blue' + + await page.close() + + @with_playwright + async def test_multiple_component_dependencies(self): + single_comp_url = TEST_SERVER_URL + "/multi" + + page: Page = await self.browser.new_page() + await page.goto(single_comp_url) + + test_js: types.js = """() => { + const bodyHTML = document.body.innerHTML; + + // Get the stylings defined via CSS + const innerEl = document.querySelector(".inner"); + const innerFontSize = globalThis.getComputedStyle(innerEl).getPropertyValue('font-size'); + + const outerEl = document.querySelector(".outer"); + const outerFontSize = globalThis.getComputedStyle(outerEl).getPropertyValue('font-size'); + + const otherEl = document.querySelector(".other"); + const otherDisplay = globalThis.getComputedStyle(otherEl).getPropertyValue('display'); + + const myStyleEl = document.querySelector(".my-style"); + const myStyleBg = globalThis.getComputedStyle(myStyleEl).getPropertyValue('background'); + + const myStyle2El = document.querySelector(".my-style2"); + const myStyle2Color = globalThis.getComputedStyle(myStyle2El).getPropertyValue('color'); + + return { + bodyHTML, + component1JsMsg: globalThis.testSimpleComponent, + component2JsMsg: globalThis.testSimpleComponentNested, + component3JsMsg: globalThis.testOtherComponent, + scriptJs1Msg: globalThis.testMsg, + scriptJs2Msg: globalThis.testMsg2, + innerFontSize, + outerFontSize, + myStyleBg, + myStyle2Color, + otherDisplay, + }; + }""" + + data = await page.evaluate(test_js) + + # Check that the actual HTML content was loaded + self.assertInHTML( + """ +
+ Variable: variable + XYZ: variable_inner +
+
123
+
xyz
+ """, + data["bodyHTML"], + count=1, + ) + + # Check components' inlined JS got loaded + self.assertEqual(data["component1JsMsg"], "kapowww!") + self.assertEqual(data["component2JsMsg"], "bongo!") + self.assertEqual(data["component3JsMsg"], "wowzee!") + + # Check JS from Media.js got loaded + self.assertEqual(data["scriptJs1Msg"], {"hello": "world"}) + self.assertEqual(data["scriptJs2Msg"], {"hello2": "world2"}) + + # Check components' inlined CSS got loaded + self.assertEqual(data["innerFontSize"], "4px") + self.assertEqual(data["outerFontSize"], "40px") + self.assertEqual(data["otherDisplay"], "flex") + + # Check CSS from Media.css got loaded + self.assertIn("rgb(0, 0, 255)", data["myStyleBg"]) # AKA 'background: blue' + self.assertEqual("rgb(255, 0, 0)", data["myStyle2Color"]) # AKA 'color: red' + + await page.close() + + @with_playwright + async def test_renders_css_nojs_env(self): + single_comp_url = TEST_SERVER_URL + "/multi" + + page: Page = await self.browser.new_page(java_script_enabled=False) + await page.goto(single_comp_url) + + test_js: types.js = """() => { + const bodyHTML = document.body.innerHTML; + + // Get the stylings defined via CSS + const innerEl = document.querySelector(".inner"); + const innerFontSize = globalThis.getComputedStyle(innerEl).getPropertyValue('font-size'); + + const outerEl = document.querySelector(".outer"); + const outerFontSize = globalThis.getComputedStyle(outerEl).getPropertyValue('font-size'); + + const otherEl = document.querySelector(".other"); + const otherDisplay = globalThis.getComputedStyle(otherEl).getPropertyValue('display'); + + const myStyleEl = document.querySelector(".my-style"); + const myStyleBg = globalThis.getComputedStyle(myStyleEl).getPropertyValue('background'); + + const myStyle2El = document.querySelector(".my-style2"); + const myStyle2Color = globalThis.getComputedStyle(myStyle2El).getPropertyValue('color'); + + return { + bodyHTML, + component1JsMsg: globalThis.testSimpleComponent, + component2JsMsg: globalThis.testSimpleComponentNested, + component3JsMsg: globalThis.testOtherComponent, + scriptJs1Msg: globalThis.testMsg, + scriptJs2Msg: globalThis.testMsg2, + innerFontSize, + outerFontSize, + myStyleBg, + myStyle2Color, + otherDisplay, + }; + }""" + + data = await page.evaluate(test_js) + + # Check that the actual HTML content was loaded + self.assertInHTML( + """ +
+ Variable: variable + XYZ: variable_inner +
+
123
+
xyz
+ """, + data["bodyHTML"], + count=1, + ) + + # Check components' inlined JS did NOT get loaded + self.assertEqual(data["component1JsMsg"], None) + self.assertEqual(data["component2JsMsg"], None) + self.assertEqual(data["component3JsMsg"], None) + + # Check JS from Media.js did NOT get loaded + self.assertEqual(data["scriptJs1Msg"], None) + self.assertEqual(data["scriptJs2Msg"], None) + + # Check components' inlined CSS got loaded + self.assertEqual(data["innerFontSize"], "4px") + self.assertEqual(data["outerFontSize"], "40px") + self.assertEqual(data["otherDisplay"], "flex") + + # Check CSS from Media.css got loaded + self.assertIn("rgb(0, 0, 255)", data["myStyleBg"]) # AKA 'background: blue' + self.assertEqual("rgb(255, 0, 0)", data["myStyle2Color"]) # AKA 'color: red' + + await page.close() diff --git a/tests/test_expression.py b/tests/test_expression.py index 38c77b24..659a2aee 100644 --- a/tests/test_expression.py +++ b/tests/test_expression.py @@ -150,7 +150,7 @@ class DynamicExprTests(BaseTestCase): self.assertEqual( rendered.strip(), - "
lorem
\n
True
\n
[{'a': 1}, {'a': 2}]
", + "\n
lorem
\n
True
\n
[{'a': 1}, {'a': 2}]
", # noqa: E501 ) @parametrize_context_behavior(["django", "isolated"]) @@ -220,7 +220,7 @@ class DynamicExprTests(BaseTestCase): self.assertEqual( rendered.strip(), - "
lorem ipsum dolor
\n
True
\n
[{'a': 1}, {'a': 2}]
\n
{'a': 3}
", # noqa E501 + "\n
lorem ipsum dolor
\n
True
\n
[{'a': 1}, {'a': 2}]
\n
{'a': 3}
", # noqa E501 ) @parametrize_context_behavior(["django", "isolated"]) @@ -290,7 +290,7 @@ class DynamicExprTests(BaseTestCase): self.assertEqual( rendered.strip(), - "
\n
abc
\n
\n
", # noqa E501 + "\n
\n
abc
\n
\n
", # noqa E501 ) @parametrize_context_behavior(["django", "isolated"]) @@ -364,7 +364,7 @@ class DynamicExprTests(BaseTestCase): self.assertEqual( rendered.strip(), - "
lorem ipsum dolor
\n
lorem ipsum dolor [{'a': 1}]
\n
True
\n
[{'a': 1}, {'a': 2}]
\n
{'a': 3}
", # noqa E501 + "\n
lorem ipsum dolor
\n
lorem ipsum dolor [{'a': 1}]
\n
True
\n
[{'a': 1}, {'a': 2}]
\n
{'a': 3}
", # noqa E501 ) @parametrize_context_behavior(["django", "isolated"]) @@ -408,7 +408,7 @@ class DynamicExprTests(BaseTestCase): self.assertEqual( rendered.strip(), - '
"
\n
{%}
\n
True
', + '\n
"
\n
{%}
\n
True
', # noqa: E501 ) @parametrize_context_behavior(["django", "isolated"]) @@ -457,7 +457,7 @@ class DynamicExprTests(BaseTestCase): self.assertEqual( rendered.strip(), - "
\n
3
\n
True
\n
\n
True
", # noqa E501 + "\n
\n
3
\n
True
\n
\n
True
", # noqa E501 ) diff --git a/tests/test_finders.py b/tests/test_finders.py index 5594127e..e8a267bb 100644 --- a/tests/test_finders.py +++ b/tests/test_finders.py @@ -124,7 +124,7 @@ class StaticFilesFinderTests(SimpleTestCase): "static_files_allowed": [ ".js", ], - "forbidden_static_files": [], + "static_files_forbidden": [], }, STATICFILES_FINDERS=[ # Default finders @@ -153,7 +153,7 @@ class StaticFilesFinderTests(SimpleTestCase): "static_files_allowed": [ re.compile(r".*"), ], - "forbidden_static_files": [ + "static_files_forbidden": [ re.compile(r"\.(?:js)$"), ], }, @@ -185,7 +185,7 @@ class StaticFilesFinderTests(SimpleTestCase): ".js", ".css", ], - "forbidden_static_files": [ + "static_files_forbidden": [ ".js", ], }, diff --git a/tests/test_html.py b/tests/test_html.py new file mode 100644 index 00000000..1d6b7a25 --- /dev/null +++ b/tests/test_html.py @@ -0,0 +1,267 @@ +from typing import List, cast + +from django.test import TestCase +from selectolax.lexbor import LexborHTMLParser, LexborNode + +from django_components.util.html import ( + is_html_parser_fragment, + parse_document_or_nodes, + parse_multiroot_html, + parse_node, +) + +from .django_test_setup import setup_test_config + +setup_test_config({"autodiscover": False}) + + +class HtmlTests(TestCase): + def test_parse_node(self): + node = parse_node( + """ +
+
    +
  • Hi
  • +
+
+ """ + ) + node.attrs["id"] = "my-id" # type: ignore[index] + node.css("li")[0].attrs["class"] = "item" # type: ignore[index] + + self.assertHTMLEqual( + node.html, + """ +
+
    +
  • Hi
  • +
+
+ """, + ) + + def test_parse_multiroot_html(self): + html = """ +
+
    +
  • Hi
  • +
+
+
+
+ 42 +
+
+ + Hello + + """ + nodes = parse_multiroot_html(html) + + self.assertHTMLEqual( + nodes[0].html, + """ +
+
    +
  • Hi
  • +
+
+ """, + ) + self.assertHTMLEqual( + nodes[1].html, + """ +
+
+ 42 +
+
+ """, + ) + self.assertHTMLEqual( + nodes[2].html, + """ + + Hello + + """, + ) + + def test_is_html_parser_fragment(self): + fragment_html = """ +
+
    +
  • Hi
  • +
+
+
+
+ 42 +
+
+ + Hello + + """ + fragment_tree = LexborHTMLParser(fragment_html) + fragment_result = is_html_parser_fragment(fragment_html, fragment_tree) + + self.assertEqual(fragment_result, True) + + doc_html = """ + + + + + + +
+
    +
  • Hi
  • +
+
+ + + """ + doc_tree = LexborHTMLParser(doc_html) + doc_result = is_html_parser_fragment(doc_html, doc_tree) + + self.assertEqual(doc_result, False) + + def test_parse_document_or_nodes__fragment(self): + fragment_html = """ +
+
    +
  • Hi
  • +
+
+
+
+ 42 +
+
+ + Hello + + """ + fragment_result = cast(List[LexborNode], parse_document_or_nodes(fragment_html)) + + self.assertHTMLEqual( + fragment_result[0].html, + """ +
+
    +
  • Hi
  • +
+
+ """, + ) + self.assertHTMLEqual( + fragment_result[1].html, + """ +
+
+ 42 +
+
+ """, + ) + self.assertHTMLEqual( + fragment_result[2].html, + """ + + Hello + + """, + ) + + def test_parse_document_or_nodes__mixed(self): + fragment_html = """ + +
+
    +
  • Hi
  • +
+
+
+
+ 42 +
+
+ + Hello + + """ + fragment_result = cast(List[LexborNode], parse_document_or_nodes(fragment_html)) + + self.assertHTMLEqual( + fragment_result[0].html, + """ + + """, + ) + self.assertHTMLEqual( + fragment_result[1].html, + """ +
+
    +
  • Hi
  • +
+
+ """, + ) + self.assertHTMLEqual( + fragment_result[2].html, + """ +
+
+ 42 +
+
+ """, + ) + self.assertHTMLEqual( + fragment_result[3].html, + """ + + Hello + + """, + ) + + def test_parse_document_or_nodes__doc(self): + doc_html = """ + + + + + + +
+
    +
  • Hi
  • +
+
+ + + """ + fragment_result = cast(LexborHTMLParser, parse_document_or_nodes(doc_html)) + + self.assertHTMLEqual( + fragment_result.html, + """ + + + + + + +
+
    +
  • Hi
  • +
+
+ + + """, + ) diff --git a/tests/test_template_loader.py b/tests/test_loader.py similarity index 53% rename from tests/test_template_loader.py rename to tests/test_loader.py index e3dae731..6babbe01 100644 --- a/tests/test_template_loader.py +++ b/tests/test_loader.py @@ -1,11 +1,12 @@ +import os import re from pathlib import Path from unittest.mock import MagicMock, patch -from django.template.engine import Engine +from django.conf import settings from django.test import override_settings -from django_components.template_loader import Loader, get_dirs +from django_components.util.loader import _filepath_to_python_module, get_component_dirs, get_component_files from .django_test_setup import setup_test_config from .testutils import BaseTestCase @@ -13,14 +14,12 @@ from .testutils import BaseTestCase setup_test_config({"autodiscover": False}) -class TemplateLoaderTest(BaseTestCase): +class ComponentDirsTest(BaseTestCase): @override_settings( BASE_DIR=Path(__file__).parent.resolve(), ) def test_get_dirs__base_dir(self): - current_engine = Engine.get_default() - loader = Loader(current_engine) - dirs = sorted(loader.get_dirs()) + dirs = sorted(get_component_dirs()) apps_dirs = [dirs[0], dirs[2]] own_dirs = [dirs[1], *dirs[3:]] @@ -43,9 +42,7 @@ class TemplateLoaderTest(BaseTestCase): BASE_DIR=Path(__file__).parent.resolve() / "test_structures" / "test_structure_1", # noqa ) def test_get_dirs__base_dir__complex(self): - current_engine = Engine.get_default() - loader = Loader(current_engine) - dirs = sorted(loader.get_dirs()) + dirs = sorted(get_component_dirs()) apps_dirs = dirs[:2] own_dirs = dirs[2:] @@ -69,10 +66,10 @@ class TemplateLoaderTest(BaseTestCase): ("with_not_str_alias", 3), ], # noqa ) - @patch("django_components.template_loader.logger.warning") + @patch("django_components.util.loader.logger.warning") def test_get_dirs__components_dirs(self, mock_warning: MagicMock): mock_warning.reset_mock() - dirs = sorted(get_dirs()) + dirs = sorted(get_component_dirs()) apps_dirs = [dirs[0], dirs[2]] own_dirs = [dirs[1], *dirs[3:]] @@ -101,7 +98,7 @@ class TemplateLoaderTest(BaseTestCase): }, ) def test_get_dirs__components_dirs__empty(self): - dirs = sorted(get_dirs()) + dirs = sorted(get_component_dirs()) apps_dirs = dirs @@ -117,10 +114,8 @@ class TemplateLoaderTest(BaseTestCase): }, ) def test_get_dirs__componenents_dirs__raises_on_relative_path_1(self): - current_engine = Engine.get_default() - loader = Loader(current_engine) with self.assertRaisesMessage(ValueError, "COMPONENTS.dirs must contain absolute paths"): - loader.get_dirs() + get_component_dirs() @override_settings( BASE_DIR=Path(__file__).parent.resolve(), @@ -129,10 +124,8 @@ class TemplateLoaderTest(BaseTestCase): }, ) def test_get_dirs__component_dirs__raises_on_relative_path_2(self): - current_engine = Engine.get_default() - loader = Loader(current_engine) with self.assertRaisesMessage(ValueError, "COMPONENTS.dirs must contain absolute paths"): - loader.get_dirs() + get_component_dirs() @override_settings( BASE_DIR=Path(__file__).parent.resolve(), @@ -141,9 +134,7 @@ class TemplateLoaderTest(BaseTestCase): }, ) def test_get_dirs__app_dirs(self): - current_engine = Engine.get_default() - loader = Loader(current_engine) - dirs = sorted(loader.get_dirs()) + dirs = sorted(get_component_dirs()) apps_dirs = dirs[1:] own_dirs = dirs[:1] @@ -168,9 +159,7 @@ class TemplateLoaderTest(BaseTestCase): }, ) def test_get_dirs__app_dirs_empty(self): - current_engine = Engine.get_default() - loader = Loader(current_engine) - dirs = sorted(loader.get_dirs()) + dirs = sorted(get_component_dirs()) own_dirs = dirs @@ -190,9 +179,7 @@ class TemplateLoaderTest(BaseTestCase): }, ) def test_get_dirs__app_dirs_not_found(self): - current_engine = Engine.get_default() - loader = Loader(current_engine) - dirs = sorted(loader.get_dirs()) + dirs = sorted(get_component_dirs()) own_dirs = dirs @@ -210,9 +197,7 @@ class TemplateLoaderTest(BaseTestCase): INSTALLED_APPS=("django_components", "tests.test_app_nested.app"), ) def test_get_dirs__nested_apps(self): - current_engine = Engine.get_default() - loader = Loader(current_engine) - dirs = sorted(loader.get_dirs()) + dirs = sorted(get_component_dirs()) apps_dirs = [dirs[0], *dirs[2:]] own_dirs = [dirs[1]] @@ -230,3 +215,124 @@ class TemplateLoaderTest(BaseTestCase): / "components", ], ) + + +class ComponentFilesTest(BaseTestCase): + @override_settings( + BASE_DIR=Path(__file__).parent.resolve(), + ) + def test_get_files__py(self): + files = sorted(get_component_files(".py")) + + dot_paths = [f.dot_path for f in files] + file_paths = [str(f.filepath) for f in files] + + self.assertEqual( + dot_paths, + [ + "components", + "components.multi_file.multi_file", + "components.relative_file.relative_file", + "components.relative_file_pathobj.relative_file_pathobj", + "components.single_file", + "components.staticfiles.staticfiles", + "components.urls", + "django_components.components", + "django_components.components.dynamic", + "tests.test_app.components.app_lvl_comp.app_lvl_comp", + ], + ) + + self.assertEqual( + [ + file_paths[0].endswith("tests/components/__init__.py"), + file_paths[1].endswith("tests/components/multi_file/multi_file.py"), + file_paths[2].endswith("tests/components/relative_file/relative_file.py"), + file_paths[3].endswith("tests/components/relative_file_pathobj/relative_file_pathobj.py"), + file_paths[4].endswith("tests/components/single_file.py"), + file_paths[5].endswith("tests/components/staticfiles/staticfiles.py"), + file_paths[6].endswith("tests/components/urls.py"), + file_paths[7].endswith("django_components/components/__init__.py"), + file_paths[8].endswith("django_components/components/dynamic.py"), + file_paths[9].endswith("tests/test_app/components/app_lvl_comp/app_lvl_comp.py"), + ], + [True for _ in range(len(file_paths))], + ) + + @override_settings( + BASE_DIR=Path(__file__).parent.resolve(), + ) + def test_get_files__js(self): + files = sorted(get_component_files(".js")) + + dot_paths = [f.dot_path for f in files] + file_paths = [str(f.filepath) for f in files] + + print(file_paths) + + self.assertEqual( + dot_paths, + [ + "components.relative_file.relative_file", + "components.relative_file_pathobj.relative_file_pathobj", + "components.staticfiles.staticfiles", + "tests.test_app.components.app_lvl_comp.app_lvl_comp", + ], + ) + + self.assertEqual( + [ + file_paths[0].endswith("tests/components/relative_file/relative_file.js"), + file_paths[1].endswith("tests/components/relative_file_pathobj/relative_file_pathobj.js"), + file_paths[2].endswith("tests/components/staticfiles/staticfiles.js"), + file_paths[3].endswith("tests/test_app/components/app_lvl_comp/app_lvl_comp.js"), + ], + [True for _ in range(len(file_paths))], + ) + + +class TestFilepathToPythonModule(BaseTestCase): + def test_prepares_path(self): + base_path = str(settings.BASE_DIR) + + the_path = os.path.join(base_path, "tests.py") + self.assertEqual( + _filepath_to_python_module(the_path, base_path, None), + "tests", + ) + + the_path = os.path.join(base_path, "tests/components/relative_file/relative_file.py") + self.assertEqual( + _filepath_to_python_module(the_path, base_path, None), + "tests.components.relative_file.relative_file", + ) + + def test_handles_nonlinux_paths(self): + base_path = str(settings.BASE_DIR).replace("/", "//") + + with patch("os.path.sep", new="//"): + the_path = os.path.join(base_path, "tests.py") + self.assertEqual( + _filepath_to_python_module(the_path, base_path, None), + "tests", + ) + + the_path = os.path.join(base_path, "tests//components//relative_file//relative_file.py") + self.assertEqual( + _filepath_to_python_module(the_path, base_path, None), + "tests.components.relative_file.relative_file", + ) + + base_path = str(settings.BASE_DIR).replace("//", "\\") + with patch("os.path.sep", new="\\"): + the_path = os.path.join(base_path, "tests.py") + self.assertEqual( + _filepath_to_python_module(the_path, base_path, None), + "tests", + ) + + the_path = os.path.join(base_path, "tests\\components\\relative_file\\relative_file.py") + self.assertEqual( + _filepath_to_python_module(the_path, base_path, None), + "tests.components.relative_file.relative_file", + ) diff --git a/tests/test_registry.py b/tests/test_registry.py index e71a5c19..1f8e8511 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -150,8 +150,8 @@ class MultipleComponentRegistriesTest(BaseTestCase): registry_a = ComponentRegistry( library=library_a, settings=RegistrySettings( - CONTEXT_BEHAVIOR=ContextBehavior.ISOLATED, - TAG_FORMATTER=component_shorthand_formatter, + context_behavior=ContextBehavior.ISOLATED.value, + tag_formatter=component_shorthand_formatter, ), ) @@ -159,8 +159,8 @@ class MultipleComponentRegistriesTest(BaseTestCase): registry_b = ComponentRegistry( library=library_b, settings=RegistrySettings( - CONTEXT_BEHAVIOR=ContextBehavior.DJANGO, - TAG_FORMATTER=component_formatter, + context_behavior=ContextBehavior.DJANGO.value, + tag_formatter=component_formatter, ), ) @@ -228,7 +228,6 @@ class ProtectedTagsTest(unittest.TestCase): @override_settings(COMPONENTS={"tag_formatter": "django_components.component_shorthand_formatter"}) def test_raises_on_overriding_our_tags(self): for tag in [ - "component_dependencies", "component_css_dependencies", "component_js_dependencies", "fill", diff --git a/tests/test_settings.py b/tests/test_settings.py index 837112ab..f10c17ed 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -2,12 +2,13 @@ from pathlib import Path from django.test import override_settings +from django_components import ComponentsSettings from django_components.app_settings import app_settings from .django_test_setup import setup_test_config from .testutils import BaseTestCase -setup_test_config() +setup_test_config(components={"autodiscover": False}) class SettingsTestCase(BaseTestCase): @@ -27,3 +28,11 @@ class SettingsTestCase(BaseTestCase): @override_settings(BASE_DIR=Path("base_dir")) def test_works_when_base_dir_is_path(self): self.assertEqual(app_settings.DIRS, [Path("base_dir/components")]) + + @override_settings(COMPONENTS={"context_behavior": "isolated"}) + def test_settings_as_dict(self): + self.assertEqual(app_settings.CONTEXT_BEHAVIOR, "isolated") + + @override_settings(COMPONENTS=ComponentsSettings(context_behavior="isolated")) + def test_settings_as_instance(self): + self.assertEqual(app_settings.CONTEXT_BEHAVIOR, "isolated") diff --git a/tests/test_tag_formatter.py b/tests/test_tag_formatter.py index 96c8ef78..0d6c769e 100644 --- a/tests/test_tag_formatter.py +++ b/tests/test_tag_formatter.py @@ -19,6 +19,11 @@ class MultiwordBlockEndTagFormatter(ShorthandComponentFormatter): return f"end {name}" +class SlashEndTagFormatter(ShorthandComponentFormatter): + def end_tag(self, name): + return f"/{name}" + + # Create a TagFormatter class to validate the public interface def create_validator_tag_formatter(tag_name: str): class ValidatorTagFormatter(ShorthandComponentFormatter): @@ -259,6 +264,46 @@ class ComponentTagTests(BaseTestCase): """, ) + @parametrize_context_behavior( + cases=["django", "isolated"], + settings={ + "COMPONENTS": { + "tag_formatter": SlashEndTagFormatter(), + }, + }, + ) + def test_forward_slash_in_end_tag(self): + @register("simple") + class SimpleComponent(Component): + template: types.django_html = """ + {% load component_tags %} + hello1 +
+ {% slot "content" default %} SLOT_DEFAULT {% endslot %} +
+ hello2 + """ + + template = Template( + """ + {% load component_tags %} + {% simple %} + OVERRIDEN! + {% /simple %} + """ + ) + rendered = template.render(Context()) + self.assertHTMLEqual( + rendered, + """ + hello1 +
+ OVERRIDEN! +
+ hello2 + """, + ) + @parametrize_context_behavior( cases=["django", "isolated"], settings={ diff --git a/tests/test_tag_parser.py b/tests/test_tag_parser.py new file mode 100644 index 00000000..a5f63360 --- /dev/null +++ b/tests/test_tag_parser.py @@ -0,0 +1,94 @@ +from django_components.util.tag_parser import TagAttr, parse_tag_attrs + +from .django_test_setup import setup_test_config +from .testutils import BaseTestCase + +setup_test_config({"autodiscover": False}) + + +class TagParserTests(BaseTestCase): + def test_tag_parser(self): + _, attrs = parse_tag_attrs("component 'my_comp' key=val key2='val2 two' ") + self.assertEqual( + attrs, + [ + TagAttr(key=None, value="component", start_index=0, quoted=False), + TagAttr(key=None, value="my_comp", start_index=10, quoted=True), + TagAttr(key="key", value="val", start_index=20, quoted=False), + TagAttr(key="key2", value="val2 two", start_index=28, quoted=True), + ], + ) + + def test_tag_parser_nested_quotes(self): + _, attrs = parse_tag_attrs("component 'my_comp' key=val key2='val2 \"two\"' text=\"organisation's\" ") + self.assertEqual( + attrs, + [ + TagAttr(key=None, value="component", start_index=0, quoted=False), + TagAttr(key=None, value="my_comp", start_index=10, quoted=True), + TagAttr(key="key", value="val", start_index=20, quoted=False), + TagAttr(key="key2", value='val2 "two"', start_index=28, quoted=True), + TagAttr(key="text", value="organisation's", start_index=46, quoted=True), + ], + ) + + def test_tag_parser_trailing_quote_single(self): + _, attrs = parse_tag_attrs("component 'my_comp' key=val key2='val2 \"two\"' text=\"organisation's\" 'abc") + + self.assertEqual( + attrs, + [ + TagAttr(key=None, value="component", start_index=0, quoted=False), + TagAttr(key=None, value="my_comp", start_index=10, quoted=True), + TagAttr(key="key", value="val", start_index=20, quoted=False), + TagAttr(key="key2", value='val2 "two"', start_index=28, quoted=True), + TagAttr(key="text", value="organisation's", start_index=46, quoted=True), + TagAttr(key=None, value="'abc", start_index=68, quoted=False), + ], + ) + + def test_tag_parser_trailing_quote_double(self): + _, attrs = parse_tag_attrs('component "my_comp" key=val key2="val2 \'two\'" text=\'organisation"s\' "abc') + self.assertEqual( + attrs, + [ + TagAttr(key=None, value="component", start_index=0, quoted=False), + TagAttr(key=None, value="my_comp", start_index=10, quoted=True), + TagAttr(key="key", value="val", start_index=20, quoted=False), + TagAttr(key="key2", value="val2 'two'", start_index=28, quoted=True), + TagAttr(key="text", value='organisation"s', start_index=46, quoted=True), + TagAttr(key=None, value='"abc', start_index=68, quoted=False), + ], + ) + + def test_tag_parser_trailing_quote_as_value_single(self): + _, attrs = parse_tag_attrs( + "component 'my_comp' key=val key2='val2 \"two\"' text=\"organisation's\" value='abc" + ) + self.assertEqual( + attrs, + [ + TagAttr(key=None, value="component", start_index=0, quoted=False), + TagAttr(key=None, value="my_comp", start_index=10, quoted=True), + TagAttr(key="key", value="val", start_index=20, quoted=False), + TagAttr(key="key2", value='val2 "two"', start_index=28, quoted=True), + TagAttr(key="text", value="organisation's", start_index=46, quoted=True), + TagAttr(key="value", value="'abc", start_index=68, quoted=False), + ], + ) + + def test_tag_parser_trailing_quote_as_value_double(self): + _, attrs = parse_tag_attrs( + 'component "my_comp" key=val key2="val2 \'two\'" text=\'organisation"s\' value="abc' + ) + self.assertEqual( + attrs, + [ + TagAttr(key=None, value="component", start_index=0, quoted=False), + TagAttr(key=None, value="my_comp", start_index=10, quoted=True), + TagAttr(key="key", value="val", start_index=20, quoted=False), + TagAttr(key="key2", value="val2 'two'", start_index=28, quoted=True), + TagAttr(key="text", value='organisation"s', start_index=46, quoted=True), + TagAttr(key="value", value='"abc', start_index=68, quoted=False), + ], + ) diff --git a/tests/test_template_parser.py b/tests/test_template_parser.py index 79d65fe8..aaceeaf7 100644 --- a/tests/test_template_parser.py +++ b/tests/test_template_parser.py @@ -8,7 +8,7 @@ from django_components.expression import ( safe_resolve_dict, safe_resolve_list, ) -from django_components.templatetags.component_tags import _parse_tag +from django_components.templatetags.component_tags import TagSpec, _parse_tag from .django_test_setup import setup_test_config from .testutils import BaseTestCase, parametrize_context_behavior @@ -21,22 +21,28 @@ class ParserTest(BaseTestCase): template_str = "{% component 42 myvar key='val' key2=val2 %}" tokens = Lexer(template_str).tokenize() parser = Parser(tokens=tokens) - tag = _parse_tag("component", parser, parser.tokens[0], params=["num", "var"], keywordonly_kwargs=True) + spec = TagSpec( + tag="component", + pos_or_keyword_args=["num", "var"], + keywordonly_args=True, + ) + tag = _parse_tag(parser, parser.tokens[0], tag_spec=spec) ctx = {"myvar": {"a": "b"}, "val2": 1} args = safe_resolve_list(ctx, tag.args) named_args = safe_resolve_dict(ctx, tag.named_args) kwargs = tag.kwargs.resolve(ctx) - self.assertListEqual(args, [42, {"a": "b"}]) - self.assertDictEqual(named_args, {"num": 42, "var": {"a": "b"}}) - self.assertDictEqual(kwargs, {"key": "val", "key2": 1}) + self.assertListEqual(args, []) + self.assertDictEqual(named_args, {}) + self.assertDictEqual(kwargs, {"num": 42, "var": {"a": "b"}, "key": "val", "key2": 1}) def test_parses_special_kwargs(self): template_str = "{% component date=date @lol=2 na-me=bzz @event:na-me.mod=bzz #my-id=True %}" tokens = Lexer(template_str).tokenize() parser = Parser(tokens=tokens) - tag = _parse_tag("component", parser, parser.tokens[0], keywordonly_kwargs=True) + spec = TagSpec(tag="component", keywordonly_args=True) + tag = _parse_tag(parser, parser.tokens[0], tag_spec=spec) ctx = Context({"date": 2024, "bzz": "fzz"}) args = safe_resolve_list(ctx, tag.args) diff --git a/tests/test_templatetags.py b/tests/test_templatetags.py index b455fed8..e174d6ee 100644 --- a/tests/test_templatetags.py +++ b/tests/test_templatetags.py @@ -30,6 +30,8 @@ class TemplateInstrumentationTest(BaseTestCase): def setUp(self): """Emulate Django test instrumentation for TestCase (see setup_test_environment)""" + super().setUp() + from django.test.utils import instrumented_test_render self.saved_render_method = Template._render @@ -514,26 +516,83 @@ class MultilineTagsTests(BaseTestCase): class NestedTagsTests(BaseTestCase): + class SimpleComponent(Component): + template: types.django_html = """ + Variable: {{ var }} + """ + + def get_context_data(self, var): + return { + "var": var, + } + # See https://github.com/EmilStenstrom/django-components/discussions/671 @parametrize_context_behavior(["django", "isolated"]) def test_nested_tags(self): - @register("test_component") - class SimpleComponent(Component): - template: types.django_html = """ - Variable: {{ var }} - """ - - def get_context_data(self, var): - return { - "var": var, - } + registry.register("test", self.SimpleComponent) template: types.django_html = """ {% load component_tags %} - {% component "test_component" var="{% lorem 1 w %}" %}{% endcomponent %} + {% component "test" var="{% lorem 1 w %}" %}{% endcomponent %} """ rendered = Template(template).render(Context()) expected = """ Variable: lorem """ self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["django", "isolated"]) + def test_nested_quote_single(self): + registry.register("test", self.SimpleComponent) + + template: types.django_html = """ + {% load component_tags %} + {% component "test" var=_("organisation's") %} {% endcomponent %} + """ + rendered = Template(template).render(Context()) + expected = """ + Variable: organisation's + """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["django", "isolated"]) + def test_nested_quote_single_self_closing(self): + registry.register("test", self.SimpleComponent) + + template: types.django_html = """ + {% load component_tags %} + {% component "test" var=_("organisation's") / %} + """ + rendered = Template(template).render(Context()) + expected = """ + Variable: organisation's + """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["django", "isolated"]) + def test_nested_quote_double(self): + registry.register("test", self.SimpleComponent) + + template: types.django_html = """ + {% load component_tags %} + {% component "test" var=_('organisation"s') %} {% endcomponent %} + """ + rendered = Template(template).render(Context()) + expected = """ + Variable: organisation"s + """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["django", "isolated"]) + def test_nested_quote_double_self_closing(self): + registry.register("test", self.SimpleComponent) + + template: types.django_html = """ + {% load component_tags %} + {% component "test" var=_('organisation"s') / %} + """ + rendered = Template(template).render(Context()) + expected = """ + Variable: organisation"s + """ + self.assertHTMLEqual(rendered, expected) diff --git a/tests/test_templatetags_component.py b/tests/test_templatetags_component.py index bf0fad5f..5d6c92ed 100644 --- a/tests/test_templatetags_component.py +++ b/tests/test_templatetags_component.py @@ -429,7 +429,7 @@ class DynamicComponentTemplateTagTest(BaseTestCase): ) @parametrize_context_behavior(["django", "isolated"]) - def test_raises_on_invalid_slots(self): + def test_ignores_invalid_slots(self): class SimpleSlottedComponent(Component): template: types.django_html = """ {% load component_tags %} @@ -461,11 +461,15 @@ class DynamicComponentTemplateTagTest(BaseTestCase): """ template = Template(simple_tag_template) - - with self.assertRaisesMessage( - TemplateSyntaxError, "Component \\'dynamic\\' passed fill that refers to undefined slot: \\'three\\'" - ): - template.render(Context({})) + rendered = template.render(Context({})) + self.assertHTMLEqual( + rendered, + """ + Variable: variable + Slot 1: HELLO_FROM_SLOT_1 + Slot 2: + """, + ) @parametrize_context_behavior(["django", "isolated"]) def test_raises_on_invalid_args(self): @@ -664,7 +668,7 @@ class ComponentTemplateSyntaxErrorTests(BaseTestCase): Template(template_str) @parametrize_context_behavior(["django", "isolated"]) - def test_text_outside_fill_tag_is_not_error(self): + def test_text_outside_fill_tag_is_not_error_when_no_fill_tags(self): # As of v0.28 this is valid, provided the component registered under "test" # contains a slot tag marked as 'default'. This is verified outside # template compilation time. @@ -677,21 +681,28 @@ class ComponentTemplateSyntaxErrorTests(BaseTestCase): Template(template_str) @parametrize_context_behavior(["django", "isolated"]) - def test_nonfill_block_outside_fill_tag_is_error(self): - with self.assertRaises(TemplateSyntaxError): - template_str: types.django_html = """ - {% load component_tags %} - {% component "test" %} - {% if True %} - {% fill "header" %}{% endfill %} - {% endif %} - {% endcomponent %} - """ - Template(template_str) + def test_text_outside_fill_tag_is_error_when_fill_tags(self): + template_str: types.django_html = """ + {% load component_tags %} + {% component "test" %} + {% lorem 3 w random %} + {% fill "header" %}{% endfill %} + {% endcomponent %} + """ + template = Template(template_str) + + with self.assertRaisesMessage( + TemplateSyntaxError, + "Illegal content passed to component 'test'. Explicit 'fill' tags cannot occur alongside other text", + ): + template.render(Context()) @parametrize_context_behavior(["django", "isolated"]) def test_unclosed_component_is_error(self): - with self.assertRaises(TemplateSyntaxError): + with self.assertRaisesMessage( + TemplateSyntaxError, + "Unclosed tag on line 3: 'component'", + ): template_str: types.django_html = """ {% load component_tags %} {% component "test" %} diff --git a/tests/test_templatetags_slot_fill.py b/tests/test_templatetags_slot_fill.py index 5e053c88..6f01d63a 100644 --- a/tests/test_templatetags_slot_fill.py +++ b/tests/test_templatetags_slot_fill.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List, Optional from django.template import Context, Template, TemplateSyntaxError -from django_components import Component, register, registry, types +from django_components import Component, Slot, register, registry, types from .django_test_setup import setup_test_config from .testutils import BaseTestCase, parametrize_context_behavior @@ -31,7 +31,7 @@ class SlottedComponentWithContext(SlottedComponent): ####################### -class ComponentSlottedTemplateTagTest(BaseTestCase): +class ComponentSlotTests(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_slotted_template_basic(self): registry.register(name="test1", component=SlottedComponent) @@ -255,9 +255,284 @@ class ComponentSlottedTemplateTagTest(BaseTestCase): """ template = Template(template_str) - with self.assertRaises(TemplateSyntaxError): - template.render(Context({})) + with self.assertRaisesMessage(TemplateSyntaxError, "Slot 'title' is marked as 'required'"): + template.render(Context()) + # NOTE: This is relevant only for the "isolated" mode + @parametrize_context_behavior(["isolated"]) + def test_slots_of_top_level_comps_can_access_full_outer_ctx(self): + class SlottedComponent(Component): + template: types.django_html = """ + {% load component_tags %} +
+
{% slot "main" default %}Easy to override{% endslot %}
+
+ """ + + def get_context_data(self, name: Optional[str] = None) -> Dict[str, Any]: + return { + "name": name, + } + + registry.register("test", SlottedComponent) + + template_str: types.django_html = """ + {% load component_tags %} + + {% component "test" %} + ABC: {{ name }} {{ some }} + {% endcomponent %} + + """ + self.template = Template(template_str) + + nested_ctx = Context() + # Check that the component can access vars across different context layers + nested_ctx.push({"some": "var"}) + nested_ctx.push({"name": "carl"}) + rendered = self.template.render(nested_ctx) + + self.assertHTMLEqual( + rendered, + """ + +
+
ABC: carl var
+
+ + """, + ) + + @parametrize_context_behavior(["django", "isolated"]) + def test_target_default_slot_as_named(self): + @register("test") + class Comp(Component): + template: types.django_html = """ + {% load component_tags %} +
+

{% slot "title" default %}Default title{% endslot %}

+

{% slot "subtitle" %}Default subtitle{% endslot %}

+
+ """ + + template_str: types.django_html = """ + {% load component_tags %} + {% component 'test' %} + {% fill "default" %}Custom title{% endfill %} + {% endcomponent %} + """ + template = Template(template_str) + + rendered = template.render(Context()) + self.assertHTMLEqual( + rendered, + """ +
+

Custom title

+

Default subtitle

+
+ """, + ) + + @parametrize_context_behavior(["django", "isolated"]) + def test_raises_on_doubly_filled_slot__same_name(self): + @register("test") + class Comp(Component): + template: types.django_html = """ + {% load component_tags %} +
+

{% slot "title" default %}Default title{% endslot %}

+

{% slot "subtitle" %}Default subtitle{% endslot %}

+
+ """ + + template_str: types.django_html = """ + {% load component_tags %} + {% component 'test' %} + {% fill "title" %}Custom title{% endfill %} + {% fill "title" %}Another title{% endfill %} + {% endcomponent %} + """ + template = Template(template_str) + + with self.assertRaisesMessage( + TemplateSyntaxError, + "Multiple fill tags cannot target the same slot name in component 'test': " + "Detected duplicate fill tag name 'title'", + ): + template.render(Context()) + + @parametrize_context_behavior(["django", "isolated"]) + def test_raises_on_doubly_filled_slot__named_and_default(self): + @register("test") + class Comp(Component): + template: types.django_html = """ + {% load component_tags %} +
+

{% slot "title" default %}Default title{% endslot %}

+

{% slot "subtitle" %}Default subtitle{% endslot %}

+
+ """ + + template_str: types.django_html = """ + {% load component_tags %} + {% component 'test' %} + {% fill "default" %}Custom title{% endfill %} + {% fill "title" %}Another title{% endfill %} + {% endcomponent %} + """ + template = Template(template_str) + + with self.assertRaisesMessage( + TemplateSyntaxError, + "Slot 'title' of component 'test' was filled twice: once explicitly and once implicitly as 'default'", + ): + template.render(Context()) + + @parametrize_context_behavior(["django", "isolated"]) + def test_raises_on_doubly_filled_slot__named_and_default_2(self): + @register("test") + class Comp(Component): + template: types.django_html = """ + {% load component_tags %} +
+

{% slot "default" default %}Default title{% endslot %}

+

{% slot "subtitle" %}Default subtitle{% endslot %}

+
+ """ + + template_str: types.django_html = """ + {% load component_tags %} + {% component 'test' %} + {% fill "default" %}Custom title{% endfill %} + {% fill "default" %}Another title{% endfill %} + {% endcomponent %} + """ + template = Template(template_str) + + with self.assertRaisesMessage( + TemplateSyntaxError, + "Multiple fill tags cannot target the same slot name in component 'test': " + "Detected duplicate fill tag name 'default'", + ): + template.render(Context()) + + @parametrize_context_behavior(["django", "isolated"]) + def test_multiple_slots_with_same_name_different_flags(self): + class TestComp(Component): + def get_context_data(self, required: bool) -> Any: + return {"required": required} + + template: types.django_html = """ + {% load component_tags %} +
+ {% if required %} +
{% slot "main" required %}1{% endslot %}
+ {% endif %} +
{% slot "main" default %}2{% endslot %}
+
+ """ + + # 1. Specify the non-required slot by its name + rendered1 = TestComp.render( + kwargs={"required": False}, + slots={ + "main": "MAIN", + }, + render_dependencies=False, + ) + + # 2. Specify the non-required slot by the "default" name + rendered2 = TestComp.render( + kwargs={"required": False}, + slots={ + "default": "MAIN", + }, + render_dependencies=False, + ) + + self.assertInHTML(rendered1, "
MAIN
") + self.assertInHTML(rendered2, "
MAIN
") + + # 3. Specify the required slot by its name + rendered3 = TestComp.render( + kwargs={"required": True}, + slots={ + "main": "MAIN", + }, + render_dependencies=False, + ) + self.assertInHTML(rendered3, "
MAIN
MAIN
") + + # 4. RAISES: Specify the required slot by the "default" name + # This raises because the slot that is marked as 'required' is NOT marked as 'default'. + with self.assertRaisesMessage( + TemplateSyntaxError, + "Slot 'main' is marked as 'required'", + ): + TestComp.render( + kwargs={"required": True}, + slots={ + "default": "MAIN", + }, + render_dependencies=False, + ) + + @parametrize_context_behavior(["django", "isolated"]) + def test_slot_in_include(self): + @register("slotted") + class SlottedWithIncludeComponent(Component): + template: types.django_html = """ + {% load component_tags %} + {% include 'slotted_template.html' %} + """ + + template_str: types.django_html = """ + {% load component_tags %} + {% component "slotted" %} + {% fill "header" %}Custom header{% endfill %} + {% fill "main" %}Custom main{% endfill %} + {% fill "footer" %}Custom footer{% endfill %} + {% endcomponent %} + """ + + rendered = Template(template_str).render(Context({})) + + expected = """ + +
Custom header
+
Custom main
+
Custom footer
+
+ """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["django", "isolated"]) + def test_slot_in_include_raises_if_isolated(self): + @register("broken_component") + class BrokenComponent(Component): + template: types.django_html = """ + {% load component_tags %} + {% include 'slotted_template.html' with context=None only %} + """ + + template_str: types.django_html = """ + {% load component_tags %} + {% component "broken_component" %} + {% fill "header" %}Custom header {% endfill %} + {% fill "main" %}Custom main{% endfill %} + {% fill "footer" %}Custom footer{% endfill %} + {% endcomponent %} + """ + + with self.assertRaisesMessage( + TemplateSyntaxError, + "Encountered a SlotNode outside of a ComponentNode context.", + ): + Template(template_str).render(Context({})) + + +class ComponentSlotDefaultTests(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_default_slot_is_fillable_by_implicit_fill_content(self): @register("test_comp") @@ -311,6 +586,59 @@ class ComponentSlottedTemplateTagTest(BaseTestCase): rendered = template.render(Context({})) self.assertHTMLEqual(rendered, expected) + @parametrize_context_behavior(["django", "isolated"]) + def test_multiple_default_slots_with_same_name(self): + @register("test_comp") + class ComponentWithDefaultSlot(Component): + template: types.django_html = """ + {% load component_tags %} +
+
{% slot "main" default %}1{% endslot %}
+
{% slot "main" default %}2{% endslot %}
+
+ """ + + template_str: types.django_html = """ + {% load component_tags %} + {% component 'test_comp' %} + {% fill "main" %}

This fills the 'main' slot.

{% endfill %} + {% endcomponent %} + """ + template = Template(template_str) + expected = """ +
+

This fills the 'main' slot.

+

This fills the 'main' slot.

+
+ """ + rendered = template.render(Context({})) + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["django", "isolated"]) + def test_multiple_default_slots_with_different_names(self): + @register("test_comp") + class ComponentWithDefaultSlot(Component): + template: types.django_html = """ + {% load component_tags %} +
+
{% slot "main" default %}1{% endslot %}
+
{% slot "other" default %}2{% endslot %}
+
+ """ + + template_str: types.django_html = """ + {% load component_tags %} + {% component 'test_comp' %} + {% fill "main" %}

This fills the 'main' slot.

{% endfill %} + {% endcomponent %} + """ + template = Template(template_str) + + with self.assertRaisesMessage( + TemplateSyntaxError, "Only one component slot may be marked as 'default', found 'main' and 'other'" + ): + template.render(Context({})) + @parametrize_context_behavior(["django", "isolated"]) def test_error_raised_when_default_and_required_slot_not_filled(self): @register("test_comp") @@ -330,8 +658,8 @@ class ComponentSlottedTemplateTagTest(BaseTestCase): """ template = Template(template_str) - with self.assertRaises(TemplateSyntaxError): - template.render(Context({})) + with self.assertRaisesMessage(TemplateSyntaxError, "Slot 'main' is marked as 'required'"): + template.render(Context()) @parametrize_context_behavior(["django", "isolated"]) def test_fill_tag_can_occur_within_component_nested_in_implicit_fill(self): @@ -382,7 +710,10 @@ class ComponentSlottedTemplateTagTest(BaseTestCase): """ - with self.assertRaises(TemplateSyntaxError): + with self.assertRaisesMessage( + TemplateSyntaxError, + "Illegal content passed to component 'test_comp'. Explicit 'fill' tags cannot occur alongside other text", + ): template_str: types.django_html = """ {% load component_tags %} {% component 'test_comp' %} @@ -390,7 +721,7 @@ class ComponentSlottedTemplateTagTest(BaseTestCase):

And add this too!

{% endcomponent %} """ - Template(template_str) + Template(template_str).render(Context({})) @parametrize_context_behavior(["django", "isolated"]) def test_comments_permitted_inside_implicit_fill_content(self): @@ -428,63 +759,26 @@ class ComponentSlottedTemplateTagTest(BaseTestCase): """ template = Template(template_str) - with self.assertRaises(TemplateSyntaxError): - template.render(Context({})) - - @parametrize_context_behavior(["django", "isolated"]) - def test_component_template_cannot_have_multiple_default_slots(self): - class BadComponent(Component): - def get_template(self, context): - template_str: types.django_html = """ - {% load django_components %} -
- {% slot "icon" %} {% endslot default %} - {% slot "description" %} {% endslot default %} -
- """ - return Template(template_str) - - c = BadComponent("name") - - with self.assertRaises(TemplateSyntaxError): - c.render(Context({})) - - @parametrize_context_behavior(["django", "isolated"]) - def test_slot_name_fill_typo_gives_helpful_error_message(self): - registry.register(name="test1", component=SlottedComponent) - - template_str: types.django_html = """ - {% load component_tags %} - {% component "test1" %} - {% fill "haeder" %} - Custom header - {% endfill %} - {% fill "main" %} - main content - {% endfill %} - {% endcomponent %} - """ - template = Template(template_str) - with self.assertRaisesMessage( TemplateSyntaxError, - ( - "Component 'test1' passed fill that refers to undefined slot: 'haeder'.\\n" - "Unfilled slot names are: ['footer', 'header'].\\n" - "Did you mean 'header'?" - ), + "Component 'test_comp' passed default fill content (i.e. without explicit 'name' kwarg), " + "even though none of its slots is marked as 'default'", ): - template.render(Context({})) + template.render(Context()) - # NOTE: This is relevant only for the "isolated" mode - @parametrize_context_behavior(["isolated"]) - def test_slots_of_top_level_comps_can_access_full_outer_ctx(self): + +class PassthroughSlotsTest(BaseTestCase): + @parametrize_context_behavior(["isolated", "django"]) + def test_if_for(self): + @register("test") class SlottedComponent(Component): template: types.django_html = """ {% load component_tags %} -
-
{% slot "main" default %}Easy to override{% endslot %}
-
+ +
{% slot "header" %}Default header{% endslot %}
+
{% slot "main" %}Default main{% endslot %}
+
{% slot "footer" %}Default footer{% endslot %}
+
""" def get_context_data(self, name: Optional[str] = None) -> Dict[str, Any]: @@ -492,35 +786,271 @@ class ComponentSlottedTemplateTagTest(BaseTestCase): "name": name, } - registry.register("test", SlottedComponent) - template_str: types.django_html = """ {% load component_tags %} - - {% component "test" %} - ABC: {{ name }} {{ some }} - {% endcomponent %} - + {% component "test" %} + {% if slot_names %} + {% for slot in slot_names %} + {% fill name=slot default="default" %} + OVERRIDEN_SLOT "{{ slot }}" - INDEX {{ forloop.counter0 }} - ORIGINAL "{{ default }}" + {% endfill %} + {% endfor %} + {% endif %} + + {% if 1 > 2 %} + {% fill "footer" %} + FOOTER + {% endfill %} + {% endif %} + {% endcomponent %} """ - self.template = Template(template_str) - - nested_ctx = Context() - # Check that the component can access vars across different context layers - nested_ctx.push({"some": "var"}) - nested_ctx.push({"name": "carl"}) - rendered = self.template.render(nested_ctx) + template = Template(template_str) + rendered = template.render(Context({"slot_names": ["header", "main"]})) self.assertHTMLEqual( rendered, """ - -
-
ABC: carl var
-
- + +
+ OVERRIDEN_SLOT "header" - INDEX 0 - ORIGINAL "Default header" +
+
+ OVERRIDEN_SLOT "main" - INDEX 1 - ORIGINAL "Default main" +
+
+ Default footer +
+
""", ) + @parametrize_context_behavior(["isolated", "django"]) + def test_with(self): + @register("test") + class SlottedComponent(Component): + template: types.django_html = """ + {% load component_tags %} + +
{% slot "header" %}Default header{% endslot %}
+
{% slot "main" %}Default main{% endslot %}
+
{% slot "footer" %}Default footer{% endslot %}
+
+ """ + + def get_context_data(self, name: Optional[str] = None) -> Dict[str, Any]: + return { + "name": name, + } + + template_str: types.django_html = """ + {% load component_tags %} + {% component "test" %} + {% with slot="header" %} + {% fill name=slot default="default" %} + OVERRIDEN_SLOT "{{ slot }}" - ORIGINAL "{{ default }}" + {% endfill %} + {% endwith %} + {% endcomponent %} + """ + template = Template(template_str) + + rendered = template.render(Context()) + self.assertHTMLEqual( + rendered, + """ + +
+ OVERRIDEN_SLOT "header" - ORIGINAL "Default header" +
+
Default main
+
Default footer
+
+ """, + ) + + @parametrize_context_behavior(["isolated", "django"]) + def test_if_for_raises_on_content_outside_fill(self): + @register("test") + class SlottedComponent(Component): + template: types.django_html = """ + {% load component_tags %} + +
{% slot "header" %}Default header{% endslot %}
+
{% slot "main" %}Default main{% endslot %}
+
{% slot "footer" %}Default footer{% endslot %}
+
+ """ + + def get_context_data(self, name: Optional[str] = None) -> Dict[str, Any]: + return { + "name": name, + } + + template_str: types.django_html = """ + {% load component_tags %} + {% component "test" %} + {% if slot_names %} + {% for slot in slot_names %} + {{ forloop.counter0 }} + {% fill name=slot default="default" %} + OVERRIDEN_SLOT + {% endfill %} + {% endfor %} + {% endif %} + + {% if 1 > 2 %} + {% fill "footer" %} + FOOTER + {% endfill %} + {% endif %} + {% endcomponent %} + """ + template = Template(template_str) + + with self.assertRaisesMessage(TemplateSyntaxError, "Illegal content passed to component 'test'"): + template.render(Context({"slot_names": ["header", "main"]})) + + @parametrize_context_behavior(["django", "isolated"]) + def test_slots_inside_loops(self): + @register("test_comp") + class OuterComp(Component): + def get_context_data(self, name: Optional[str] = None) -> Dict[str, Any]: + return { + "slots": ["header", "main", "footer"], + } + + template: types.django_html = """ + {% load component_tags %} + {% for slot_name in slots %} +
+ {% slot name=slot_name %} + {{ slot_name }} + {% endslot %} +
+ {% endfor %} + """ + + template_str: types.django_html = """ + {% load component_tags %} + {% component "test_comp" %} + {% fill "header" %} + CUSTOM HEADER + {% endfill %} + {% fill "main" %} + CUSTOM MAIN + {% endfill %} + {% endcomponent %} + """ + template = Template(template_str) + rendered = template.render(Context()) + + expected = """ +
CUSTOM HEADER
+
CUSTOM MAIN
+
footer
+ """ + self.assertHTMLEqual(rendered, expected) + + @parametrize_context_behavior(["django", "isolated"]) + def test_passthrough_slots(self): + registry.register("slotted", SlottedComponent) + + @register("test_comp") + class OuterComp(Component): + def get_context_data(self, name: Optional[str] = None) -> Dict[str, Any]: + return { + "slots": self.input.slots, + } + + template: types.django_html = """ + {% load component_tags %} +
+ {% component "slotted" %} + {% for slot_name in slots %} + {% fill name=slot_name %} + {% slot name=slot_name / %} + {% endfill %} + {% endfor %} + {% endcomponent %} +
+ """ + + template_str: types.django_html = """ + {% load component_tags %} + {% component "test_comp" %} + {% fill "header" %} + CUSTOM HEADER + {% endfill %} + {% fill "main" %} + CUSTOM MAIN + {% endfill %} + {% endcomponent %} + """ + template = Template(template_str) + rendered = template.render(Context()) + + expected = """ +
+ +
CUSTOM HEADER
+
CUSTOM MAIN
+
Default footer
+
+
+ """ + self.assertHTMLEqual(rendered, expected) + + # NOTE: Ideally we'd (optionally) raise an error / warning here, but it's not possible + # with current implementation. So this tests serves as a documentation of the current behavior. + @parametrize_context_behavior(["django", "isolated"]) + def test_passthrough_slots_unknown_fills_ignored(self): + registry.register("slotted", SlottedComponent) + + @register("test_comp") + class OuterComp(Component): + def get_context_data(self, name: Optional[str] = None) -> Dict[str, Any]: + return { + "slots": self.input.slots, + } + + template: types.django_html = """ + {% load component_tags %} +
+ {% component "slotted" %} + {% for slot_name in slots %} + {% fill name=slot_name %} + {% slot name=slot_name / %} + {% endfill %} + {% endfor %} + {% endcomponent %} +
+ """ + + template_str: types.django_html = """ + {% load component_tags %} + {% component "test_comp" %} + {% fill "header1" %} + CUSTOM HEADER + {% endfill %} + {% fill "main" %} + CUSTOM MAIN + {% endfill %} + {% endcomponent %} + """ + template = Template(template_str) + rendered = template.render(Context()) + + expected = """ +
+ +
Default header
+
CUSTOM MAIN
+
Default footer
+
+
+ """ + self.assertHTMLEqual(rendered, expected) + # See https://github.com/EmilStenstrom/django-components/issues/698 class NestedSlotsTests(BaseTestCase): @@ -1036,6 +1566,43 @@ class ScopedSlotTest(BaseTestCase): """ self.assertHTMLEqual(rendered, expected) + @parametrize_context_behavior(["django", "isolated"]) + def test_slot_data_and_default_on_default_slot(self): + @register("test") + class TestComponent(Component): + template: types.django_html = """ + {% load component_tags %} +
+ {% slot "slot_a" abc=abc var123=var123 %} Default text A {% endslot %} + {% slot "slot_b" abc=abc var123=var123 default %} Default text B {% endslot %} +
+ """ + + def get_context_data(self): + return { + "abc": "xyz", + "var123": 456, + } + + template: types.django_html = """ + {% load component_tags %} + {% component "test" %} + {% fill name="default" data="slot_data_in_fill" default="slot_var" %} + {{ slot_data_in_fill.abc }} + {{ slot_var }} + {{ slot_data_in_fill.var123 }} + {% endfill %} + {% endcomponent %} + """ + rendered = Template(template).render(Context()) + expected = """ +
+ Default text A + xyz Default text B 456 +
+ """ + self.assertHTMLEqual(rendered, expected) + @parametrize_context_behavior(["django", "isolated"]) def test_slot_data_raises_on_slot_data_and_slot_default_same_var(self): @register("test") @@ -1482,37 +2049,23 @@ class SlotFillTemplateSyntaxErrorTests(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_fill_with_no_parent_is_error(self): - with self.assertRaises(TemplateSyntaxError): + with self.assertRaisesMessage( + TemplateSyntaxError, + "FillNode.render() (AKA {% fill ... %} block) cannot be rendered outside of a Component context", + ): template_str: types.django_html = """ {% load component_tags %} {% fill "header" %}contents{% endfill %} """ Template(template_str).render(Context({})) - @parametrize_context_behavior(["django", "isolated"]) - def test_isolated_slot_is_error(self): - @register("broken_component") - class BrokenComponent(Component): - template: types.django_html = """ - {% load component_tags %} - {% include 'slotted_template.html' with context=None only %} - """ - - template_str: types.django_html = """ - {% load component_tags %} - {% component "broken_component" %} - {% fill "header" %}Custom header {% endfill %} - {% fill "main" %}Custom main{% endfill %} - {% fill "footer" %}Custom footer{% endfill %} - {% endcomponent %} - """ - - with self.assertRaises(KeyError): - Template(template_str).render(Context({})) - @parametrize_context_behavior(["django", "isolated"]) def test_non_unique_fill_names_is_error(self): - with self.assertRaises(TemplateSyntaxError): + with self.assertRaisesMessage( + TemplateSyntaxError, + "Multiple fill tags cannot target the same slot name in component 'test': " + "Detected duplicate fill tag name 'header'", + ): template_str: types.django_html = """ {% load component_tags %} {% component "test" %} @@ -1524,7 +2077,11 @@ class SlotFillTemplateSyntaxErrorTests(BaseTestCase): @parametrize_context_behavior(["django", "isolated"]) def test_non_unique_fill_names_is_error_via_vars(self): - with self.assertRaises(TemplateSyntaxError): + with self.assertRaisesMessage( + TemplateSyntaxError, + "Multiple fill tags cannot target the same slot name in component 'test': " + "Detected duplicate fill tag name 'header'", + ): template_str: types.django_html = """ {% load component_tags %} {% with var1="header" var2="header" %} @@ -1647,3 +2204,86 @@ class SlotBehaviorTests(BaseTestCase): """, ) + + +class SlotInputTests(BaseTestCase): + @parametrize_context_behavior(["django", "isolated"]) + def test_slots_accessible_when_python_render(self): + slots: Dict = {} + + @register("test") + class SlottedComponent(Component): + template: types.django_html = """ + {% load component_tags %} +
{% slot "header" %}Default header{% endslot %}
+
{% slot "main" %}Default main header{% endslot %}
+
{% slot "footer" %}Default footer{% endslot %}
+ """ + + def get_context_data(self, input: Optional[int] = None) -> Dict[str, Any]: + nonlocal slots + slots = self.input.slots + return {} + + self.assertEqual(slots, {}) + + template_str: types.django_html = """ + {% load component_tags %} + {% component "test" input=1 %} + {% fill "header" data="data1" %} + data1_in_slot1: {{ data1|safe }} + {% endfill %} + {% fill "main" / %} + {% endcomponent %} + """ + template = Template(template_str) + template.render(Context()) + + self.assertListEqual( + list(slots.keys()), + ["header", "main"], + ) + self.assertTrue(callable(slots["header"])) + self.assertTrue(callable(slots["main"])) + self.assertTrue("footer" not in slots) + + @parametrize_context_behavior(["django", "isolated"]) + def test_slots_normalized_as_slot_instances(self): + slots: Dict[str, Slot] = {} + + @register("test") + class SlottedComponent(Component): + template: types.django_html = """ + {% load component_tags %} +
{% slot "header" %}Default header{% endslot %}
+
{% slot "main" %}Default main header{% endslot %}
+ + """ + + def get_context_data(self, input: Optional[int] = None) -> Dict[str, Any]: + nonlocal slots + slots = self.input.slots + return {} + + self.assertEqual(slots, {}) + + header_slot = Slot(lambda *a, **kw: "HEADER_SLOT") + main_slot_str = "MAIN_SLOT" + footer_slot_fn = lambda *a, **kw: "FOOTER_SLOT" # noqa: E731 + + SlottedComponent.render( + slots={ + "header": header_slot, + "main": main_slot_str, + "footer": footer_slot_fn, + } + ) + + self.assertIsInstance(slots["header"], Slot) + self.assertEqual(slots["header"](Context(), None, None), "HEADER_SLOT") # type: ignore[arg-type] + + self.assertIsInstance(slots["main"], Slot) + self.assertEqual(slots["main"](Context(), None, None), "MAIN_SLOT") # type: ignore[arg-type] + + self.assertIsInstance(slots["footer"], Slot) + self.assertEqual(slots["footer"](Context(), None, None), "FOOTER_SLOT") # type: ignore[arg-type] diff --git a/tests/test_utils.py b/tests/test_utils.py index efa147bf..75849ce5 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,4 +1,4 @@ -from django_components.utils import is_str_wrapped_in_quotes +from django_components.util.misc import is_str_wrapped_in_quotes from .django_test_setup import setup_test_config from .testutils import BaseTestCase diff --git a/tests/testutils.py b/tests/testutils.py index 6251e92b..21731587 100644 --- a/tests/testutils.py +++ b/tests/testutils.py @@ -2,7 +2,7 @@ import contextlib import functools import sys from typing import Any, Dict, List, Optional, Tuple, Union -from unittest.mock import Mock +from unittest.mock import Mock, patch from django.template import Context, Node from django.template.loader import engines @@ -10,7 +10,7 @@ from django.template.response import TemplateResponse from django.test import SimpleTestCase, override_settings from django_components.app_settings import ContextBehavior -from django_components.autodiscover import autodiscover +from django_components.autodiscovery import autodiscover from django_components.component_registry import registry from django_components.middleware import ComponentDependencyMiddleware @@ -20,7 +20,13 @@ middleware = ComponentDependencyMiddleware(get_response=lambda _: response_stash class BaseTestCase(SimpleTestCase): - def tearDown(self) -> None: + def setUp(self): + super().setUp() + self._start_gen_id_patch() + + def tearDown(self): + self._stop_gen_id_patch() + super().tearDown() registry.clear() @@ -28,6 +34,22 @@ class BaseTestCase(SimpleTestCase): _create_template.cache_remove() # type: ignore[attr-defined] + # Mock the `generate` function used inside `gen_id` so it returns deterministic IDs + def _start_gen_id_patch(self): + # Random number so that the generated IDs are "hex-looking", e.g. a1bc3d + self._gen_id_count = 10599485 + + def mock_gen_id(*args, **kwargs): + self._gen_id_count += 1 + return hex(self._gen_id_count)[2:] + + self._gen_id_patch = patch("django_components.util.misc.generate", side_effect=mock_gen_id) + self._gen_id_patch.start() + + def _stop_gen_id_patch(self): + self._gen_id_patch.stop() + self._gen_id_count = 10599485 + request = Mock() mock_template = Mock() @@ -142,12 +164,16 @@ def parametrize_context_behavior(cases: List[ContextBehParam], settings: Optiona # Because of this, we need to clear the loader cache, and, on error, we need to # propagate the info on which test case failed. @functools.wraps(test_func) - def wrapper(*args, **kwargs): + def wrapper(self: BaseTestCase, *args, **kwargs): for case in cases: # Clear loader cache, see https://stackoverflow.com/a/77531127/9788634 for engine in engines.all(): engine.engine.template_loaders[0].reset() + # Reset gen_id + self._stop_gen_id_patch() + self._start_gen_id_patch() + case_has_data = not isinstance(case, str) if isinstance(case, str): @@ -169,9 +195,9 @@ def parametrize_context_behavior(cases: List[ContextBehParam], settings: Optiona # Call the test function with the fixture as an argument try: if case_has_data: - test_func(*args, context_behavior_data=fixture, **kwargs) + test_func(self, *args, context_behavior_data=fixture, **kwargs) else: - test_func(*args, **kwargs) + test_func(self, *args, **kwargs) except Exception as err: # Give a hint on which iteration the test failed raise RuntimeError(