Skip to content

Commit 1850dfa

Browse files
feat(wizards/reportcontrol): add copy to other IEDs (openscd#632)
* feat(foundation/scl): add existFcdaReference * feat(wizards/reportcontrol): add copy menu action * test(wizards/reportcontrol): adopt snapshots * git commit -m"feat(wizards/reportcontrol): add translation" * git commit -m"test(wizards/reportcontrol): add first integration tests" * test(wizards/reportcontrol): add another integration test * test(wizards/reportcontrol): fix snapshots
1 parent f255bfb commit 1850dfa

File tree

11 files changed

+2477
-3
lines changed

11 files changed

+2477
-3
lines changed

src/foundation/scl.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { crossProduct } from '../foundation.js';
2+
3+
function getDataModelChildren(parent: Element): Element[] {
4+
if (['LDevice', 'Server'].includes(parent.tagName))
5+
return Array.from(parent.children).filter(
6+
child =>
7+
child.tagName === 'LDevice' ||
8+
child.tagName === 'LN0' ||
9+
child.tagName === 'LN'
10+
);
11+
12+
const id =
13+
parent.tagName === 'LN' || parent.tagName === 'LN0'
14+
? parent.getAttribute('lnType')
15+
: parent.getAttribute('type');
16+
17+
return Array.from(
18+
parent.ownerDocument.querySelectorAll(
19+
`LNodeType[id="${id}"] > DO, DOType[id="${id}"] > SDO, DOType[id="${id}"] > DA, DAType[id="${id}"] > BDA`
20+
)
21+
);
22+
}
23+
24+
export function existFcdaReference(fcda: Element, ied: Element): boolean {
25+
const [ldInst, prefix, lnClass, lnInst, doName, daName, fc] = [
26+
'ldInst',
27+
'prefix',
28+
'lnClass',
29+
'lnInst',
30+
'doName',
31+
'daName',
32+
'fc',
33+
].map(attr => fcda.getAttribute(attr));
34+
35+
const sinkLdInst = ied.querySelector(`LDevice[inst="${ldInst}"]`);
36+
if (!sinkLdInst) return false;
37+
38+
const prefixSelctors = prefix
39+
? [`[prefix="${prefix}"]`]
40+
: ['[prefix=""]', ':not([prefix])'];
41+
const lnInstSelectors = lnInst
42+
? [`[inst="${lnInst}"]`]
43+
: ['[inst=""]', ':not([inst])'];
44+
45+
const anyLnSelector = crossProduct(
46+
['LN0', 'LN'],
47+
prefixSelctors,
48+
[`[lnClass="${lnClass}"]`],
49+
lnInstSelectors
50+
)
51+
.map(strings => strings.join(''))
52+
.join(',');
53+
54+
const sinkAnyLn = ied.querySelector(anyLnSelector);
55+
if (!sinkAnyLn) return false;
56+
57+
const doNames = doName?.split('.');
58+
if (!doNames) return false;
59+
60+
let parent: Element | undefined = sinkAnyLn;
61+
for (const doNameAttr of doNames) {
62+
parent = getDataModelChildren(parent).find(
63+
child => child.getAttribute('name') === doNameAttr
64+
);
65+
if (!parent) return false;
66+
}
67+
68+
const daNames = daName?.split('.');
69+
const someFcInSink = getDataModelChildren(parent).some(
70+
da => da.getAttribute('fc') === fc
71+
);
72+
if (!daNames && someFcInSink) return true;
73+
if (!daNames) return false;
74+
75+
let sinkFc = '';
76+
for (const daNameAttr of daNames) {
77+
parent = getDataModelChildren(parent).find(
78+
child => child.getAttribute('name') === daNameAttr
79+
);
80+
81+
if (parent?.getAttribute('fc')) sinkFc = parent.getAttribute('fc')!;
82+
83+
if (!parent) return false;
84+
}
85+
86+
if (sinkFc !== fc) return false;
87+
88+
return true;
89+
}

src/translations/de.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,16 @@ export const de: Translations = {
526526
remove:
527527
'{{type}} "{{name}}" and referenzierte Element von IED {{iedName}} entfernt',
528528
},
529+
hints: {
530+
source: 'Quell-IED',
531+
missingServer: 'Kein Server vorhanden',
532+
exist: '{{type}} mit dem Namen {{name}} existiert',
533+
noMatchingData: 'Keine Datenübereinstimmung',
534+
valid: 'Kann kopiert werden',
535+
},
536+
label: {
537+
copy: 'Kopie in anderen IEDs ertellen',
538+
},
529539
},
530540
add: 'Hinzufügen',
531541
new: 'Neu',

