diff --git a/foundation/utils.spec.ts b/foundation/utils.spec.ts
index 638051d..95e2ede 100644
--- a/foundation/utils.spec.ts
+++ b/foundation/utils.spec.ts
@@ -1,6 +1,6 @@
/* eslint-disable no-unused-expressions */
import { expect } from '@open-wc/testing';
-import { removeDOsNotInSelection } from './utils.js';
+import { removeDOsNotInSelection, getOrphanedTypes } from './utils.js';
describe('foundation/utils', () => {
describe('removeDOsNotInSelection', () => {
@@ -85,4 +85,75 @@ describe('foundation/utils', () => {
expect(result.tagName).to.equal('LNodeType');
});
});
+
+ describe('getOrphanedTypes', () => {
+ it('returns candidates that are unreferenced in the document', () => {
+ // Post-edit doc: LNodeType only has Beh — A and its chain are orphaned
+ const doc = new DOMParser().parseFromString(
+ `
+
+
+
+
+
+
+
+
+
+
+ `,
+ 'application/xml'
+ );
+ const candidates = new Set(['DOType_A', 'DOType_Sub', 'DOType_Beh']);
+ const orphaned = getOrphanedTypes(doc, candidates);
+ const orphanedIds = orphaned.map(el => el.getAttribute('id'));
+
+ // DOType_A is unreferenced → orphaned
+ expect(orphanedIds).to.include('DOType_A');
+ // DOType_Sub was only referenced by DOType_A → cascade orphan
+ expect(orphanedIds).to.include('DOType_Sub');
+ // DOType_Beh is still referenced by the LNodeType → NOT orphaned
+ expect(orphanedIds).to.not.include('DOType_Beh');
+ });
+
+ it('does not return types still referenced by other LNodeTypes', () => {
+ // Two LNodeTypes share DOType_Shared; one is removed but the other remains
+ const doc = new DOMParser().parseFromString(
+ `
+
+
+
+
+
+
+
+ `,
+ 'application/xml'
+ );
+ const candidates = new Set(['DOType_Removed', 'DOType_Shared']);
+ const orphaned = getOrphanedTypes(doc, candidates);
+ const orphanedIds = orphaned.map(el => el.getAttribute('id'));
+
+ expect(orphanedIds).to.include('DOType_Removed');
+ expect(orphanedIds).to.not.include('DOType_Shared');
+ });
+
+ it('returns empty array when all candidates are still referenced', () => {
+ const doc = new DOMParser().parseFromString(
+ `
+
+
+
+
+
+
+ `,
+ 'application/xml'
+ );
+ const candidates = new Set(['DOType_A']);
+ const orphaned = getOrphanedTypes(doc, candidates);
+
+ expect(orphaned).to.have.lengthOf(0);
+ });
+ });
});
diff --git a/foundation/utils.ts b/foundation/utils.ts
index 1c01287..dd1c03e 100644
--- a/foundation/utils.ts
+++ b/foundation/utils.ts
@@ -40,6 +40,44 @@ export function filterSelection(
return filteredTree;
}
+/**
+ * Finds and returns elements from candidateIds that are no longer referenced in
+ * the given document after edits, including those affected by cascading removals.
+ * Always provide all type IDs that existed before the edit as candidateIds, so that
+ * any new types added during the edit are not mistakenly removed.
+ */
+export function getOrphanedTypes(
+ doc: XMLDocument,
+ candidateIds: Set
+): Element[] {
+ const dtt = doc.querySelector(':root > DataTypeTemplates');
+ if (!dtt) return [];
+
+ const dttClone = dtt.cloneNode(true) as Element;
+ const toRemove: string[] = [];
+
+ let changed = true;
+ while (changed) {
+ changed = false;
+ for (const id of candidateIds) {
+ if (!toRemove.includes(id)) {
+ if (!dttClone.querySelector(`:scope *[type="${id}"]`)) {
+ const el = dttClone.querySelector(`:scope > *[id="${id}"]`);
+ if (el) {
+ toRemove.push(id);
+ el.remove();
+ changed = true;
+ }
+ }
+ }
+ }
+ }
+
+ return toRemove
+ .map(id => dtt.querySelector(`:scope > *[id="${id}"]`))
+ .filter((el): el is Element => el !== null);
+}
+
/**
* Creates a clone of an LNodeType with only the DOs that are present in the selection.
* DOs not included in the selection are removed from the cloned element.
diff --git a/scl-template-update.spec.ts b/scl-template-update.spec.ts
index e77848f..92dfd8d 100644
--- a/scl-template-update.spec.ts
+++ b/scl-template-update.spec.ts
@@ -88,7 +88,7 @@ describe('NsdTemplateUpdater', () => {
(element.shadowRoot?.querySelector('md-fab') as HTMLElement).click();
await element.updateComplete;
- expect(listener).to.have.been.calledOnce;
+ expect(listener).to.have.been.called;
const updateEdits = listener.args[0][0].detail.edit;
expect(updateEdits).to.have.length.greaterThan(0);
@@ -97,6 +97,38 @@ describe('NsdTemplateUpdater', () => {
);
}).timeout(5000);
+ it('preserves DOTypes still referenced by the updated LNodeType', async () => {
+ localStorage.removeItem('template-update-setting');
+
+ const event = {
+ detail: { id: 'MMXU$oscd$_c53e78191fabefa3' },
+ } as CustomEvent;
+ element.onLNodeTypeSelect(event);
+ await new Promise(res => {
+ setTimeout(res, 0);
+ });
+
+ element.treeUI.selection = mmxuSelection;
+ await element.updateComplete;
+
+ (element.shadowRoot?.querySelector('md-fab') as HTMLElement).click();
+ await element.updateComplete;
+
+ // The updated LNodeType must still carry DO[name="A"] and its referenced
+ // DOType must still exist — i.e. the document must be schema-valid.
+ const updatedLNodeType = element.doc?.querySelector(
+ 'LNodeType[id="MMXU$oscd$_c53e78191fabefa3"]'
+ );
+ expect(updatedLNodeType).to.exist;
+ expect(updatedLNodeType?.querySelector('DO[name="A"]')).to.exist;
+
+ const aDoTypeId = updatedLNodeType
+ ?.querySelector('DO[name="A"]')
+ ?.getAttribute('type');
+ expect(aDoTypeId).to.exist;
+ expect(element.doc?.querySelector(`DOType[id="${aDoTypeId}"]`)).to.exist;
+ }).timeout(5000);
+
it('swaps MMXU when swap mode is configured', async () => {
localStorage.setItem('template-update-setting', 'swap');
@@ -162,7 +194,7 @@ describe('NsdTemplateUpdater', () => {
setTimeout(res, 200);
});
- expect(listener).to.have.been.calledOnce;
+ expect(listener).to.have.been.called;
const updateEdits = listener.args[0][0].detail.edit;
expect(updateEdits).to.have.length.greaterThan(0);
@@ -304,7 +336,7 @@ describe('NsdTemplateUpdater', () => {
// First update
(element.shadowRoot?.querySelector('md-fab') as HTMLElement).click();
await element.updateComplete;
- expect(listener).to.have.been.calledOnce;
+ expect(listener).to.have.been.called;
// Second update with same selection should not delete the LNodeType
listener.resetHistory();
@@ -365,6 +397,12 @@ describe('NsdTemplateUpdater', () => {
// Verify 'Beh' DO still exists
const doBeh = lNodeType?.querySelector('DO[name="Beh"]');
expect(doBeh).to.exist;
+
+ // Verify the DOType that was exclusively used by the removed DO A is
+ // cleaned up as an orphan (targeted post-edit cleanup).
+ expect(
+ element.doc?.querySelector('DOType[id="A$oscd$_41824603f63b26ac"]')
+ ).to.not.exist;
}).timeout(5000);
it('updates description when making selection changes', async () => {
diff --git a/scl-template-update.ts b/scl-template-update.ts
index 808e278..6e4aaf1 100644
--- a/scl-template-update.ts
+++ b/scl-template-update.ts
@@ -44,6 +44,7 @@ import {
isLNodeTypeReferenced,
filterSelection,
removeDOsNotInSelection,
+ getOrphanedTypes,
} from './foundation/utils.js';
export default class NsdTemplateUpdated extends ScopedElementsMixin(
@@ -255,6 +256,15 @@ export default class NsdTemplateUpdated extends ScopedElementsMixin(
});
if (updateSetting === UpdateSetting.Update) {
+ const oldCandidateIds = new Set(
+ Array.from(
+ this.doc.querySelectorAll(
+ ':root > DataTypeTemplates > DOType, :root > DataTypeTemplates > DAType, :root > DataTypeTemplates > EnumType'
+ )
+ )
+ .map(el => el.getAttribute('id')!)
+ .filter(Boolean)
+ );
const allEdits = this.buildUpdateEdits(
inserts,
currentLNodeType,
@@ -268,6 +278,19 @@ export default class NsdTemplateUpdated extends ScopedElementsMixin(
title: `Update ${lnID}`,
})
);
+
+ const orphaned = getOrphanedTypes(this.doc, oldCandidateIds);
+ if (orphaned.length > 0) {
+ this.dispatchEvent(
+ newEditEvent(
+ orphaned.map(node => ({ node })),
+ {
+ squash: true,
+ title: `Update ${lnID}`,
+ }
+ )
+ );
+ }
}
this.showSuccessFeedback(lnID, 'update');
@@ -337,10 +360,6 @@ export default class NsdTemplateUpdated extends ScopedElementsMixin(
const supportingTypes = inserts.filter(
insert => insert !== lNodeTypeInsert
);
- const removeOld = removeDataType(
- { node: currentLNodeType },
- { force: true }
- );
return [
...supportingTypes,
@@ -349,7 +368,7 @@ export default class NsdTemplateUpdated extends ScopedElementsMixin(
node: newLNodeType,
reference: lNodeTypeInsert.reference,
},
- ...removeOld,
+ { node: currentLNodeType },
];
}