Skip to content

Commit 4a59281

Browse files
Merge pull request #2320 from laws-africa/live-editing
Live editing
2 parents abd96e3 + 6bb3485 commit 4a59281

30 files changed

+864
-673
lines changed

.github/workflows/test.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,10 @@ jobs:
5454
- name: Install node dependencies
5555
run: |
5656
npm ci --no-audit --prefer-offline --ignore-scripts
57-
npm install -g jshint sass
57+
npm install -g sass
58+
59+
- name: Run linters
60+
run: npm run lint
5861

5962
- name: Build javascript
6063
run: |
@@ -73,7 +76,6 @@ jobs:
7376
run: |
7477
PATH=/usr/share/fop-2.5/fop:$PATH
7578
coverage run manage.py test
76-
jshint indigo_app/static/javascript/indigo/ --exclude indigo_app/static/javascript/indigo/bluebell-monaco.js
7779
7880
- name: Publish Test Results
7981
uses: EnricoMi/publish-unit-test-result-action/linux@v2

.jshintrc

Lines changed: 0 additions & 3 deletions
This file was deleted.

eslint.config.mjs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import globals from "globals";
2+
import js from "@eslint/js";
3+
4+
export default [
5+
js.configs.recommended,
6+
{
7+
ignores: ["indigo_app/static/javascript/indigo/bluebell-monaco.js"],
8+
},
9+
{
10+
rules: {
11+
"no-undef": "off",
12+
"no-unused-vars": "off"
13+
}
14+
},
15+
{
16+
languageOptions: {
17+
globals: globals.browser
18+
}
19+
},
20+
];

