Skip to content

Commit

Permalink
Target DOM changes from an action to only update a part of the DOM wi…
Browse files Browse the repository at this point in the history
…th `partial` attribute.
  • Loading branch information
adamghill committed Jan 24, 2021
1 parent 6a122ee commit a1c7973
Show file tree
Hide file tree
Showing 13 changed files with 453 additions and 20 deletions.
3 changes: 3 additions & 0 deletions django_unicorn/static/js/attribute.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export class Attribute {
this.isPoll = false;
this.isLoading = false;
this.isTarget = false;
this.isPartial = false;
this.isDirty = false;
this.isKey = false;
this.isPK = false;
Expand Down Expand Up @@ -46,6 +47,8 @@ export class Attribute {
this.isLoading = true;
} else if (contains(this.name, ":target")) {
this.isTarget = true;
} else if (contains(this.name, ":partial")) {
this.isPartial = true;
} else if (contains(this.name, ":dirty")) {
this.isDirty = true;
} else if (this.name === "unicorn:key" || this.name === "u:key") {
Expand Down
11 changes: 6 additions & 5 deletions django_unicorn/static/js/component.js
Original file line number Diff line number Diff line change
Expand Up @@ -216,10 +216,11 @@ export class Component {
/**
* Calls the method for a particular component.
*/
callMethod(methodName, errCallback) {
callMethod(methodName, partial, errCallback) {
const action = {
type: "callMethod",
payload: { name: methodName },
partial,
};
this.actionQueue.push(action);

Expand Down Expand Up @@ -276,7 +277,7 @@ export class Component {
);

// Call the method once before the timer starts
this.callMethod(this.poll.method, this.handlePollError);
this.callMethod(this.poll.method, null, this.handlePollError);
this.startPolling();
}
}
Expand All @@ -293,15 +294,15 @@ export class Component {
this.poll.disableData = this.poll.disableData.slice(1);

if (this.data[this.poll.disableData]) {
this.callMethod(this.poll.method, this.handlePollError);
this.callMethod(this.poll.method, null, this.handlePollError);
}

this.poll.disableData = `!${this.poll.disableData}`;
} else if (!this.data[this.poll.disableData]) {
this.callMethod(this.poll.method, this.handlePollError);
this.callMethod(this.poll.method, null, this.handlePollError);
}
} else {
this.callMethod(this.poll.method, this.handlePollError);
this.callMethod(this.poll.method, null, this.handlePollError);
}
}
}, this.poll.timing);
Expand Down
9 changes: 9 additions & 0 deletions django_unicorn/static/js/element.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export class Element {
this.db = {};
this.field = {};
this.target = null;
this.partial = {};
this.key = null;
this.events = [];
this.errors = [];
Expand Down Expand Up @@ -97,6 +98,14 @@ export class Element {
}
} else if (attribute.isTarget) {
this.target = attribute.value;
} else if (attribute.isPartial) {
if (attribute.modifiers.id) {
this.partial.id = attribute.value;
} else if (attribute.modifiers.key) {
this.partial.key = attribute.value;
} else {
this.partial.target = attribute.value;
}
} else if (attribute.eventType) {
const action = {};
action.name = attribute.value;
Expand Down
7 changes: 5 additions & 2 deletions django_unicorn/static/js/eventListeners.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ export function addActionEventListener(component, eventType) {
let targetElement = new Element(event.target);

// Make sure that the target element is a unicorn element.
// Handles events fired from an element inside a unicorn element
// e.g. <button u:click="click"><span>Click!</span></button>
if (targetElement && !targetElement.isUnicorn) {
targetElement = targetElement.getUnicornParent();
}
Expand Down Expand Up @@ -138,6 +140,7 @@ export function addActionEventListener(component, eventType) {
}
});

// Add the value of any child element of the target that is a lazy db to the action queue
const dbElsInTargetScope = component.dbEls.filter((e) =>
e.el.isSameNode(childEl)
);
Expand Down Expand Up @@ -238,11 +241,11 @@ export function addActionEventListener(component, eventType) {
if (action.key) {
if (action.key === toKebabCase(event.key)) {
handleLoading(component, targetElement);
component.callMethod(action.name);
component.callMethod(action.name, targetElement.partial);
}
} else {
handleLoading(component, targetElement);
component.callMethod(action.name);
component.callMethod(action.name, targetElement.partial);
}
}
});
Expand Down
34 changes: 31 additions & 3 deletions django_unicorn/static/js/messageSender.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getCsrfToken, hasValue, isFunction } from "./utils.js";
import { $, getCsrfToken, hasValue, isFunction } from "./utils.js";
import { MORPHDOM_OPTIONS } from "./morphdom/2.6.1/options.js";

