@jinntec/fore 2.8.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,107 +1,142 @@
1
+ // src/actions/fx-delete.js
2
+
1
3
  import { AbstractAction } from './abstract-action.js';
2
4
  import { Fore } from '../fore.js';
3
- import { evaluateXPathToNodes } from '../xpath-evaluation.js';
4
5
  import { XPathUtil } from '../xpath-util.js';
5
6
  import getInScopeContext from '../getInScopeContext.js';
7
+ import { evaluateXPathToNodes } from '../xpath-evaluation.js';
8
+ import { getPath } from '../xpath-path.js';
6
9
 
7
10
  /**
8
- * `fx-delete`
9
- * deletes nodes from instance data.
11
+ * fx-delete
10
12
  *
11
- * @fires deleted event
12
- * @customElement
13
- * @demo demo/todo.html
13
+ * XML: remove DOM nodes using XPath.
14
+ * JSON: delete JSONNode(s) (node.__jsonlens__ === true) without calling FontoXPath.
15
+ *
16
+ * Key requirement for JSON:
17
+ * - do NOT trigger full refresh
18
+ * - emit 'deleted' with stable pre-delete indices so repeats can update incrementally
19
+ * - update parent via parent.set(nextValue) so children are rebuilt (no "zombie rows")
14
20
  */
