Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ venv.bak/
pip-wheel-metadata
TODO.md

# pycharm
.idea/

node_modules/
tags
staticfiles/
16 changes: 10 additions & 6 deletions django_unicorn/components/unicorn_template_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,16 @@ def render(self):
self.component._init_script = init_script
self.component._json_tag = json_tag
else:
json_tags = []
json_tags.append(json_tag)

for child in self.component.children:
init_script = f"{init_script} {child._init_script}"
json_tags.append(child._json_tag)
json_tags = [json_tag]

descendants = []
descendants.append(self.component)
while descendants:
descendant = descendants.pop()
for child in descendant.children:
init_script = f"{init_script} {child._init_script}"
json_tags.append(child._json_tag)
descendants.append(child)

script_tag = soup.new_tag("script")
script_tag["type"] = "module"
Expand Down
60 changes: 30 additions & 30 deletions django_unicorn/components/unicorn_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
UnicornCacheError,
)
from ..settings import get_setting
from ..utils import get_cacheable_component, is_non_string_sequence
from ..utils import CacheableComponent, is_non_string_sequence
from .fields import UnicornField
from .unicorn_template_response import UnicornTemplateResponse

Expand Down Expand Up @@ -166,26 +166,35 @@ def construct_component(


class UnicornView(TemplateView):
response_class = UnicornTemplateResponse
# These class variables are required to set these via kwargs
component_name: str = ""
component_key: str = ""
component_id: str = ""
request = None
parent = None
children = []

# Caches to reduce the amount of time introspecting the class
_methods_cache: Dict[str, Callable] = {}
_attribute_names_cache: List[str] = []
_hook_methods_cache: List[str] = []
def __init__(self, **kwargs):
self.response_class = UnicornTemplateResponse

# Dictionary with key: attribute name; value: pickled attribute value
_resettable_attributes_cache: Dict[str, Any] = {}
self.component_name: str = ""
self.component_key: str = ""
self.component_id: str = ""

# JavaScript method calls
calls = []
# Without these instance variables calling UnicornView() outside the
# Django view/template logic (i.e. in unit tests) results in odd results.
self.request: HttpRequest = None
self.parent: UnicornView = None
self.children: List[UnicornView] = []

# Caches to reduce the amount of time introspecting the class
self._methods_cache: Dict[str, Callable] = {}
self._attribute_names_cache: List[str] = []
self._hook_methods_cache: List[str] = []

# Dictionary with key: attribute name; value: pickled attribute value
self._resettable_attributes_cache: Dict[str, Any] = {}

# JavaScript method calls
self.calls = []

def __init__(self, **kwargs):
super().__init__(**kwargs)

assert self.component_name, "Component name is required"
Expand Down Expand Up @@ -395,27 +404,17 @@ def _cache_component(self, request: HttpRequest, parent=None, **kwargs):
# Put the component's class in a module cache
views_cache[self.component_id] = (self.__class__, parent, kwargs)

cacheable_component = None

# Put the instantiated component into a module cache and the Django cache
try:
cacheable_component = get_cacheable_component(self)
with CacheableComponent(self):
if COMPONENTS_MODULE_CACHE_ENABLED:
constructed_views_cache[self.component_id] = self

cache = caches[get_cache_alias()]
cache.set(self.component_cache_key, self)
except UnicornCacheError as e:
logger.warning(e)

if cacheable_component:
if COMPONENTS_MODULE_CACHE_ENABLED:
constructed_views_cache[self.component_id] = cacheable_component

cache = caches[get_cache_alias()]
cache.set(cacheable_component.component_cache_key, cacheable_component)

# Re-set `request` on the component that got removed when making it cacheable
self.request = request

for child in self.children:
child.request = request

@timed
def get_frontend_context_variables(self) -> str:
"""
Expand Down Expand Up @@ -509,6 +508,7 @@ def get_context_data(self, **kwargs):
"component_id": self.component_id,
"component_name": self.component_name,
"component_key": self.component_key,
"component": self,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm relatively new to Django (in the last year) so I'm not too sure on how bad this would be to add to the context.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this might be fine, but I do wonder if it exposes some data that shouldn't be available in the template. What data did you need in the template? If it was just parent and children, we could explicitly add them to the context? The other approach is add component like you have, but deprecate component_id, component_key, component_name since that would be duplicative going forward.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought of deprecating those three component pieces, but figured it might break anyone dependent on them. If you're going to bump the minor version, it seems like a safer change to deprecate them.

On what I need component for: I'm using it to set the 'implied' parent. Basically I found that view was always only set to the highest level component, so it wouldn't work to pass in parent=view past the first level. Since this unicorn info is set in the context as it goes down the tree, it was the easiest way to get the parent to each child at each level. I could have used component_id/key/name to look up the parent again, but that seems inefficient when the parent was just called before the child.

It does expose the whole component to the template when all I really need it for is to set the parent of each child. However I didn't think of a cleaner way that wouldn't add look ups or complications.

"errors": self.errors,
}
}
Expand Down
12 changes: 10 additions & 2 deletions django_unicorn/static/unicorn/js/component.js
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ export class Component {
} else {
// Can hard-code `forceModelUpdate` to `true` since it is always required for
// `callMethod` actions
this.setModelValues(triggeringElements, true);
this.setModelValues(triggeringElements, true, true);
}
});
}
Expand Down Expand Up @@ -448,9 +448,10 @@ export class Component {
* Sets all model values.
* @param {[Element]} triggeringElements The elements that triggered the event.
*/
setModelValues(triggeringElements, forceModelUpdates) {
setModelValues(triggeringElements, forceModelUpdates, updateParents) {
triggeringElements = triggeringElements || [];
forceModelUpdates = forceModelUpdates || false;
updateParents = updateParents || false;

let lastTriggeringElement = null;

Expand Down Expand Up @@ -490,6 +491,13 @@ export class Component {
this.setValue(element);
}
});

if (updateParents) {
const parent = this.getParentComponent();
if (parent) {
parent.setModelValues(triggeringElements, forceModelUpdates, updateParents);
}
}
}

/**
Expand Down
2 changes: 1 addition & 1 deletion django_unicorn/static/unicorn/js/eventListeners.js
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ export function addModelEventListener(component, element, eventType) {
triggeringElements.push(element);
}

component.setModelValues(triggeringElements, forceModelUpdate);
component.setModelValues(triggeringElements, forceModelUpdate, true);
}
}
);
Expand Down
5 changes: 3 additions & 2 deletions django_unicorn/static/unicorn/js/messageSender.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export function send(component, callback) {
component.return = responseJson.return || {};
component.hash = responseJson.hash;

const parent = responseJson.parent || {};
let parent = responseJson.parent || {};
const rerenderedComponent = responseJson.dom || {};
const partials = responseJson.partials || [];
const { checksum } = responseJson;
Expand All @@ -160,7 +160,7 @@ export function send(component, callback) {
}

// Refresh the parent component if there is one
if (hasValue(parent) && hasValue(parent.id)) {
while (hasValue(parent) && hasValue(parent.id)) {
const parentComponent = component.getParentComponent(parent.id);

if (parentComponent && parentComponent.id === parent.id) {
Expand Down Expand Up @@ -193,6 +193,7 @@ export function send(component, callback) {
child.refreshEventListeners();
});
}
parent = parent.parent || {};
}

if (partials.length > 0) {
Expand Down
4 changes: 2 additions & 2 deletions django_unicorn/static/unicorn/js/unicorn.min.js

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions django_unicorn/templatetags/unicorn.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,17 @@ def render(self, context):

if "parent" in resolved_kwargs:
self.parent = resolved_kwargs.pop("parent")
else:
# if there is no explicit parent, but this node is rendering under an existing
# unicorn template, set that as the parent
try:
implicit_parent = template.Variable("unicorn.component").resolve(
context
)
if implicit_parent:
self.parent = implicit_parent
except template.VariableDoesNotExist:
pass # no implicit parent present

component_id = None

Expand Down
70 changes: 46 additions & 24 deletions django_unicorn/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,33 +72,55 @@ def dicts_equal(dictionary_one: Dict, dictionary_two: Dict) -> bool:
return is_valid


def get_cacheable_component(
component: "django_unicorn.views.UnicornView",
) -> "django_unicorn.views.UnicornView":
class CacheableComponent:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll be interested in what you think of this approach. I found having to create the cacheable component, then restore back the extra_context/request everywhere was scary. Meaning I could forget a spot and not notice. I would hope the enter/exit/with approach would help with that, but maybe its awkward.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a much better pattern and using a context manager totally makes sense for this use case.

"""
Converts a component into something that is cacheable/pickleable.
Updates a component into something that is cacheable/pickleable. Use in a `with` statement
or explicitly call `__enter__` `__exit__` to use. It will restore the original component
on exit.
"""

component.request = None

if component.extra_context:
component.extra_context = None

if component.parent:
component.parent = get_cacheable_component(component.parent)

for child in component.children:
if child.request is not None:
child = get_cacheable_component(child)

try:
pickle.dumps(component)
except (TypeError, AttributeError, NotImplementedError, pickle.PicklingError) as e:
raise UnicornCacheError(
f"Cannot cache component '{type(component)}' because it is not picklable: {type(e)}: {e}"
) from e

return component
def __init__(self, component: "django_unicorn.views.UnicornView"):
self._state = {}
self.cacheable_component = component

def __enter__(self):
components = []
components.append(self.cacheable_component)
while components:
component = components.pop()
if component.component_id in self._state:
continue
if hasattr(component, "extra_context"):
extra_context = component.extra_context
component.extra_context = None
else:
extra_context = None
request = component.request
component.request = None
self._state[component.component_id] = (component, request, extra_context)
if component.parent:
components.append(component.parent)
for child in component.children:
components.append(child)

for component, _, _ in self._state.values():
try:
pickle.dumps(component)
except (
TypeError,
AttributeError,
NotImplementedError,
pickle.PicklingError,
) as e:
raise UnicornCacheError(
f"Cannot cache component '{type(component)}' because it is not picklable: {type(e)}: {e}"
) from e

def __exit__(self, *args):
for component, request, extra_context in self._state.values():
component.request = request
if extra_context:
component.extra_context = extra_context


def get_type_hints(obj) -> Dict:
Expand Down
23 changes: 15 additions & 8 deletions django_unicorn/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
get_serial_enabled,
get_serial_timeout,
)
from django_unicorn.utils import generate_checksum, get_cacheable_component
from django_unicorn.utils import CacheableComponent, generate_checksum
from django_unicorn.views.action_parsers import call_method, sync_input
from django_unicorn.views.objects import ComponentRequest
from django_unicorn.views.utils import set_property_from_data
Expand Down Expand Up @@ -207,7 +207,8 @@ def _process_component_request(
cache = caches[get_cache_alias()]

try:
cache.set(component.component_cache_key, get_cacheable_component(component))
with CacheableComponent(component):
cache.set(component.component_cache_key, component)
except UnicornCacheError as e:
logger.warning(e)

Expand Down Expand Up @@ -307,8 +308,9 @@ def _process_component_request(
)

parent_component = component.parent
parent_res = res

if parent_component:
while parent_component:
# TODO: Should parent_component.hydrate() be called?
parent_frontend_context_variables = loads(
parent_component.get_frontend_context_variables()
Expand All @@ -325,10 +327,12 @@ def _process_component_request(
component.parent_rendered(parent_dom)

try:
cache.set(
parent_component.component_cache_key,
get_cacheable_component(parent_component),
)

with CacheableComponent(parent_component):
cache.set(
parent_component.component_cache_key,
parent_component,
)
except UnicornCacheError as e:
logger.warning(e)

Expand All @@ -340,7 +344,10 @@ def _process_component_request(
}
)

res.update({"parent": parent})
parent_res.update({"parent": parent})
component = parent_component
parent_component = parent_component.parent
parent_res = parent

return res

Expand Down
6 changes: 6 additions & 0 deletions docs/source/changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## v0.49.3

- Fix: Support nested children past the first level ([#476](https://github.com/adamghill/django-unicorn/pull/507).

[All changes since 0.49.2](https://github.com/adamghill/django-unicorn/compare/0.49.2...0.49.3).

## v0.49.2

- Fix: Calling methods with a model typehint would fail after being called multiple times ([#476](https://github.com/adamghill/django-unicorn/pull/476) by [stat1c-void](https://github.com/stat1c-void)).
Expand Down
3 changes: 2 additions & 1 deletion example/coffee/management/commands/import_flavors.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from django.core.management.base import BaseCommand, CommandError

from ...models import Flavor
from ...models import Favorite, Flavor


class Command(BaseCommand):
Expand All @@ -20,3 +20,4 @@ def handle(self, *args, **options):
parent = Flavor.objects.filter(name=parent_name).first()
flavor = Flavor(name=name, label=label, parent=parent)
flavor.save()
Favorite.objects.create(flavor=flavor)
Loading