/**
Expand Down Expand Up @@ -104,7 +104,9 @@ export function send(component, callback) {
component.return = responseJson.return || {};

const parent = responseJson.parent || {};
const rerenderedComponent = responseJson.dom;
const rerenderedComponent = responseJson.dom || {};
const partials = responseJson.partials || [];
const { checksum } = responseJson;

// Handle poll
const poll = responseJson.poll || {};
Expand Down Expand Up @@ -146,7 +148,33 @@ export function send(component, callback) {
}
}

component.morphdom(component.root, rerenderedComponent, MORPHDOM_OPTIONS);
if (partials.length > 0) {
for (let i = 0; i < partials.length; i++) {
const partial = partials[i];
let targetDom = null;

if (partial.key) {
targetDom = $(`[unicorn\\:key="${partial.key}"]`, component.root);
} else if (partial.id) {
targetDom = $(`#${partial.id}`, component.root);
}

if (targetDom) {
component.morphdom(targetDom, partial.dom, MORPHDOM_OPTIONS);
}
}

if (checksum) {
component.root.setAttribute("unicorn:checksum", checksum);
component.refreshChecksum();
}
} else {
component.morphdom(
component.root,
rerenderedComponent,
MORPHDOM_OPTIONS
);
}

// Re-init to refresh the root and checksum based on the new data
component.init();
Expand Down
2 changes: 1 addition & 1 deletion django_unicorn/static/js/unicorn.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export function getComponent(componentNameOrKey) {
export function call(componentNameOrKey, methodName) {
const component = getComponent(componentNameOrKey);

component.callMethod(methodName, (err) => {
component.callMethod(methodName, null, (err) => {
console.error(err);
});
}
Expand Down
60 changes: 56 additions & 4 deletions django_unicorn/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from .errors import UnicornViewError
from .message import ComponentRequest, Return
from .serializer import loads
from .utils import generate_checksum


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -227,10 +228,12 @@ def message(request: HttpRequest, component_name: str = None) -> JsonResponse:

is_reset_called = False
return_data = None
partials = []

for action in component_request.action_queue:
action_type = action.get("type")
payload = action.get("payload", {})
partials.append(action.get("partial"))

if action_type == "syncInput":
property_name = payload.get("name")
Expand Down Expand Up @@ -290,6 +293,8 @@ def message(request: HttpRequest, component_name: str = None) -> JsonResponse:
instance.save()
pk = instance.pk
elif action_type == "callMethod":
print("payload", payload)

call_method_name = payload.get("name", "")
assert call_method_name, "Missing 'name' key for callMethod"

Expand Down Expand Up @@ -366,14 +371,61 @@ def message(request: HttpRequest, component_name: str = None) -> JsonResponse:
component.validate(model_names=model_names_to_validate)

rendered_component = component.render()
partial_doms = []

if partials and all(partials):
soup = BeautifulSoup(rendered_component, features="html.parser")

for partial in partials:
partial_found = False
only_id = False
only_key = False

target = partial.get("target")

if not target:
target = partial.get("key")

if target:
only_key = True

if not target:
target = partial.get("id")

if target:
only_id = True

assert target, "Partial target is required"

if not only_id:
for element in soup.find_all():
if (
"unicorn:key" in element.attrs
and element.attrs["unicorn:key"] == target
):
partial_doms.append({"key": target, "dom": str(element)})
partial_found = True
break

if not partial_found and not only_key:
for element in soup.find_all():
if "id" in element.attrs and element.attrs["id"] == target:
partial_doms.append({"id": target, "dom": str(element)})
partial_found = True
break

res = {
"id": component_request.id,
"dom": rendered_component,
"data": component_request.data,
"errors": component.errors,
"checksum": generate_checksum(orjson.dumps(component_request.data)),
}

if partial_doms:
res.update({"partials": partial_doms})
else:
res.update({"dom": rendered_component})

if return_data:
res.update(
{"return": return_data.get_data(),}
Expand All @@ -396,9 +448,9 @@ def message(request: HttpRequest, component_name: str = None) -> JsonResponse:
parent_component.get_frontend_context_variables()
)

dom = parent_component.render()
parent_dom = parent_component.render()

soup = BeautifulSoup(dom, features="html.parser")
soup = BeautifulSoup(parent_dom, features="html.parser")
checksum = None

# TODO: This doesn't create the same checksum for some reason
Expand All @@ -413,7 +465,7 @@ def message(request: HttpRequest, component_name: str = None) -> JsonResponse:
{
"parent": {
"id": parent_component.component_id,
"dom": dom,
"dom": parent_dom,
"checksum": checksum,
"data": loads(parent_frontend_context_variables),
"errors": parent_component.errors,
Expand Down
13 changes: 9 additions & 4 deletions example/unicorn/templates/unicorn/html-inputs.html
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
<div>
<div>
<div id="boolean-toggles-id">
<h2>Checkbox</h2>
<input type="checkbox" unicorn:model="is_checked" id="check">
{% if is_checked %}Yes! 🦄{% else %}Nope 🙁{% endif %}
</div>

<div>
<div unicorn:key="boolean-toggles-key">
<h2>Boolean Toggles</h2>
{% if another_check %}YAYAYA{% else %}Noooooooo{% endif %}<br />
<button unicorn:click="$toggle('is_checked', 'another_check')" id="toggle-check">Toggle booleans</button>
{% if another_check %}Checked ✅{% else %}Not checked ❎{% endif %}
<br />
<button unicorn:click="$toggle('is_checked', 'another_check')">Toggle booleans (normal)</button><br />
<button unicorn:click="$toggle('is_checked', 'another_check')" u:partial.key="boolean-toggles-key">Toggle booleans (partial.key)</button>
<button unicorn:click="$toggle('is_checked', 'another_check')" u:partial.id="boolean-toggles-id">Toggle booleans (partial.id)</button><br />
<button unicorn:click="$toggle('is_checked', 'another_check')" u:partial="boolean-toggles-id">Toggle booleans (partial boolean-toggles-id)</button>
<button unicorn:click="$toggle('is_checked', 'another_check')" u:partial="boolean-toggles-key">Toggle booleans (partial boolean-toggles-key)</button>
</div>

<div>
Expand Down
2 changes: 1 addition & 1 deletion example/www/templates/www/html-inputs.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{% extends "www/base.html" %}
{% load static unicorn %}
{% load unicorn %}

{% block content %}

Expand Down
Loading

0 comments on commit a1c7973

Please sign in to comment.