Skip to content

Commit f9ce712

Browse files
Implement unicorn:dirty targeting support
1 parent dc4ede5 commit f9ce712

File tree

5 files changed

+304
-1
lines changed

5 files changed

+304
-1
lines changed

src/django_unicorn/static/unicorn/js/component.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ export class Component {
174174
this.actionEvents = {};
175175
this.modelEls = [];
176176
this.loadingEls = [];
177+
this.dirtyEls = [];
177178
this.visibilityEls = [];
178179

179180
try {
@@ -212,6 +213,14 @@ export class Component {
212213
}
213214
}
214215

216+
// Collect elements that carry u:dirty but no u:model so that
217+
// handleDirty() can apply/revert their state when a targeted
218+
// model input changes. An element can be in both dirtyEls and
219+
// loadingEls if it has both attributes.
220+
if (!hasValue(element.model) && hasValue(element.dirty)) {
221+
this.dirtyEls.push(element);
222+
}
223+
215224
if (hasValue(element.key)) {
216225
this.keyEls.push(element);
217226
}

src/django_unicorn/static/unicorn/js/eventListeners.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,49 @@ export function handleLoading(component, targetElement) {
7979
});
8080
}
8181

82+
/**
83+
* Handles dirty elements in the component that target a model input element.
84+
*
85+
* Elements with `u:dirty` (and no `u:model`) are collected into
86+
* `component.dirtyEls`. When a model input changes, this function applies or
87+
* reverts the dirty state on every dirty element whose `u:target` matches the
88+
* triggering model element.
89+
*
90+
* Untargeted dirty elements (no `u:target`) are always dirtied on any model
91+
* change, but are only cleared after a server response (via messageSender).
92+
*
93+
* @param {Component} component Component.
94+
* @param {Element} modelElement The model Element that changed.
95+
* @param {boolean} revert Pass `true` to revert the dirty state.
96+
*/
97+
export function handleDirty(component, modelElement, revert) {
98+
component.dirtyEls.forEach((dirtyElement) => {
99+
if (dirtyElement.target) {
100+
// Targeted: apply/revert only when the triggering model element
101+
// matches the specified target (looked up by id then by unicorn:key).
102+
let targetedEl = $(`#${dirtyElement.target}`, component.root);
103+
104+
if (!targetedEl) {
105+
component.keyEls.forEach((keyElement) => {
106+
if (!targetedEl && keyElement.key === dirtyElement.target) {
107+
targetedEl = keyElement.el;
108+
}
109+
});
110+
}
111+
112+
if (targetedEl && modelElement.el.isSameNode(targetedEl)) {
113+
dirtyElement.handleDirty(revert);
114+
}
115+
} else {
116+
// Untargeted: become dirty on any model change. Do not auto-revert
117+
// during editing — the element is only cleared after a server response.
118+
if (!revert) {
119+
dirtyElement.handleDirty(false);
120+
}
121+
}
122+
});
123+
}
124+
82125
/**
83126
* Parse arguments and deal with nested data.
84127
*
@@ -268,8 +311,10 @@ export function addModelEventListener(component, element, eventType) {
268311
if (component.data[element.model.name] !== element.getValue()) {
269312
isDirty = true;
270313
element.handleDirty();
314+
handleDirty(component, element);
271315
} else {
272316
element.handleDirty(true);
317+
handleDirty(component, element, true);
273318
}
274319

275320
if (element.model.isLazy) {

src/django_unicorn/static/unicorn/js/messageSender.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,13 @@ export function send(component, callback) {
154154
element.handleDirty(true);
155155
});
156156

157+
// Revert any separate dirty elements (u:dirty without u:model) that
158+
// target a model input. These are not in modelEls so they need to be
159+
// reset explicitly after the server has synced state.
160+
component.dirtyEls.forEach((dirtyElement) => {
161+
dirtyElement.handleDirty(true);
162+
});
163+
157164
// Merge the data from the response into the component's data
158165
Object.keys(responseJson.data || {}).forEach((key) => {
159166
component.data[key] = responseJson.data[key];

src/django_unicorn/static/unicorn/js/unicorn.min.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/js/component/dirty.test.js

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import test from "ava";
2+
import { getComponent } from "../utils.js";
3+
import { handleDirty } from "../../../src/django_unicorn/static/unicorn/js/eventListeners.js";
4+
5+
test("dirtyEls: element with u:dirty.class and no u:model is collected", (t) => {
6+
const html = `
7+
<div unicorn:id="5jypjiyb" unicorn:name="text-inputs" unicorn:meta="GXzew3Km">
8+
<input u:model="name" id="nameInput">
9+
<div u:dirty.class="is-dirty" u:target="nameInput"></div>
10+
</div>`;
11+
const component = getComponent(html);
12+
13+
t.is(component.dirtyEls.length, 1);
14+
t.deepEqual(component.dirtyEls[0].dirty.classes, ["is-dirty"]);
15+
});
16+
17+
test("dirtyEls: element with u:model is NOT added to dirtyEls (self-dirty handled inline)", (t) => {
18+
const html = `
19+
<div unicorn:id="5jypjiyb" unicorn:name="text-inputs" unicorn:meta="GXzew3Km">
20+
<input u:model="name" u:dirty.class="is-dirty">
21+
</div>`;
22+
const component = getComponent(html);
23+
24+
t.is(component.dirtyEls.length, 0);
25+
t.is(component.modelEls.length, 1);
26+
});
27+
28+
test("dirtyEls: multiple separate dirty elements are all collected", (t) => {
29+
const html = `
30+
<div unicorn:id="5jypjiyb" unicorn:name="text-inputs" unicorn:meta="GXzew3Km">
31+
<input u:model="name" id="nameInput">
32+
<div id="wrapper" u:dirty.class="is-dirty" u:target="nameInput"></div>
33+
<span u:dirty.attr="disabled" u:target="nameInput"></span>
34+
</div>`;
35+
const component = getComponent(html);
36+
37+
t.is(component.dirtyEls.length, 2);
38+
});
39+
40+
test("dirtyEls: element without u:dirty modifier produces empty dirty object and is NOT collected", (t) => {
41+
// A bare u:dirty with no modifier like .class or .attr results in an empty
42+
// dirty object, which should not be added to dirtyEls.
43+
const html = `
44+
<div unicorn:id="5jypjiyb" unicorn:name="text-inputs" unicorn:meta="GXzew3Km">
45+
<input u:model="name">
46+
</div>`;
47+
const component = getComponent(html);
48+
49+
t.is(component.dirtyEls.length, 0);
50+
});
51+
52+
test("handleDirty: adds dirty class to element that targets the changed model input (by id)", (t) => {
53+
const html = `
54+
<div unicorn:id="5jypjiyb" unicorn:name="text-inputs" unicorn:meta="GXzew3Km">
55+
<input u:model="name" id="nameInput">
56+
<div u:dirty.class="is-dirty" u:target="nameInput"></div>
57+
</div>`;
58+
const component = getComponent(html);
59+
60+
const modelElement = component.modelEls[0];
61+
const dirtyElement = component.dirtyEls[0];
62+
63+
t.is(dirtyElement.el.classList.length, 0);
64+
65+
handleDirty(component, modelElement);
66+
67+
t.is(dirtyElement.el.classList.length, 1);
68+
t.is(dirtyElement.el.classList[0], "is-dirty");
69+
});
70+
71+
test("handleDirty: reverts dirty class when model input returns to original value", (t) => {
72+
const html = `
73+
<div unicorn:id="5jypjiyb" unicorn:name="text-inputs" unicorn:meta="GXzew3Km">
74+
<input u:model="name" id="nameInput">
75+
<div u:dirty.class="is-dirty is-already-dirty" u:target="nameInput" class="is-dirty is-already-dirty"></div>
76+
</div>`;
77+
const component = getComponent(html);
78+
79+
const modelElement = component.modelEls[0];
80+
const dirtyElement = component.dirtyEls[0];
81+
82+
t.is(dirtyElement.el.classList.length, 2);
83+
84+
handleDirty(component, modelElement, true);
85+
86+
t.is(dirtyElement.el.classList.length, 0);
87+
});
88+
89+
test("handleDirty: does NOT touch a dirty element that targets a different id", (t) => {
90+
const html = `
91+
<div unicorn:id="5jypjiyb" unicorn:name="text-inputs" unicorn:meta="GXzew3Km">
92+
<input u:model="name" id="nameInput">
93+
<input u:model="email" id="emailInput">
94+
<div u:dirty.class="is-dirty" u:target="emailInput"></div>
95+
</div>`;
96+
const component = getComponent(html);
97+
98+
const nameModelElement = component.modelEls[0];
99+
const dirtyElement = component.dirtyEls[0];
100+
101+
t.is(dirtyElement.el.classList.length, 0);
102+
103+
// Only name changed — dirty element targets emailInput, so it must stay clean
104+
handleDirty(component, nameModelElement);
105+
106+
t.is(dirtyElement.el.classList.length, 0);
107+
});
108+
109+
test("handleDirty: adds dirty class to element that targets the model input by key", (t) => {
110+
const html = `
111+
<div unicorn:id="5jypjiyb" unicorn:name="text-inputs" unicorn:meta="GXzew3Km">
112+
<input u:model="name" u:key="nameKey">
113+
<div u:dirty.class="is-dirty" u:target="nameKey"></div>
114+
</div>`;
115+
const component = getComponent(html);
116+
117+
const modelElement = component.modelEls[0];
118+
const dirtyElement = component.dirtyEls[0];
119+
120+
t.is(dirtyElement.el.classList.length, 0);
121+
122+
handleDirty(component, modelElement);
123+
124+
t.is(dirtyElement.el.classList.length, 1);
125+
t.is(dirtyElement.el.classList[0], "is-dirty");
126+
});
127+
128+
129+
test("handleDirty: sets attr on element that targets the model input", (t) => {
130+
const html = `
131+
<div unicorn:id="5jypjiyb" unicorn:name="text-inputs" unicorn:meta="GXzew3Km">
132+
<input u:model="name" id="nameInput">
133+
<div u:dirty.attr="readonly" u:target="nameInput"></div>
134+
</div>`;
135+
const component = getComponent(html);
136+
137+
const modelElement = component.modelEls[0];
138+
const dirtyElement = component.dirtyEls[0];
139+
140+
// Note: the test walker overrides getAttribute, so use hasAttribute instead
141+
t.false(dirtyElement.el.hasAttribute("readonly"));
142+
143+
handleDirty(component, modelElement);
144+
145+
t.true(dirtyElement.el.hasAttribute("readonly"));
146+
});
147+
148+
test("handleDirty: removes attr when reverting on targeted element", (t) => {
149+
const html = `
150+
<div unicorn:id="5jypjiyb" unicorn:name="text-inputs" unicorn:meta="GXzew3Km">
151+
<input u:model="name" id="nameInput">
152+
<div u:dirty.attr="readonly" u:target="nameInput" readonly="readonly"></div>
153+
</div>`;
154+
const component = getComponent(html);
155+
156+
const modelElement = component.modelEls[0];
157+
const dirtyElement = component.dirtyEls[0];
158+
159+
t.true(dirtyElement.el.hasAttribute("readonly"));
160+
161+
handleDirty(component, modelElement, true);
162+
163+
t.false(dirtyElement.el.hasAttribute("readonly"));
164+
});
165+
166+
167+
test("handleDirty: untargeted dirty element becomes dirty on any model change", (t) => {
168+
const html = `
169+
<div unicorn:id="5jypjiyb" unicorn:name="text-inputs" unicorn:meta="GXzew3Km">
170+
<input u:model="name" id="nameInput">
171+
<div u:dirty.class="form-changed"></div>
172+
</div>`;
173+
const component = getComponent(html);
174+
175+
const modelElement = component.modelEls[0];
176+
const dirtyElement = component.dirtyEls[0];
177+
178+
t.is(dirtyElement.el.classList.length, 0);
179+
180+
handleDirty(component, modelElement);
181+
182+
t.is(dirtyElement.el.classList.length, 1);
183+
t.is(dirtyElement.el.classList[0], "form-changed");
184+
});
185+
186+
test("handleDirty: untargeted dirty element is NOT auto-reverted during editing (only after server response)", (t) => {
187+
const html = `
188+
<div unicorn:id="5jypjiyb" unicorn:name="text-inputs" unicorn:meta="GXzew3Km">
189+
<input u:model="name" id="nameInput">
190+
<div u:dirty.class="form-changed" class="form-changed"></div>
191+
</div>`;
192+
const component = getComponent(html);
193+
194+
const modelElement = component.modelEls[0];
195+
const dirtyElement = component.dirtyEls[0];
196+
197+
// Class is already present (element was dirtied earlier)
198+
t.is(dirtyElement.el.classList.length, 1);
199+
200+
handleDirty(component, modelElement, true);
201+
202+
t.is(dirtyElement.el.classList.length, 1);
203+
t.is(dirtyElement.el.classList[0], "form-changed");
204+
});
205+
206+
test("handleDirty: removes class on targeted element (class.remove modifier)", (t) => {
207+
const html = `
208+
<div unicorn:id="5jypjiyb" unicorn:name="text-inputs" unicorn:meta="GXzew3Km">
209+
<input u:model="name" id="nameInput">
210+
<div u:dirty.class.remove="btn-clean" u:target="nameInput" class="btn-clean"></div>
211+
</div>`;
212+
const component = getComponent(html);
213+
214+
const modelElement = component.modelEls[0];
215+
const dirtyElement = component.dirtyEls[0];
216+
217+
t.is(dirtyElement.el.classList.length, 1);
218+
t.is(dirtyElement.el.classList[0], "btn-clean");
219+
220+
handleDirty(component, modelElement);
221+
222+
t.is(dirtyElement.el.classList.length, 0);
223+
});
224+
225+
test("handleDirty: restores removed class when reverting on targeted element", (t) => {
226+
const html = `
227+
<div unicorn:id="5jypjiyb" unicorn:name="text-inputs" unicorn:meta="GXzew3Km">
228+
<input u:model="name" id="nameInput">
229+
<div u:dirty.class.remove="btn-clean" u:target="nameInput" class=""></div>
230+
</div>`;
231+
const component = getComponent(html);
232+
233+
const modelElement = component.modelEls[0];
234+
const dirtyElement = component.dirtyEls[0];
235+
236+
t.is(dirtyElement.el.classList.length, 0);
237+
238+
handleDirty(component, modelElement, true);
239+
240+
t.is(dirtyElement.el.classList.length, 1);
241+
t.is(dirtyElement.el.classList[0], "btn-clean");
242+
});

0 commit comments

Comments
 (0)