15
21
  class FxDelete extends AbstractAction {
16
22
  static get properties() {
17
23
  return {
18
24
  ...super.properties,
19
- ref: {
20
- type: String,
21
- },
25
+ ref: { type: String },
22
26
  };
23
27
  }
24
28
 
25
- /**
26
- * deletes nodes from instance data.
27
- *
28
- * Will NOT perform delete if nodeset is pointing to document node, document fragment, root node or being readonly.
29
- */
30
29
  async perform() {
31
- const inscopeContext = getInScopeContext(this.getAttributeNode('ref') || this, this.ref);
32
- this.nodeset = evaluateXPathToNodes(this.ref, inscopeContext, this);
30
+ const fore = this.getOwnerForm();
33
31
 
34
- // console.log('delete nodeset ', this.nodeset);
32
+ const ref = (this.getAttribute('ref') || this.ref || '.').trim() || '.';
35
33
 
36
- const instanceId = XPathUtil.resolveInstance(this, this.ref);
37
- const instance = this.getModel().getInstance(instanceId);
34
+ // IMPORTANT: keep correct scoping for vars/templates/repeats
35
+ const inscope = getInScopeContext(this.getAttributeNode('ref') || this, ref);
38
36
 
39
- // const path = instance && this.nodeset.length !== 0 ? evaluateXPathToString('path()', this.nodeset[0], instance) : '';
37
+ const instanceId = XPathUtil.resolveInstance(this, ref);
38
+ const model = this.getModel();
39
+ const instance = model.getInstance(instanceId);
40
40
 
41
- const path = Fore.getDomNodeIndexString(this.nodeset);
41
+ const isJson =
42
+ !!instance &&
43
+ (instance.type === 'json' ||
44
+ (typeof instance.getAttribute === 'function' && instance.getAttribute('type') === 'json'));
42
45
 
43
- const nodesToDelete = this.nodeset;
46
+ if (isJson) {
47
+ // JSON path stays as-is
48
+ return this._performJsonDelete(ref, inscope, instance, instanceId, fore);
49
+ }
44
50
 
45
- this.dispatchEvent(
46
- new CustomEvent('execute-action', {
47
- composed: true,
48
- bubbles: true,
49
- cancelable: true,
50
- detail: { action: this, event: this.event, path },
51
- }),
52
- );
51
+ // XML path restored to proper semantics (readonly + modelItems + root safety)
52
+ return this._performXmlDelete(ref, inscope, instance, instanceId, fore);
53
+ }
53
54
 
54
- const fore = this.getOwnerForm();
55
+ async _performXmlDelete(ref, inscopeContext, instance, instanceId, fore) {
56
+ const nodesToDelete = evaluateXPathToNodes(ref, inscopeContext, this);
57
+ this.nodeset = nodesToDelete;
58
+
59
+ // Nothing to do
60
+ if (!nodesToDelete || (Array.isArray(nodesToDelete) && nodesToDelete.length === 0)) {
61
+ return [];
62
+ }
63
+
64
+ // Normalize to array
65
+ const nodes = Array.isArray(nodesToDelete) ? nodesToDelete : [nodesToDelete];
55
66
 
56
- let parent;
67
+ // Never delete instance(), document nodes, or instance root element
68
+ // (matches expectations in delete.test.js)
69
+ const instRoot =
70
+ instance && instance.instanceData && instance.instanceData.documentElement
71
+ ? instance.instanceData.documentElement
72
+ : null;
57
73
 
58
74
  const removedNodes = [];
59
- if (Array.isArray(nodesToDelete)) {
60
- if (nodesToDelete.length === 0) {
61
- return;
75
+ let parent = null;
76
+
77
+ for (const node of nodes) {
78
+ if (!node) continue;
79
+
80
+ // hard stop: never delete document-ish nodes
81
+ if (node.nodeType === Node.DOCUMENT_NODE || node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
82
+ continue;
62
83
  }
63
- parent = nodesToDelete[0].parentNode;
64
84
 
65
- nodesToDelete.forEach(item => {
66
- if (this._deleteNode(parent, item)) {
67
- fore.signalChangeToElement(item.localName);
68
- removedNodes.push(item);
69
- }
70
- });
71
- if (removedNodes.length) {
72
- fore.signalChangeToElement(parent.localName);
85
+ // hard stop: never delete instance root element
86
+ if (instRoot && node === instRoot) {
87
+ continue;
73
88
  }
74
- } else {
75
- parent = nodesToDelete.parentNode;
76
- if (this._deleteNode(parent, nodesToDelete)) {
77
- fore.signalChangeToElement(parent.localName);
78
89
 
79
- fore.signalChangeToElement(nodesToDelete.localName);
80
- removedNodes.push(nodesToDelete);
90
+ // Determine parent now (needed for event + safety checks)
91
+ const p = node.parentNode;
92
+ if (!p) continue;
93
+
94
+ // Use the guarded delete helper (checks readonly + safety)
95
+ if (this._deleteXmlNode(p, node)) {
96
+ removedNodes.push(node);
97
+ parent = p;
98
+
99
+ // keep Fore’s change signaling (helps recalculation / refresh)
100
+ try {
101
+ if (node.localName) fore?.signalChangeToElement?.(node.localName);
102
+ } catch (_e) {}
81
103
  }
82
104
  }
83
105
 
84
- if (removedNodes.length) {
85
- await Fore.dispatch(instance, 'deleted', {
86
- ref: path,
87
- deletedNodes: removedNodes,
88
- instanceId,
89
- parent,
90
- foreId: fore.id,
91
- });
92
- this.needsUpdate = true;
106
+ if (removedNodes.length === 0) {
107
+ // delete failed (eg readonly) => no refresh requested by tests
108
+ return [];
109
+ }
110
+
111
+ // also signal parent changed
112
+ try {
113
+ if (parent?.localName) fore?.signalChangeToElement?.(parent.localName);
114
+ } catch (_e) {}
115
+
116
+ // Dispatch deleted event
117
+ await Fore.dispatch(instance, 'deleted', {
118
+ ref,
119
+ deletedNodes: removedNodes,
120
+ instanceId,
121
+ parent,
122
+ foreId: fore?.id,
123
+ isJson: false,
124
+ });
125
+
126
+ this.needsUpdate = true;
127
+
128
+ // return paths (not asserted by tests, but useful)
129
+ try {
130
+ return removedNodes.map(n => getPath(n, instanceId));
131
+ } catch (_e) {
132
+ return [];
93
133
  }
94
134
  }
95
135
 
96
- /**
97
- * Delete a node (if allowed). Does not hold for JSON
98
- *
99
- * @param {ParentNode} parent - The parent of the node to remove
100
- * @param {ChildNode} node - The child to remove
101
- *
102
- * @returns {boolean} Whether the delete is allowed and succeeded
103
- */
104
- _deleteNode(parent, node) {
136
+ _deleteXmlNode(parent, node) {
137
+ if (!parent || !node) return false;
138
+
139
+ // Safety: do not delete documents / fragments / detached
105
140
  if (
106
141
  parent.nodeType === Node.DOCUMENT_NODE ||
107
142
  node.nodeType === Node.DOCUMENT_NODE ||
@@ -111,19 +146,335 @@ class FxDelete extends AbstractAction {
111
146
  return false;
112
147
  }
113
148
 
149
+ // Respect readonly facet
114
150
  const mi = this.getModel().getModelItem(node);
115
- // Note that the model item can be absent, For elements that had no controls on them.
116
- // In that case, allow removals
117
- if (mi?.readonly) {
118
- return false;
119
- }
151
+ if (mi?.readonly) return false;
120
152
 
153
+ // Execute deletion
121
154
  parent.removeChild(node);
122
155
 
156
+ // Remove ModelItems for the deleted subtree (this is what your failing tests assert)
123
157
  this.getModel().removeModelItem(node);
124
158
 
125
159
  return true;
126
160
  }
161
+ // -----------------
162
+ // JSON delete branch
163
+ // -----------------
164
+
165
+ async _performJsonDelete(ref, inscopeContext, instance, instanceId, fore) {
166
+ const nodesToDelete = this._resolveJsonNodeset(ref, inscopeContext, instance);
167
+ this.nodeset = nodesToDelete;
168
+
169
+ if (!nodesToDelete || nodesToDelete.length === 0) {
170
+ this.needsUpdate = true;
171
+ return [];
172
+ }
173
+
174
+ // Snapshot stable info BEFORE mutation
175
+ const deletedIndexes0 = Array.from(nodesToDelete)
176
+ .map(n => (n && typeof n.keyOrIndex === 'number' ? n.keyOrIndex : -1))
177
+ .filter(i => i >= 0);
178
+
179
+ const parentBefore = nodesToDelete?.[0]?.parent || null;
180
+
181
+ // For logging / execute-action
182
+ const path = this._jsonNodesetPath(nodesToDelete);
183
+
184
+ this.dispatchEvent(
185
+ new CustomEvent('execute-action', {
186
+ composed: true,
187
+ bubbles: true,
188
+ cancelable: true,
189
+ detail: { action: this, event: this.event, path, isJson: true },
190
+ }),
191
+ );
192
+
193
+ // Perform deletion (mutates via parent.set to rebuild children)
194
+ const removed = this._deleteJsonNodes(nodesToDelete);
195
+
196
+ if (!removed || removed.length === 0) {
197
+ this.needsUpdate = true;
198
+ return [];
199
+ }
200
+
201
+ // Build a repeat-routable ref so your repeat filter works:
202
+ // repeat ref container is like: instance('data')?movies
203
+ const repeatRef = this._buildRepeatContainerRef(ref, instanceId, parentBefore);
204
+
205
+ await Fore.dispatch(instance, 'deleted', {
206
+ // This MUST match repeat's container ref (not a $path)
207
+ ref: repeatRef,
208
+
209
+ // keep path for debugging
210
+ path,
211
+
212
+ deletedNodes: removed,
213
+ deletedIndexes0,
214
+ instanceId,
215
+ parent: parentBefore,
216
+ foreId: fore?.id,
217
+ isJson: true,
218
+ });
219
+
220
+ // CRITICAL: do NOT trigger full refresh for JSON deletes
221
+ this.needsUpdate = true;
222
+
223
+ // return useful paths (optional)
224
+ return removed.map(n => (typeof n.getPath === 'function' ? n.getPath() : '')).filter(Boolean);
225
+ }
226
+
227
+ _isJsonNode(n) {
228
+ return !!n && n.__jsonlens__ === true;
229
+ }
230
+
231
+ _buildRepeatContainerRef(originalRef, instanceId, parent) {
232
+ // If we deleted from an array container with a key like "movies", use that.
233
+ if (parent && Array.isArray(parent.value) && typeof parent.keyOrIndex === 'string') {
234
+ return `instance('${instanceId}')?${parent.keyOrIndex}`;
235
+ }
236
+
237
+ // Otherwise normalize the original ref:
238
+ // - strip predicates/brackets
239
+ // - strip trailing ?*
240
+ // - strip trailing whitespace
241
+ let s = String(originalRef || '').trim();
242
+ s = s.replace(/\[[^\]]*\]/g, '');
243
+ if (s.endsWith('?*')) s = s.slice(0, -2);
244
+ return s || `instance('${instanceId}')`;
245
+ }
246
+
247
+ /**
248
+ * Resolve ref to JSONNode(s) without calling fontoxpath.
249
+ * Supports:
250
+ * - '.'
251
+ * - '?a?b'
252
+ * - "instance('id')?a?b"
253
+ * - '?*' (returns children)
254
+ * - bracket steps: '?movies[2]' and '?movies[index(\"movies\")]'
255
+ */
256
+ _resolveJsonNodeset(ref, inscopeContext, instance) {
257
+ const resolveIndexFunction = expr => {
258
+ const s = String(expr ?? '').trim();
259
+ const m = s.match(/^index\s*\(\s*(['"])(.*?)\1\s*\)\s*$/);
260
+ if (!m) return null;
261
+
262
+ const repeatId = m[2];
263
+ const fore = this.getOwnerForm();
264
+ if (!fore) return 1;
265
+
266
+ let repeat = null;
267
+ try {
268
+ repeat = fore.querySelector(`#${CSS.escape(repeatId)}`);
269
+ } catch (_e) {
270
+ repeat = fore.querySelector(`#${repeatId}`);
271
+ }
272
+ if (!repeat) return 1;
273
+
274
+ const attr = repeat.getAttribute('index');
275
+ let idx = Number(attr);
276
+ if (!Number.isFinite(idx) || idx < 1) idx = Number(repeat.index);
277
+
278
+ return Number.isFinite(idx) && idx >= 1 ? idx : 1;
279
+ };
280
+
281
+ const resolveBracketIndex1 = idxExpr => {
282
+ const t = String(idxExpr ?? '').trim();
283
+ if (/^\d+$/.test(t)) return Number(t);
284
+
285
+ const viaIndex = resolveIndexFunction(t);
286
+ if (viaIndex !== null) return viaIndex;
287
+
288
+ const n = Number(t);
289
+ return Number.isFinite(n) ? n : null;
290
+ };
291
+
292
+ if (ref === '.') {
293
+ if (this._isJsonNode(inscopeContext)) return [inscopeContext];
294
+ if (this._isJsonNode(instance?.nodeset)) return [instance.nodeset];
295
+ return [];
296
+ }
297
+
298
+ const parsed = this._parseJsonLensRef(ref);
299
+ if (!parsed) return [];
300
+
301
+ const model = this.getModel();
302
+ const targetInstance = model?.getInstance?.(parsed.instanceId) || instance;
303
+ const root = targetInstance?.nodeset;
304
+ if (!this._isJsonNode(root)) return [];
305
+
306
+ let node = parsed.hasExplicitInstance
307
+ ? root
308
+ : this._isJsonNode(inscopeContext)
309
+ ? inscopeContext
310
+ : root;
311
+
312
+ for (const step of parsed.steps) {
313
+ if (!node) return [];
314
+
315
+ if (step === '*') return Array.isArray(node.children) ? node.children : [];
316
+
317
+ if (typeof step === 'number') {
318
+ node = node.get(step) || null;
319
+ continue;
320
+ }
321
+
322
+ if (typeof step === 'string') {
323
+ const bm = step.match(/^(.*?)\[(.+)\]$/);
324
+ if (bm) {
325
+ const prop = bm[1].trim();
326
+ const idxExpr = bm[2].trim();
327
+
328
+ const container = prop ? (typeof node.get === 'function' ? node.get(prop) : null) : node;
329
+ if (!container) return [];
330
+ if (!Array.isArray(container.value)) return [];
331
+
332
+ const idx1 = resolveBracketIndex1(idxExpr);
333
+ if (!Number.isFinite(idx1) || idx1 < 1) return [];
334
+
335
+ const idx0 = idx1 - 1;
336
+ node = container.get(idx0) || null;
337
+ continue;
338
+ }
339
+
340
+ node = typeof node.get === 'function' ? node.get(step) : null;
341
+ continue;
342
+ }
343
+
344
+ return [];
345
+ }
346
+
347
+ if (!node) return [];
348
+ if (Array.isArray(node.value)) return node.children || [];
349
+ return [node];
350
+ }
351
+
352
+ _parseJsonLensRef(ref, defaultInstanceId = 'default') {
353
+ if (!ref) return null;
354
+ const s = String(ref).trim();
355
+
356
+ const instMatch = s.match(/^instance\s*\(\s*(['"])(.*?)\1\s*\)\s*(\?.*)?$/);
357
+ let instanceId;
358
+ let lensPart;
359
+
360
+ if (instMatch) {
361
+ instanceId = instMatch[2];
362
+ lensPart = instMatch[3] || '';
363
+ } else {
364
+ if (!s.startsWith('?')) return null;
365
+ instanceId = defaultInstanceId;
366
+ lensPart = s;
367
+ }
368
+
369
+ const steps = lensPart
370
+ .split('?')
371
+ .filter(Boolean)
372
+ .map(part => {
373
+ if (part === '*') return '*';
374
+ if (/^\d+$/.test(part)) return Number(part) - 1; // 1-based -> 0-based
375
+ return part;
376
+ });
377
+
378
+ return { instanceId, steps, hasExplicitInstance: !!instMatch };
379
+ }
380
+
381
+ _jsonNodesetPath(nodes) {
382
+ const first = nodes?.[0];
383
+ if (!first) return '';
384
+ if (typeof first.getPath === 'function') return first.getPath();
385
+ const inst = first.instanceId || 'default';
386
+ return `$${inst}/`;
387
+ }
388
+
389
+ /**
390
+ * Delete JSON nodes by updating their parent via parent.set(nextValue).
391
+ * This is the ONLY reliable way to prevent stale parent.children (zombie rows).
392
+ */
393
+ _deleteJsonNodes(nodes) {
394
+ const model = this.getModel();
395
+ const removed = [];
396
+
397
+ const candidates = Array.from(nodes || []).filter(n => this._isJsonNode(n) && n.parent);
398
+
399
+ // Group by parent so we can apply one parent.set per parent
400
+ const byParent = new Map();
401
+ for (const n of candidates) {
402
+ const p = n.parent;
403
+ if (!byParent.has(p)) byParent.set(p, []);
404
+ byParent.get(p).push(n);
405
+ }
406
+
407
+ const canDelete = n => {
408
+ try {
409
+ const mi = model?.getModelItem?.(n);
410
+ return !mi?.readonly;
411
+ } catch (_e) {
412
+ return true;
413
+ }
414
+ };
415
+
416
+ for (const [parent, nodesForParent] of byParent.entries()) {
417
+ const allowed = nodesForParent.filter(canDelete);
418
+ if (allowed.length === 0) continue;
419
+
420
+ // Array parent
421
+ if (Array.isArray(parent.value)) {
422
+ const indices = allowed
423
+ .map(n => (typeof n.keyOrIndex === 'number' ? n.keyOrIndex : -1))
424
+ .filter(i => i >= 0)
425
+ .sort((a, b) => b - a);
426
+
427
+ if (indices.length === 0) continue;
428
+
429
+ const idxSet = new Set(indices);
430
+ const next = parent.value.filter((_v, i) => !idxSet.has(i));
431
+
432
+ if (typeof parent.set === 'function') {
433
+ parent.set(next);
434
+ } else {
435
+ // Fallback: mutate (may still be stale if no set exists)
436
+ indices.forEach(i => parent.value.splice(i, 1));
437
+ }
438
+
439
+ // record removed nodes (old node identities are fine for repeat removal)
440
+ allowed.forEach(n => {
441
+ removed.push(n);
442
+ try {
443
+ model?.removeModelItem?.(n);
444
+ } catch (_e) {}
445
+ });
446
+
447
+ continue;
448
+ }
449
+
450
+ // Object parent
451
+ if (parent.value && typeof parent.value === 'object') {
452
+ const keys = allowed
453
+ .map(n => (typeof n.keyOrIndex === 'string' ? n.keyOrIndex : null))
454
+ .filter(Boolean);
455
+
456
+ if (keys.length === 0) continue;
457
+
458
+ const next = { ...parent.value };
459
+ keys.forEach(k => delete next[k]);
460
+
461
+ if (typeof parent.set === 'function') {
462
+ parent.set(next);
463
+ } else {
464
+ keys.forEach(k => delete parent.value[k]);
465
+ }
466
+
467
+ allowed.forEach(n => {
468
+ removed.push(n);
469
+ try {
470
+ model?.removeModelItem?.(n);
471
+ } catch (_e) {}
472
+ });
473
+ }
474
+ }
475
+
476
+ return removed;
477
+ }
127
478
  }
128
479
 
129
480
  if (!customElements.get('fx-delete')) {