src/translations/en.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,14 @@ export const en = {
523523
remove:
524524
'Removed {{type}} "{{name}}" and its referenced elements from IED {{iedName}}',
525525
},
526+
hints: {
527+
source: 'Source IED',
528+
missingServer: 'Not A Server',
529+
exist: '{{type}} with name {{name}} already exist',
530+
noMatchingData: 'No matching data',
531+
valid: 'Can be copied',
532+
},
533+
label: { copy: 'Copy to other IEDs' },
526534
},
527535
add: 'Add',
528536
new: 'New',

src/wizards/foundation/finder.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,16 @@ export function iEDPicker(doc: XMLDocument): TemplateResult {
4848
></finder-list>`;
4949
}
5050

51+
export function iEDsPicker(doc: XMLDocument): TemplateResult {
52+
return html`<finder-list
53+
multi
54+
path="${JSON.stringify(['SCL: '])}"
55+
.read=${getReader(doc.querySelector('SCL')!, getIED)}
56+
.getDisplayString=${getDisplayString}
57+
.getTitle=${(path: string[]) => path[path.length - 1]}
58+
></finder-list>`;
59+
}
60+
5161
export function getDataModelChildren(parent: Element): Element[] {
5262
if (['LDevice', 'Server'].includes(parent.tagName))
5363
return Array.from(parent.children).filter(

src/wizards/reportcontrol.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { get, translate } from 'lit-translate';
33

44
import '@material/mwc-button';
55
import '@material/mwc-list/mwc-list-item';
6+
import '@material/mwc-list/mwc-check-list-item';
67
import { List } from '@material/mwc-list';
78
import { ListItemBase } from '@material/mwc-list/mwc-list-item-base';
89
import { SingleSelectedEvent } from '@material/mwc-list/mwc-list-foundation';
@@ -32,13 +33,15 @@ import {
3233
WizardAction,
3334
MenuAction,
3435
} from '../foundation.js';
36+
import { FilteredList } from '../filtered-list.js';
3537
import { FinderList } from '../finder-list.js';
3638
import { dataAttributePicker, iEDPicker } from './foundation/finder.js';
3739
import { maxLength, patterns } from './foundation/limits.js';
3840
import { editDataSetWizard } from './dataset.js';
3941
import { newFCDA } from './fcda.js';
4042
import { contentOptFieldsWizard, editOptFieldsWizard } from './optfields.js';
4143
import { contentTrgOpsWizard, editTrgOpsWizard } from './trgops.js';
44+
import { existFcdaReference } from '../foundation/scl.js';
4245

4346
interface ContentOptions {
4447
name: string | null;
@@ -407,6 +410,147 @@ function getRptEnabledAction(
407410
};
408411
}
409412

413+
function copyReportControlActions(element: Element): WizardActor {
414+
return (_: WizardInputElement[], wizard: Element) => {
415+
const doc = element.ownerDocument;
416+
417+
const iedItems = <ListItemBase[]>(
418+
wizard.shadowRoot?.querySelector<FilteredList>('filtered-list')?.selected
419+
);
420+
421+
const complexActions: ComplexAction[] = [];
422+
iedItems.forEach(iedItem => {
423+
const ied = doc.querySelector(selector('IED', iedItem.value));
424+
if (!ied) return;
425+
426+
const sinkLn0 = ied.querySelector('LN0');
427+
if (!sinkLn0) return [];
428+
429+
const sourceDataSet = element.parentElement?.querySelector(
430+
`DataSet[name="${element.getAttribute('datSet')}"]`
431+
);
432+
if (
433+
sourceDataSet &&
434+
sinkLn0.querySelector(
435+
`DataSet[name="${sourceDataSet!.getAttribute('name')}"]`
436+
)
437+
)
438+
return [];
439+
440+
if (
441+
sinkLn0.querySelector(
442+
`ReportControl[name="${element.getAttribute('name')}"]`
443+
)
444+
)
445+
return [];
446+
447+
// clone DataSet and make sure that FCDA is valid in ied
448+
const sinkDataSet = <Element>sourceDataSet?.cloneNode(true);
449+
Array.from(sinkDataSet.querySelectorAll('FCDA')).forEach(fcda => {
450+
if (!existFcdaReference(fcda, ied)) sinkDataSet.removeChild(fcda);
451+
});
452+
if (sinkDataSet.children.length === 0) return []; // when no data left no copy needed
453+
454+
const sinkReportControl = <Element>element.cloneNode(true);
455+
456+
const source = element.closest('IED')?.getAttribute('name');
457+
const sink = ied.getAttribute('name');
458+
459+
complexActions.push({
460+
title: `ReportControl copied from ${source} to ${sink}`,
461+
actions: [
462+
{ new: { parent: sinkLn0, element: sinkDataSet } },
463+
{ new: { parent: sinkLn0, element: sinkReportControl } },
464+
],
465+
});
466+
});
467+
468+
return complexActions;
469+
};
470+
}
471+
472+
function renderIedListItem(sourceCb: Element, ied: Element): TemplateResult {
473+
const sourceDataSet = sourceCb.parentElement?.querySelector(
474+
`DataSet[name="${sourceCb.getAttribute('datSet')}"]`
475+
);
476+
477+
const isSourceIed =
478+
sourceCb.closest('IED')?.getAttribute('name') === ied.getAttribute('name');
479+
const ln0 = ied.querySelector('AccessPoint > Server > LDevice > LN0');
480+
const hasCbNameConflict = ln0?.querySelector(
481+
`ReportControl[name="${sourceCb.getAttribute('name')}"]`
482+
)
483+
? true
484+
: false;
485+
const hasDataSetConflict = ln0?.querySelector(
486+
`DataSet[name="${sourceDataSet?.getAttribute('name')}"]`
487+
)
488+
? true
489+
: false;
490+
491+
// clone DataSet and make sure that FCDA is valid in ied
492+
const sinkDataSet = <Element>sourceDataSet?.cloneNode(true);
493+
Array.from(sinkDataSet.querySelectorAll('FCDA')).forEach(fcda => {
494+
if (!existFcdaReference(fcda, ied)) sinkDataSet.removeChild(fcda);
495+
});
496+
const hasDataMatch = sinkDataSet.children.length > 0;
497+
498+
const primSpan = ied.getAttribute('name');
499+
let secondSpan = '';
500+
if (isSourceIed) secondSpan = get('controlblock.hints.source');
501+
else if (!ln0) secondSpan = get('controlblock.hints.missingServer');
502+
else if (hasDataSetConflict && !isSourceIed)
503+
secondSpan = get('controlblock.hints.exist', {
504+
type: 'RerportControl',
505+
name: sourceCb.getAttribute('name')!,
506+
});
507+
else if (hasCbNameConflict && !isSourceIed)
508+
secondSpan = get('controlblock.hints.exist', {
509+
type: 'DataSet',
510+
name: sourceCb.getAttribute('name')!,
511+
});
512+
else if (!hasDataMatch) secondSpan = get('controlblock.hints.noMatchingData');
513+
else secondSpan = get('controlBlock.hints.valid');
514+
515+
return html`<mwc-check-list-item
516+
twoline
517+
value="${identity(ied)}"
518+
?disabled=${isSourceIed ||
519+
!ln0 ||
520+
hasCbNameConflict ||
521+
hasDataSetConflict ||
522+
!hasDataMatch}
523+
><span>${primSpan}</span
524+
><span slot="secondary">${secondSpan}</span></mwc-check-list-item
525+
>`;
526+
}
527+
528+
export function reportControlCopyToIedSelector(element: Element): Wizard {
529+
return [
530+
{
531+
title: get('report.wizard.location'),
532+
primary: {
533+
icon: 'save',
534+
label: get('save'),
535+
action: copyReportControlActions(element),
536+
},
537+
content: [
538+
html`<filtered-list multi
539+
>${Array.from(element.ownerDocument.querySelectorAll('IED')).map(
540+
ied => renderIedListItem(element, ied)
541+
)}</filtered-list
542+
>`,
543+
],
544+
},
545+
];
546+
}
547+
548+
function openIedsSelector(element: Element): WizardMenuActor {
549+
return (): WizardAction[] => {
550+
return [() => reportControlCopyToIedSelector(element)];
551+
};
552+
}
553+
410554
export function removeReportControl(element: Element): WizardMenuActor {
411555
return (): WizardAction[] => {
412556
const complexAction = removeReportControlAction(element);
@@ -538,6 +682,12 @@ export function editReportControlWizard(element: Element): Wizard {
538682
action: openOptFieldsWizard(optFields),
539683
});
540684

685+
menuActions.push({
686+
icon: 'copy',
687+
label: get('controlblock.label.copy'),
688+
action: openIedsSelector(element),
689+
});
690+
541691
return [
542692
{
543693
title: get('wizard.title.edit', { tagName: element.tagName }),

0 commit comments

Comments
 (0)