diff --git a/src/editors/ied/ied-container.ts b/src/editors/ied/ied-container.ts index eb2e24f857..764569ef82 100644 --- a/src/editors/ied/ied-container.ts +++ b/src/editors/ied/ied-container.ts @@ -7,9 +7,11 @@ import { TemplateResult, } from 'lit-element'; import { nothing } from 'lit-html'; +import { translate } from "lit-translate"; +import {wizards} from "../../wizards/wizard-library.js"; import '../../action-pane.js'; -import { getDescriptionAttribute, getNameAttribute } from '../../foundation.js'; +import {getDescriptionAttribute, getNameAttribute, newWizardEvent} from '../../foundation.js'; import './access-point-container.js'; /** [[`IED`]] plugin subeditor for editing `IED` element. */ @@ -19,6 +21,11 @@ export class IedContainer extends LitElement { @property({ attribute: false }) element!: Element; + private openEditWizard(): void { + const wizard = wizards['IED'].edit(this.element); + if (wizard) this.dispatchEvent(newWizardEvent(wizard)); + } + private header(): TemplateResult { const name = getNameAttribute(this.element); const desc = getDescriptionAttribute(this.element); @@ -28,6 +35,13 @@ export class IedContainer extends LitElement { render(): TemplateResult { return html` + + this.openEditWizard()} + > + + ${Array.from(this.element.querySelectorAll(':scope > AccessPoint')).map( ap => html` = { + IED: + [{ + elementQuery: `Association`, + attribute: 'iedName' + }, { + elementQuery: `ClientLN`, + attribute: 'iedName' + }, { + elementQuery: `ConnectedAP`, + attribute: 'iedName' + }, { + elementQuery: `ExtRef`, + attribute: 'iedName' + }, { + elementQuery: `KDC`, + attribute: 'iedName' + }, { + elementQuery: `LNode`, + attribute: 'iedName' + }, { + elementQuery: `GSEControl > IEDName`, + attribute: null + }, { + elementQuery: `SampledValueControl > IEDName`, + attribute: null + }] +} + +function cloneElement(element: Element, attributeName: string, value: string | null): Element { + const newElement = element.cloneNode(false); + if (value === null) { + newElement.removeAttribute(attributeName); + } else { + newElement.setAttribute(attributeName, value); + } + return newElement; +} + +function cloneElementAndTextContent(element: Element, value: string | null): Element { + const newElement = element.cloneNode(false); + newElement.textContent = value; + return newElement; +} + +export function updateReferences(element: Element, oldValue: string | null, newValue: string): SimpleAction[] { + if (oldValue === newValue) { + return []; + } + + const referenceInfo = referenceInfos[element.tagName]; + if (referenceInfo === undefined) { + return []; + } + + const actions: SimpleAction[] = []; + referenceInfo.forEach(info => { + if (info.attribute !== null) { + Array.from(element.ownerDocument.querySelectorAll(`${info.elementQuery}[${info.attribute}="${oldValue}"]`)) + .filter(isPublic) + .forEach(element => { + const newElement = cloneElement(element, info.attribute!, newValue); + actions.push({old: {element}, new: {element: newElement}}); + }) + } else { + Array.from(element.ownerDocument.querySelectorAll(`${info.elementQuery}`)) + .filter(element => element.textContent === oldValue) + .filter(isPublic) + .forEach(element => { + const newElement = cloneElementAndTextContent(element, newValue); + actions.push({old: {element}, new: {element: newElement}}); + }) + } + }) + return actions; +} diff --git a/src/wizards/ied.ts b/src/wizards/ied.ts new file mode 100644 index 0000000000..7d780ca822 --- /dev/null +++ b/src/wizards/ied.ts @@ -0,0 +1,99 @@ +import {html, TemplateResult} from 'lit-element'; +import {get, translate} from 'lit-translate'; + +import { + cloneElement, + ComplexAction, + EditorAction, + getValue, + isPublic, + Wizard, + WizardActor, + WizardInput, +} from '../foundation.js'; +import {patterns} from "./foundation/limits.js"; +import {updateReferences} from "./foundation/references.js"; + +const iedNamePattern = "[A-Za-z][0-9A-Za-z_]{0,2}|" + + "[A-Za-z][0-9A-Za-z_]{4,63}|" + + "[A-MO-Za-z][0-9A-Za-z_]{3}|" + + "N[0-9A-Za-np-z_][0-9A-Za-z_]{2}|" + + "No[0-9A-Za-mo-z_][0-9A-Za-z_]|" + + "Non[0-9A-Za-df-z_]"; + +export function updateIED(element: Element): WizardActor { + return (inputs: WizardInput[]): EditorAction[] => { + const name = getValue(inputs.find(i => i.label === 'name')!)!; + const oldName = element.getAttribute('name'); + const desc = getValue(inputs.find(i => i.label === 'desc')!); + + if ( name === oldName && + desc === element.getAttribute('desc')) { + return []; + } + + const complexAction: ComplexAction = { + actions: [], + title: get('ied.action.updateied', {iedName: name}), + }; + + const newElement = cloneElement(element, { name, desc }); + complexAction.actions.push({ old: { element }, new: { element: newElement } }); + complexAction.actions.push(...updateReferences(element, oldName, name)); + return complexAction.actions.length ? [complexAction] : []; + }; +} + +export function renderIEDWizard( + name: string | null, + desc: string | null, + reservedNames: string[] +): TemplateResult[] { + return [ + html``, + html``, + ]; +} + +export function reservedNamesIED(currentElement: Element): string[] { + return Array.from( + currentElement.parentNode!.querySelectorAll('IED') + ) + .filter(isPublic) + .map(ied => ied.getAttribute('name') ?? '') + .filter(name => name !== currentElement.getAttribute('name')); +} + +export function editIEDWizard(element: Element): Wizard { + return [ + { + title: get('ied.wizard.title.edit'), + element, + primary: { + icon: 'edit', + label: get('save'), + action: updateIED(element), + }, + content: renderIEDWizard( + element.getAttribute('name'), + element.getAttribute('desc'), + reservedNamesIED(element) + ), + }, + ]; +} diff --git a/src/wizards/wizard-library.ts b/src/wizards/wizard-library.ts index f700809f1e..5b311bad02 100644 --- a/src/wizards/wizard-library.ts +++ b/src/wizards/wizard-library.ts @@ -9,6 +9,7 @@ import { createSubstationWizard, substationEditWizard } from './substation.js'; import { editTerminalWizard } from './terminal.js'; import { voltageLevelCreateWizard, voltageLevelEditWizard } from './voltagelevel.js'; import { editPowerTransformerWizard } from "./powertransformer.js"; +import { editIEDWizard } from "./ied.js"; type SclElementWizard = (element: Element) => Wizard | undefined; @@ -252,7 +253,7 @@ export const wizards: Record< create: emptyWizard, }, IED: { - edit: emptyWizard, + edit: editIEDWizard, create: emptyWizard, }, IEDName: { diff --git a/src/zeroline/ied-editor.ts b/src/zeroline/ied-editor.ts index 28b57b33e3..01dc313656 100644 --- a/src/zeroline/ied-editor.ts +++ b/src/zeroline/ied-editor.ts @@ -15,6 +15,7 @@ import '../action-icon.js'; import { createClientLnWizard } from '../wizards/clientln.js'; import { gooseIcon, smvIcon } from '../icons.js'; import { newWizardEvent } from '../foundation.js'; +import { wizards } from "../wizards/wizard-library.js"; import { selectGseControlWizard } from '../wizards/gsecontrol.js'; import { selectSampledValueControlWizard } from '../wizards/sampledvaluecontrol.js'; @@ -32,6 +33,11 @@ export class IedEditor extends LitElement { @query('.connectreport') connectReport!: Fab; + private openEditWizard(): void { + const wizard = wizards['IED'].edit(this.element); + if (wizard) this.dispatchEvent(newWizardEvent(wizard)); + } + private openCommunicationMapping(): void { const sendingIeds = Array.from( this.element.closest('SCL')?.querySelectorAll('IED') ?? [] @@ -71,6 +77,13 @@ export class IedEditor extends LitElement { mini @click="${() => this.openSmvControlSelection()}" >${smvIcon} `; } diff --git a/test/testfiles/wizards/ied.scd b/test/testfiles/wizards/ied.scd new file mode 100644 index 0000000000..0db66d418d --- /dev/null +++ b/test/testfiles/wizards/ied.scd @@ -0,0 +1,673 @@ + + +
+ TrainingIEC61850 + + + +
+ + + 110.0 + + + + + + + + + + + + + + + + + + + + + + + + + 20 + + + + + + + 100.0 + +
+

192.168.210.111

+

255.255.255.0

+

192.168.210.1

+

1,3,9999,23

+

23

+

00000001

+

0001

+

0001

+
+ +
+

01-0C-CD-01-00-10

+

005

+

4

+

0010

+
+
+ +

RJ45

+
+
+
+ + +
+

192.168.0.112

+

255.255.255.0

+

192.168.210.1

+

1,3,9999,23

+

23

+

00000001

+

0001

+

0001

+
+
+ +
+

192.168.0.113

+

255.255.255.0

+

192.168.210.1

+

1,3,9999,23

+

23

+

00000001

+

0001

+

0001

+
+ +
+

01-0C-CD-04-00-20

+

007

+

4

+

4002

+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + IED2 + + + + + + + status-only + + + + + + + sbo-with-enhanced-security + + + + + + + status-only + + + + + + + + 1 + + + + sbo-with-enhanced-security + + + + + + + + + + status-only + + + + + + + + + + + + + + + status-only + + + + + + + + + direct-with-normal-security + + + + + + + sbo-with-normal-security + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + status-only + + + + + + + + + + + + + + + + + + status-only + + + + + + + + + + + + + + + + + + + + + + status-only + + + + + + + status-only + + + + + + + + + + + status-only + + + + + + + direct-with-enhanced-security + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + IED2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + sbo-with-enhanced-security + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + IEC 61850-8-1:2003 + + + + + + + + + + + + + + + + + + + + + + + + + status-only + direct-with-normal-security + sbo-with-normal-security + direct-with-enhanced-security + sbo-with-enhanced-security + + + on + blocked + test + test/blocked + off + + + Ok + Warning + Alarm + + + not-supported + bay-control + station-control + remote-control + automatic-bay + automatic-station + automatic-remote + maintenance + process + + +
diff --git a/test/unit/editors/ied/__snapshots__/ied-container.test.snap.js b/test/unit/editors/ied/__snapshots__/ied-container.test.snap.js index d6291d1ef2..de25a91024 100644 --- a/test/unit/editors/ied/__snapshots__/ied-container.test.snap.js +++ b/test/unit/editors/ied/__snapshots__/ied-container.test.snap.js @@ -3,6 +3,13 @@ export const snapshots = {}; snapshots["ied-container looks like the latest snapshot"] = ` + + + + diff --git a/test/unit/wizards/__snapshots__/ied.test.snap.js b/test/unit/wizards/__snapshots__/ied.test.snap.js new file mode 100644 index 0000000000..12d6ae42f9 --- /dev/null +++ b/test/unit/wizards/__snapshots__/ied.test.snap.js @@ -0,0 +1,47 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["Wizards for SCL element IED looks like the latest snapshot"] = +` +
+ + + + +
+ + + + +
+`; +/* end snapshot Wizards for SCL element IED looks like the latest snapshot */ + diff --git a/test/unit/wizards/foundation.ts b/test/unit/wizards/foundation.ts index 04c2d27dd8..eed3965a21 100644 --- a/test/unit/wizards/foundation.ts +++ b/test/unit/wizards/foundation.ts @@ -1,12 +1,12 @@ import {expect} from "@open-wc/testing"; -import {isUpdate, Update, WizardActor, WizardInput} from "../../../src/foundation.js"; +import {isUpdate, SimpleAction, Update, WizardActor, WizardInput} from "../../../src/foundation.js"; import {WizardTextField} from "../../../src/wizard-textfield.js"; const noOp = () => { return; }; -const newWizard = (done = noOp) => { +export const newWizard = (done = noOp) => { const element = document.createElement('mwc-dialog'); element.close = done; return element; @@ -32,6 +32,31 @@ export function expectWizardNoUpdateAction(wizardActor: WizardActor, inputs: Wiz expect(updateActions).to.be.empty; } +export function expectUpdateAction(simpleAction: SimpleAction, tagName: string, attributeName: string, + oldValue: string | null, newValue: string | null) { + expect(simpleAction).to.satisfy(isUpdate); + + expect((simpleAction).old.element.tagName).to.be.equal(tagName); + if (oldValue === null) { + expect((simpleAction).old.element).to.not.have.attribute(attributeName); + } else { + expect((simpleAction).old.element).to.have.attribute(attributeName, oldValue); + } + + expect((simpleAction).new.element.tagName).to.be.equal(tagName); + if (newValue === null) { + expect((simpleAction).new.element).to.not.have.attribute(attributeName); + } else { + expect((simpleAction).new.element).to.have.attribute(attributeName, newValue); + } +} + +export function expectUpdateTextValue(action: Update, parentTagName: string, oldValue: string, newValue: string): void { + expect(action.old.element.parentElement!.tagName).to.be.equal(parentTagName); + expect(action.old.element.textContent).to.be.equal(oldValue); + expect(action.new.element.textContent).to.be.equal(newValue); +} + export async function fetchDoc(docName: string): Promise { return await fetch(docName) .then(response => response.text()) diff --git a/test/unit/wizards/foundation/references.test.ts b/test/unit/wizards/foundation/references.test.ts new file mode 100644 index 0000000000..14ade3bc86 --- /dev/null +++ b/test/unit/wizards/foundation/references.test.ts @@ -0,0 +1,42 @@ +import {expectUpdateAction, expectUpdateTextValue, fetchDoc} from "../foundation.js"; +import {updateReferences} from "../../../../src/wizards/foundation/references.js"; +import {expect} from "@open-wc/testing"; +import {Update} from "../../../../src/foundation.js"; + +describe('Update reference for ', () => { + let doc: XMLDocument; + + describe('IED ', () => { + beforeEach(async () => { + doc = await fetchDoc('/test/testfiles/wizards/ied.scd'); + }); + + it('will update all references to IED1', async function () { + const oldName = "IED1" + const newName = "NewIED1"; + const ied = doc.querySelector(`IED[name="${oldName}"]`)!; + + const updateActions = updateReferences(ied, oldName, newName); + expect(updateActions.length).to.equal(9); + + expectUpdateAction(updateActions[0], 'Association', 'iedName', oldName, newName); + expectUpdateAction(updateActions[1], 'ClientLN', 'iedName', oldName, newName); + expectUpdateAction(updateActions[3], 'ConnectedAP', 'iedName', oldName, newName); + expectUpdateAction(updateActions[4], 'ExtRef', 'iedName', oldName, newName); + expectUpdateAction(updateActions[8], 'KDC', 'iedName', oldName, newName); + }); + + it('will update all references to IED2', async function () { + const oldName = "IED2" + const newName = "NewIED2"; + const ied = doc.querySelector(`IED[name="${oldName}"]`)!; + + const updateActions = updateReferences(ied, oldName, newName); + expect(updateActions.length).to.equal(8); + + expectUpdateAction(updateActions[4], 'LNode', 'iedName', oldName, newName); + expectUpdateTextValue(updateActions[6], 'GSEControl', oldName, newName); + expectUpdateTextValue(updateActions[7], 'SampledValueControl', oldName, newName); + }); + }); +}); diff --git a/test/unit/wizards/ied.test.ts b/test/unit/wizards/ied.test.ts new file mode 100644 index 0000000000..469441ea57 --- /dev/null +++ b/test/unit/wizards/ied.test.ts @@ -0,0 +1,73 @@ +import {expect, fixture, html} from '@open-wc/testing'; + +import '../../mock-wizard.js'; +import {MockWizard} from '../../mock-wizard.js'; + +import {WizardTextField} from '../../../src/wizard-textfield.js'; +import {ComplexAction, isSimple, isUpdate, Update, WizardInput} from '../../../src/foundation.js'; +import {editIEDWizard, updateIED} from '../../../src/wizards/ied.js'; + +import { + expectUpdateAction, + expectWizardNoUpdateAction, + fetchDoc, newWizard, + setWizardTextFieldValue, +} from './foundation.js'; + +describe('Wizards for SCL element IED', () => { + let doc: XMLDocument; + let ied: Element; + let element: MockWizard; + let inputs: WizardInput[]; + + beforeEach(async () => { + doc = await fetchDoc('/test/testfiles/wizards/ied.scd'); + ied = doc.querySelector('IED[name="IED3"]')!; + + element = await fixture(html``); + const wizard = editIEDWizard(ied); + element.workflow.push(() => wizard); + await element.requestUpdate(); + inputs = Array.from(element.wizardUI.inputs); + }); + + it('update name should be updated in document', async function () { + await setWizardTextFieldValue(inputs[0], 'OtherIED3'); + + const complexAction = updateIED(ied)(inputs, newWizard()); + expect(complexAction.length).to.equal(1); + expect(complexAction[0]).to.not.satisfy(isSimple); + + const simpleActions = (complexAction[0]).actions; + expect(simpleActions.length).to.equal(2); + + expectUpdateAction(simpleActions[0], 'IED', 'name', 'IED3', 'OtherIED3'); + expectUpdateAction(simpleActions[1], 'ConnectedAP', 'iedName', 'IED3', 'OtherIED3'); + }); + + it('update name should be unique in document', async function () { + await setWizardTextFieldValue(inputs[0], 'IED2'); + expect(inputs[0].checkValidity()).to.be.false; + }); + + it('update description should be updated in document', async function () { + await setWizardTextFieldValue(inputs[1], 'Some description'); + + const complexAction = updateIED(ied)(inputs, newWizard()); + expect(complexAction.length).to.equal(1); + expect(complexAction[0]).to.not.satisfy(isSimple); + + const simpleActions = (complexAction[0]).actions; + expect(simpleActions.length).to.equal(1); + + expectUpdateAction(simpleActions[0], 'IED', 'desc', null, 'Some description'); + }); + + it('when no fields changed there will be no update action', async function () { + expectWizardNoUpdateAction(updateIED(ied), inputs); + }); + + it('looks like the latest snapshot', async () => { + await expect(element.wizardUI.dialog).dom.to.equalSnapshot(); + }); +}); diff --git a/test/unit/zeroline/__snapshots__/ied-editor.test.snap.js b/test/unit/zeroline/__snapshots__/ied-editor.test.snap.js index c2ed11ba51..7d0f2f8d88 100644 --- a/test/unit/zeroline/__snapshots__/ied-editor.test.snap.js +++ b/test/unit/zeroline/__snapshots__/ied-editor.test.snap.js @@ -30,6 +30,13 @@ snapshots["A component to visualize SCL element IED looks like the latest snapsh + + `; /* end snapshot A component to visualize SCL element IED looks like the latest snapshot */