Skip to content

Commit 7470e2d

Browse files
feat(wizards/substation/l-node-editor): add duplicate button (openscd#782)
* refactor(wizards/lnode): move lnInst generator function to foundation * feat(editors/substation/l-node-editor): add copy content button * refactor: on review comments
1 parent 965f10a commit 7470e2d

File tree

7 files changed

+212
-34
lines changed

7 files changed

+212
-34
lines changed

src/editors/substation/l-node-editor.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@ import {
88
} from 'lit-element';
99

1010
import '../../action-icon.js';
11-
import { identity, newActionEvent, newWizardEvent } from '../../foundation.js';
11+
import {
12+
cloneElement,
13+
identity,
14+
newActionEvent,
15+
newLnInstGenerator,
16+
newWizardEvent,
17+
} from '../../foundation.js';
1218
import {
1319
automationLogicalNode,
1420
controlLogicalNode,
@@ -75,6 +81,27 @@ export class LNodeEditor extends LitElement {
7581
private get missingIedReference(): boolean {
7682
return this.element.getAttribute('iedName') === 'None' ?? false;
7783
}
84+
@state()
85+
private get isIEDReference(): boolean {
86+
return this.element.getAttribute('iedName') !== 'None';
87+
}
88+
89+
private cloneLNodeElement(): void {
90+
const lnClass = this.element.getAttribute('lnClass');
91+
if (!lnClass) return;
92+
93+
const uniqueLnInst = newLnInstGenerator(this.element.parentElement!)(
94+
lnClass
95+
);
96+
if (!uniqueLnInst) return;
97+
98+
const newElement = cloneElement(this.element, { lnInst: uniqueLnInst });
99+
this.dispatchEvent(
100+
newActionEvent({
101+
new: { parent: this.element.parentElement!, element: newElement },
102+
})
103+
);
104+
}
78105

79106
private openEditWizard(): void {
80107
const wizard = wizards['LNode'].edit(this.element);
@@ -111,6 +138,14 @@ export class LNodeEditor extends LitElement {
111138
icon="delete"
112139
@click="${() => this.remove()}}"
113140
></mwc-fab
114-
></action-icon>`;
141+
>${this.isIEDReference
142+
? html``
143+
: html`<mwc-fab
144+
slot="action"
145+
mini
146+
icon="content_copy"
147+
@click=${() => this.cloneLNodeElement()}
148+
></mwc-fab>`}
149+
</action-icon>`;
115150
}
116151
}

src/foundation.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -662,7 +662,9 @@ function kDCSelector(tagName: SCLTag, identity: string): string {
662662
}
663663

664664
function associationIdentity(e: Element): string {
665-
return `${identity(e.parentElement)}>${e.getAttribute('associationID')??''}`;
665+
return `${identity(e.parentElement)}>${
666+
e.getAttribute('associationID') ?? ''
667+
}`;
666668
}
667669

668670
function associationSelector(tagName: SCLTag, identity: string): string {
@@ -2756,6 +2758,43 @@ export function getChildElementsByTagName(
27562758
);
27572759
}
27582760

2761+
/** maximum value for `lnInst` attribute */
2762+
const maxLnInst = 99;
2763+
const lnInstRange = Array(maxLnInst)
2764+
.fill(1)
2765+
.map((_, i) => `${i + 1}`);
2766+
2767+
/**
2768+
* @param parent - The LNodes' parent element to be scanned once for `lnInst`
2769+
* values already in use. Be sure to create a new generator every time the
2770+
* children of this element change.
2771+
* @returns a function generating increasing unused `lnInst` values for
2772+
* `lnClass` LNodes within `parent` on subsequent invocations
2773+
*/
2774+
export function newLnInstGenerator(
2775+
parent: Element
2776+
): (lnClass: string) => string | undefined {
2777+
const generators = new Map<string, () => string | undefined>();
2778+
2779+
return (lnClass: string) => {
2780+
if (!generators.has(lnClass)) {
2781+
const lnInsts = new Set(
2782+
getChildElementsByTagName(parent, 'LNode')
2783+
.filter(lnode => lnode.getAttribute('lnClass') === lnClass)
2784+
.map(lNode => lNode.getAttribute('lnInst')!)
2785+
);
2786+
2787+
generators.set(lnClass, () => {
2788+
const uniqueLnInst = lnInstRange.find(lnInst => !lnInsts.has(lnInst));
2789+
if (uniqueLnInst) lnInsts.add(uniqueLnInst);
2790+
return uniqueLnInst;
2791+
});
2792+
}
2793+
2794+
return generators.get(lnClass)!();
2795+
};
2796+
}
2797+
27592798
declare global {
27602799
interface ElementEventMap {
27612800
['pending-state']: PendingStateEvent;

src/wizards/lnode.ts

Lines changed: 2 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -27,38 +27,10 @@ import {
2727
WizardActor,
2828
WizardInputElement,
2929
WizardMenuActor,
30+
newLnInstGenerator,
3031
} from '../foundation.js';
3132
import { patterns } from './foundation/limits.js';
3233

33-
const maxLnInst = 99;
34-
const lnInstRange = Array(maxLnInst)
35-
.fill(1)
36-
.map((_, i) => `${i + 1}`);
37-
38-
function uniqueLnInstGenerator(
39-
parent: Element
40-
): (lnClass: string) => string | undefined {
41-
const generators = new Map<string, () => string | undefined>();
42-
43-
return (lnClass: string) => {
44-
if (!generators.has(lnClass)) {
45-
const lnInsts = new Set(
46-
getChildElementsByTagName(parent, 'LNode')
47-
.filter(lnode => lnode.getAttribute('lnClass') === lnClass)
48-
.map(lNode => lNode.getAttribute('lnInst')!)
49-
);
50-
51-
generators.set(lnClass, () => {
52-
const uniqueLnInst = lnInstRange.find(lnInst => !lnInsts.has(lnInst));
53-
if (uniqueLnInst) lnInsts.add(uniqueLnInst);
54-
return uniqueLnInst;
55-
});
56-
}
57-
58-
return generators.get(lnClass)!();
59-
};
60-
}
61-
6234
function createLNodeAction(parent: Element): WizardActor {
6335
return (
6436
inputs: WizardInputElement[],
@@ -75,7 +47,7 @@ function createLNodeAction(parent: Element): WizardActor {
7547
})
7648
.filter(item => item !== null);
7749

78-
const lnInstGenerator = uniqueLnInstGenerator(parent);
50+
const lnInstGenerator = newLnInstGenerator(parent);
7951

8052
const createActions: Create[] = <Create[]>selectedLNodeTypes
8153
.map(selectedLNodeType => {

test/integration/editors/substation/l-node-editor-wizarding-editing.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,4 +113,56 @@ describe('l-node-editor wizarding editing integration', () => {
113113
).to.have.attribute('lnInst', '31');
114114
});
115115
});
116+
117+
describe('has a copy content icon button that', () => {
118+
let contentCopyButton: HTMLElement;
119+
120+
beforeEach(async () => {
121+
element!.element = doc.querySelector(
122+
'SubFunction[name="mySubFunc2"] > LNode[lnClass="XSWI"]'
123+
)!;
124+
await parent.updateComplete;
125+
126+
contentCopyButton = <HTMLElement>(
127+
element?.shadowRoot?.querySelector('mwc-fab[icon="content_copy"]')
128+
);
129+
await parent.updateComplete;
130+
});
131+
132+
it('adds new LNode element', async () => {
133+
contentCopyButton.click();
134+
await parent.updateComplete;
135+
136+
expect(
137+
doc.querySelectorAll(
138+
'SubFunction[name="mySubFunc2"] > LNode[lnClass="XSWI"]'
139+
)
140+
).to.have.lengthOf(3);
141+
});
142+
143+
it('makes sure the lnInst is always unique', async () => {
144+
contentCopyButton.click();
145+
contentCopyButton.click();
146+
contentCopyButton.click();
147+
await parent.updateComplete;
148+
149+
expect(
150+
doc.querySelectorAll(
151+
'SubFunction[name="mySubFunc2"] > LNode[lnClass="XSWI"]'
152+
)
153+
).to.have.lengthOf(5);
154+
155+
const lnInsts = Array.from(
156+
doc.querySelectorAll(
157+
'SubFunction[name="mySubFunc2"] > LNode[lnClass="XSWI"]'
158+
)
159+
).map(lNode => lNode.getAttribute('lnInst')!);
160+
161+
const duplicates = lnInsts.filter(
162+
(item, index) => lnInsts.indexOf(item) !== index
163+
);
164+
165+
expect(duplicates).to.lengthOf(0);
166+
});
167+
});
116168
});

test/testfiles/zeroline/functions.scd

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,12 @@
3737
<LNode iedName="None" prefix="DC" lnClass="CSWI" lnInst="1"/>
3838
<LNode iedName="None" prefix="DC" lnClass="CILO" lnInst="1"/>
3939
</SubFunction>
40-
<SubFunction name="mySubFunc2"/>
40+
<SubFunction name="mySubFunc2">
41+
<LNode iedName="None" prefix="DC" lnClass="XSWI" lnInst="1"/>
42+
<LNode iedName="None" prefix="DC" lnClass="CSWI" lnInst="1"/>
43+
<LNode iedName="None" prefix="DC" lnClass="CILO" lnInst="1"/>
44+
<LNode iedName="None" prefix="DC" lnClass="XSWI" lnInst="3"/>
45+
</SubFunction>
4146
</Function>
4247
<Function name="bay2Func">
4348
<LNode iedName="None" prefix="DC" lnClass="XSWI" lnInst="1"/>

test/unit/editors/substation/__snapshots__/l-node-editor.test.snap.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ snapshots["web component rendering LNode element as instance of a LNodeType only
4545
slot="action"
4646
>
4747
</mwc-fab>
48+
<mwc-fab
49+
icon="content_copy"
50+
mini=""
51+
slot="action"
52+
>
53+
</mwc-fab>
4854
</action-icon>
4955
`;
5056
/* end snapshot web component rendering LNode element as instance of a LNodeType only looks like the latest snapshot */

test/unit/foundation.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
cloneElement,
2626
depth,
2727
getUniqueElementName,
28+
newLnInstGenerator,
2829
} from '../../src/foundation.js';
2930

3031
import { MockAction } from './mock-actions.js';
@@ -604,4 +605,72 @@ describe('foundation', () => {
604605
it('returns Infinity if given a circularly defined object or array', () =>
605606
expect(depth(circular)).to.not.be.finite);
606607
});
608+
609+
describe('generator function for new `lnInst` attribute', () => {
610+
let lnInstGenerator: (lnClass: string) => string | undefined;
611+
let parent: Element;
612+
613+
describe('with existing unique lnInst', () => {
614+
beforeEach(() => {
615+
parent = new DOMParser().parseFromString(
616+
`<Function name="someName">
617+
<LNode name="None" lnClass="CSWI" lnInst="1"/>
618+
<LNode name="None" lnClass="XCBR" lnInst="1"/>
619+
<LNode name="None" lnClass="CILO" lnInst="1"/>
620+
<LNode name="None" lnClass="CSWI" lnInst="2"/>
621+
<LNode name="None" lnClass="PDIS" lnInst="1"/>
622+
<LNode name="None" lnClass="CSWI" lnInst="5"/>
623+
<LNode name="None" lnClass="CSWI" lnInst="6"/>
624+
<LNode name="None" lnClass="CSWI" lnInst="8"/>
625+
</Function>`,
626+
'application/xml'
627+
).documentElement;
628+
629+
lnInstGenerator = newLnInstGenerator(parent);
630+
});
631+
632+
it('returns unique lnInst called once', () =>
633+
expect(lnInstGenerator('CSWI')).to.equal('3'));
634+
635+
it('returns unique lnInst called several times', () => {
636+
expect(lnInstGenerator('CSWI')).to.equal('3');
637+
expect(lnInstGenerator('CSWI')).to.equal('4');
638+
expect(lnInstGenerator('CSWI')).to.equal('7');
639+
expect(lnInstGenerator('CSWI')).to.equal('9');
640+
});
641+
642+
it('returns unique lnInst called several times', () => {
643+
expect(lnInstGenerator('CSWI')).to.equal('3');
644+
expect(lnInstGenerator('CSWI')).to.equal('4');
645+
expect(lnInstGenerator('CSWI')).to.equal('7');
646+
expect(lnInstGenerator('CSWI')).to.equal('9');
647+
});
648+
});
649+
650+
describe('with missing unique lnInst for lnClass PDIS', () => {
651+
beforeEach(() => {
652+
parent = new DOMParser().parseFromString(
653+
`<Function name="someName">
654+
</Function>`,
655+
'application/xml'
656+
).documentElement;
657+
658+
for (let i = 1; i <= 99; i++) {
659+
const lNode = new DOMParser().parseFromString(
660+
`<LNode iedName="None" lnClass="PDIS" lnInst="${i}" />`,
661+
'application/xml'
662+
).documentElement;
663+
parent.appendChild(lNode);
664+
}
665+
666+
lnInstGenerator = newLnInstGenerator(parent);
667+
});
668+
669+
it('return undefined for the lnClass PDIS', () =>
670+
expect(lnInstGenerator('PDIS')).to.be.undefined);
671+
672+
it('return unique lnInst for another lnClass', () =>
673+
expect(lnInstGenerator('CSWI')).to.equal('1'));
674+
});
675+
});
607676
});

0 commit comments

Comments
 (0)