indigo_app/js/components/DocumentTOCView.vue

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,16 @@ export default {
114114
};
115115
116116
function generateToc (node) {
117-
const $node = $(node);
117+
let num = null;
118+
for (const n of node.children) {
119+
if (n.localName === 'num') {
120+
num = n;
121+
break;
122+
}
123+
}
118124
119125
const item = {
120-
num: $node.children('num').text(),
126+
num: num ? num.textContent : '',
121127
heading: getHeadingText(node),
122128
element: node,
123129
type: node.localName,
@@ -226,7 +232,7 @@ export default {
226232
227233
onTitleClick (e) {
228234
e.detail.preventDefault();
229-
if (!Indigo.view.bodyEditorView || Indigo.view.bodyEditorView.canCancelEdits()) {
235+
if (!Indigo.view.sourceEditorView || Indigo.view.sourceEditorView.confirmAndDiscardChanges()) {
230236
this.selectItem(e.target.item.index);
231237
}
232238
}

indigo_app/js/enrichments/popups.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { IEnrichment, IPopupEnrichmentProvider } from '@lawsafrica/indigo-akn/dist/enrichments/popups';
1+
import { IEnrichment, IPopupEnrichmentProvider, PopupEnrichmentManager } from '@lawsafrica/indigo-akn/dist/enrichments/popups';
22
import { IRangeTarget } from '@lawsafrica/indigo-akn/dist/ranges';
33
import { Instance as Tippy } from 'tippy.js';
44
// @ts-ignore
@@ -20,12 +20,19 @@ class LinterEnrichment implements IEnrichment {
2020
export class PopupIssuesProvider implements IPopupEnrichmentProvider {
2121
protected issues: any;
2222
protected vue: any;
23+
protected popupManager: PopupEnrichmentManager;
2324

24-
constructor (issues: any) {
25+
constructor (issues: any, popupManager: PopupEnrichmentManager) {
2526
this.issues = issues;
27+
this.popupManager = popupManager;
2628
this.vue = createComponent('LinterPopup', {propsData: {issue: null}});
2729
this.vue.$on('fix', (issue: any) => issue.fix());
2830
this.vue.$mount();
31+
32+
// a new issue may be added asynchronously, in which case we need to re-apply our enrichments
33+
// @ts-ignore
34+
const reapply = _.debounce(() => this.popupManager.applyProviderEnrichments(this), 200);
35+
this.issues.on('add', reapply);
2936
}
3037

3138
getEnrichments(): IEnrichment[] {

indigo_app/static/javascript/indigo-app.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.

indigo_app/static/javascript/indigo/models.js

Lines changed: 102 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,27 @@
1313
.replace(/'/g, "'");
1414
}
1515

16-
/* A model for the content of a document. The API handles it separately
17-
* to the document metadata since the content can be very big.
16+
/**
17+
* A model for the content of a document. The API handles it separately to the document metadata since the content
18+
* can be very big.
19+
*
20+
* The model also manages an XML DOM form of the document content, and updates the DOM if the text content changes.
21+
* The reverse is not true: the text content is not kept up to date for performance reasons. If the text content
22+
* is required, it must first be serialised with toXml().
1823
*
19-
* The model also manages an XML DOM form of the document content and keeps
20-
* the two in sync.
24+
* This model fires custom events:
2125
*
22-
* This model fires an additional 'change:dom' event when the XML DOM
23-
* tree itself has changed due to a modification by this model.
26+
* - mutation - when the XML DOM is manipulated by any means, based on the MutationObserver class. The parameter
27+
* for the event is a MutationRecord object.
28+
* - change:dom - when the XML DOM is manipulated by any means, after all the mutation events have been fired.
2429
*/
2530
Indigo.DocumentContent = Backbone.Model.extend({
2631
initialize: function(options) {
2732
this.document = options.document;
2833
this.document.content = this;
2934
this.xmlDocument = null;
35+
this.observer = null;
3036
this.on('change:content', this.contentChanged, this);
31-
this.on('change:dom', this.domChanged, this);
3237
},
3338

3439
isNew: function() {
@@ -40,27 +45,91 @@
4045
return this.document.url() + '/content';
4146
},
4247

48+
setupMutationObserver: function () {
49+
this.observer = new MutationObserver((mutations) => {
50+
for (const mutation of mutations) {
51+
console.log('mutation', mutation);
52+
this.trigger('mutation', this, mutation);
53+
}
54+
this.trigger('change:dom', this);
55+
this.trigger('change', this);
56+
});
57+
58+
this.observer.observe(this.xmlDocument, {
59+
childList: true,
60+
attributes: true,
61+
subtree: true,
62+
});
63+
},
64+
65+
/**
66+
* Determine the impact of a mutation on the provided element.
67+
* @param mutation MutationRecord
68+
* @param element Element in this XML document
69+
* @returns 'changed' if the mutation impacts the element, 'removed' if the element was removed from the tree,
70+
* 'replaced', if the element has been replaced (with a node in mutation.addedNodes),
71+
* or null if there is no impact
72+
*/
73+
getMutationImpact(mutation, element) {
74+
const target = mutation.target;
75+
76+
if (mutation.type === 'childList') {
77+
if (mutation.removedNodes[0] === element) {
78+
if (mutation.addedNodes.length === 0) {
79+
// the element itself was removed
80+
return 'removed';
81+
}
82+
83+
// the element has been replaced
84+
return 'replaced';
85+
}
86+
87+
const ownerDocument = target.nodeType === Node.DOCUMENT_NODE ? target : target.ownerDocument;
88+
if (!ownerDocument.contains(element)) {
89+
// the change removed xmlElement from the tree
90+
return 'removed';
91+
}
92+
}
93+
94+
if (target === element || element.contains(target)) {
95+
// the mutated node is xmlElement itself or one of its descendants
96+
return 'changed';
97+
}
98+
99+
// we don't care about attribute or character data changes elsewhere in the document
100+
},
101+
43102
contentChanged: function(model, newValue, options) {
44-
// don't bother updating the DOM if the source of this event
45-
// is already a change to the DOM
46-
if (options && options.fromXmlDocument) return;
103+
let root = null;
47104

48105
try {
49-
this.xmlDocument = $.parseXML(newValue);
106+
root = Indigo.parseXml(newValue);
50107
} catch(e) {
51108
Indigo.errorView.show("The document has invalid XML.");
52109
return;
53110
}
54111

55-
options.fromContent = true;
56-
this.trigger('change:dom', model, options);
57-
},
112+
if (!this.xmlDocument) {
113+
this.xmlDocument = root;
114+
this.setupMutationObserver();
115+
this.trigger('change:dom', this);
116+
this.trigger('change', this);
117+
} else {
118+
root = root.documentElement;
119+
this.xmlDocument.adoptNode(root);
120+
this.xmlDocument.documentElement.replaceWith(root);
121+
}
58122

59-
domChanged: function(model, options) {
60-
// don't bother updating the content if this event was
61-
// originally triggered by a content change
62-
if (options && options.fromContent) return;
123+
// clear the content, so that any change later will always trigger a change event, because we don't keep
124+
// the content synced with the changes in the DOM
125+
this.set('content', '', {silent: true});
126+
},
63127

128+
/**
129+
* Rewrite all eIds and component names to ensure they are correct. This should be done after the DOM structure
130+
* is changed.
131+
*/
132+
rewriteIds: function() {
64133
// rewrite all eIds before setting the content
65134
// in provision mode, retain the eId of the parent element as the prefix
66135
let eidPrefix;
@@ -70,7 +139,6 @@
70139
new indigoAkn.EidRewriter().rewriteAllEids(this.xmlDocument.documentElement, eidPrefix);
71140
// rewrite all attachment FRBR URI work components too
72141
new indigoAkn.WorkComponentRewriter().rewriteAllAttachmentWorkComponents(this.xmlDocument.documentElement);
73-
this.set('content', this.toXml(), {fromXmlDocument: true});
74142
},
75143

76144
// serialise an XML node, or the entire document if node is not given, to a string
@@ -100,16 +168,15 @@
100168

101169
if (!oldNode || !oldNode.parentElement) {
102170
if (del) {
103-
// TODO: we don't currently support deleting whole document
104171
throw "Cannot currently delete the entire document.";
105172
}
106173

107174
// entire document has changed
108175
if (newNodes.length !== 1) {
109176
throw "Expected exactly one newNode, got " + newNodes.length;
110177
}
111-
console.log('Replacing whole document');
112-
this.xmlDocument = first.ownerDocument;
178+
this.xmlDocument.adoptNode(first);
179+
this.xmlDocument.documentElement.replaceWith(first);
113180

114181
} else {
115182
if (del) {
@@ -121,13 +188,14 @@
121188
// just a fragment has changed
122189
console.log('Replacing node with ' + newNodes.length + ' new node(s)');
123190

124-
first = oldNode.ownerDocument.importNode(first, true);
191+
oldNode.ownerDocument.adoptNode(first);
125192
oldNode.parentElement.replaceChild(first, oldNode);
126193

127194
// now append the other nodes, starting at the end
128195
// because it makes the insert easier
129196
for (var i = newNodes.length-1; i > 0; i--) {
130-
var node = first.ownerDocument.importNode(newNodes[i], true);
197+
const node = newNodes[i];
198+
first.ownerDocument.adoptNode(node);
131199

132200
if (first.nextElementSibling) {
133201
first.parentElement.insertBefore(node, first.nextElementSibling);
@@ -138,8 +206,8 @@
138206
}
139207
}
140208

141-
// domChanged will rewrite all eIds
142-
this.trigger('change:dom');
209+
this.rewriteIds();
210+
143211
return first;
144212
},
145213

@@ -159,19 +227,6 @@
159227
return this.xmlDocument.evaluate(expression, context, nsLookup, result);
160228
},
161229

162-
/** Get an array of <act> and <doc> elements for this document.
163-
*/
164-
componentElements: function() {
165-
var components = [],
166-
result = this.xpath('/a:akomaNtoso/a:act/a:meta | /a:akomaNtoso/a:act/a:attachments/a:attachment/a:*/a:meta');
167-
168-
for (var i = 0; i < result.snapshotLength; i++) {
169-
components.push(result.snapshotItem(i).parentElement);
170-
}
171-
172-
return components;
173-
},
174-
175230
/** Get an element by id, which is potentially scoped to a component (eg. "schedule1/table-1").
176231
* @param scopedId
177232
*/
@@ -188,16 +243,18 @@
188243
save: function(options) {
189244
// When saving document contents, save all document details, so that we capture all
190245
// changes in a single revision on the server.
191-
// We do this by delegating to the document object.
192-
let content = this.get('content');
246+
// We do this by delegating the actual save to the document object.
247+
248+
// serialise the xml from the live DOM
249+
let content = this.toXml();
250+
193251
if (Indigo.Preloads.provisionEid) {
194252
content = `<akomaNtoso xmlns="${this.xmlDocument.firstChild.getAttribute('xmlns')}">${content}</akomaNtoso>`;
195253
}
196254
this.document.attributes.content = content;
197255
this.document.attributes.provision_eid = Indigo.Preloads.provisionEid;
198-
var result = this.document.save();
199-
// XXX works around https://github.com/Code4SA/indigo/issues/20 by not parsing
200-
// the response to the save() call
256+
const result = this.document.save();
257+
// don't re-parse the content in the response to the save() call
201258
delete this.document.attributes.content;
202259
this.document.setClean();
203260
this.trigger('sync');
@@ -256,7 +313,7 @@
256313
parts.push(this.get('number'));
257314

258315
// clean the parts
259-
parts = _.map(parts, function(p) { return (p || "").replace(/[ \/]/g, ''); });
316+
parts = _.map(parts, function(p) { return (p || "").replace(/[ /]/g, ''); });
260317

261318
this.set('frbr_uri', parts.join('/').toLowerCase());
262319
},
@@ -445,12 +502,6 @@
445502
return url;
446503
},
447504

448-
setWork: function(work) {
449-
this.set('frbr_uri', work.get('frbr_uri'));
450-
this.work = work;
451-
this.trigger('change change:work');
452-
},
453-
454505
/** Get the Tradition description for this document's country.
455506
*/
456507
tradition: function() {

indigo_app/static/javascript/indigo/utils.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,28 @@ $(function() {
3030
}
3131
}
3232
setTimeout(nukeToasts, 3 * 1000);
33+
34+
/**
35+
* Parses text into an XML document.
36+
* @param text
37+
* @returns {Document}
38+
* @throws {Error} if the text is not valid XML
39+
*/
40+
Indigo.parseXml = function(text) {
41+
const parser = new DOMParser();
42+
const doc = parser.parseFromString(text, "application/xml");
43+
if (doc.querySelector("parsererror")) {
44+
throw Error("Invalid XML: " + new XMLSerializer().serializeToString(doc));
45+
}
46+
return doc;
47+
};
48+
49+
/**
50+
* This converts a jquery deferred into javascript promise/async function
51+
*/
52+
Indigo.deferredToAsync = async function(deferred) {
53+
await new Promise((resolve, reject) => {
54+
deferred.then(resolve).fail(reject);
55+
});
56+
};
3357
});

0 commit comments

Comments
 (0)