diff --git a/.github/ISSUE_TEMPLATE/issue-form.yml b/.github/ISSUE_TEMPLATE/issue-form.yml index 342316ad..b4a4b892 100644 --- a/.github/ISSUE_TEMPLATE/issue-form.yml +++ b/.github/ISSUE_TEMPLATE/issue-form.yml @@ -14,10 +14,3 @@ body: description: Describe what ought to be done, and why that will address the reasons for action mentioned above. validations: required: false -- type: textarea - attributes: - label: Work Items - description: | - An itemized list or detailed description of the work to be done to based on the proposed actions above. - validations: - required: false diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..92ac3c3d --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,11 @@ +# Description + +A summary of the changes. + +# Checklist: + +Please update this checklist as you complete each item: + +- [ ] Tests have been included for all bug fixes or added functionality. +- [ ] The `changelog.rst` has been updated with any significant changes, if necessary. +- [ ] GitHub Issues which may be closed by this PR have been linked. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a696e82..a283c7d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,16 @@ Types of changes are to be listed in this order - Nothing (yet) +## [1.1.0] - 2022-07-01 + +### Added + +- `django_css` and `django_js` components to defer loading CSS & JS files until needed. + +### Changed + +- Bumped the minimum IDOM version to 0.39.0 + ## [1.0.0] - 2022-05-22 ### Added @@ -103,6 +113,7 @@ Types of changes are to be listed in this order - Support for IDOM within the Django [unreleased]: https://github.com/idom-team/django-idom/compare/1.0.0...HEAD +[1.1.0]: https://github.com/idom-team/django-idom/compare/1.0.0...1.1.0 [1.0.0]: https://github.com/idom-team/django-idom/compare/0.0.5...1.0.0 [0.0.5]: https://github.com/idom-team/django-idom/compare/0.0.4...0.0.5 [0.0.4]: https://github.com/idom-team/django-idom/compare/0.0.3...0.0.4 diff --git a/README.md b/README.md index 0e72bd99..58f86611 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ -Django-IDOM connects your project to a ReactJS frontend, allowing you to create **interactive websites without needing JavaScript!** +Django-IDOM connects your Python project to a ReactJS frontend, allowing you to create **interactive websites without needing JavaScript!** Following ReactJS styling, web elements are combined into [reusable "components"](https://idom-docs.herokuapp.com/docs/guides/creating-interfaces/your-first-components/index.html#parametrizing-components). These components can utilize [hooks](https://idom-docs.herokuapp.com/docs/reference/hooks-api.html) and [events](https://idom-docs.herokuapp.com/docs/guides/adding-interactivity/responding-to-events/index.html#async-event-handlers) to create infinitely complex web pages. @@ -13,21 +13,19 @@ When needed, IDOM can [use components directly from NPM](https://idom-docs.herok Any Python web framework with Websockets can support IDOM. See below for what frameworks are supported out of the box. -| Supported Frameworks | Supported Frameworks (External) | -| ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Supported Frameworks | Supported Frameworks (External) | +| --- | --- | | [`Flask`, `FastAPI`, `Sanic`, `Tornado`](https://idom-docs.herokuapp.com/docs/guides/getting-started/installing-idom.html#officially-supported-servers) | [`Django`](https://github.com/idom-team/django-idom), [`Plotly-Dash`](https://github.com/idom-team/idom-dash), [`Jupyter`](https://github.com/idom-team/idom-jupyter) | ---- - # At a Glance ## `my_app/components.py` -You'll need a file to define your [IDOM](https://github.com/idom-team/idom) components. We recommend creating a `components.py` file within your chosen **Django app** to start out. +You'll need a file to define your [IDOM](https://github.com/idom-team/idom) components. We recommend creating a `components.py` file within your chosen **Django app** to start out. Within this file, we will create a simple `HelloWorld` component. @@ -46,7 +44,7 @@ def HelloWorld(recipient: str): -In your **Django app**'s HTML located within your `templates` folder, you can now embed your IDOM component using the `component` template tag. Within this tag, you will need to type in your dotted path to the component function as the first argument. +In your **Django app**'s HTML template, you can now embed your IDOM component using the `component` template tag. Within this tag, you will need to type in your dotted path to the component function as the first argument. Additonally, you can pass in keyword arguments into your component function. For example, after reading the code below, pay attention to how the function definition for `HelloWorld` (_in the previous example_) accepts a `recipient` argument. @@ -65,8 +63,6 @@ Additonally, you can pass in keyword arguments into your component function. For ---- - # Resources @@ -76,4 +72,5 @@ Follow the links below to find out more about this project. - [Try it Now](https://mybinder.org/v2/gh/idom-team/idom-jupyter/main?urlpath=lab/tree/notebooks/introduction.ipynb) - Check out IDOM in a Jupyter Notebook. - [Documentation](https://idom-team.github.io/django-idom) - Learn how to install, run, and use IDOM. - [Community Forum](https://github.com/idom-team/idom/discussions) - Ask questions, share ideas, and show off projects. + diff --git a/docs/contribute/django-idom.md b/docs/contribute/django-idom.md index 4284ef66..6199d4f8 100644 --- a/docs/contribute/django-idom.md +++ b/docs/contribute/django-idom.md @@ -1,6 +1,6 @@ ???+ tip "Looking to contribute features that are not Django specific?" - Everything within the `django-idom` repository must be specific to Django integration. Check out the [IDOM Core documentation](https://idom-docs.herokuapp.com/docs/about/contributor-guide.html) to contribute general features, such as: components, hooks, events, etc. + Everything within the `django-idom` repository must be specific to Django integration. Check out the [IDOM Core documentation](https://idom-docs.herokuapp.com/docs/about/contributor-guide.html) to contribute general features such as: components, hooks, events, and more. If you plan to make code changes to this repository, you'll need to install the following dependencies first: diff --git a/docs/features/components.md b/docs/features/components.md new file mode 100644 index 00000000..947a4431 --- /dev/null +++ b/docs/features/components.md @@ -0,0 +1,121 @@ +## Django CSS + +Allows you to defer loading a CSS stylesheet until a component begins rendering. This stylesheet must be stored within [Django's static files](https://docs.djangoproject.com/en/dev/howto/static-files/). + +```python title="components.py" +from idom import component, html +from django_idom.components import django_css + +@component +def MyComponent(): + return html.div( + django_css("css/buttons.css"), + html.button("My Button!"), + ) +``` + +??? question "Should I put `django_css` at the top of my component?" + + Yes, if the stylesheet contains styling for your component. + +??? question "Can I load static CSS using `html.link` instead?" + + While you can load stylesheets with `html.link`, keep in mind that loading this way **does not** ensure load order. Thus, your stylesheet will be loaded after your component is displayed. This would likely cause some visual jankiness, so use this at your own discretion. + + Here's an example on what you should avoid doing for Django static files: + + ```python + from idom import component, html + from django.templatetags.static import static + + @component + def MyComponent(): + return html.div( + html.link({"rel": "stylesheet", "href": static("css/buttons.css")}), + html.button("My Button!"), + ) + ``` + +??? question "How do I load external CSS?" + + `django_css` can only be used with local static files. + + For external CSS, substitute `django_css` with `html.link`. + + ```python + from idom import component, html + + @component + def MyComponent(): + return html.div( + html.link({"rel": "stylesheet", "href": "https://example.com/external-styles.css"}), + html.button("My Button!"), + ) + ``` + +??? question "Why not load my CSS in `#!html `?" + + Traditionally, stylesheets are loaded in your `#!html ` using the `#!jinja {% load static %}` template tag. + + To help improve webpage load times, you can use the `django_css` component to defer loading your stylesheet until it is needed. + +## Django JS + +Allows you to defer loading JavaScript until a component begins rendering. This JavaScript must be stored within [Django's static files](https://docs.djangoproject.com/en/dev/howto/static-files/). + +```python title="components.py" +from idom import component, html +from django_idom.components import django_js + +@component +def MyComponent(): + return html.div( + html.button("My Button!"), + django_js("js/scripts.js"), + ) +``` + +??? question "Should I put `django_js` at the bottom of my component?" + + Yes, if your scripts are reliant on the contents of the component. + +??? question "Can I load static JavaScript using `html.script` instead?" + + While you can load JavaScript with `html.script`, keep in mind that loading this way **does not** ensure load order. Thus, your JavaScript will likely be loaded at an arbitrary time after your component is displayed. + + Here's an example on what you should avoid doing for Django static files: + + ```python + from idom import component, html + from django.templatetags.static import static + + @component + def MyComponent(): + return html.div( + html.script({"src": static("js/scripts.js")}), + html.button("My Button!"), + ) + ``` + +??? question "How do I load external JS?" + + `django_js` can only be used with local static files. + + For external JavaScript, substitute `django_js` with `html.script`. + + ```python + from idom import component, html + + @component + def MyComponent(): + return html.div( + html.script({"src": static("https://example.com/external-scripts.js")}), + html.button("My Button!"), + ) + ``` + +??? question "Why not load my JS in `#!html `?" + + Traditionally, JavaScript is loaded in your `#!html ` using the `#!jinja {% load static %}` template tag. + + To help improve webpage load times, you can use the `django_js` component to defer loading your JavaScript until it is needed. diff --git a/docs/features/hooks.md b/docs/features/hooks.md index a024e816..e1cc4d04 100644 --- a/docs/features/hooks.md +++ b/docs/features/hooks.md @@ -1,5 +1,9 @@ # Django Hooks +???+ tip "Looking for more hooks?" + + Check out the [IDOM Core docs](https://idom-docs.herokuapp.com/docs/reference/hooks-api.html?highlight=hooks) on hooks! + ## Use Websocket You can fetch the Django Channels websocket at any time by using `use_websocket`. @@ -14,7 +18,7 @@ def MyComponent(): return html.div(my_websocket) ``` ---- + ## Use Scope @@ -30,13 +34,12 @@ def MyComponent(): return html.div(my_scope) ``` ---- ## Use Location ??? info "This hook's behavior will be changed in a future update" - This hook will eventually be updated to return the client's current webpage URL. This will come in alongside our built-in [Single Page Application (SPA) support](https://github.com/idom-team/idom/issues/569). + This hook will be updated to return the browser's current URL. This will come in alongside our built-in [Single Page Application (SPA) support](https://github.com/idom-team/idom/issues/569). This is a shortcut that returns the Websocket's `path`. diff --git a/docs/getting-started/initial-steps.md b/docs/getting-started/initial-steps.md index daef5d4d..98a58d0b 100644 --- a/docs/getting-started/initial-steps.md +++ b/docs/getting-started/initial-steps.md @@ -1,6 +1,6 @@ ???+ summary - Set up a Django Project with at least one app. + Set up a **Django Project** with at least one app. --- @@ -10,7 +10,7 @@ For the examples within this section, we will assume you've placed the files [ge ??? question "How do I organize my Django project for IDOM?" - Django-IDOM has no project structure requirements. Organize everything as you wish, just like any Django project. + Django-IDOM has no project structure requirements. Organize everything as you wish, just like any **Django project**. ??? question "I've never used Django, what do I need to learn?" diff --git a/docs/getting-started/render-view.md b/docs/getting-started/render-view.md index 03fb8d6f..deb6a14e 100644 --- a/docs/getting-started/render-view.md +++ b/docs/getting-started/render-view.md @@ -32,8 +32,8 @@ Now, navigate to `http://127.0.0.1:8000/example/`. If you copy-pasted the compon ??? question "Which urls.py do I add my views to?" - For simple Django projects, you can easily add all of your views directly into the **Django project**'s `urls.py`. However, as you start increase your project's complexity you might end up with way too much within one file. + For simple **Django projects**, you can easily add all of your views directly into the **Django project's** `urls.py`. However, as you start increase your project's complexity you might end up with way too much within one file. Once you reach that point, we recommend creating an individual `urls.py` within each of your **Django apps**. - Then, within your **Django project**'s `urls.py` you will use Django's [`include` function](https://docs.djangoproject.com/en/dev/ref/urls/) to link it all together. + Then, within your **Django project's** `urls.py` you will use Django's [`include` function](https://docs.djangoproject.com/en/dev/ref/urls/) to link it all together. diff --git a/docs/index.md b/docs/index.md index 39874a5f..9b11f2dc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,7 +6,7 @@ hide: {% include-markdown "../README.md" start="" end="" %} -## It's React for Django Developers. +## ReactJS for Django Developers. --- diff --git a/docs/installation/index.md b/docs/installation/index.md index cdf9820b..a2a6e391 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -1,8 +1,6 @@ - +???+ tip "Learning Django first is recommended!" -These docs assumes you have created [a basic **Django project**](https://docs.djangoproject.com/en/dev/intro/tutorial01/), which also involves generating/installing at least one **Django app**. If not, check out this [9 minute YouTube tutorial](https://www.youtube.com/watch?v=ZsJRXS_vrw0) created by _IDG TECHtalk_. - - + These docs assumes you have created [a basic **Django project**](https://docs.djangoproject.com/en/dev/intro/tutorial01/), which involves creating and installing at least one **Django app**. If not, check out this [9 minute YouTube tutorial](https://www.youtube.com/watch?v=ZsJRXS_vrw0) created by _IDG TECHtalk_. ## Install from PyPI @@ -10,7 +8,7 @@ These docs assumes you have created [a basic **Django project**](https://docs.dj pip install django-idom ``` -You'll also need to modify a few files in your Django project... +You'll also need to modify a few files in your **Django project**... --- @@ -25,11 +23,14 @@ INSTALLED_APPS = [ ] ``` -??? warning "Enabling ASGI on Django (Required)" +??? warning "Enable Django ASGI (Required)" Django-IDOM requires ASGI in order to use Websockets. - If you haven't [enabled ASGI](https://channels.readthedocs.io/en/stable/installation.html) on your Django project yet, you'll need to add `channels` to `INSTALLED_APPS` and set your `ASGI_APPLICATION` variable. + If you haven't enabled ASGI on your **Django project** yet, you'll need to add `channels` to `INSTALLED_APPS` and set your `ASGI_APPLICATION` variable. + + Read the [Django Channels Docs](https://channels.readthedocs.io/en/stable/installation.html) for more info. + ```python title="settings.py" INSTALLED_APPS = [ "channels", diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 00000000..2f8c7dab --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,12 @@ +.md-footer__inner { + display: none; +} + +.md-tabs, +.md-header { + background-color: var(--md-footer-bg-color--dark); +} + +.md-typeset :is(.admonition, details) { + margin: 1em 0; +} diff --git a/mkdocs.yml b/mkdocs.yml index 487f21ae..50cef329 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -9,6 +9,7 @@ nav: - 4. Render Your View: getting-started/render-view.md - 5. Learn More: getting-started/learn-more.md - Exclusive Features: + - Components: features/components.md - Hooks: features/hooks.md - Template Tag: features/templatetag.md - Contribute: @@ -20,13 +21,15 @@ nav: theme: name: material palette: - - scheme: slate + - media: "(prefers-color-scheme: dark)" + scheme: slate toggle: icon: material/toggle-switch name: Switch to light mode primary: deep-orange - accent: deep-orange - - scheme: default + accent: orange + - media: "(prefers-color-scheme: light)" + scheme: default toggle: icon: material/toggle-switch-off-outline name: Switch to dark mode @@ -37,7 +40,6 @@ theme: - navigation.tabs - toc.integrate - navigation.top - icon: repo: fontawesome/brands/github @@ -60,9 +62,22 @@ plugins: - git-revision-date-localized: fallback_to_build_date: true +extra: + generator: false + +extra_css: + - stylesheets/extra.css + +watch: + - docs + - mkdocs.yml + - README.md + - CHANGELOG.md + site_name: Django IDOM Docs site_author: Archmonger site_description: React for Django developers. +copyright: Copyright © 2022 IDOM Team repo_url: https://github.com/idom-team/django-idom site_url: https://idom-team.github.io/django-idom repo_name: idom-team/django-idom diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index 7b9f9420..08e42fad 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -1,3 +1,3 @@ channels >=3.0.0 -idom >=0.38.0, <0.39.0 +idom >=0.39.0, <0.40.0 aiofile >=3.0 diff --git a/requirements/test-env.txt b/requirements/test-env.txt index 6f2e151e..61ee65ee 100644 --- a/requirements/test-env.txt +++ b/requirements/test-env.txt @@ -1,3 +1,3 @@ django -selenium +selenium <= 4.2.0 twisted diff --git a/src/django_idom/__init__.py b/src/django_idom/__init__.py index af2dec38..857f92e1 100644 --- a/src/django_idom/__init__.py +++ b/src/django_idom/__init__.py @@ -1,7 +1,7 @@ -from . import hooks +from . import components, hooks from .websocket.consumer import IdomWebsocket from .websocket.paths import IDOM_WEBSOCKET_PATH -__version__ = "1.0.0" -__all__ = ["IDOM_WEBSOCKET_PATH", "IdomWebsocket", "hooks"] +__version__ = "1.1.0" +__all__ = ["IDOM_WEBSOCKET_PATH", "IdomWebsocket", "hooks", "components"] diff --git a/src/django_idom/components.py b/src/django_idom/components.py new file mode 100644 index 00000000..1433d30a --- /dev/null +++ b/src/django_idom/components.py @@ -0,0 +1,52 @@ +import os + +from django.contrib.staticfiles.finders import find +from idom import component, html + +from django_idom.config import IDOM_CACHE + + +@component +def django_css(static_path: str): + """Fetches a CSS static file for use within IDOM. This allows for deferred CSS loading. + + Args: + static_path: The path to the static file. This path is identical to what you would + use on a `static` template tag. + """ + return html._(html.style(_cached_static_contents(static_path))) + + +@component +def django_js(static_path: str): + """Fetches a JS static file for use within IDOM. This allows for deferred JS loading. + + Args: + static_path: The path to the static file. This path is identical to what you would + use on a `static` template tag. + """ + return html.script(_cached_static_contents(static_path)) + + +def _cached_static_contents(static_path: str): + # Try to find the file within Django's static files + abs_path = find(static_path) + if not abs_path: + raise FileNotFoundError( + f"Could not find static file {static_path} within Django's static files." + ) + + # Fetch the file from cache, if available + # Cache is preferrable to `use_memo` due to multiprocessing capabilities + last_modified_time = os.stat(abs_path).st_mtime + cache_key = f"django_idom:static_contents:{static_path}" + file_contents = IDOM_CACHE.get(cache_key, version=last_modified_time) + if file_contents is None: + with open(abs_path, encoding="utf-8") as static_file: + file_contents = static_file.read() + IDOM_CACHE.delete(cache_key) + IDOM_CACHE.set( + cache_key, file_contents, timeout=None, version=last_modified_time + ) + + return file_contents diff --git a/src/django_idom/templatetags/idom.py b/src/django_idom/templatetags/idom.py index 5707733d..7b8f3610 100644 --- a/src/django_idom/templatetags/idom.py +++ b/src/django_idom/templatetags/idom.py @@ -15,6 +15,21 @@ @register.inclusion_tag("idom/component.html") def component(_component_id_, **kwargs): + """ + This tag is used to embed an existing IDOM component into your HTML template. + + The first argument within this tag is your dotted path to the component function. + + Subsequent values are keyworded arguments are passed into your component:: + + {% load idom %} + + + + {% component "example_project.my_app.components.HelloWorld" recipient="World" %} + + + """ _register_component(_component_id_) class_ = kwargs.pop("class", "") diff --git a/tests/test_app/components.py b/tests/test_app/components.py index 2efd878c..687bfea2 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -71,3 +71,27 @@ def UseLocation(): f"UseLocation: {location}", idom.html.hr(), ) + + +@idom.component +def StaticCSS(): + return idom.html.div( + {"id": "static-css"}, + django_idom.components.django_css("static-css-test.css"), + idom.html.div({"style": {"display": "inline"}}, "StaticCSS: "), + idom.html.button("This text should be blue."), + idom.html.hr(), + ) + + +@idom.component +def StaticJS(): + success = False + return idom.html._( + idom.html.div( + {"id": "static-js", "data-success": success}, + f"StaticJS: {success}", + django_idom.components.django_js("static-js-test.js"), + ), + idom.html.hr(), + ) diff --git a/tests/test_app/static/static-css-test.css b/tests/test_app/static/static-css-test.css new file mode 100644 index 00000000..d5565d70 --- /dev/null +++ b/tests/test_app/static/static-css-test.css @@ -0,0 +1,3 @@ +#static-css button { + color: rgba(0, 0, 255, 1); +} diff --git a/tests/test_app/static/static-js-test.js b/tests/test_app/static/static-js-test.js new file mode 100644 index 00000000..b4423acf --- /dev/null +++ b/tests/test_app/static/static-js-test.js @@ -0,0 +1,3 @@ +let el = document.body.querySelector("#static-js"); +el.textContent = "StaticJS: True"; +el.dataset.success = "true"; diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index 0c52a8cc..c597cdf9 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -19,6 +19,8 @@

IDOM Test Page

{% component "test_app.components.UseWebsocket" %}
{% component "test_app.components.UseScope" %}
{% component "test_app.components.UseLocation" %}
+
{% component "test_app.components.StaticCSS" %}
+
{% component "test_app.components.StaticJS" %}
diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index fbfda829..33a8c6e8 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -59,6 +59,16 @@ def test_use_location(self): element = self.driver.find_element_by_id("use-location") self.assertEqual(element.get_attribute("data-success"), "true") + def test_static_css(self): + element = self.driver.find_element_by_css_selector("#static-css button") + self.assertEqual( + element.value_of_css_property("color"), "rgba(0, 0, 255, 1)" + ) + + def test_static_js(self): + element = self.driver.find_element_by_id("static-js") + self.assertEqual(element.get_attribute("data-success"), "true") + def make_driver(page_load_timeout, implicit_wait_timeout): options = webdriver.ChromeOptions()