From 385b700736c2f8057fc7847d75242787d963b708 Mon Sep 17 00:00:00 2001 From: mrfolksy Date: Sat, 4 Jan 2025 19:04:14 +0000 Subject: [PATCH] Support Inertia 2.0 deferred props feature (#56) * support deferred props * fixed typo * fixed formatting on utils, set the default group to be default on deferred props and added additional tests cases * set indentation to 2 spaces on build_deffered_props --------- Co-authored-by: Your Name Co-authored-by: Brandon Shar <6599653+BrandonShar@users.noreply.github.com> --- README.md | 81 +++++++++++++++++++++++++-------- inertia/__init__.py | 2 +- inertia/http.py | 23 ++++++++-- inertia/test.py | 9 +++- inertia/tests/test_rendering.py | 37 +++++++++++++++ inertia/tests/testapp/urls.py | 2 + inertia/tests/testapp/views.py | 19 +++++++- inertia/utils.py | 10 ++++ 8 files changed, 157 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 7e8118f..d126639 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ ![image](https://user-images.githubusercontent.com/6599653/114456558-032e2200-9bab-11eb-88bc-a19897f417ba.png) - # Inertia.js Django Adapter ## Installation @@ -8,11 +7,13 @@ ### Backend Install the following python package via pip + ```bash pip install inertia-django ``` Add the Inertia app to your `INSTALLED_APPS` in `settings.py` + ```python INSTALLED_APPS = [ # django apps, @@ -22,6 +23,7 @@ INSTALLED_APPS = [ ``` Add the Inertia middleware to your `MIDDLEWARE` in `settings.py` + ```python MIDDLEWARE = [ # django middleware, @@ -36,27 +38,29 @@ Now you're all set! ### Frontend -Django specific frontend docs coming soon. For now, we recommend installing [django_vite](https://github.com/MrBin99/django-vite) +Django specific frontend docs coming soon. For now, we recommend installing [django_vite](https://github.com/MrBin99/django-vite) and following the commits on the Django Vite [example repo](https://github.com/MrBin99/django-vite-example). Once Vite is setup with your frontend of choice, just replace the contents of `entry.js` with [this file (example in react)](https://github.com/BrandonShar/inertia-rails-template/blob/main/app/frontend/entrypoints/application.jsx) - -You can also check out the official Inertia docs at https://inertiajs.com/. +You can also check out the official Inertia docs at https://inertiajs.com/. ### CSRF -Django's CSRF tokens are tightly coupled with rendering templates so Inertia Django automatically handles adding the CSRF cookie for you to each Inertia response. Because the default names Django users for the CSRF headers don't match Axios (the Javascript request library Inertia uses), we'll need to either modify Axios's defaults OR Django's settings. +Django's CSRF tokens are tightly coupled with rendering templates so Inertia Django automatically handles adding the CSRF cookie for you to each Inertia response. Because the default names Django users for the CSRF headers don't match Axios (the Javascript request library Inertia uses), we'll need to either modify Axios's defaults OR Django's settings. **You only need to choose one of the following options, just pick whichever makes the most sense to you!** In your `entry.js` file + ```javascript -axios.defaults.xsrfHeaderName = "X-CSRFToken" -axios.defaults.xsrfCookieName = "csrftoken" +axios.defaults.xsrfHeaderName = "X-CSRFToken"; +axios.defaults.xsrfCookieName = "csrftoken"; ``` + OR In your Django `settings.py` file + ```python CSRF_HEADER_NAME = 'HTTP_X_XSRF_TOKEN' CSRF_COOKIE_NAME = 'XSRF-TOKEN' @@ -102,7 +106,7 @@ from .models import User def inertia_share(get_response): def middleware(request): - share(request, + share(request, app_name=settings.APP_NAME, user_count=lambda: User.objects.count(), # evaluated lazily at render time user=lambda: request.user, # evaluated lazily at render time @@ -114,7 +118,7 @@ def inertia_share(get_response): ### External Redirects -It is possible to redirect to an external website, or even another non-Inertia endpoint in your app while handling an Inertia request. +It is possible to redirect to an external website, or even another non-Inertia endpoint in your app while handling an Inertia request. This can be accomplished using a server-side initiated `window.location` visit via the `location` method: ```python @@ -124,10 +128,11 @@ def external(): return location("http://foobar.com/") ``` -It will generate a `409 Conflict` response and include the destination URL in the `X-Inertia-Location` header. +It will generate a `409 Conflict` response and include the destination URL in the `X-Inertia-Location` header. When this response is received client-side, Inertia will automatically perform a `window.location = url` visit. ### Lazy Props + On the front end, Inertia supports the concept of "partial reloads" where only the props requested are returned by the server. Sometimes, you may want to use this flow to avoid processing a particularly slow prop on the intial load. In this case, you can use `Lazy props`. Lazy props aren't evaluated unless they're specifically requested by name in a partial reload. @@ -142,10 +147,46 @@ def example(request): } ``` +### Deferred Props + +As of version 2.0, Inertia supports the ability to defer the fetching of props until after the page has been initially rendered. Essentially this is similar to the concept of `Lazy props` however Inertia provides convenient frontend components to automatically fetch the deferred props after the page has initially loaded, instead of requiring the user to initiate a reload. For more info, see [Deferred props](https://inertiajs.com/deferred-props) in the Inertia documentation. + +To mark props as deferred on the server side use the `defer` function. + +```python +from inertia import defer, inertia + +@inertia('ExampleComponent') +def example(request): + return { + 'name': lambda: 'Brandon', # this will be rendered on the first load as usual + 'data': defer(lambda: some_long_calculation()), # this will only be run after the frontend has initially loaded and inertia requests this prop + } +``` + +#### Grouping requests + +By default, all deferred props get fetched in one request after the initial page is rendered, but you can choose to fetch data in parallel by grouping props together. + +```python +from inertia import defer, inertia + +@inertia('ExampleComponent') +def example(request): + return { + 'name': lambda: 'Brandon', # this will be rendered on the first load as usual + 'data': defer(lambda: some_long_calculation()), + 'data1': defer(lambda: some_long_calculation1(), group='group'), + 'data2': defer(lambda: some_long_calculation1(), 'group'), + } +``` + +In the example above, the `data1`, and `data2` props will be fetched in one request, while the `data` prop will be fetched in a separate request in parallel. Group names are arbitrary strings and can be anything you choose. + ### Json Encoding -Inertia Django ships with a custom JsonEncoder at `inertia.utils.InertiaJsonEncoder` that extends Django's -`DjangoJSONEncoder` with additional logic to handle encoding models and Querysets. If you have other json +Inertia Django ships with a custom JsonEncoder at `inertia.utils.InertiaJsonEncoder` that extends Django's +`DjangoJSONEncoder` with additional logic to handle encoding models and Querysets. If you have other json encoding logic you'd prefer, you can set a new JsonEncoder via the settings. ### History Encryption @@ -191,9 +232,11 @@ class LogoutView(auth_views.LogoutView): ### SSR #### Backend + Enable SSR via the `INERTIA_SSR_URL` and `INERTIA_SSR_ENABLED` settings #### Frontend + Coming Soon! ## Settings @@ -225,22 +268,22 @@ class ExampleTestCase(InertiaTestCase): # check the component self.assertComponentUsed('Event/Index') - + # access the component name self.assertEqual(self.component(), 'Event/Index') - + # props (including shared props) self.assertHasExactProps({name: 'Brandon', sport: 'hockey'}) self.assertIncludesProps({sport: 'hockey'}) - + # access props self.assertEquals(self.props()['name'], 'Brandon') - + # template data self.assertHasExactTemplateData({name: 'Brian', sport: 'basketball'}) self.assertIncludesTemplateData({sport: 'basketball'}) - - # access template data + + # access template data self.assertEquals(self.template_data()['name'], 'Brian') ``` @@ -255,6 +298,6 @@ for you to simulate an inertia response. You can access and use it just like the A huge thank you to the community members who have worked on InertiaJS for Django before us. Parts of this repo were particularly inspired by [Andres Vargas](https://github.com/zodman) and [Samuel Girardin](https://github.com/girardinsamuel). Additional thanks to Andres for the Pypi project. -*Maintained and sponsored by the team at [bellaWatt](https://bellawatt.com/)* +_Maintained and sponsored by the team at [bellaWatt](https://bellawatt.com/)_ [![bellaWatt Logo](https://user-images.githubusercontent.com/6599653/114456832-5607d980-9bab-11eb-99c8-ab39867c384e.png)](https://bellawatt.com/) diff --git a/inertia/__init__.py b/inertia/__init__.py index 29ae20d..4f91952 100644 --- a/inertia/__init__.py +++ b/inertia/__init__.py @@ -1,3 +1,3 @@ from .http import inertia, render, location -from .utils import lazy +from .utils import lazy, defer from .share import share diff --git a/inertia/http.py b/inertia/http.py index 57c64a4..a1ccc46 100644 --- a/inertia/http.py +++ b/inertia/http.py @@ -5,7 +5,7 @@ from json import dumps as json_encode from functools import wraps import requests -from .utils import LazyProp +from .utils import DeferredProp, LazyProp INERTIA_REQUEST_ENCRYPT_HISTORY = "_inertia_encrypt_history" INERTIA_SESSION_CLEAR_HISTORY = "_inertia_clear_history" @@ -37,11 +37,22 @@ def build_props(): if key not in partial_keys(): del _props[key] else: - if isinstance(_props[key], LazyProp): + if isinstance(_props[key], LazyProp) or isinstance(_props[key], DeferredProp): del _props[key] return deep_transform_callables(_props) + def build_deferred_props(): + if is_a_partial_render(): + return None + + _deferred_props = {} + for key, prop in props.items(): + if isinstance(prop, DeferredProp): + _deferred_props.setdefault(prop.group, []).append(key) + + return _deferred_props + def render_ssr(): data = json_encode(page_data(), cls=settings.INERTIA_JSON_ENCODER) response = requests.post( @@ -64,7 +75,7 @@ def page_data(): if not isinstance(clear_history, bool): raise TypeError(f"Expected boolean for clear_history, got {type(clear_history).__name__}") - return { + _page = { 'component': component, 'props': build_props(), 'url': request.build_absolute_uri(), @@ -73,6 +84,12 @@ def page_data(): 'clearHistory': clear_history, } + _deferred_props = build_deferred_props() + if _deferred_props: + _page['deferredProps'] = _deferred_props + + return _page + if 'X-Inertia' in request.headers: return JsonResponse( data=page_data(), diff --git a/inertia/test.py b/inertia/test.py index 49d9401..92d3663 100644 --- a/inertia/test.py +++ b/inertia/test.py @@ -53,8 +53,8 @@ def assertHasExactTemplateData(self, template_data): def assertComponentUsed(self, component_name): self.assertEqual(component_name, self.component()) -def inertia_page(url, component='TestComponent', props={}, template_data={}): - return { +def inertia_page(url, component='TestComponent', props={}, template_data={}, deferred_props=None): + _page = { 'component': component, 'props': props, 'url': f'http://testserver/{url}/', @@ -63,6 +63,11 @@ def inertia_page(url, component='TestComponent', props={}, template_data={}): 'clearHistory': False, } + if deferred_props: + _page['deferredProps'] = deferred_props + + return _page + def inertia_div(*args, **kwargs): page = inertia_page(*args, **kwargs) return f'
' diff --git a/inertia/tests/test_rendering.py b/inertia/tests/test_rendering.py index 1893e62..89b93f0 100644 --- a/inertia/tests/test_rendering.py +++ b/inertia/tests/test_rendering.py @@ -109,3 +109,40 @@ def test_that_csrf_is_included_even_on_initial_page_load(self): response = self.client.get('/props/') self.assertIsNotNone(response.cookies.get('csrftoken')) + +class DeferredPropsTestCase(InertiaTestCase): + def test_deferred_props_are_set(self): + self.assertJSONResponse( + self.inertia.get('/defer/'), + inertia_page( + 'defer', + props={'name': 'Brian'}, + deferred_props={'default': ['sport']}) + ) + + def test_deferred_props_are_grouped(self): + self.assertJSONResponse( + self.inertia.get('/defer-group/'), + inertia_page( + 'defer-group', + props={'name': 'Brian'}, + deferred_props={'group': ['sport', 'team'], 'default': ['grit']}) + ) + + def test_deferred_props_are_included_when_requested(self): + self.assertJSONResponse( + self.inertia.get('/defer/', HTTP_X_INERTIA_PARTIAL_DATA='sport', HTTP_X_INERTIA_PARTIAL_COMPONENT='TestComponent'), + inertia_page('defer', props={'sport': 'Basketball'}) + ) + + + def test_only_deferred_props_in_group_are_included_when_requested(self): + self.assertJSONResponse( + self.inertia.get('/defer-group/', HTTP_X_INERTIA_PARTIAL_DATA='sport,team', HTTP_X_INERTIA_PARTIAL_COMPONENT='TestComponent'), + inertia_page('defer-group', props={'sport': 'Basketball', 'team': 'Bulls'}) + ) + + self.assertJSONResponse( + self.inertia.get('/defer-group/', HTTP_X_INERTIA_PARTIAL_DATA='grit', HTTP_X_INERTIA_PARTIAL_COMPONENT='TestComponent'), + inertia_page('defer-group', props={'grit': 'intense'}) + ) diff --git a/inertia/tests/testapp/urls.py b/inertia/tests/testapp/urls.py index 106ab89..80c352c 100644 --- a/inertia/tests/testapp/urls.py +++ b/inertia/tests/testapp/urls.py @@ -8,6 +8,8 @@ path('props/', views.props_test), path('template_data/', views.template_data_test), path('lazy/', views.lazy_test), + path('defer/', views.defer_test), + path('defer-group/', views.defer_group_test), path('complex-props/', views.complex_props_test), path('share/', views.share_test), path('inertia-redirect/', views.inertia_redirect_test), diff --git a/inertia/tests/testapp/views.py b/inertia/tests/testapp/views.py index d4e38e1..1db82d8 100644 --- a/inertia/tests/testapp/views.py +++ b/inertia/tests/testapp/views.py @@ -1,7 +1,7 @@ from django.http.response import HttpResponse from django.shortcuts import redirect from django.utils.decorators import decorator_from_middleware -from inertia import inertia, render, lazy, share, location +from inertia import inertia, render, lazy, defer, share, location from inertia.http import INERTIA_SESSION_CLEAR_HISTORY, clear_history, encrypt_history class ShareMiddleware: @@ -52,6 +52,23 @@ def lazy_test(request): 'grit': lazy(lambda: 'intense'), } +@inertia('TestComponent') +def defer_test(request): + return { + 'name': 'Brian', + 'sport': defer(lambda: 'Basketball') + } + + +@inertia('TestComponent') +def defer_group_test(request): + return { + 'name': 'Brian', + 'sport': defer(lambda: 'Basketball', 'group'), + 'team': defer(lambda: 'Bulls', 'group'), + 'grit': defer(lambda: 'intense') + } + @inertia('TestComponent') def complex_props_test(request): return { diff --git a/inertia/utils.py b/inertia/utils.py index ec33d12..93b96d4 100644 --- a/inertia/utils.py +++ b/inertia/utils.py @@ -23,6 +23,16 @@ def __init__(self, prop): def __call__(self): return self.prop() if callable(self.prop) else self.prop +class DeferredProp: + def __init__(self, prop, group): + self.prop = prop + self.group = group + + def __call__(self): + return self.prop() if callable(self.prop) else self.prop def lazy(prop): return LazyProp(prop) + +def defer(prop, group="default"): + return DeferredProp(prop, group)