From d47a3dac6c57ee814cfbd78f3636fe872a052568 Mon Sep 17 00:00:00 2001 From: Christopher Lepski <139237321+clepski@users.noreply.github.com> Date: Tue, 7 Oct 2025 13:57:42 +0200 Subject: [PATCH 1/8] fix: Disable experimental require module to make node 20.19 and above work (#1709) --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index fbe5f9636a..e2f7a99a87 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,11 @@ ], "scripts": { "clean": "lerna run clean", - "build": "npx nx run-many -t build --all", + "build": "NODE_OPTIONS=--no-experimental-require-module npx nx run-many -t build --all", "doc": "lerna run doc", "test": "npx nx run-many -t test --all --parallel=false", "graph": "npx nx graph", - "start": "npx rimraf packages/distribution/node_modules/.cache/snowpack/build/lit@2.8.0 && lerna run start", + "start": "npx rimraf packages/distribution/node_modules/.cache/snowpack/build/lit@2.8.0 && NODE_OPTIONS=--no-experimental-require-module lerna run start", "serve": "nx run @openscd/distribution:serve" }, "repository": { From cf45fe92e4a09066ca9b426b282486229dfbc43a Mon Sep 17 00:00:00 2001 From: Nora Blomaard Date: Mon, 13 Oct 2025 13:01:39 +0200 Subject: [PATCH 2/8] feat: add virtual ied (#1712) * feat: add create virtual IED dialog * fix: replace 'get' with 'translate' in IED editor * fix: remove button attributes * feat: refactor and add tests * fix: update node to 20.x in PR preview workflow * fix: update node to 20.x in build & deploy workflow * fix: remove properties from createLLN0LNodeType function --- .github/workflows/build-and-deploy.yml | 6 +- .github/workflows/pr-preview.yml | 6 +- package-lock.json | 7 + packages/openscd/src/translations/de.ts | 6 + packages/openscd/src/translations/en.ts | 6 + packages/plugins/package.json | 1 + packages/plugins/src/editors/IED.ts | 280 +++++++++------ .../src/editors/ied/create-ied-dialog.ts | 128 +++++++ packages/plugins/src/editors/ied/da-wizard.ts | 4 +- packages/plugins/src/editors/ied/do-wizard.ts | 4 +- .../plugins/src/editors/ied/foundation.ts | 84 ++++- .../test/integration/editors/IED.test.ts | 60 +++- .../editors/__snapshots__/IED.test.snap.js | 322 ++++++++++-------- .../editors/ied/create-ied-dialog.test.ts | 78 +++++ .../test/unit/editors/ied/foundation.test.ts | 74 +++- 15 files changed, 793 insertions(+), 273 deletions(-) create mode 100644 packages/plugins/src/editors/ied/create-ied-dialog.ts create mode 100644 packages/plugins/test/unit/editors/ied/create-ied-dialog.test.ts diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index c8bccfa815..d7c5191fa8 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -15,10 +15,10 @@ jobs: with: submodules: "true" - - name: Use Node.js 18.x - uses: actions/setup-node@v1 + - name: Use Node.js 20.x + uses: actions/setup-node@v4 with: - node-version: "18.x" + node-version: "20.x" - name: Install and Build OpenSCD run: | diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index af23fa5ea8..86905cb2b1 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -24,10 +24,10 @@ jobs: with: submodules: "true" - - name: Use Node.js 18.x - uses: actions/setup-node@v1 + - name: Use Node.js 20.x + uses: actions/setup-node@v4 with: - node-version: "18.x" + node-version: "20.x" - name: Install and Build OpenSCD if: github.event.action != 'closed' # You might want to skip the build if the PR has been closed diff --git a/package-lock.json b/package-lock.json index b78d3ec678..28d46ba23e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7075,6 +7075,12 @@ "@types/trusted-types": "^2.0.2" } }, + "node_modules/@openenergytools/scl-lib": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@openenergytools/scl-lib/-/scl-lib-1.8.0.tgz", + "integrity": "sha512-l8D7boLoUpP+C/J2ue7aY/12euRtK2k4EYKsyysoFto9sySCnlX/KWom5ruP7MtH8n0BEMSUuFg1gKfC+lD80Q==", + "license": "Apache-2.0" + }, "node_modules/@openscd/addons": { "resolved": "packages/addons", "link": true @@ -34027,6 +34033,7 @@ "@material/mwc-switch": "0.22.1", "@material/mwc-textarea": "0.22.1", "@material/mwc-textfield": "0.22.1", + "@openenergytools/scl-lib": "^1.8.0", "@openscd/core": "*", "@openscd/open-scd": "*", "@openscd/wizards": "*", diff --git a/packages/openscd/src/translations/de.ts b/packages/openscd/src/translations/de.ts index b4cef871fd..35be0f927c 100644 --- a/packages/openscd/src/translations/de.ts +++ b/packages/openscd/src/translations/de.ts @@ -229,6 +229,7 @@ export const de: Translations = { missing: 'Kein IED vorhanden', toggleChildElements: 'Kindelemente umschalten', settings: 'Services für IED or AccessPoint', + createIed: 'Virtuelles IED erstellen', wizard: { daTitle: 'DA Informationen anzeigen', doTitle: 'DO Informationen anzeigen', @@ -248,6 +249,11 @@ export const de: Translations = { daBType: 'DA Typ', daValue: 'DA Wert', }, + createDialog: { + iedName: 'IED Name', + nameFormatError: 'IED Name darf keine Leerzeichen enthalten', + nameUniqueError: 'IED Name ist bereits vergeben', + }, }, ied: { wizard: { diff --git a/packages/openscd/src/translations/en.ts b/packages/openscd/src/translations/en.ts index ecf7cb4ca0..7692f89b11 100644 --- a/packages/openscd/src/translations/en.ts +++ b/packages/openscd/src/translations/en.ts @@ -226,6 +226,7 @@ export const en = { missing: 'No IED', toggleChildElements: 'Toggle child elements', settings: 'Show Services the IED/AccessPoint provides', + createIed: 'Create Virtual IED', wizard: { daTitle: 'Show DA Info', doTitle: 'Show DO Info', @@ -245,6 +246,11 @@ export const en = { daBType: 'Data attribute type', daValue: 'Data attribute value', }, + createDialog: { + iedName: 'IED Name', + nameFormatError: 'IED name cannot contain spaces', + nameUniqueError: 'IED name already exists', + }, }, ied: { wizard: { diff --git a/packages/plugins/package.json b/packages/plugins/package.json index 9e38d085dd..a925d00edc 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -29,6 +29,7 @@ "@material/mwc-switch": "0.22.1", "@material/mwc-textarea": "0.22.1", "@material/mwc-textfield": "0.22.1", + "@openenergytools/scl-lib": "^1.8.0", "@openscd/core": "*", "@openscd/open-scd": "*", "@openscd/wizards": "*", diff --git a/packages/plugins/src/editors/IED.ts b/packages/plugins/src/editors/IED.ts index 12b6183c54..2ce7c4d4cc 100644 --- a/packages/plugins/src/editors/IED.ts +++ b/packages/plugins/src/editors/IED.ts @@ -1,55 +1,64 @@ import { css, html, - LitElement, + query, property, - PropertyValues, state, + LitElement, + PropertyValues, TemplateResult, } from 'lit-element'; -import { get } from 'lit-translate'; +import { translate } from 'lit-translate'; import { nothing } from 'lit-html'; - import '@material/mwc-list/mwc-check-list-item'; import '@material/mwc-list/mwc-radio-list-item'; - +import '@material/mwc-button'; import '@openscd/open-scd/src/oscd-filter-button.js'; -import { SelectedItemsChangedEvent } from '@openscd/open-scd/src/oscd-filter-button.js'; import './ied/ied-container.js'; import './ied/element-path.js'; +import './ied/create-ied-dialog.js'; +import { + findLLN0LNodeType, + createLLN0LNodeType, + createIEDStructure, +} from './ied/foundation.js'; import { compareNames, getDescriptionAttribute, getNameAttribute, } from '@openscd/open-scd/src/foundation.js'; +import { SelectedItemsChangedEvent } from '@openscd/open-scd/src/oscd-filter-button.js'; import { Nsdoc } from '@openscd/open-scd/src/foundation/nsdoc.js'; import { getIcon } from '@openscd/open-scd/src/icons/icons.js'; -import { OscdApi } from '@openscd/core'; +import { OscdApi, newEditEventV2, InsertV2 } from '@openscd/core'; +import { CreateIedDialog } from './ied/create-ied-dialog.js'; /** An editor [[`plugin`]] for editing the `IED` section. */ export default class IedPlugin extends LitElement { /** The document being edited as provided to plugins by [[`OpenSCD`]]. */ @property() doc!: XMLDocument; - + @property({ type: Number }) editCount = -1; - + /** All the nsdoc files that are being uploaded via the settings. */ @property() nsdoc!: Nsdoc; - + @property() oscdApi: OscdApi | null = null; - + + @query('create-ied-dialog') createIedDialog!: CreateIedDialog; + @state() selectedIEDs: string[] = []; - + @state() selectedLNClasses: string[] = []; - + @state() private get iedList(): Element[] { return this.doc @@ -65,26 +74,26 @@ export default class IedPlugin extends LitElement { const uniqueLNClassList: string[] = []; if (currentIed) { return Array.from(currentIed.querySelectorAll('LN0, LN')) - .filter(element => element.hasAttribute('lnClass')) - .filter(element => { - const lnClass = element.getAttribute('lnClass') ?? ''; - if (uniqueLNClassList.includes(lnClass)) { - return false; - } - uniqueLNClassList.push(lnClass); - return true; - }) - .sort((a, b) => { - const aLnClass = a.getAttribute('lnClass') ?? ''; - const bLnClass = b.getAttribute('lnClass') ?? ''; - - return aLnClass.localeCompare(bLnClass); - }) - .map(element => { - const lnClass = element.getAttribute('lnClass'); - const label = this.nsdoc.getDataDescription(element).label; - return [lnClass, label]; - }) as string[][]; + .filter(element => element.hasAttribute('lnClass')) + .filter(element => { + const lnClass = element.getAttribute('lnClass') ?? ''; + if (uniqueLNClassList.includes(lnClass)) { + return false; + } + uniqueLNClassList.push(lnClass); + return true; + }) + .sort((a, b) => { + const aLnClass = a.getAttribute('lnClass') ?? ''; + const bLnClass = b.getAttribute('lnClass') ?? ''; + + return aLnClass.localeCompare(bLnClass); + }) + .map(element => { + const lnClass = element.getAttribute('lnClass'); + const label = this.nsdoc.getDataDescription(element).label; + return [lnClass, label]; + }) as string[][]; } return []; } @@ -115,6 +124,33 @@ export default class IedPlugin extends LitElement { this.storePluginState(); } + private createVirtualIED(iedName: string): void { + const inserts: InsertV2[] = []; + + const existingLLN0 = findLLN0LNodeType(this.doc); + const lnTypeId = existingLLN0?.getAttribute('id') || 'PlaceholderLLN0'; + + const ied = createIEDStructure(this.doc, iedName, lnTypeId); + + const dataTypeTemplates = this.doc.querySelector('DataTypeTemplates'); + inserts.push({ + parent: this.doc.querySelector('SCL')!, + node: ied, + reference: dataTypeTemplates, + }); + + if (!existingLLN0) { + const lnodeTypeInserts = createLLN0LNodeType(this.doc, lnTypeId); + inserts.push(...lnodeTypeInserts); + } + + this.dispatchEvent(newEditEventV2(inserts)); + + this.selectedIEDs = [iedName]; + this.selectedLNClasses = []; + this.requestUpdate('selectedIed'); + } + protected updated(_changedProperties: PropertyValues): void { super.updated(_changedProperties); @@ -148,8 +184,9 @@ export default class IedPlugin extends LitElement { private loadPluginState(): void { const stateApi = this.oscdApi?.pluginState; - const selectedIEDs: string[] | null = (stateApi?.getState()?.selectedIEDs as string[]) ?? null; - + const selectedIEDs: string[] | null = + (stateApi?.getState()?.selectedIEDs as string[]) ?? null; + if (selectedIEDs) { this.onSelectionChange(selectedIEDs); } @@ -157,7 +194,7 @@ export default class IedPlugin extends LitElement { private storePluginState(): void { const stateApi = this.oscdApi?.pluginState; - + if (stateApi) { stateApi.setState({ selectedIEDs: this.selectedIEDs }); } @@ -185,96 +222,113 @@ export default class IedPlugin extends LitElement { first.every((val, index) => val === second[index]) ); }; - - const selectionChanged = !equalArrays( - this.selectedIEDs, - selectedIeds - ); - + + const selectionChanged = !equalArrays(this.selectedIEDs, selectedIeds); + if (!selectionChanged) { return; } - + this.lNClassListOpenedOnce = false; this.selectedIEDs = selectedIeds; this.selectedLNClasses = []; this.requestUpdate('selectedIed'); } - render(): TemplateResult { + private renderIEDList(): TemplateResult { const iedList = this.iedList; - if (iedList.length > 0) { - return html`
-
-

${get('filters')}:

- - - ${iedList.map(ied => { - const name = getNameAttribute(ied); - const descr = getDescriptionAttribute(ied); - const type = ied.getAttribute('type'); - const manufacturer = ied.getAttribute('manufacturer'); - return html` - ${name} ${descr ? html` (${descr})` : html``} - - ${type} ${type && manufacturer ? html`—` : nothing} - ${manufacturer} - - `; - })} - - - - ${getIcon('lNIcon')} - ${this.lnClassList.map(lnClassInfo => { - const value = lnClassInfo[0]; - const label = lnClassInfo[1]; - return html` - ${label} - `; - })} - - - -
- - -
`; + if (iedList.length === 0) { + return html`

+ ${translate('iededitor.missing')} +

`; } - return html`

- ${get('iededitor.missing')} -

`; + return html`
+
+

${translate('filters')}:

+ + ${iedList.map(ied => { + const name = getNameAttribute(ied); + const descr = getDescriptionAttribute(ied); + const type = ied.getAttribute('type'); + const manufacturer = ied.getAttribute('manufacturer'); + return html` + ${name} ${descr ? html` (${descr})` : html``} + + ${type} ${type && manufacturer ? html`—` : nothing} + ${manufacturer} + + `; + })} + + + + ${getIcon('lNIcon')} + ${this.lnClassList.map(lnClassInfo => { + const value = lnClassInfo[0]; + const label = lnClassInfo[1]; + return html` + ${label} + `; + })} + + + +
+ + +
`; + } + + render(): TemplateResult { + return html`
+ this.createIedDialog.show()} + > + ${translate('iededitor.createIed')} + + ${this.renderIEDList()} + this.createVirtualIED(iedName)} + > +
`; } static styles = css` :host { width: 100vw; + position: relative; } section { @@ -301,5 +355,11 @@ export default class IedPlugin extends LitElement { margin-left: auto; padding-right: 12px; } + + .add-ied-button { + display: block; + float: right; + margin: 8px 12px 0 0; + } `; } diff --git a/packages/plugins/src/editors/ied/create-ied-dialog.ts b/packages/plugins/src/editors/ied/create-ied-dialog.ts new file mode 100644 index 0000000000..f7950607cd --- /dev/null +++ b/packages/plugins/src/editors/ied/create-ied-dialog.ts @@ -0,0 +1,128 @@ +import { + css, + html, + LitElement, + property, + state, + TemplateResult, + query, + customElement, +} from 'lit-element'; +import { get, translate } from 'lit-translate'; + +import { Dialog } from '@material/mwc-dialog'; +import '@material/mwc-button'; +import '@material/mwc-textfield'; + +/** A dialog component for creating virtual IEDs */ +@customElement('create-ied-dialog') +export class CreateIedDialog extends LitElement { + @property() + doc!: XMLDocument; + + @property({ type: Function }) + onConfirm!: (iedName: string) => void; + + @query('#createIedDialog') dialog!: Dialog; + + @state() + private newIedName = ''; + + get open() { + return this.dialog?.open ?? false; + } + + private isIedNameValid(name: string): boolean { + const trimmedName = name.trim(); + return ( + trimmedName.length > 0 && + !trimmedName.includes(' ') && + this.isIedNameUnique(trimmedName) + ); + } + + private getIedNameError(name: string): string { + const trimmedName = name.trim(); + if (trimmedName.length === 0) return ''; + if (trimmedName.includes(' ')) + return get('iededitor.createDialog.nameFormatError'); + if (!this.isIedNameUnique(trimmedName)) + return get('iededitor.createDialog.nameUniqueError'); + return ''; + } + + private isIedNameUnique(name: string): boolean { + const existingNames = Array.from(this.doc.querySelectorAll('IED')) + .map(ied => ied.getAttribute('name')) + .filter(n => n !== null) as string[]; + + return !existingNames.includes(name); + } + + public show(): void { + this.newIedName = ''; + this.dialog.show(); + } + + private close(): void { + this.dialog.close(); + this.newIedName = ''; + } + + private handleCreate(): void { + if (this.isIedNameValid(this.newIedName)) { + this.onConfirm(this.newIedName); + this.close(); + } + } + + render(): TemplateResult { + const isNameValid = this.isIedNameValid(this.newIedName); + const errorMessage = this.getIedNameError(this.newIedName); + + return html` + +
+ { + const error = this.getIedNameError(value); + return { + valid: error === '', + customError: error !== '', + }; + }} + autoValidate + @input=${(e: Event) => { + this.newIedName = (e.target as HTMLInputElement).value; + }} + required + style="width: 100%; margin-bottom: 16px;" + > +
+ + ${translate('cancel')} + + + ${translate('create')} + +
+ `; + } + + static styles = css` + .dialog-content { + margin-top: 16px; + } + `; +} diff --git a/packages/plugins/src/editors/ied/da-wizard.ts b/packages/plugins/src/editors/ied/da-wizard.ts index 2f8b486229..a9b0fccd27 100644 --- a/packages/plugins/src/editors/ied/da-wizard.ts +++ b/packages/plugins/src/editors/ied/da-wizard.ts @@ -15,7 +15,7 @@ import { Nsdoc } from '@openscd/open-scd/src/foundation/nsdoc.js'; import { findDOTypeElement, findElement, - findLogicaNodeElement, + findLogicalNodeElement, getValueElements, } from './foundation.js'; @@ -38,7 +38,7 @@ function renderFields( const iedElement = findElement(ancestors, 'IED'); const accessPointElement = findElement(ancestors, 'AccessPoint'); const lDeviceElement = findElement(ancestors, 'LDevice'); - const logicalNodeElement = findLogicaNodeElement(ancestors); + const logicalNodeElement = findLogicalNodeElement(ancestors); const doElement = findElement(ancestors, 'DO'); const doTypeElement = findDOTypeElement(doElement); diff --git a/packages/plugins/src/editors/ied/do-wizard.ts b/packages/plugins/src/editors/ied/do-wizard.ts index d2e07f05d4..fafee42bff 100644 --- a/packages/plugins/src/editors/ied/do-wizard.ts +++ b/packages/plugins/src/editors/ied/do-wizard.ts @@ -15,7 +15,7 @@ import { Nsdoc } from '@openscd/open-scd/src/foundation/nsdoc.js'; import { findDOTypeElement, findElement, - findLogicaNodeElement, + findLogicalNodeElement, } from './foundation.js'; function renderFields( @@ -27,7 +27,7 @@ function renderFields( const iedElement = findElement(ancestors, 'IED'); const accessPointElement = findElement(ancestors, 'AccessPoint'); const lDeviceElement = findElement(ancestors, 'LDevice'); - const logicalNodeElement = findLogicaNodeElement(ancestors); + const logicalNodeElement = findLogicalNodeElement(ancestors); const doTypeElement = findDOTypeElement(element); return [ diff --git a/packages/plugins/src/editors/ied/foundation.ts b/packages/plugins/src/editors/ied/foundation.ts index c85048a756..6f9cd44b37 100644 --- a/packages/plugins/src/editors/ied/foundation.ts +++ b/packages/plugins/src/editors/ied/foundation.ts @@ -5,6 +5,9 @@ import { getNameAttribute, } from '@openscd/open-scd/src/foundation.js'; import { Nsdoc } from '@openscd/open-scd/src/foundation/nsdoc.js'; +import { createElement } from '@openscd/xml'; +import { InsertV2 } from '@openscd/core'; +import { insertSelectedLNodeType } from '@openenergytools/scl-lib/dist/tDataTypeTemplates/insertSelectedLNodeType.js'; /** Base class for all containers inside the IED Editor. */ export class Container extends LitElement { @@ -63,7 +66,7 @@ export function findElement( * @param ancestors - The list of elements to search in for an LN or LN0 element. * @returns The LN0/LN Element found or null if not found. */ -export function findLogicaNodeElement(ancestors: Element[]): Element | null { +export function findLogicalNodeElement(ancestors: Element[]): Element | null { let element = findElement(ancestors, 'LN0'); if (!element) { element = findElement(ancestors, 'LN'); @@ -71,6 +74,85 @@ export function findLogicaNodeElement(ancestors: Element[]): Element | null { return element; } +/** + * Find an existing LLN0 LNodeType in the document. + * @param doc - The XML document to search in. + * @returns The LLN0 LNodeType element or null if not found. + */ +export function findLLN0LNodeType(doc: XMLDocument): Element | null { + return doc.querySelector('DataTypeTemplates > LNodeType[lnClass="LLN0"]'); +} + +/** + * Create a minimal LLN0 LNodeType with essential data objects. + * @param doc - The XML document to create the LNodeType in. + * @param id - Optional ID for the LNodeType, defaults to 'LLN0_OpenSCD'. + * @returns Array of InsertV2 operations to create the LNodeType and dependencies. + */ +export function createLLN0LNodeType(doc: XMLDocument, id: string): InsertV2[] { + const selection = { + Beh: { + stVal: { + on: {}, + blocked: {}, + test: {}, + 'test/blocked': {}, + off: {}, + }, + q: {}, + t: {}, + }, + }; + + const logicalnode = { + class: 'LLN0', + id, + }; + + return insertSelectedLNodeType(doc, selection, logicalnode); +} + +/** + * Create a basic IED structure with the specified name. + * @param doc - The XML document to create the IED in. + * @param iedName - The name for the new IED. + * @param lnTypeId - The LNodeType ID to use for the LN0. + * @param manufacturer - Optional manufacturer name, defaults to 'OpenSCD'. + * @returns The created IED element. + */ +export function createIEDStructure( + doc: XMLDocument, + iedName: string, + lnTypeId: string, + manufacturer: string = 'OpenSCD' +): Element { + const ied = createElement(doc, 'IED', { + name: iedName, + manufacturer, + }); + + const accessPoint = createElement(doc, 'AccessPoint', { name: 'AP1' }); + ied.appendChild(accessPoint); + + const server = createElement(doc, 'Server', {}); + accessPoint.appendChild(server); + + const authentication = createElement(doc, 'Authentication', {}); + server.appendChild(authentication); + + const lDevice = createElement(doc, 'LDevice', { inst: 'LD1' }); + server.appendChild(lDevice); + + const ln0 = createElement(doc, 'LN0', { + lnClass: 'LLN0', + inst: '', + lnType: lnTypeId, + }); + lDevice.appendChild(ln0); + + return ied; +} + /** * With the passed DO Element retrieve the type attribute and search for the DOType in the DataType Templates section. * @param element - The DO Element. diff --git a/packages/plugins/test/integration/editors/IED.test.ts b/packages/plugins/test/integration/editors/IED.test.ts index afa85430d3..bad304d26a 100644 --- a/packages/plugins/test/integration/editors/IED.test.ts +++ b/packages/plugins/test/integration/editors/IED.test.ts @@ -113,7 +113,11 @@ describe('IED Plugin', () => { oscdApi.pluginState.setState(null); parent = await fixture( html`` ); element = parent.getActivePlugin(); @@ -382,7 +386,9 @@ describe('IED Plugin', () => { element.disconnectedCallback(); const api = new OscdApi('IED'); - expect(api.pluginState.getState()).to.deep.equal({ selectedIEDs: ['IED3'] }); + expect(api.pluginState.getState()).to.deep.equal({ + selectedIEDs: ['IED3'], + }); }); }); @@ -397,6 +403,56 @@ describe('IED Plugin', () => { expect(element.selectedIEDs).to.deep.equal(['IED3']); }); }); + + describe('virtual IED creation', () => { + it('should render create IED button', () => { + const createButton = + element.shadowRoot!.querySelector('.add-ied-button'); + expect(createButton).to.exist; + expect(createButton!.textContent).to.include('Create Virtual IED'); + }); + + it('should show create IED dialog when button is clicked', async () => { + const createButton = element.shadowRoot!.querySelector( + '.add-ied-button' + ) as HTMLElement; + const dialog = + element.shadowRoot!.querySelector('create-ied-dialog')!; + + let dialogShowCalled = false; + (dialog as any).show = () => { + dialogShowCalled = true; + }; + + createButton.click(); + await element.updateComplete; + + expect(dialogShowCalled).to.be.true; + }); + + it('should create virtual IED when confirmed through dialog', async () => { + let editEventDetail: any = null; + element.addEventListener('oscd-edit-v2', (event: Event) => { + editEventDetail = (event as CustomEvent).detail; + }); + + const dialog = element.shadowRoot!.querySelector( + 'create-ied-dialog' + ) as any; + + const onConfirm = dialog.onConfirm; + onConfirm('TestVirtualIED'); + + await element.updateComplete; + + expect(editEventDetail).to.exist; + expect(editEventDetail.edit).to.be.an('array'); + expect(editEventDetail.edit.length).to.be.greaterThan(0); + + expect(element.selectedIEDs).to.deep.equal(['TestVirtualIED']); + expect(element.selectedLNClasses).to.deep.equal([]); + }); + }); }); }); diff --git a/packages/plugins/test/integration/editors/__snapshots__/IED.test.snap.js b/packages/plugins/test/integration/editors/__snapshots__/IED.test.snap.js index 2e0ad05334..e84e7432bb 100644 --- a/packages/plugins/test/integration/editors/__snapshots__/IED.test.snap.js +++ b/packages/plugins/test/integration/editors/__snapshots__/IED.test.snap.js @@ -2,163 +2,193 @@ export const snapshots = {}; snapshots["IED Plugin without a doc loaded looks like the latest snapshot"] = -`

- - No IED - -

+`
+ + Create Virtual IED + +

+ + No IED + +

+ + +
`; /* end snapshot IED Plugin without a doc loaded looks like the latest snapshot */ snapshots["IED Plugin with a doc loaded containing no IEDs looks like the latest snapshot"] = -`

- - No IED - -

+`
+ + Create Virtual IED + +

+ + No IED + +

+ + +
`; /* end snapshot IED Plugin with a doc loaded containing no IEDs looks like the latest snapshot */ snapshots["IED Plugin with a doc loaded containing IEDs looks like the latest snapshot"] = -`
-
-

- Filters: -

- - + + Create Virtual IED + +
+
+

+ Filters: +

+ - IED1 - - DummyIED — - DummyManufactorer - - - - IED2 - - DummyIED — - DummyManufactorer - - - - IED3 - - DummyIED — - DummyManufactorer - - - - IED4 - - DummyIED — - DummyManufactorer - - - + IED1 + + DummyIED — + DummyManufactorer + + + + IED2 + + DummyIED — + DummyManufactorer + + + + IED3 + + DummyIED — + DummyManufactorer + + + + IED4 + + DummyIED — + DummyManufactorer + + + + IED5 + + DummyIED — + DummyManufactorer + + + + - IED5 - - DummyIED — - DummyManufactorer + - - - - - - - CILO - - - CSWI - - - LLN0 - - - XCBR - - - XSWI - - - - -
- - -
+ + CILO + + + CSWI + + + LLN0 + + + XCBR + + + XSWI + +
+ + +
+ + +
+ + + `; /* end snapshot IED Plugin with a doc loaded containing IEDs looks like the latest snapshot */ diff --git a/packages/plugins/test/unit/editors/ied/create-ied-dialog.test.ts b/packages/plugins/test/unit/editors/ied/create-ied-dialog.test.ts new file mode 100644 index 0000000000..069d1da268 --- /dev/null +++ b/packages/plugins/test/unit/editors/ied/create-ied-dialog.test.ts @@ -0,0 +1,78 @@ +import { expect, fixture, html } from '@open-wc/testing'; +import { SinonSpy, spy } from 'sinon'; + +import '@material/mwc-dialog'; +import '../../../../src/editors/ied/create-ied-dialog.js'; +import { CreateIedDialog } from '../../../../src/editors/ied/create-ied-dialog.js'; + +describe('create-ied-dialog', async () => { + let element: CreateIedDialog; + let doc: XMLDocument; + let onConfirmSpy: SinonSpy; + + beforeEach(async () => { + doc = new DOMParser().parseFromString( + ` + + `, + 'application/xml' + ); + + onConfirmSpy = spy(); + + element = await fixture(html` + + + `); + }); + + it('should show and hide dialog', async () => { + await element.updateComplete; + element.show(); + await element.updateComplete; + expect(element.dialog.open).to.be.true; + + element['close'](); + await element.updateComplete; + expect(element.dialog.open).to.be.false; + }); + + describe('name validation', () => { + it('should reject empty names', () => { + expect(element['isIedNameValid']('')).to.be.false; + expect(element['isIedNameValid'](' ')).to.be.false; + }); + + it('should reject names with spaces', () => { + expect(element['isIedNameValid']('IED Name')).to.be.false; + }); + + it('should reject existing names', () => { + expect(element['isIedNameValid']('ExistingIED')).to.be.false; + }); + + it('should accept valid unique names', () => { + expect(element['isIedNameValid']('NewIED')).to.be.true; + expect(element['isIedNameValid']('IED_123')).to.be.true; + }); + }); + + it('should call onConfirm with valid name', async () => { + element['newIedName'] = 'ValidNewIED'; + await element.updateComplete; + + element['handleCreate'](); + + expect(onConfirmSpy).to.have.been.calledOnceWith('ValidNewIED'); + expect(element['newIedName']).to.equal(''); + }); + + it('should not call onConfirm with invalid name', async () => { + element['newIedName'] = 'Invalid Name'; + await element.updateComplete; + + element['handleCreate'](); + + expect(onConfirmSpy).to.not.have.been.called; + }); +}); diff --git a/packages/plugins/test/unit/editors/ied/foundation.test.ts b/packages/plugins/test/unit/editors/ied/foundation.test.ts index 1fee705129..1a43680a4e 100644 --- a/packages/plugins/test/unit/editors/ied/foundation.test.ts +++ b/packages/plugins/test/unit/editors/ied/foundation.test.ts @@ -3,7 +3,9 @@ import { expect } from '@open-wc/testing'; import { findDOTypeElement, findElement, - findLogicaNodeElement, + findLLN0LNodeType, + findLogicalNodeElement, + createIEDStructure, getInstanceDAElement, getValueElements, } from '../../../../src/editors/ied/foundation.js'; @@ -48,7 +50,7 @@ describe('ied-foundation', async () => { )!; const ancestors = getAncestorsFromDA(daElement, 'Dummy.XCBR1.Pos'); - const lnElement = findLogicaNodeElement(ancestors); + const lnElement = findLogicalNodeElement(ancestors); expect(lnElement).to.be.not.null; expect(lnElement?.tagName).to.be.equal('LN'); }); @@ -59,13 +61,13 @@ describe('ied-foundation', async () => { )!; const ancestors = getAncestorsFromDO(doElement); - const lnElement = findLogicaNodeElement(ancestors); + const lnElement = findLogicalNodeElement(ancestors); expect(lnElement).to.be.not.null; expect(lnElement?.tagName).to.be.equal('LN0'); }); it('will not find LN(0) Element in list', async () => { - const lnElement = findLogicaNodeElement([]); + const lnElement = findLogicalNodeElement([]); expect(lnElement).to.be.null; }); }); @@ -168,4 +170,68 @@ describe('ied-foundation', async () => { expect(dai).to.be.null; }); }); + + describe('findLLN0LNodeType', async () => { + it('will find existing LLN0 LNodeType in document', async () => { + const lln0Type = findLLN0LNodeType(validSCL); + expect(lln0Type).to.be.not.null; + expect(lln0Type?.tagName).to.be.equal('LNodeType'); + expect(lln0Type?.getAttribute('lnClass')).to.be.equal('LLN0'); + expect(lln0Type?.getAttribute('id')).to.be.equal('Dummy.LLN0'); + }); + + it('will return null if no LLN0 LNodeType exists', async () => { + const docWithoutLLN0 = new DOMParser().parseFromString( + ` + + + + `, + 'application/xml' + ); + + const lln0Type = findLLN0LNodeType(docWithoutLLN0); + expect(lln0Type).to.be.null; + }); + }); + + describe('createIEDStructure', async () => { + let testDoc: XMLDocument; + + beforeEach(() => { + testDoc = new DOMParser().parseFromString( + ``, + 'application/xml' + ); + }); + + it('creates a basic IED structure with required elements', async () => { + const ied = createIEDStructure(testDoc, 'TestIED', 'LLN0_Type'); + + expect(ied).to.be.not.null; + expect(ied.tagName).to.be.equal('IED'); + expect(ied.getAttribute('name')).to.be.equal('TestIED'); + expect(ied.getAttribute('manufacturer')).to.be.equal('OpenSCD'); + + const accessPoint = ied.querySelector('AccessPoint'); + expect(accessPoint).to.be.not.null; + expect(accessPoint?.getAttribute('name')).to.be.equal('AP1'); + + const server = accessPoint?.querySelector('Server'); + expect(server).to.be.not.null; + + const authentication = server?.querySelector('Authentication'); + expect(authentication).to.be.not.null; + + const lDevice = server?.querySelector('LDevice'); + expect(lDevice).to.be.not.null; + expect(lDevice?.getAttribute('inst')).to.be.equal('LD1'); + + const ln0 = lDevice?.querySelector('LN0'); + expect(ln0).to.be.not.null; + expect(ln0?.getAttribute('lnClass')).to.be.equal('LLN0'); + expect(ln0?.getAttribute('inst')).to.be.equal(''); + expect(ln0?.getAttribute('lnType')).to.be.equal('LLN0_Type'); + }); + }); }); From 003161fd5a0b363477ece059629ed2c0d6d86aa0 Mon Sep 17 00:00:00 2001 From: Christopher Lepski <139237321+clepski@users.noreply.github.com> Date: Mon, 13 Oct 2025 13:32:26 +0200 Subject: [PATCH 3/8] fix: Require lnInst only for regular lns (#1713) --- .../src/editors/subscription/foundation.ts | 6 +++++- .../editors/bugfix1711-can-create-extref.scd | 18 ++++++++++++++++++ .../editors/subscription/foundation.test.ts | 18 ++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 packages/plugins/test/testfiles/editors/bugfix1711-can-create-extref.scd diff --git a/packages/plugins/src/editors/subscription/foundation.ts b/packages/plugins/src/editors/subscription/foundation.ts index 739678d5df..11355c5b92 100644 --- a/packages/plugins/src/editors/subscription/foundation.ts +++ b/packages/plugins/src/editors/subscription/foundation.ts @@ -690,7 +690,11 @@ export function canCreateValidExtRef( 'lnInst', 'doName', ].map(attr => fcda.getAttribute(attr)); - if (!iedName || !ldInst || !lnClass || !lnInst || !doName) { + + // lnInst is only required for ln that are not ln0 + const hasValidLnInst = lnClass === 'LLN0' || !!lnInst; + + if (!iedName || !ldInst || !lnClass || !hasValidLnInst || !doName) { return false; } diff --git a/packages/plugins/test/testfiles/editors/bugfix1711-can-create-extref.scd b/packages/plugins/test/testfiles/editors/bugfix1711-can-create-extref.scd new file mode 100644 index 0000000000..ae4a0970c3 --- /dev/null +++ b/packages/plugins/test/testfiles/editors/bugfix1711-can-create-extref.scd @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/packages/plugins/test/unit/editors/subscription/foundation.test.ts b/packages/plugins/test/unit/editors/subscription/foundation.test.ts index 8d74983f6e..0d427a55b1 100644 --- a/packages/plugins/test/unit/editors/subscription/foundation.test.ts +++ b/packages/plugins/test/unit/editors/subscription/foundation.test.ts @@ -1,6 +1,7 @@ import { expect } from '@open-wc/testing'; import { + canCreateValidExtRef, createExtRefElement, getExistingSupervision, getExtRef, @@ -334,4 +335,21 @@ describe('foundation', () => { expect(extRef).to.equal(expectedExtRef) }); }); + + describe('regression test for bugfix 1711', () => { + beforeEach(async () => { + doc = await fetch('/test/testfiles/editors/bugfix1711-can-create-extref.scd') + .then(response => response.text()) + .then(str => new DOMParser().parseFromString(str, 'application/xml')); + }); + + it('should return true for canCreateValidExtRef on FCDA without lnInst for LN0', () => { + const fcda = doc.querySelector('FCDA')!; + const controlBlock = doc.querySelector('IED[name="IED1"] GSEControl[name="gseControl"]')!; + + const canCreateExtRef = canCreateValidExtRef(fcda, controlBlock); + + expect(canCreateExtRef).to.be.true; + }); + }); }); From 0c1074bf9d4f154a06c8031e593974c1f618fead Mon Sep 17 00:00:00 2001 From: Nora Blomaard Date: Tue, 21 Oct 2025 09:43:49 +0200 Subject: [PATCH 4/8] feat: add elements to virtual ied (#1714) * feat: add accesspoint to virtual IED * feat: add LDevice to server * feat: add LN to LDevice element * fix: improve amount validation * test: update snaps and add tests for ap dialog * add unit tests * update snapshot * feat: add prefix field to ln dialog * fix: prefix has max length of 8 * feat: add custom tooltip * refactor: simplify show method by using reset * fix: LDevice inst is required * fix: move tooltip to own component --- packages/openscd/src/translations/de.ts | 29 ++ packages/openscd/src/translations/en.ts | 31 +- packages/plugins/src/components/tooltip.ts | 112 +++++++ .../editors/ied/add-access-point-dialog.ts | 250 ++++++++++++++ .../src/editors/ied/add-ldevice-dialog.ts | 134 ++++++++ .../plugins/src/editors/ied/add-ln-dialog.ts | 306 ++++++++++++++++++ .../src/editors/ied/create-ied-dialog.ts | 14 +- .../plugins/src/editors/ied/foundation.ts | 74 +++++ .../plugins/src/editors/ied/ied-container.ts | 58 +++- .../src/editors/ied/ldevice-container.ts | 48 ++- .../src/editors/ied/server-container.ts | 57 +++- .../testfiles/editors/minimalVirtualIED.scd | 34 ++ .../test/unit/components/tooltip.test.ts | 126 ++++++++ .../add-access-point-dialog.test.snap.js | 46 +++ .../add-ldevice-dialog.test.snap.js | 40 +++ .../__snapshots__/add-ln-dialog.test.snap.js | 76 +++++ .../__snapshots__/ied-container.test.snap.js | 9 + .../ldevice-container.test.snap.js | 36 +++ .../server-container.test.snap.js | 36 +++ .../ied/add-access-point-dialog.test.ts | 115 +++++++ .../editors/ied/add-ldevice-dialog.test.ts | 64 ++++ .../unit/editors/ied/add-ln-dialog.test.ts | 94 ++++++ 22 files changed, 1771 insertions(+), 18 deletions(-) create mode 100644 packages/plugins/src/components/tooltip.ts create mode 100644 packages/plugins/src/editors/ied/add-access-point-dialog.ts create mode 100644 packages/plugins/src/editors/ied/add-ldevice-dialog.ts create mode 100644 packages/plugins/src/editors/ied/add-ln-dialog.ts create mode 100644 packages/plugins/test/testfiles/editors/minimalVirtualIED.scd create mode 100644 packages/plugins/test/unit/components/tooltip.test.ts create mode 100644 packages/plugins/test/unit/editors/ied/__snapshots__/add-access-point-dialog.test.snap.js create mode 100644 packages/plugins/test/unit/editors/ied/__snapshots__/add-ldevice-dialog.test.snap.js create mode 100644 packages/plugins/test/unit/editors/ied/__snapshots__/add-ln-dialog.test.snap.js create mode 100644 packages/plugins/test/unit/editors/ied/add-access-point-dialog.test.ts create mode 100644 packages/plugins/test/unit/editors/ied/add-ldevice-dialog.test.ts create mode 100644 packages/plugins/test/unit/editors/ied/add-ln-dialog.test.ts diff --git a/packages/openscd/src/translations/de.ts b/packages/openscd/src/translations/de.ts index 35be0f927c..c6a8233c7f 100644 --- a/packages/openscd/src/translations/de.ts +++ b/packages/openscd/src/translations/de.ts @@ -230,6 +230,7 @@ export const de: Translations = { toggleChildElements: 'Kindelemente umschalten', settings: 'Services für IED or AccessPoint', createIed: 'Virtuelles IED erstellen', + addAccessPoint: 'AccessPoint hinzufügen', wizard: { daTitle: 'DA Informationen anzeigen', doTitle: 'DO Informationen anzeigen', @@ -254,6 +255,34 @@ export const de: Translations = { nameFormatError: 'IED Name darf keine Leerzeichen enthalten', nameUniqueError: 'IED Name ist bereits vergeben', }, + addAccessPointDialog: { + title: 'AccessPoint hinzufügen', + nameHelper: 'AccessPoint Name', + descHelper: 'AccessPoint Beschreibung', + apName: 'AccessPoint Name', + createServerAt: 'ServerAt hinzufügen', + selectAccessPoint: 'AccessPoint auswählen', + serverAtDesc: 'ServerAt Beschreibung', + nameFormatError: 'AccessPoint Name darf keine Leerzeichen enthalten', + nameUniqueError: 'AccessPoint Name ist bereits vergeben', + nameTooLongError: 'AccessPoint Name ist zu lang', + }, + addLDeviceDialog: { + title: 'LDevice hinzufügen', + inst: 'LDevice inst', + desc: 'LDevice Beschreibung', + instRequiredError: 'LDevice inst ist erforderlich', + instFormatError: 'LDevice inst darf keine Leerzeichen enthalten', + instUniqueError: 'LDevice inst ist bereits vergeben', + instTooLongError: 'LDevice inst ist zu lang', + }, + addLnDialog: { + title: 'LN hinzufügen', + amount: 'Menge', + prefix: 'Prefix', + filter: 'Logical Node Types filtern', + noResults: 'Keine Logical Node Types gefunden', + }, }, ied: { wizard: { diff --git a/packages/openscd/src/translations/en.ts b/packages/openscd/src/translations/en.ts index 7692f89b11..8345e33ac2 100644 --- a/packages/openscd/src/translations/en.ts +++ b/packages/openscd/src/translations/en.ts @@ -227,6 +227,7 @@ export const en = { toggleChildElements: 'Toggle child elements', settings: 'Show Services the IED/AccessPoint provides', createIed: 'Create Virtual IED', + addAccessPoint: 'Add AccessPoint', wizard: { daTitle: 'Show DA Info', doTitle: 'Show DO Info', @@ -247,10 +248,38 @@ export const en = { daValue: 'Data attribute value', }, createDialog: { - iedName: 'IED Name', + iedName: 'IED name', nameFormatError: 'IED name cannot contain spaces', nameUniqueError: 'IED name already exists', }, + addAccessPointDialog: { + title: 'Add AccessPoint', + nameHelper: 'AccessPoint name', + descHelper: 'AccessPoint description', + apName: 'AccessPoint name', + createServerAt: 'Add ServerAt', + selectAccessPoint: 'Select AccessPoint', + serverAtDesc: 'ServerAt description', + nameFormatError: 'AccessPoint name cannot contain spaces', + nameUniqueError: 'AccessPoint name already exists', + nameTooLongError: 'AccessPoint name is too long', + }, + addLDeviceDialog: { + title: 'Add LDevice', + inst: 'LDevice inst', + desc: 'LDevice description', + instRequiredError: 'LDevice inst is required', + instFormatError: 'Invalid inst', + instUniqueError: 'LDevice inst already exists', + instTooLongError: 'LDevice inst is too long', + }, + addLnDialog: { + title: 'Add LN', + amount: 'Amount', + prefix: 'Prefix', + filter: 'Filter Logical Node Types', + noResults: 'No Logical Node Types found', + }, }, ied: { wizard: { diff --git a/packages/plugins/src/components/tooltip.ts b/packages/plugins/src/components/tooltip.ts new file mode 100644 index 0000000000..81690af258 --- /dev/null +++ b/packages/plugins/src/components/tooltip.ts @@ -0,0 +1,112 @@ +import { + css, + customElement, + html, + LitElement, + property, + TemplateResult, +} from 'lit-element'; + +/** A tooltip element that follows the mouse cursor and displays a text box. */ +@customElement('oscd-tooltip-4c6027dd') +export class OscdTooltip extends LitElement { + @property({ type: String }) + text = ''; + + @property({ type: Boolean, reflect: true }) + visible = false; + + @property({ type: Number }) + x = 0; + + @property({ type: Number }) + y = 0; + + @property({ type: Number }) + offset = 12; + + private pendingFrame = 0; + + show(text: string, clientX: number, clientY: number): void { + this.text = text; + this.visible = true; + this.updatePosition(clientX, clientY); + } + + hide(): void { + this.visible = false; + this.text = ''; + if (this.pendingFrame) { + cancelAnimationFrame(this.pendingFrame); + this.pendingFrame = 0; + } + } + + updatePosition(clientX: number, clientY: number): void { + this.x = clientX + this.offset; + this.y = clientY + this.offset; + + if (this.pendingFrame) return; + + this.pendingFrame = requestAnimationFrame(() => { + this.pendingFrame = 0; + if (!this.visible) return; + + const tipRect = this.getBoundingClientRect(); + let x = this.x; + let y = this.y; + + const innerW = window.innerWidth; + const innerH = window.innerHeight; + + if (x + tipRect.width + this.offset > innerW) { + x = this.x - this.offset - tipRect.width - this.offset; + } + if (x < this.offset) x = this.offset; + + if (y + tipRect.height + this.offset > innerH) { + y = this.y - this.offset - tipRect.height - this.offset; + } + if (y < this.offset) y = this.offset; + + this.style.transform = `translate3d(${Math.round(x)}px, ${Math.round( + y + )}px, 0)`; + }); + } + + render(): TemplateResult { + return html`${this.text}`; + } + + static styles = css` + :host { + position: fixed; + pointer-events: none; + background: rgba(20, 20, 20, 0.95); + color: rgba(240, 240, 240, 0.98); + padding: 6px 8px; + border-radius: 4px; + font-size: 0.85em; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4); + z-index: 6000; + max-width: 60vw; + border: 1px solid rgba(255, 255, 255, 0.04); + left: 0; + top: 0; + transform: translate3d(0, 0, 0); + will-change: transform; + opacity: 1; + transition: opacity 0.15s ease-in-out; + } + + :host(:not([visible])) { + opacity: 0; + pointer-events: none; + } + + :host([hidden]) { + display: none; + } + `; +} diff --git a/packages/plugins/src/editors/ied/add-access-point-dialog.ts b/packages/plugins/src/editors/ied/add-access-point-dialog.ts new file mode 100644 index 0000000000..d709a0fca2 --- /dev/null +++ b/packages/plugins/src/editors/ied/add-access-point-dialog.ts @@ -0,0 +1,250 @@ +import { + css, + html, + LitElement, + property, + state, + TemplateResult, + query, + customElement, +} from 'lit-element'; +import { get, translate } from 'lit-translate'; + +import { Dialog } from '@material/mwc-dialog'; +import '@material/mwc-dialog'; +import '@material/mwc-button'; +import '@material/mwc-textfield'; +import '@material/mwc-switch'; +import '@material/mwc-formfield'; +import '@material/mwc-select'; +import '@material/mwc-list/mwc-list-item'; + +import { + getExistingAccessPointNames, + getAccessPointsWithServer, +} from './foundation.js'; +import { TextField } from '@material/mwc-textfield'; + +export interface AccessPointCreationData { + name: string; + createServerAt: boolean; + serverAtApName?: string; + serverAtDesc?: string; +} + +/** A dialog component for adding new AccessPoints */ +@customElement('add-access-point-dialog') +export class AddAccessPointDialog extends LitElement { + @property() + doc!: XMLDocument; + + @property() + ied!: Element; + + @property({ type: Function }) + onConfirm!: (data: AccessPointCreationData) => void; + + @query('#createAccessPointDialog') dialog!: Dialog; + + @query('#apName') apNameField!: TextField; + + @state() + private apName = ''; + + @state() + private createServerAt = false; + + @state() + private serverAtApName = ''; + + @state() + private serverAtDesc = ''; + + get open() { + return this.dialog?.open ?? false; + } + + private isApNameUnique(name: string): boolean { + const existingNames = getExistingAccessPointNames(this.ied); + return !existingNames.includes(name); + } + + private get accessPointsWithServer(): string[] { + return getAccessPointsWithServer(this.ied); + } + + public show(): void { + this.reset(); + this.dialog.show(); + } + + private reset(): void { + this.apName = ''; + this.createServerAt = false; + this.serverAtApName = ''; + this.serverAtDesc = ''; + } + + private close(): void { + this.dialog.close(); + this.reset(); + } + + private handleCreate(): void { + if (this.apNameField.checkValidity()) { + const data: AccessPointCreationData = { + name: this.apName, + createServerAt: this.createServerAt, + serverAtApName: this.createServerAt ? this.serverAtApName : undefined, + serverAtDesc: + this.createServerAt && this.serverAtDesc + ? this.serverAtDesc + : undefined, + }; + this.onConfirm(data); + this.close(); + } + } + + private getApNameError(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ''; + if (!/^[A-Za-z0-9][0-9A-Za-z_]*$/.test(trimmed)) + return get('iededitor.addAccessPointDialog.nameFormatError'); + if (trimmed.length > 32) + return get('iededitor.addAccessPointDialog.nameTooLongError'); + if (!this.isApNameUnique(trimmed)) + return get('iededitor.addAccessPointDialog.nameUniqueError'); + return ''; + } + + private renderServerAtSection(): TemplateResult { + return html` + + { + this.createServerAt = (e.target as HTMLInputElement).checked; + this.serverAtApName = this.createServerAt + ? this.accessPointsWithServer[0] + : ''; + }} + > + + ${this.createServerAt + ? html` + { + e.stopPropagation(); + this.serverAtApName = (e.target as HTMLSelectElement).value; + }} + @click=${(e: Event) => e.stopPropagation()} + @closed=${(e: Event) => e.stopPropagation()} + style="width: 100%; margin-bottom: 16px;" + > + ${this.accessPointsWithServer.map( + (ap: string) => + html`${ap}` + )} + + { + this.serverAtDesc = (e.target as HTMLInputElement).value; + }} + style="width: 100%; margin-bottom: 16px;" + > + ` + : ''} + `; + } + + render(): TemplateResult { + return html` + +
+ { + const error = this.getApNameError(value); + return { + valid: error === '', + customError: error !== '', + }; + }} + pattern="[A-Za-z0-9][0-9A-Za-z_]*" + maxLength="32" + required + autoValidate + helper=${translate('iededitor.addAccessPointDialog.apName')} + dialogInitialFocus + style="width: 100%; margin-bottom: 16px;" + @input=${(e: Event) => { + this.apName = (e.target as HTMLInputElement).value; + }} + > + ${this.renderServerAtSection()} +
+ + ${translate('close')} + + + ${translate('add')} + +
+ `; + } + + static styles = css` + .dialog-content { + margin-top: 16px; + width: 320px; + max-width: 100vw; + box-sizing: border-box; + } + + mwc-formfield { + display: block; + } + + mwc-select, + mwc-textfield { + width: 100%; + min-width: 0; + max-width: 100%; + box-sizing: border-box; + } + + mwc-formfield { + margin-bottom: 12px; + } + `; +} diff --git a/packages/plugins/src/editors/ied/add-ldevice-dialog.ts b/packages/plugins/src/editors/ied/add-ldevice-dialog.ts new file mode 100644 index 0000000000..740f39d7e9 --- /dev/null +++ b/packages/plugins/src/editors/ied/add-ldevice-dialog.ts @@ -0,0 +1,134 @@ +import { + css, + customElement, + html, + LitElement, + property, + query, + state, + TemplateResult, +} from 'lit-element'; +import { translate, get } from 'lit-translate'; + +import { Dialog } from '@material/mwc-dialog'; +import '@material/mwc-dialog'; +import '@material/mwc-textfield'; +import '@material/mwc-button'; + +import { getLDeviceInsts } from './foundation'; + +export interface LDeviceData { + inst: string; +} + +/** Dialog for adding a new LDevice to a Server. */ +@customElement('add-ldevice-dialog') +export class AddLDeviceDialog extends LitElement { + @property() + server!: Element; + + @property({ type: Function }) + onConfirm!: (data: LDeviceData) => void; + + @query('#addLDeviceDialog') dialog!: Dialog; + + @state() + private inst: string = ''; + + connectedCallback(): void { + super.connectedCallback(); + } + + public show(): void { + this.inst = ''; + this.dialog.show(); + } + + private close(): void { + this.dialog.close(); + } + + private handleCreate(): void { + const data: LDeviceData = { + inst: this.inst, + }; + this.onConfirm(data); + this.close(); + } + + private get lDeviceInst(): string[] { + return getLDeviceInsts(this.server); + } + + private getInstError(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return get('iededitor.addLDeviceDialog.instRequiredError'); + if (!/^[A-Za-z0-9][0-9A-Za-z_]*$/.test(trimmed)) + return get('iededitor.addLDeviceDialog.instFormatError'); + if (trimmed.length > 64) + return get('iededitor.addLDeviceDialog.instTooLongError'); + if (this.lDeviceInst.includes(trimmed)) + return get('iededitor.addLDeviceDialog.instUniqueError'); + return ''; + } + + render(): TemplateResult { + const error = this.getInstError(this.inst); + return html` + +
+ { + const err = this.getInstError(value); + return { + valid: err === '', + customError: err !== '', + }; + }} + pattern="[A-Za-z0-9][0-9A-Za-z_]*" + maxLength="64" + required + autoValidate + dialogInitialFocus + @input=${(e: Event) => { + this.inst = (e.target as HTMLInputElement).value; + }} + style="width: 100%;" + > +
+ + ${translate('close')} + + + ${translate('add')} + +
+ `; + } + static styles = css` + .dialog-content { + margin-top: 16px; + width: 320px; + max-width: 100vw; + box-sizing: border-box; + } + `; +} diff --git a/packages/plugins/src/editors/ied/add-ln-dialog.ts b/packages/plugins/src/editors/ied/add-ln-dialog.ts new file mode 100644 index 0000000000..35a90466ec --- /dev/null +++ b/packages/plugins/src/editors/ied/add-ln-dialog.ts @@ -0,0 +1,306 @@ +import { + css, + customElement, + html, + LitElement, + property, + query, + state, + TemplateResult, +} from 'lit-element'; +import { translate } from 'lit-translate'; + +import { Dialog } from '@material/mwc-dialog'; +import '@material/mwc-dialog'; +import '@material/mwc-textfield'; +import '@material/mwc-button'; +import '@material/mwc-select'; +import '@material/mwc-list/mwc-list-item'; + +import '../../components/tooltip'; +import { OscdTooltip } from '../../components/tooltip'; +import { getLNodeTypes } from './foundation'; + +export interface LNData { + lnType: string; + lnClass: string; + amount: number; + prefix?: string; +} + +/** Dialog for adding a new LN to a LDevice. */ +@customElement('add-ln-dialog') +export class AddLnDialog extends LitElement { + @property({ attribute: false }) + doc!: XMLDocument; + + @property({ type: Function }) + onConfirm!: (data: LNData) => void; + + @query('#addLnDialog') + dialog!: Dialog; + + @query('oscd-tooltip-4c6027dd') + tooltip!: OscdTooltip; + + @state() + lnType: string = ''; + + @state() + amount: number = 1; + + @state() + filterText = ''; + + @state() + prefix: string = ''; + + private get lNodeTypes(): Array<{ + id: string; + lnClass: string; + desc?: string; + }> { + if (!this.doc) return []; + return getLNodeTypes(this.doc).map(lnType => ({ + id: lnType.getAttribute('id') || '', + lnClass: lnType.getAttribute('lnClass') || '', + desc: lnType.getAttribute('desc') || undefined, + })); + } + + private get filteredLNodeTypes() { + const filter = this.filterText.trim().toLowerCase(); + if (!filter) return this.lNodeTypes; + return this.lNodeTypes.filter( + t => + t.lnClass.toLowerCase().includes(filter) || + t.id.toLowerCase().includes(filter) || + (t.desc?.toLowerCase().includes(filter) ?? false) + ); + } + + public show(): void { + this.lnType = ''; + this.amount = 1; + this.filterText = ''; + this.prefix = ''; + this.dialog.show(); + } + + private close(): void { + this.dialog.close(); + } + + private isPrefixValid(prefix: string): boolean { + if (prefix === '') return true; + if (prefix.length > 8) return false; + return /^[A-Za-z][0-9A-Za-z_]*$/.test(prefix); + } + + private handleCreate(): void { + const selectedType = this.lNodeTypes.find(t => t.id === this.lnType); + if (!selectedType) return; + const data: LNData = { + lnType: selectedType.id, + lnClass: selectedType.lnClass, + amount: this.amount, + ...(this.prefix && { prefix: this.prefix }), + }; + + this.onConfirm(data); + this.close(); + } + + private onListItemEnter(e: MouseEvent, id: string): void { + const target = e.currentTarget as HTMLElement; + const idSpan = target.querySelector('[data-ln-id]') as HTMLElement; + + const isOverflowing = idSpan.scrollWidth > idSpan.clientWidth; + if (!isOverflowing || !this.tooltip) return; + + this.tooltip.show(id, e.clientX, e.clientY); + } + + private onListItemMove(e: MouseEvent): void { + if (!this.tooltip || !this.tooltip.visible) return; + this.tooltip.updatePosition(e.clientX, e.clientY); + } + + private onListItemLeave(): void { + if (this.tooltip) { + this.tooltip.hide(); + } + } + + render(): TemplateResult { + return html` + +
+
+ { + e.stopPropagation(); + this.filterText = (e.target as HTMLInputElement).value; + }} + style="margin-bottom: 8px; width: 100%;" + > +
+ e.stopPropagation()} + > + ${this.filteredLNodeTypes.length === 0 + ? html`${translate( + 'iededitor.addLnDialog.noResults' + )}` + : this.filteredLNodeTypes.map( + t => html` + { + e.stopPropagation(); + this.lnType = t.id; + }} + value=${t.id} + dialogAction="none" + style="cursor: pointer;" + @mouseenter=${(e: MouseEvent) => + this.onListItemEnter(e, t.id)} + @mousemove=${(e: MouseEvent) => + this.onListItemMove(e)} + @mouseleave=${() => this.onListItemLeave()} + > + ${t.id} + ${t.desc || ''} + + ` + )} + +
+
+ { + e.stopPropagation(); + this.prefix = (e.target as HTMLInputElement).value; + }} + pattern="[A-Za-z][0-9A-Za-z_]*" + style="width: 100%; margin-top: 12px;" + data-testid="prefix" + > + { + e.stopPropagation(); + this.amount = Number((e.target as HTMLInputElement).value); + }} + @click=${(e: Event) => e.stopPropagation()} + @mousedown=${(e: Event) => e.stopPropagation()} + @mouseup=${(e: Event) => e.stopPropagation()} + style="width: 100%; margin-top: 12px;" + > +
+ + ${translate('close')} + + + ${translate('add')} + + +
+ `; + } + + static styles = css` + .dialog-content { + margin-top: 16px; + width: 400px; + max-width: 100vw; + box-sizing: border-box; + } + + .ln-list-scroll { + width: 100%; + height: 240px; + overflow-y: auto; + border: 1px solid #ccc; + border-radius: 4px; + background: var(--mdc-theme-surface, #fff); + } + + mwc-list-item { + --mdc-list-item-graphic-size: 0; + min-height: 56px; + height: 56px; + max-height: 56px; + padding-top: 0; + padding-bottom: 0; + } + + mwc-list-item[selected] { + background: var(--mdc-theme-primary, #6200ee); + } + + mwc-list-item[selected] .ln-list-id, + mwc-list-item[selected] .ln-list-desc { + color: var(--mdc-theme-on-primary, #fff); + } + + mwc-list-item > span.ln-list-id, + mwc-list-item > span.ln-list-desc { + display: block; + width: 100%; + } + + .ln-list-id { + font-size: 1em; + line-height: 1.2; + color: var(--mdc-theme-on-surface, #222); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .ln-list-desc { + font-size: 0.95em; + color: var(--mdc-theme-text-secondary-on-background, #666); + line-height: 1.1; + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + `; +} diff --git a/packages/plugins/src/editors/ied/create-ied-dialog.ts b/packages/plugins/src/editors/ied/create-ied-dialog.ts index f7950607cd..e53ec724c0 100644 --- a/packages/plugins/src/editors/ied/create-ied-dialog.ts +++ b/packages/plugins/src/editors/ied/create-ied-dialog.ts @@ -98,16 +98,22 @@ export class CreateIedDialog extends LitElement { customError: error !== '', }; }} + required autoValidate + helper=${translate('iededitor.createDialog.iedName')} + dialogInitialFocus + style="width: 100%; margin-bottom: 16px;" @input=${(e: Event) => { this.newIedName = (e.target as HTMLInputElement).value; }} - required - style="width: 100%; margin-bottom: 16px;" > - - ${translate('cancel')} + + ${translate('close')} = { apName }; + + if (desc) { + attributes.desc = desc; + } + + return createElement(doc, 'ServerAt', attributes); +} + +/** + * Get all existing AccessPoint names from the current IED. + * @param ied - The IED element to search in. + * @returns Array of AccessPoint names. + */ +export function getExistingAccessPointNames(ied: Element): string[] { + return Array.from(ied.querySelectorAll(':scope > AccessPoint')) + .map(ap => ap.getAttribute('name')) + .filter((name): name is string => name !== null); +} + +/** + * Get AccessPoint names that contain a Server element (can be referenced by ServerAt). + * @param ied - The IED element to search in. + * @returns Array of AccessPoint names that have Server elements. + */ +export function getAccessPointsWithServer(ied: Element): string[] { + return Array.from(ied.querySelectorAll(':scope > AccessPoint')) + .filter(ap => ap.querySelector(':scope > Server')) + .map(ap => ap.getAttribute('name')) + .filter((name): name is string => name !== null); +} + /** * With the passed DO Element retrieve the type attribute and search for the DOType in the DataType Templates section. * @param element - The DO Element. @@ -228,6 +282,26 @@ export function newFullElementPathEvent( }); } +/** + * Get all LDevice inst values from a Server element. + * @param server - The Server element to search in. + * @returns Array of LDevice inst values. + */ +export function getLDeviceInsts(server: Element): string[] { + return Array.from(server.querySelectorAll(':scope > LDevice')).map( + ld => ld.getAttribute('inst') || '' + ); +} + +/** + * Get LNodeType elements from DataTypeTemplates in the document. + * @param doc - The XML document to search in. + * @returns Array of LNodeType elements. + */ +export function getLNodeTypes(doc: XMLDocument): Element[] { + return Array.from(doc.querySelectorAll('DataTypeTemplates > LNodeType')); +} + declare global { interface ElementEventMap { ['full-element-path']: FullElementPathEvent; diff --git a/packages/plugins/src/editors/ied/ied-container.ts b/packages/plugins/src/editors/ied/ied-container.ts index 12134a61e5..a70c3b3fdb 100644 --- a/packages/plugins/src/editors/ied/ied-container.ts +++ b/packages/plugins/src/editors/ied/ied-container.ts @@ -3,24 +3,31 @@ import { customElement, html, property, + query, TemplateResult, } from 'lit-element'; import { nothing } from 'lit-html'; -import { get } from 'lit-translate'; +import { translate } from 'lit-translate'; import '@openscd/open-scd/src/action-pane.js'; import './access-point-container.js'; +import './add-access-point-dialog.js'; import { wizards } from '../../wizards/wizard-library.js'; -import { Container } from './foundation.js'; +import { Container, createAccessPoint, createServerAt } from './foundation.js'; import { getDescriptionAttribute, getNameAttribute, newWizardEvent, } from '@openscd/open-scd/src/foundation.js'; import { newActionEvent } from '@openscd/core/foundation/deprecated/editor.js'; +import { newEditEventV2, InsertV2 } from '@openscd/core'; import { removeIEDWizard } from '../../wizards/ied.js'; import { editServicesWizard } from '../../wizards/services.js'; +import { + AddAccessPointDialog, + AccessPointCreationData, +} from './add-access-point-dialog.js'; /** [[`IED`]] plugin subeditor for editing `IED` element. */ @customElement('ied-container') @@ -28,11 +35,40 @@ export class IedContainer extends Container { @property() selectedLNClasses: string[] = []; + @query('add-access-point-dialog') + addAccessPointDialog!: AddAccessPointDialog; + private openEditWizard(): void { const wizard = wizards['IED'].edit(this.element); if (wizard) this.dispatchEvent(newWizardEvent(wizard)); } + private createAccessPoint(data: AccessPointCreationData): void { + const inserts: InsertV2[] = []; + const accessPoint = createAccessPoint(this.doc, data.name); + + inserts.push({ + parent: this.element, + node: accessPoint, + reference: null, + }); + + if (data.createServerAt && data.serverAtApName) { + const serverAt = createServerAt( + this.doc, + data.serverAtApName, + data.serverAtDesc + ); + inserts.push({ + parent: accessPoint, + node: serverAt, + reference: null, + }); + } + + this.dispatchEvent(newEditEventV2(inserts)); + } + private renderServicesIcon(): TemplateResult { const services: Element | null = this.element.querySelector('Services'); @@ -40,7 +76,7 @@ export class IedContainer extends Container { return html``; } - return html` + return html` this.openSettingsWizard(services)} @@ -77,19 +113,25 @@ export class IedContainer extends Container { render(): TemplateResult { return html` developer_board - + this.removeIED()} > - + this.openEditWizard()} > ${this.renderServicesIcon()} + + this.addAccessPointDialog.show()} + > + ${Array.from(this.element.querySelectorAll(':scope > AccessPoint')).map( ap => html`` )} + + this.createAccessPoint(data)} + > `; } diff --git a/packages/plugins/src/editors/ied/ldevice-container.ts b/packages/plugins/src/editors/ied/ldevice-container.ts index 2c9f97456d..a18ddb7747 100644 --- a/packages/plugins/src/editors/ied/ldevice-container.ts +++ b/packages/plugins/src/editors/ied/ldevice-container.ts @@ -9,10 +9,13 @@ import { TemplateResult, } from 'lit-element'; import { nothing } from 'lit-html'; -import { get } from 'lit-translate'; +import { get, translate } from 'lit-translate'; import { IconButtonToggle } from '@material/mwc-icon-button-toggle'; +import { newEditEventV2 } from '@openscd/core'; +import { createElement } from '@openscd/xml'; +import { logicalDeviceIcon } from '@openscd/open-scd/src/icons/ied-icons.js'; import { getDescriptionAttribute, getInstanceAttribute, @@ -20,13 +23,15 @@ import { getLdNameAttribute, newWizardEvent, } from '@openscd/open-scd/src/foundation.js'; -import { logicalDeviceIcon } from '@openscd/open-scd/src/icons/ied-icons.js'; - -import '@openscd/open-scd/src/action-pane.js'; -import './ln-container.js'; import { wizards } from '../../wizards/wizard-library.js'; import { Container } from './foundation.js'; +import { lnInstGenerator } from '@openenergytools/scl-lib/dist/generator/lnInstGenerator.js'; +import { AddLnDialog, LNData } from './add-ln-dialog.js'; + +import '@openscd/open-scd/src/action-pane.js'; +import './ln-container.js'; +import './add-ln-dialog.js'; /** [[`IED`]] plugin subeditor for editing `LDevice` element. */ @customElement('ldevice-container') @@ -37,6 +42,9 @@ export class LDeviceContainer extends Container { @query('#toggleButton') toggleButton!: IconButtonToggle | undefined; + @query('add-ln-dialog') + addLnDialog!: AddLnDialog; + private openEditWizard(): void { const wizard = wizards['LDevice'].edit(this.element); if (wizard) this.dispatchEvent(newWizardEvent(wizard)); @@ -76,6 +84,26 @@ export class LDeviceContainer extends Container { ); } + private handleAddLN(data: LNData) { + const getInst = lnInstGenerator(this.element, 'LN'); + const inserts = []; + + for (let i = 0; i < data.amount; i++) { + const inst = getInst(data.lnClass); + if (!inst) break; + const lnAttrs = { + lnClass: data.lnClass, + lnType: data.lnType, + inst: inst, + ...(data.prefix ? { prefix: data.prefix } : {}), + }; + const ln = createElement(this.doc, 'LN', lnAttrs); + inserts.push({ parent: this.element, node: ln, reference: null }); + } + + this.dispatchEvent(newEditEventV2(inserts)); + } + render(): TemplateResult { const lnElements = this.lnElements; @@ -87,6 +115,12 @@ export class LDeviceContainer extends Container { @click=${() => this.openEditWizard()} > + + this.addLnDialog.show()} + > + ${lnElements.length > 0 ? html` + this.handleAddLN(data)} + > `; } diff --git a/packages/plugins/src/editors/ied/server-container.ts b/packages/plugins/src/editors/ied/server-container.ts index 7568ddc545..99825a0fbb 100644 --- a/packages/plugins/src/editors/ied/server-container.ts +++ b/packages/plugins/src/editors/ied/server-container.ts @@ -3,16 +3,28 @@ import { html, property, PropertyValues, + query, state, TemplateResult, } from 'lit-element'; import { nothing } from 'lit-html'; +import { translate } from 'lit-translate'; -import '@openscd/open-scd/src/action-pane.js'; -import './ldevice-container.js'; import { serverIcon } from '@openscd/open-scd/src/icons/ied-icons.js'; import { getDescriptionAttribute } from '@openscd/open-scd/src/foundation.js'; -import { Container } from './foundation.js'; +import { createElement } from '@openscd/xml'; +import { newEditEventV2 } from '@openscd/core'; + +import { + Container, + findLLN0LNodeType, + createLLN0LNodeType, +} from './foundation.js'; +import { AddLDeviceDialog, LDeviceData } from './add-ldevice-dialog.js'; + +import '@openscd/open-scd/src/action-pane.js'; +import './ldevice-container.js'; +import './add-ldevice-dialog.js'; /** [[`IED`]] plugin subeditor for editing `Server` element. */ @customElement('server-container') @@ -20,6 +32,9 @@ export class ServerContainer extends Container { @property() selectedLNClasses: string[] = []; + @query('add-ldevice-dialog') + addAccessPointDialog!: AddLDeviceDialog; + private header(): TemplateResult { const desc = getDescriptionAttribute(this.element); @@ -51,9 +66,41 @@ export class ServerContainer extends Container { ); } + private handleAddLDevice(data: LDeviceData) { + const inserts: any[] = []; + const lln0Type = findLLN0LNodeType(this.doc); + const lnTypeId = lln0Type?.getAttribute('id') || 'PlaceholderLLN0'; + + if (!lln0Type) { + const lnodeTypeInserts = createLLN0LNodeType(this.doc, lnTypeId); + inserts.push(...lnodeTypeInserts); + } + const lDevice = createElement(this.doc, 'LDevice', { + inst: data.inst, + }); + + const ln0 = createElement(this.doc, 'LN0', { + lnClass: 'LLN0', + lnType: lnTypeId, + }); + + lDevice.appendChild(ln0); + inserts.push({ parent: this.element, node: lDevice, reference: null }); + this.dispatchEvent(newEditEventV2(inserts)); + } + render(): TemplateResult { return html` ${serverIcon} + + this.addAccessPointDialog.show()} + > + ${this.lDeviceElements.map( server => html`` )} + this.handleAddLDevice(data)} + > `; } } diff --git a/packages/plugins/test/testfiles/editors/minimalVirtualIED.scd b/packages/plugins/test/testfiles/editors/minimalVirtualIED.scd new file mode 100644 index 0000000000..821c21a4bb --- /dev/null +++ b/packages/plugins/test/testfiles/editors/minimalVirtualIED.scd @@ -0,0 +1,34 @@ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + on + blocked + test + test/blocked + off + + + diff --git a/packages/plugins/test/unit/components/tooltip.test.ts b/packages/plugins/test/unit/components/tooltip.test.ts new file mode 100644 index 0000000000..1cd3a098c2 --- /dev/null +++ b/packages/plugins/test/unit/components/tooltip.test.ts @@ -0,0 +1,126 @@ +import { expect, fixture, html } from '@open-wc/testing'; +import { OscdTooltip } from '../../../src/components/tooltip'; +import '../../../src/components/tooltip.js'; + +describe('oscd-tooltip', () => { + let element: OscdTooltip; + + beforeEach(async () => { + element = await fixture( + html`` + ); + }); + + it('should have default properties', () => { + expect(element.text).to.equal(''); + expect(element.visible).to.be.false; + expect(element.x).to.equal(0); + expect(element.y).to.equal(0); + expect(element.offset).to.equal(12); + }); + + it('should render with slotted content', async () => { + element.text = 'Test tooltip'; + await element.updateComplete; + expect(element.shadowRoot?.textContent).to.include('Test tooltip'); + }); + + describe('show()', () => { + it('should display tooltip with text and position', async () => { + element.show('Show this text', 100, 200); + await element.updateComplete; + + expect(element.text).to.equal('Show this text'); + expect(element.visible).to.be.true; + expect(element.x).to.equal(112); + expect(element.y).to.equal(212); + }); + }); + + describe('hide()', () => { + it('should hide tooltip and clear text', async () => { + element.show('Test text', 100, 100); + await element.updateComplete; + expect(element.visible).to.be.true; + expect(element.text).to.equal('Test text'); + + element.hide(); + await element.updateComplete; + expect(element.visible).to.be.false; + expect(element.text).to.equal(''); + }); + }); + + describe('updatePosition()', () => { + beforeEach(async () => { + element.show('Test', 100, 100); + await element.updateComplete; + }); + + it('should update x and y coordinates', () => { + element.updatePosition(200, 300); + expect(element.x).to.equal(212); + expect(element.y).to.equal(312); + }); + + it('should schedule position update via requestAnimationFrame', done => { + element.updatePosition(150, 250); + + setTimeout(() => { + expect(element.style.transform).to.include('translate3d'); + done(); + }, 20); + }); + + it('should not schedule multiple frames if already pending', () => { + element.updatePosition(100, 100); + element.updatePosition(200, 200); + expect(element['pendingFrame']).to.be.greaterThan(0); + }); + }); + + describe('viewport boundary handling', () => { + it('should adjust position if tooltip would overflow right edge', done => { + const nearRightEdge = window.innerWidth - 10; + element.show('Long tooltip text that needs space', nearRightEdge, 100); + + setTimeout(() => { + expect(element.style.transform).to.include('translate3d'); + done(); + }, 20); + }); + + it('should adjust position if tooltip would overflow bottom edge', done => { + const nearBottom = window.innerHeight - 10; + element.show('Tooltip near bottom', 100, nearBottom); + + setTimeout(() => { + expect(element.style.transform).to.include('translate3d'); + done(); + }, 20); + }); + }); + + describe('custom offset', () => { + it('should respect custom offset value', async () => { + element.offset = 20; + element.show('Test', 100, 100); + await element.updateComplete; + + expect(element.x).to.equal(120); + expect(element.y).to.equal(120); + }); + }); + + describe('visibility states', () => { + it('should reflect visible attribute', async () => { + element.visible = true; + await element.updateComplete; + expect(element.hasAttribute('visible')).to.be.true; + + element.visible = false; + await element.updateComplete; + expect(element.hasAttribute('visible')).to.be.false; + }); + }); +}); diff --git a/packages/plugins/test/unit/editors/ied/__snapshots__/add-access-point-dialog.test.snap.js b/packages/plugins/test/unit/editors/ied/__snapshots__/add-access-point-dialog.test.snap.js new file mode 100644 index 0000000000..b71c136591 --- /dev/null +++ b/packages/plugins/test/unit/editors/ied/__snapshots__/add-access-point-dialog.test.snap.js @@ -0,0 +1,46 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["add-access-point-dialog looks like the latest snapshot"] = +` +
+ + + + + + +
+ + [close] + + + [add] + +
+`; +/* end snapshot add-access-point-dialog looks like the latest snapshot */ + diff --git a/packages/plugins/test/unit/editors/ied/__snapshots__/add-ldevice-dialog.test.snap.js b/packages/plugins/test/unit/editors/ied/__snapshots__/add-ldevice-dialog.test.snap.js new file mode 100644 index 0000000000..6eea5e4074 --- /dev/null +++ b/packages/plugins/test/unit/editors/ied/__snapshots__/add-ldevice-dialog.test.snap.js @@ -0,0 +1,40 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["add-ldevice-dialog looks like the latest snapshot"] = +` +
+ + +
+ + [close] + + + [add] + +
+`; +/* end snapshot add-ldevice-dialog looks like the latest snapshot */ + diff --git a/packages/plugins/test/unit/editors/ied/__snapshots__/add-ln-dialog.test.snap.js b/packages/plugins/test/unit/editors/ied/__snapshots__/add-ln-dialog.test.snap.js new file mode 100644 index 0000000000..01b14bc9a0 --- /dev/null +++ b/packages/plugins/test/unit/editors/ied/__snapshots__/add-ln-dialog.test.snap.js @@ -0,0 +1,76 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["add-ln-dialog looks like the latest snapshot"] = +` +
+
+ + +
+ + + + PlaceholderLLN0 + + + + + +
+
+ + + + +
+ + [close] + + + [add] + +
+`; +/* end snapshot add-ln-dialog looks like the latest snapshot */ + diff --git a/packages/plugins/test/unit/editors/ied/__snapshots__/ied-container.test.snap.js b/packages/plugins/test/unit/editors/ied/__snapshots__/ied-container.test.snap.js index c3afe52b78..c2b4f7b5a0 100644 --- a/packages/plugins/test/unit/editors/ied/__snapshots__/ied-container.test.snap.js +++ b/packages/plugins/test/unit/editors/ied/__snapshots__/ied-container.test.snap.js @@ -27,8 +27,17 @@ snapshots["ied-container looks like the latest snapshot"] = + + + + + + `; /* end snapshot ied-container looks like the latest snapshot */ diff --git a/packages/plugins/test/unit/editors/ied/__snapshots__/ldevice-container.test.snap.js b/packages/plugins/test/unit/editors/ied/__snapshots__/ldevice-container.test.snap.js index e320ea49d7..9c399766b4 100644 --- a/packages/plugins/test/unit/editors/ied/__snapshots__/ldevice-container.test.snap.js +++ b/packages/plugins/test/unit/editors/ied/__snapshots__/ldevice-container.test.snap.js @@ -12,6 +12,13 @@ snapshots["ldevice-container LDevice Element with LN Elements and all LN Element + + + + + + `; /* end snapshot ldevice-container LDevice Element with LN Elements and all LN Elements displayed looks like the latest snapshot */ @@ -59,6 +68,13 @@ snapshots["ldevice-container LDevice Element with LN Elements and some LN Elemen + + + + + + `; /* end snapshot ldevice-container LDevice Element with LN Elements and some LN Elements displayed looks like the latest snapshot */ @@ -96,8 +114,17 @@ snapshots["ldevice-container LDevice Element with LN Elements and no LN Elements + + + +
+ + `; /* end snapshot ldevice-container LDevice Element with LN Elements and no LN Elements displayed looks like the latest snapshot */ @@ -113,8 +140,17 @@ snapshots["ldevice-container LDevice Element without LN Element looks like the l + + + +
+ + `; /* end snapshot ldevice-container LDevice Element without LN Element looks like the latest snapshot */ diff --git a/packages/plugins/test/unit/editors/ied/__snapshots__/server-container.test.snap.js b/packages/plugins/test/unit/editors/ied/__snapshots__/server-container.test.snap.js index b6f51f9e89..68770ecd92 100644 --- a/packages/plugins/test/unit/editors/ied/__snapshots__/server-container.test.snap.js +++ b/packages/plugins/test/unit/editors/ied/__snapshots__/server-container.test.snap.js @@ -5,10 +5,19 @@ snapshots["server-container Server Element with LDevice Elements and all LN Elem ` + + + + + + `; /* end snapshot server-container Server Element with LDevice Elements and all LN Elements of the LDevice Element displayed looks like the latest snapshot */ @@ -17,8 +26,17 @@ snapshots["server-container Server Element with LDevice Elements and some LN Ele ` + + + + + + `; /* end snapshot server-container Server Element with LDevice Elements and some LN Elements displayed looks like the latest snapshot */ @@ -27,6 +45,15 @@ snapshots["server-container Server Element with LDevice Elements and no LN Eleme ` + + + + + + `; /* end snapshot server-container Server Element with LDevice Elements and no LN Elements displayed looks like the latest snapshot */ @@ -35,6 +62,15 @@ snapshots["server-container Server Element without LDevice Element looks like th ` + + + + + + `; /* end snapshot server-container Server Element without LDevice Element looks like the latest snapshot */ diff --git a/packages/plugins/test/unit/editors/ied/add-access-point-dialog.test.ts b/packages/plugins/test/unit/editors/ied/add-access-point-dialog.test.ts new file mode 100644 index 0000000000..36431afa03 --- /dev/null +++ b/packages/plugins/test/unit/editors/ied/add-access-point-dialog.test.ts @@ -0,0 +1,115 @@ +import { expect, fixture, html } from '@open-wc/testing'; +import { SinonSpy, spy } from 'sinon'; +import { AddAccessPointDialog } from '../../../../src/editors/ied/add-access-point-dialog'; +import '../../../../src/editors/ied/add-access-point-dialog.js'; + +describe('add-access-point-dialog', () => { + let element: AddAccessPointDialog; + let doc: XMLDocument; + let ied: Element; + let onConfirmSpy: SinonSpy; + + beforeEach(async () => { + doc = new DOMParser().parseFromString( + ` + + `, + 'application/xml' + ); + ied = doc.querySelector('IED')!; + onConfirmSpy = spy(); + + element = await fixture( + html`` + ); + }); + + it('looks like the latest snapshot', async () => { + element.show(); + await element.updateComplete; + expect(element).shadowDom.to.equalSnapshot(); + }); + + it('should show and hide dialog', async () => { + element.show(); + await element.updateComplete; + expect(element.dialog.open).to.be.true; + + element['close'](); + await element.updateComplete; + expect(element.dialog.open).to.be.false; + }); + + describe('access point name validation', () => { + it('should reject names with invalid format', () => { + expect(element['getApNameError']('1 invalid')).to.equal( + '[iededitor.addAccessPointDialog.nameFormatError]' + ); + expect(element['getApNameError']('!bad')).to.equal( + '[iededitor.addAccessPointDialog.nameFormatError]' + ); + }); + + it('should reject names that are too long', () => { + const longName = 'a'.repeat(33); + expect(element['getApNameError'](longName)).to.equal( + '[iededitor.addAccessPointDialog.nameTooLongError]' + ); + }); + + it('should accept valid names', () => { + expect(element['getApNameError']('ValidName1')).to.equal(''); + }); + }); + + describe('creating access point', () => { + it('should call onConfirm with correct name', async () => { + element.show(); + await element.updateComplete; + + element.apNameField.value = 'ValidName1'; + element.apNameField.dispatchEvent(new Event('input')); + + const addButton = element.shadowRoot?.querySelector( + '[data-testid="add-access-point-button"]' + ); + (addButton as HTMLElement)?.click(); + await element.updateComplete; + + expect(onConfirmSpy.calledOnce).to.be.true; + expect(onConfirmSpy.firstCall.args[0]).to.deep.include({ + name: 'ValidName1', + createServerAt: false, + }); + }); + + it('should call onConfirm with serverAt data', async () => { + element.show(); + await element.updateComplete; + element.apNameField.value = 'ValidName2'; + element.apNameField.dispatchEvent(new Event('input')); + element['createServerAt'] = true; + element['serverAtApName'] = 'AP1'; + element['serverAtDesc'] = 'Description for AP1'; + await element.updateComplete; + + const addButton = element.shadowRoot?.querySelector( + '[data-testid="add-access-point-button"]' + ); + (addButton as HTMLElement)?.click(); + await element.updateComplete; + + expect(onConfirmSpy.calledOnce).to.be.true; + expect(onConfirmSpy.firstCall.args[0]).to.deep.include({ + name: 'ValidName2', + createServerAt: true, + serverAtApName: 'AP1', + serverAtDesc: 'Description for AP1', + }); + }); + }); +}); diff --git a/packages/plugins/test/unit/editors/ied/add-ldevice-dialog.test.ts b/packages/plugins/test/unit/editors/ied/add-ldevice-dialog.test.ts new file mode 100644 index 0000000000..ce05f8b439 --- /dev/null +++ b/packages/plugins/test/unit/editors/ied/add-ldevice-dialog.test.ts @@ -0,0 +1,64 @@ +import { expect, fixture, html } from '@open-wc/testing'; +import { SinonSpy, spy } from 'sinon'; +import { AddLDeviceDialog } from '../../../../src/editors/ied/add-ldevice-dialog'; +import '../../../../src/editors/ied/add-ldevice-dialog.js'; + +describe('add-ldevice-dialog', () => { + let element: AddLDeviceDialog; + let doc: XMLDocument; + let server: Element; + let onConfirmSpy: SinonSpy; + + beforeEach(async () => { + doc = await fetch('/test/testfiles/editors/minimalVirtualIED.scd') + .then(response => response.text()) + .then(str => new DOMParser().parseFromString(str, 'application/xml')); + server = doc.querySelector('IED > AccessPoint > Server')!; + onConfirmSpy = spy(); + element = await fixture( + html`` + ); + }); + + it('looks like the latest snapshot', async () => { + element.show(); + await element.updateComplete; + expect(element).shadowDom.to.equalSnapshot(); + }); + + it('should show and hide dialog', async () => { + element.show(); + await element.updateComplete; + expect(element.dialog.open).to.be.true; + + element['close'](); + await element.updateComplete; + expect(element.dialog.open).to.be.false; + }); + + describe('LDevice inst validation', () => { + it('should reject empty names', () => { + expect(element['getInstError']('')).to.equal( + '[iededitor.addLDeviceDialog.instRequiredError]' + ); + expect(element['getInstError'](' ')).to.equal( + '[iededitor.addLDeviceDialog.instRequiredError]' + ); + }); + + it('should reject names with wrong pattern', () => { + expect(element['getInstError']('invalid name')).to.equal( + '[iededitor.addLDeviceDialog.instFormatError]' + ); + }); + + it('should reject existing names', () => { + expect(element['getInstError']('LD1')).to.equal( + '[iededitor.addLDeviceDialog.instUniqueError]' + ); + }); + }); +}); diff --git a/packages/plugins/test/unit/editors/ied/add-ln-dialog.test.ts b/packages/plugins/test/unit/editors/ied/add-ln-dialog.test.ts new file mode 100644 index 0000000000..a40ea978f8 --- /dev/null +++ b/packages/plugins/test/unit/editors/ied/add-ln-dialog.test.ts @@ -0,0 +1,94 @@ +import { expect, fixture, html } from '@open-wc/testing'; +import { SinonSpy, spy } from 'sinon'; +import { AddLnDialog } from '../../../../src/editors/ied/add-ln-dialog'; +import '../../../../src/editors/ied/add-ln-dialog.js'; + +describe('add-ln-dialog', () => { + let element: AddLnDialog; + let doc: XMLDocument; + let onConfirmSpy: SinonSpy; + + beforeEach(async () => { + doc = await fetch('/test/testfiles/editors/minimalVirtualIED.scd') + .then(response => response.text()) + .then(str => new DOMParser().parseFromString(str, 'application/xml')); + onConfirmSpy = spy(); + element = await fixture( + html`` + ); + }); + + it('looks like the latest snapshot', async () => { + element.show(); + await element.updateComplete; + expect(element).shadowDom.to.equalSnapshot(); + }); + + it('should show and hide dialog', async () => { + element.show(); + await element.updateComplete; + expect(element.dialog.open).to.be.true; + + element['close'](); + await element.updateComplete; + expect(element.dialog.open).to.be.false; + }); + + it('displays filtered LN types', async () => { + element.show(); + await element.updateComplete; + expect(element.filterText).to.equal(''); + expect(element['filteredLNodeTypes'].length).to.equal(1); + + element.filterText = 'NonExistingFilter'; + await element.updateComplete; + expect(element['filteredLNodeTypes'].length).to.equal(0); + }); + + it('should create LN data on confirm', async () => { + element.show(); + await element.updateComplete; + const listItems = element.shadowRoot?.querySelectorAll('mwc-list-item'); + const targetItem = listItems + ? Array.from(listItems).find(item => item.value === 'PlaceholderLLN0') + : undefined; + if (targetItem) { + targetItem.click(); + await element.updateComplete; + } + + const prefixInput = element.shadowRoot?.querySelector( + '[data-testid="prefix"]' + ); + (prefixInput as HTMLInputElement).value = 'MyPrefix'; + (prefixInput as HTMLInputElement).dispatchEvent( + new Event('input', { bubbles: true, composed: true }) + ); + + const amountInput = element.shadowRoot?.querySelector( + '[data-testid="amount"]' + ); + (amountInput as HTMLInputElement).value = '3'; + (amountInput as HTMLInputElement).dispatchEvent( + new Event('input', { bubbles: true, composed: true }) + ); + + await element.updateComplete; + const addButton = element.shadowRoot?.querySelector( + '[data-testid="add-ln-button"]' + ); + (addButton as HTMLElement)?.click(); + await element.updateComplete; + + expect(onConfirmSpy.calledOnce).to.be.true; + expect(onConfirmSpy.firstCall.args[0]).to.deep.include({ + lnType: 'PlaceholderLLN0', + lnClass: 'LLN0', + prefix: 'MyPrefix', + amount: 3, + }); + }); +}); From 106688bd736b449a3182045ecb6fccc5a51067c7 Mon Sep 17 00:00:00 2001 From: Nora Blomaard Date: Mon, 27 Oct 2025 15:46:40 +0100 Subject: [PATCH 5/8] feat: edit and delete virtual IED elements (#1715) * feat: edit IED manufacturer * feat: edit and delete accesspoint+refs * feat: edit and delete LDevice * feat: edit LN(0) and delete LN * test: update snapshots and unit tests * fix: update inst duplication check * feat: add access point wizard tests --- packages/openscd/src/translations/de.ts | 14 ++ packages/openscd/src/translations/en.ts | 14 ++ .../src/editors/ied/access-point-container.ts | 68 ++++-- .../src/editors/ied/ldevice-container.ts | 15 ++ .../plugins/src/editors/ied/ln-container.ts | 55 +++-- .../src/editors/ied/server-container.ts | 1 + packages/plugins/src/wizards/accesspoint.ts | 136 ++++++++++++ .../plugins/src/wizards/foundation/actions.ts | 28 ++- .../src/wizards/foundation/references.ts | 19 +- packages/plugins/src/wizards/ied.ts | 10 +- packages/plugins/src/wizards/ldevice.ts | 30 ++- packages/plugins/src/wizards/ln.ts | 77 +++++-- packages/plugins/src/wizards/ln0.ts | 23 ++- .../plugins/src/wizards/wizard-library.ts | 3 +- .../access-point-container.test.snap.js | 48 +++++ .../ldevice-container.test.snap.js | 24 +++ .../__snapshots__/ln-container.test.snap.js | 12 ++ .../__snapshots__/accesspoint.test.snap.js | 93 +++++++++ .../wizards/__snapshots__/ied.test.snap.js | 4 +- .../__snapshots__/ldevice.test.snap.js | 8 +- .../test/unit/wizards/accesspoint.test.ts | 195 ++++++++++++++++++ .../plugins/test/unit/wizards/ldevice.test.ts | 2 +- packages/plugins/test/unit/wizards/ln.test.ts | 15 +- .../plugins/test/unit/wizards/ln0.test.ts | 61 +++--- 24 files changed, 831 insertions(+), 124 deletions(-) create mode 100644 packages/plugins/src/wizards/accesspoint.ts create mode 100644 packages/plugins/test/unit/wizards/__snapshots__/accesspoint.test.snap.js create mode 100644 packages/plugins/test/unit/wizards/accesspoint.test.ts diff --git a/packages/openscd/src/translations/de.ts b/packages/openscd/src/translations/de.ts index c6a8233c7f..c3be050ffb 100644 --- a/packages/openscd/src/translations/de.ts +++ b/packages/openscd/src/translations/de.ts @@ -284,10 +284,23 @@ export const de: Translations = { noResults: 'Keine Logical Node Types gefunden', }, }, + accesspoint: { + wizard: { + nameHelper: 'AccessPoint Name', + descHelper: 'AccessPoint Beschreibung', + title: { + add: 'AccessPoint hinzufügen', + edit: 'AccessPoint bearbeiten', + delete: 'AccessPoint mit Abhängigkeiten entfernen', + references: 'Gelöschte Abhängikeiten', + }, + }, + }, ied: { wizard: { nameHelper: 'Name des IED', descHelper: 'Beschreibung des IED', + manufacturerHelper: 'Hersteller des IED', title: { edit: 'IED bearbeiten', delete: 'IED mit Abhängigkeiten entfernen', @@ -304,6 +317,7 @@ export const de: Translations = { nameHelper: 'Name des Logisches Gerät', noNameSupportHelper: 'IED unterstützt keine funktionale Benennung', descHelper: 'Beschreibung des Logisches Gerät', + instHelper: 'Instanz des Logisches Gerät', title: { edit: 'Logisches Gerät bearbeiten', }, diff --git a/packages/openscd/src/translations/en.ts b/packages/openscd/src/translations/en.ts index 8345e33ac2..9d98b081b1 100644 --- a/packages/openscd/src/translations/en.ts +++ b/packages/openscd/src/translations/en.ts @@ -281,10 +281,23 @@ export const en = { noResults: 'No Logical Node Types found', }, }, + accesspoint: { + wizard: { + nameHelper: 'AccessPoint name', + descHelper: 'AccessPoint description', + title: { + add: 'Add AccessPoint', + edit: 'Edit AccessPoint', + delete: 'Remove AccessPoint with references', + references: 'References to be removed', + }, + }, + }, ied: { wizard: { nameHelper: 'IED name', descHelper: 'IED description', + manufacturerHelper: 'IED manufacturer', title: { edit: 'Edit IED', delete: 'Remove IED with references', @@ -301,6 +314,7 @@ export const en = { nameHelper: 'Logical device name', noNameSupportHelper: "IED doesn't support Functional Naming", descHelper: 'Logical device description', + instHelper: 'Logical device inst', title: { edit: 'Edit logical device', }, diff --git a/packages/plugins/src/editors/ied/access-point-container.ts b/packages/plugins/src/editors/ied/access-point-container.ts index 88be47fdc9..ee9614cc1b 100644 --- a/packages/plugins/src/editors/ied/access-point-container.ts +++ b/packages/plugins/src/editors/ied/access-point-container.ts @@ -8,7 +8,7 @@ import { TemplateResult, } from 'lit-element'; import { nothing } from 'lit-html'; -import { get } from 'lit-translate'; +import { get, translate } from 'lit-translate'; import { getDescriptionAttribute, @@ -16,7 +16,10 @@ import { newWizardEvent, } from '@openscd/open-scd/src/foundation.js'; import { accessPointIcon } from '@openscd/open-scd/src/icons/ied-icons.js'; +import { newActionEvent } from '@openscd/core/foundation/deprecated/editor.js'; +import { wizards } from '../../wizards/wizard-library.js'; import { editServicesWizard } from '../../wizards/services.js'; +import { removeAccessPointWizard } from '../../wizards/accesspoint.js'; import '@openscd/open-scd/src/action-pane.js'; import './server-container.js'; @@ -29,6 +32,16 @@ export class AccessPointContainer extends Container { @property() selectedLNClasses: string[] = []; + @state() + private get lnElements(): Element[] { + return Array.from(this.element.querySelectorAll(':scope > LN')).filter( + element => { + const lnClass = element.getAttribute('lnClass') ?? ''; + return this.selectedLNClasses.includes(lnClass); + } + ); + } + protected updated(_changedProperties: PropertyValues): void { super.updated(_changedProperties); @@ -45,27 +58,22 @@ export class AccessPointContainer extends Container { return html``; } - return html` - this.openSettingsWizard(services)} - > - `; + return html` this.openSettingsWizard(services)} + >`; } - private openSettingsWizard(services: Element): void { - const wizard = editServicesWizard(services); + private openEditWizard(): void { + const wizard = wizards['AccessPoint'].edit(this.element); if (wizard) this.dispatchEvent(newWizardEvent(wizard)); } - @state() - private get lnElements(): Element[] { - return Array.from(this.element.querySelectorAll(':scope > LN')).filter( - element => { - const lnClass = element.getAttribute('lnClass') ?? ''; - return this.selectedLNClasses.includes(lnClass); - } - ); + private openSettingsWizard(services: Element): void { + const wizard = editServicesWizard(services); + if (wizard) this.dispatchEvent(newWizardEvent(wizard)); } private header(): TemplateResult { @@ -75,11 +83,37 @@ export class AccessPointContainer extends Container { return html`${name}${desc ? html` — ${desc}` : nothing}`; } + private removeAccessPoint(): void { + const wizard = removeAccessPointWizard(this.element); + if (wizard) { + this.dispatchEvent(newWizardEvent(() => wizard)); + } else { + // If no Wizard is needed, just remove the element. + this.dispatchEvent( + newActionEvent({ + old: { parent: this.element.parentElement!, element: this.element }, + }) + ); + } + } + render(): TemplateResult { const lnElements = this.lnElements; return html` ${accessPointIcon} + this.removeAccessPoint()} + > + this.openEditWizard()} + > ${this.renderServicesIcon()} ${Array.from(this.element.querySelectorAll(':scope > Server')).map( server => diff --git a/packages/plugins/src/editors/ied/ldevice-container.ts b/packages/plugins/src/editors/ied/ldevice-container.ts index a18ddb7747..e15f711cb0 100644 --- a/packages/plugins/src/editors/ied/ldevice-container.ts +++ b/packages/plugins/src/editors/ied/ldevice-container.ts @@ -23,6 +23,7 @@ import { getLdNameAttribute, newWizardEvent, } from '@openscd/open-scd/src/foundation.js'; +import { newActionEvent } from '@openscd/core/foundation/deprecated/editor.js'; import { wizards } from '../../wizards/wizard-library.js'; import { Container } from './foundation.js'; @@ -104,11 +105,25 @@ export class LDeviceContainer extends Container { this.dispatchEvent(newEditEventV2(inserts)); } + private removeLDevice(): void { + this.dispatchEvent( + newActionEvent({ + old: { parent: this.element.parentElement!, element: this.element }, + }) + ); + } + render(): TemplateResult { const lnElements = this.lnElements; return html` ${logicalDeviceIcon} + this.removeLDevice()} + > ${doElements.length > 0 - ? html` - - - - this.requestUpdate()} - > - ` + ? html`${this.element.tagName === 'LN' + ? html` this.removeElement()} + >` + : nothing} + + + + this.requestUpdate()} + > + ` : nothing} ${this.toggleButton?.on ? doElements.map( diff --git a/packages/plugins/src/editors/ied/server-container.ts b/packages/plugins/src/editors/ied/server-container.ts index 99825a0fbb..4e948638c2 100644 --- a/packages/plugins/src/editors/ied/server-container.ts +++ b/packages/plugins/src/editors/ied/server-container.ts @@ -81,6 +81,7 @@ export class ServerContainer extends Container { const ln0 = createElement(this.doc, 'LN0', { lnClass: 'LLN0', + inst: '', lnType: lnTypeId, }); diff --git a/packages/plugins/src/wizards/accesspoint.ts b/packages/plugins/src/wizards/accesspoint.ts new file mode 100644 index 0000000000..356a99ea8c --- /dev/null +++ b/packages/plugins/src/wizards/accesspoint.ts @@ -0,0 +1,136 @@ +import { html, TemplateResult } from 'lit-element'; +import { get } from 'lit-translate'; + +import '@openscd/open-scd/src/wizard-textfield.js'; +import { + newWizardEvent, + Wizard, + WizardInputElement, + WizardActor, + isPublic, + identity, +} from '@openscd/open-scd/src/foundation.js'; +import { + Delete, + ComplexAction, + EditorAction, +} from '@openscd/core/foundation/deprecated/editor.js'; +import { updateNamingAttributeWithReferencesAction } from './foundation/actions.js'; +import { deleteReferences } from './foundation/references.js'; +import { patterns } from './foundation/limits.js'; + +export function renderAccessPointWizard( + name: string | null, + desc: string | null, + reservedNames: string[] +): TemplateResult[] { + return [ + html` + `, + html` + `, + ]; +} +export function removeAccessPointWizard(element: Element): Wizard | null { + const references = deleteReferences(element); + if (references.length > 0) { + return [ + { + title: get('accesspoint.wizard.title.delete'), + content: renderAccessPointReferencesWizard(references), + primary: { + icon: 'delete', + label: get('remove'), + action: removeAccessPointAndReferences(element), + }, + }, + ]; + } + return null; +} + +export function editAccessPointWizard(element: Element): Wizard { + const name = element.getAttribute('name'); + const desc = element.getAttribute('desc'); + return [ + { + title: get('accesspoint.wizard.title.edit'), + element, + primary: { + icon: 'edit', + label: get('save'), + action: updateNamingAttributeWithReferencesAction( + element, + 'accesspoint.action.updateAccessPoint' + ), + }, + content: renderAccessPointWizard( + name, + desc, + reservedNamesAccessPoint(element) + ), + }, + ]; +} + +function renderAccessPointReferencesWizard( + references: Delete[] +): TemplateResult[] { + return [ + html`
+

${get('accesspoint.wizard.title.references')}

+ + ${references.map(reference => { + const oldElement = reference.old.element; + return html` + ${oldElement.tagName} + ${identity(reference.old.element)} + `; + })} + +
`, + ]; +} + +function reservedNamesAccessPoint(currentElement: Element): string[] { + const ied = currentElement.closest('IED'); + if (!ied) return []; + + return Array.from(ied.querySelectorAll(':scope > AccessPoint')) + .filter(isPublic) + .map(ap => ap.getAttribute('name') ?? '') + .filter(name => name !== currentElement.getAttribute('name')); +} + +export function removeAccessPointAndReferences(element: Element): WizardActor { + return (inputs: WizardInputElement[], wizard: Element): EditorAction[] => { + wizard.dispatchEvent(newWizardEvent()); + const referencesDeleteActions = deleteReferences(element); + const name = element.getAttribute('name') ?? 'Unknown'; + const complexAction: ComplexAction = { + actions: [], + title: get('ied.action.deleteAccessPoint', { name }), + }; + complexAction.actions.push({ + old: { parent: element.parentElement!, element }, + }); + complexAction.actions.push(...referencesDeleteActions); + return [complexAction]; + }; +} diff --git a/packages/plugins/src/wizards/foundation/actions.ts b/packages/plugins/src/wizards/foundation/actions.ts index 67393f1b3d..3cc95f8b63 100644 --- a/packages/plugins/src/wizards/foundation/actions.ts +++ b/packages/plugins/src/wizards/foundation/actions.ts @@ -4,14 +4,12 @@ import { WizardInputElement, } from '@openscd/open-scd/src/foundation.js'; -import { - cloneElement, -} from '@openscd/xml'; +import { cloneElement } from '@openscd/xml'; import { ComplexAction, EditorAction, - createUpdateAction + createUpdateAction, } from '@openscd/core/foundation/deprecated/editor'; import { get } from 'lit-translate'; import { updateReferences } from './references.js'; @@ -69,6 +67,13 @@ export function updateNamingAttributeWithReferencesAction( return (inputs: WizardInputElement[]): EditorAction[] => { const newAttributes: Record = {}; processNamingAttributes(newAttributes, element, inputs); + processOptionalAttribute( + newAttributes, + element, + inputs, + 'manufacturer', + 'manufacturer' + ); if (Object.keys(newAttributes).length == 0) { return []; } @@ -103,6 +108,21 @@ export function processNamingAttributes( } } +function processOptionalAttribute( + newAttributes: Record, + element: Element, + inputs: WizardInputElement[], + inputLabel: string, + attrName: string +): void { + const input = inputs.find(i => i.label === inputLabel); + if (!input) return; + const value = getValue(input); + if (value !== element.getAttribute(attrName)) { + newAttributes[attrName] = value; + } +} + export function addMissingAttributes( element: Element, newAttributes: Record diff --git a/packages/plugins/src/wizards/foundation/references.ts b/packages/plugins/src/wizards/foundation/references.ts index 6e6f48a41f..7893ac9521 100644 --- a/packages/plugins/src/wizards/foundation/references.ts +++ b/packages/plugins/src/wizards/foundation/references.ts @@ -2,11 +2,14 @@ import { getNameAttribute, isPublic, } from '@openscd/open-scd/src/foundation.js'; -import { - Delete, - Replace -} from '@openscd/core/foundation/deprecated/editor'; -const referenceInfoTags = ['IED', 'Substation', 'VoltageLevel', 'Bay'] as const; +import { Delete, Replace } from '@openscd/core/foundation/deprecated/editor'; +const referenceInfoTags = [ + 'IED', + 'AccessPoint', + 'Substation', + 'VoltageLevel', + 'Bay', +] as const; type ReferencesInfoTag = (typeof referenceInfoTags)[number]; type FilterFunction = ( @@ -66,6 +69,12 @@ const referenceInfos: Record< filter: simpleTextContentFilter(`LN > DOI > DAI > Val`), }, ], + AccessPoint: [ + { + attributeName: 'apName', + filter: simpleAttributeFilter(`ServerAt`), + }, + ], Substation: [ { attributeName: 'substationName', diff --git a/packages/plugins/src/wizards/ied.ts b/packages/plugins/src/wizards/ied.ts index e4fbb4693e..56c5581b12 100644 --- a/packages/plugins/src/wizards/ied.ts +++ b/packages/plugins/src/wizards/ied.ts @@ -15,11 +15,11 @@ import { WizardInputElement, WizardMenuActor, } from '@openscd/open-scd/src/foundation.js'; -import { +import { ComplexAction, Delete, EditorAction, - newActionEvent + newActionEvent, } from '@openscd/core/foundation/deprecated/editor.js'; import { patterns } from './foundation/limits.js'; @@ -72,9 +72,9 @@ export function renderIEDWizard( >`, html``, html``, html``, ]; } @@ -75,10 +79,21 @@ function ldNameIsAllowed(element: Element): boolean { return false; } +function reservedInstLDevice(currentElement: Element): string[] { + const ied = currentElement.closest('IED'); + if (!ied) return []; + + return Array.from( + ied.querySelectorAll(':scope > AccessPoint > Server > LDevice') + ) + .map(ld => ld.getAttribute('inst') ?? '') + .filter(name => name !== currentElement.getAttribute('inst')); +} + function updateAction(element: Element): WizardActor { return (inputs: WizardInputElement[]): SimpleAction[] => { const ldAttrs: Record = {}; - const ldKeys = ['ldName', 'desc']; + const ldKeys = ['desc', 'inst']; ldKeys.forEach(key => { ldAttrs[key] = getValue(inputs.find(i => i.label === key)!); }); @@ -110,7 +125,8 @@ export function editLDeviceWizard(element: Element): Wizard { element.getAttribute('ldName'), !ldNameIsAllowed(element), element.getAttribute('desc'), - element.getAttribute('inst') + element.getAttribute('inst'), + reservedInstLDevice(element) ), }, ]; diff --git a/packages/plugins/src/wizards/ln.ts b/packages/plugins/src/wizards/ln.ts index 0bf172882b..59722af218 100644 --- a/packages/plugins/src/wizards/ln.ts +++ b/packages/plugins/src/wizards/ln.ts @@ -12,14 +12,14 @@ import { import { cloneElement } from '@openscd/xml'; import { SimpleAction } from '@openscd/core/foundation/deprecated/editor.js'; -import { patterns } from './foundation/limits.js'; export function renderLNWizard( lnType: string | null, desc: string | null, prefix: string | null, lnClass: string | null, - inst: string | null + inst: string | null, + reservedInst: string[] ): TemplateResult[] { return [ html``, @@ -52,8 +51,10 @@ export function renderLNWizard( html``, ]; } @@ -66,19 +67,66 @@ function updateAction(element: Element): WizardActor { ldAttrs[key] = getValue(inputs.find(i => i.label === key)!); }); - if (ldKeys.some(key => ldAttrs[key] !== element.getAttribute(key))) { - const newElement = cloneElement(element, ldAttrs); - return [ - { - old: { element }, - new: { element: newElement }, - }, - ]; + if (!ldKeys.some(key => ldAttrs[key] !== element.getAttribute(key))) { + return []; + } + + const ldevice = element.closest('LDevice'); + if (ldevice) { + const newPrefix = ldAttrs['prefix'] || ''; + const newLnClass = ldAttrs['lnClass']; + const newInst = ldAttrs['inst']; + + const isDuplicate = Array.from( + ldevice.querySelectorAll(':scope > LN') + ).some( + ln => + ln !== element && + (ln.getAttribute('prefix') || '') === newPrefix && + ln.getAttribute('lnClass') === newLnClass && + ln.getAttribute('inst') === newInst + ); + + if (isDuplicate) { + return []; + } } - return []; + + const newElement = cloneElement(element, ldAttrs); + return [ + { + old: { element }, + new: { element: newElement }, + }, + ]; }; } +function reservedInstLN( + currentElement: Element, + prefixInput?: string +): string[] { + const ldevice = currentElement.closest('LDevice'); + if (!ldevice) return []; + + const currentLnClass = currentElement.getAttribute('lnClass'); + + const targetPrefix = + prefixInput !== undefined + ? prefixInput + : currentElement.getAttribute('prefix') || ''; + + const lnElements = Array.from(ldevice.querySelectorAll(':scope > LN')).filter( + ln => + ln !== currentElement && + (ln.getAttribute('prefix') || '') === targetPrefix && + ln.getAttribute('lnClass') === currentLnClass + ); + + return lnElements + .map(ln => ln.getAttribute('inst')) + .filter(inst => inst !== null) as string[]; +} export function editLNWizard(element: Element): Wizard { return [ @@ -95,7 +143,8 @@ export function editLNWizard(element: Element): Wizard { element.getAttribute('desc'), element.getAttribute('prefix'), element.getAttribute('lnClass'), - element.getAttribute('inst') + element.getAttribute('inst'), + reservedInstLN(element) ), }, ]; diff --git a/packages/plugins/src/wizards/ln0.ts b/packages/plugins/src/wizards/ln0.ts index ad06a7e1a6..1a77faa076 100644 --- a/packages/plugins/src/wizards/ln0.ts +++ b/packages/plugins/src/wizards/ln0.ts @@ -1,7 +1,9 @@ import { html, TemplateResult } from 'lit-element'; import { get } from 'lit-translate'; +import '@material/mwc-list/mwc-list-item'; import '@openscd/open-scd/src/wizard-textfield.js'; +import '@openscd/open-scd/src/wizard-select.js'; import { getValue, Wizard, @@ -14,20 +16,31 @@ import { cloneElement } from '@openscd/xml'; import { SimpleAction } from '@openscd/core/foundation/deprecated/editor.js'; import { patterns } from './foundation/limits.js'; +function getLNodeTypeOptions(element: Element): string[] { + const doc = element.ownerDocument; + const lNodeTypes = Array.from( + doc.querySelectorAll('DataTypeTemplates > LNodeType[lnClass="LLN0"]') + ); + return lNodeTypes.map(lnt => lnt.getAttribute('id')!).filter(id => id); +} + export function renderLN0Wizard( + lnodeTypeIds: string[], lnType: string | null, desc: string | null, lnClass: string | null, inst: string | null ): TemplateResult[] { return [ - html``, + >${lnodeTypeIds.map( + id => html`${id}` + )}`, html` + + + +
@@ -17,6 +29,18 @@ snapshots["access-point-container with LN Elements and all LN Classes displayed ` + + + +
@@ -43,6 +67,18 @@ snapshots["access-point-container with LN Elements and some LN Classes hidden lo ` + + + +
@@ -61,6 +97,18 @@ snapshots["access-point-container with LN Elements and all LN Classes hidden loo ` + + + +
diff --git a/packages/plugins/test/unit/editors/ied/__snapshots__/ldevice-container.test.snap.js b/packages/plugins/test/unit/editors/ied/__snapshots__/ldevice-container.test.snap.js index 9c399766b4..5dd245472c 100644 --- a/packages/plugins/test/unit/editors/ied/__snapshots__/ldevice-container.test.snap.js +++ b/packages/plugins/test/unit/editors/ied/__snapshots__/ldevice-container.test.snap.js @@ -5,6 +5,12 @@ snapshots["ldevice-container LDevice Element with LN Elements and all LN Element ` + + + + + + + + + + + + +
+ + + + +
+ + + + + +`; +/* end snapshot Wizards for SCL element AccessPoint edit AccessPoint looks like the latest snapshot */ + +snapshots["Wizards for SCL element AccessPoint remove AccessPoint with references looks like the latest snapshot"] = +` +
+
+

+ [accesspoint.wizard.title.references] +

+ + + + ServerAt + + + test>AP2 + + + +
+
+ + + + +
+`; +/* end snapshot Wizards for SCL element AccessPoint remove AccessPoint with references looks like the latest snapshot */ + diff --git a/packages/plugins/test/unit/wizards/__snapshots__/ied.test.snap.js b/packages/plugins/test/unit/wizards/__snapshots__/ied.test.snap.js index fd95e37b4e..e3570944f8 100644 --- a/packages/plugins/test/unit/wizards/__snapshots__/ied.test.snap.js +++ b/packages/plugins/test/unit/wizards/__snapshots__/ied.test.snap.js @@ -56,9 +56,9 @@ snapshots["Wizards for SCL element IED edit IED looks like the latest snapshot"] >
diff --git a/packages/plugins/test/unit/wizards/accesspoint.test.ts b/packages/plugins/test/unit/wizards/accesspoint.test.ts new file mode 100644 index 0000000000..c11e8a3abf --- /dev/null +++ b/packages/plugins/test/unit/wizards/accesspoint.test.ts @@ -0,0 +1,195 @@ +import { expect, fixture, html } from '@open-wc/testing'; + +import { + ComplexAction, + isSimple, +} from '@openscd/core/foundation/deprecated/editor.js'; +import '@openscd/open-scd/src/addons/Wizards.js'; +import { OscdWizards } from '@openscd/open-scd/src/addons/Wizards.js'; +import { WizardInputElement } from '@openscd/open-scd/src/foundation.js'; +import { WizardTextField } from '@openscd/open-scd/src/wizard-textfield.js'; + +import { + editAccessPointWizard, + removeAccessPointAndReferences, + removeAccessPointWizard, +} from '../../../src/wizards/accesspoint.js'; +import { updateNamingAttributeWithReferencesAction } from '../../../src/wizards/foundation/actions.js'; +import { + expectDeleteAction, + expectUpdateAction, + expectWizardNoUpdateAction, + fetchDoc, + setWizardTextFieldValue, +} from './test-support.js'; + +describe('Wizards for SCL element AccessPoint', () => { + let doc: XMLDocument; + let accessPoint: Element; + let element: OscdWizards; + let inputs: WizardInputElement[]; + + describe('edit AccessPoint', () => { + beforeEach(async () => { + doc = await fetchDoc('/test/testfiles/wizards/ied.scd'); + accessPoint = doc.querySelector( + 'IED[name="IED3"] > AccessPoint[name="P1"]' + )!; + + element = await fixture( + html`` + ); + const wizard = editAccessPointWizard(accessPoint); + element.workflow.push(() => wizard); + await element.requestUpdate(); + inputs = Array.from(element.wizardUI.inputs); + }); + + it('contains a wizard-textfield with the current "name" value', async () => { + expect( + (inputs).find(textField => textField.label == 'name') + ?.value + ).to.be.equal(accessPoint.getAttribute('name')); + }); + + it('contains a wizard-textfield with the current "desc" value', async () => { + expect( + (inputs).find(textField => textField.label == 'desc') + ?.value + ).to.be.equal(accessPoint.getAttribute('desc') || ''); + }); + + it('update name should be updated in document', async function () { + await setWizardTextFieldValue(inputs[0], 'P2'); + + const complexAction = updateNamingAttributeWithReferencesAction( + accessPoint, + 'accesspoint.action.updateAccessPoint' + )(inputs, element.wizardUI); + 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], 'AccessPoint', 'name', 'P1', 'P2'); + }); + + it('update name should be unique within IED', async function () { + accessPoint = doc.querySelector( + 'IED[name="IED3"] > AccessPoint[name="P2"]' + )!; + + element = await fixture( + html`` + ); + const wizard = editAccessPointWizard(accessPoint); + element.workflow.push(() => wizard); + await element.requestUpdate(); + inputs = Array.from(element.wizardUI.inputs); + + await setWizardTextFieldValue(inputs[0], 'P1'); + expect(inputs[0].checkValidity()).to.be.false; + }); + + it('update description should be updated in document', async function () { + await setWizardTextFieldValue( + inputs[1], + 'New description' + ); + + const complexAction = updateNamingAttributeWithReferencesAction( + accessPoint, + 'accesspoint.action.updateAccessPoint' + )(inputs, element.wizardUI); + 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], + 'AccessPoint', + 'desc', + null, + 'New description' + ); + }); + + it('when no fields changed there will be no update action', async function () { + expectWizardNoUpdateAction( + updateNamingAttributeWithReferencesAction( + accessPoint, + 'accesspoint.action.updateAccessPoint' + ), + element.wizardUI, + inputs + ); + }); + + it('looks like the latest snapshot', async () => { + await expect(element.wizardUI.dialog).dom.to.equalSnapshot(); + }); + }); + + describe('remove AccessPoint', () => { + beforeEach(async () => { + doc = await fetchDoc('/test/testfiles/editors/minimalVirtualIED.scd'); + }); + describe('with references', () => { + beforeEach(async () => { + accessPoint = doc.querySelector( + 'IED[name="test"] > AccessPoint[name="AP1"]' + )!; + + element = await fixture( + html`` + ); + const wizard = removeAccessPointWizard(accessPoint); + element.workflow.push(() => wizard!); + await element.requestUpdate(); + inputs = Array.from(element.wizardUI.inputs); + }); + + it('should return a wizard when AccessPoint has references', async function () { + const wizard = removeAccessPointWizard(accessPoint); + expect(wizard!.length).to.eq(1); + expect(wizard![0]?.title).to.eq('[accesspoint.wizard.title.delete]'); + }); + + it('remove AccessPoint should return expected actions including references', async function () { + const complexAction = removeAccessPointAndReferences(accessPoint)( + inputs, + element.wizardUI + ); + + expect(complexAction.length).to.equal(1); + expect(complexAction[0]).to.not.satisfy(isSimple); + + const simpleActions = (complexAction[0]).actions; + expect(simpleActions.length).to.be.greaterThan(1); + + expectDeleteAction(simpleActions[0], 'AccessPoint'); + expectDeleteAction(simpleActions[1], 'ServerAt'); + }); + + it('looks like the latest snapshot', async () => { + await expect(element.wizardUI.dialog).dom.to.equalSnapshot(); + }); + }); + + describe('without references', () => { + beforeEach(async () => { + accessPoint = doc.querySelector( + 'IED[name="test"] > AccessPoint[name="AP2"]' + )!; + }); + + it('should return null when AccessPoint has no references', async function () { + const wizard = removeAccessPointWizard(accessPoint); + expect(wizard).to.be.null; + }); + }); + }); +}); diff --git a/packages/plugins/test/unit/wizards/ldevice.test.ts b/packages/plugins/test/unit/wizards/ldevice.test.ts index 8946bd49fe..b688f2e57d 100644 --- a/packages/plugins/test/unit/wizards/ldevice.test.ts +++ b/packages/plugins/test/unit/wizards/ldevice.test.ts @@ -40,7 +40,7 @@ describe('Wizards for SCL element LDevice', () => { it('contains a wizard-textfield with a non-empty "inst" value', async () => { expect( - (inputs).find(textField => textField.label == 'ldInst') + (inputs).find(textField => textField.label == 'inst') ?.value ).to.be.equal(ldevice.getAttribute('inst')); }); diff --git a/packages/plugins/test/unit/wizards/ln.test.ts b/packages/plugins/test/unit/wizards/ln.test.ts index 784c7bec10..a4f98420b1 100644 --- a/packages/plugins/test/unit/wizards/ln.test.ts +++ b/packages/plugins/test/unit/wizards/ln.test.ts @@ -7,8 +7,6 @@ import { WizardInputElement } from '@openscd/open-scd/src/foundation.js'; import { fetchDoc, setWizardTextFieldValue } from './test-support.js'; import { WizardTextField } from '@openscd/open-scd/src/wizard-textfield.js'; - - describe('ln wizards', () => { let doc: XMLDocument; let element: OscdWizards; @@ -21,12 +19,7 @@ describe('ln wizards', () => { lnClass: 'LN-class', inst: '1', }; - const readonlyFields = [ - 'lnType', - 'prefix', - 'lnClass', - 'inst' - ]; + const readonlyFields = ['lnType', 'lnClass']; const ln = ( new DOMParser().parseFromString( @@ -52,16 +45,16 @@ describe('ln wizards', () => { it(`contains a wizard-textfield with a non-empty "${key}" value`, async () => { expect( (inputs).find( - (textField) => textField.label === key + textField => textField.label === key )?.value ).to.equal(value); }); }); - readonlyFields.forEach((field) => { + readonlyFields.forEach(field => { it(`is a readonly field ${field}`, async () => { const input = (inputs).find( - (textField) => textField.label === field + textField => textField.label === field ) as WizardTextField; expect(input.readOnly).to.be.true; diff --git a/packages/plugins/test/unit/wizards/ln0.test.ts b/packages/plugins/test/unit/wizards/ln0.test.ts index b5e605059c..495878e119 100644 --- a/packages/plugins/test/unit/wizards/ln0.test.ts +++ b/packages/plugins/test/unit/wizards/ln0.test.ts @@ -4,38 +4,23 @@ import { OscdWizards } from '@openscd/open-scd/src/addons/Wizards'; import { editLN0Wizard } from '../../../src/wizards/ln0.js'; import { WizardInputElement } from '@openscd/open-scd/src/foundation.js'; -import { fetchDoc, setWizardTextFieldValue } from './test-support.js'; +import { fetchDoc } from './test-support.js'; import { WizardTextField } from '@openscd/open-scd/src/wizard-textfield.js'; - - describe('ln0 wizards', () => { let doc: XMLDocument; let element: OscdWizards; let inputs: WizardInputElement[]; + let ln: Element; - const values = { - lnType: 'LN0-type', - desc: 'LN0-description', - lnClass: 'LN0-class', - inst: '1', - }; - const readonlyFields = [ - 'lnType', - 'lnClass', - 'inst' - ]; - - const ln = ( - new DOMParser().parseFromString( - ``, - 'application/xml' - ).documentElement - ); + const readonlyFields = ['lnClass', 'inst']; beforeEach(async () => { doc = await fetchDoc('/test/testfiles/wizards/ied.scd'); + ln = doc.querySelector('LN0')!; + expect(ln).to.exist; + element = await fixture( html`` ); @@ -46,20 +31,36 @@ describe('ln0 wizards', () => { inputs = Array.from(element.wizardUI.inputs); }); - Object.entries(values).forEach(([key, value]) => { - it(`contains a wizard-textfield with a non-empty "${key}" value`, async () => { - expect( - (inputs).find( - (textField) => textField.label === key - )?.value - ).to.equal(value); + it('contains a wizard-select for lnType with available LNodeTypes', async () => { + const lnTypeInput = (inputs).find( + input => input.label === 'lnType' + ); + expect(lnTypeInput).to.exist; + expect(lnTypeInput?.tagName).to.equal('WIZARD-SELECT'); + }); + + it('lnType select contains LNodeType options with lnClass="LLN0"', async () => { + const lnTypeSelect = (inputs).find( + input => input.label === 'lnType' + ); + const options = lnTypeSelect?.querySelectorAll('mwc-list-item'); + expect(options).to.have.length.greaterThan(0); + + const lln0Types = doc.querySelectorAll('LNodeType[lnClass="LLN0"]'); + expect(options).to.have.lengthOf(lln0Types.length); + }); + + it('contains required fields', async () => { + ['lnType', 'lnClass'].forEach(field => { + const input = (inputs).find(i => i.label === field); + expect(input).to.exist; }); }); - readonlyFields.forEach((field) => { + readonlyFields.forEach(field => { it(`is a readonly field ${field}`, async () => { const input = (inputs).find( - (textField) => textField.label === field + textField => textField.label === field ) as WizardTextField; expect(input.readOnly).to.be.true; From e9291fbc73a91a45594e4333af7e0c1ba7d83dcf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Oct 2025 09:59:24 +0100 Subject: [PATCH 6/8] chore(main): release 0.43.0 (#1710) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 15 +++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 57c4a5e739..ff068419ac 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,5 +1,5 @@ { "packages/openscd": "0.37.0", "packages/core": "0.1.4", - ".": "0.42.0" + ".": "0.43.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 46abc2fe18..a1d0c6f032 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [0.43.0](https://github.com/openscd/open-scd/compare/v0.42.0...v0.43.0) (2025-10-27) + + +### Features + +* add elements to virtual ied ([#1714](https://github.com/openscd/open-scd/issues/1714)) ([0c1074b](https://github.com/openscd/open-scd/commit/0c1074bf9d4f154a06c8031e593974c1f618fead)) +* add virtual ied ([#1712](https://github.com/openscd/open-scd/issues/1712)) ([cf45fe9](https://github.com/openscd/open-scd/commit/cf45fe92e4a09066ca9b426b282486229dfbc43a)) +* edit and delete virtual IED elements ([#1715](https://github.com/openscd/open-scd/issues/1715)) ([106688b](https://github.com/openscd/open-scd/commit/106688bd736b449a3182045ecb6fccc5a51067c7)) + + +### Bug Fixes + +* Disable experimental require module to make node 20.19 and above work ([#1709](https://github.com/openscd/open-scd/issues/1709)) ([d47a3da](https://github.com/openscd/open-scd/commit/d47a3dac6c57ee814cfbd78f3636fe872a052568)) +* Require lnInst only for regular lns ([#1713](https://github.com/openscd/open-scd/issues/1713)) ([003161f](https://github.com/openscd/open-scd/commit/003161fd5a0b363477ece059629ed2c0d6d86aa0)) + ## [0.42.0](https://github.com/openscd/open-scd/compare/v0.41.0...v0.42.0) (2025-09-15) diff --git a/package-lock.json b/package-lock.json index 28d46ba23e..3c3486b639 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscd-monorepo", - "version": "0.42.0", + "version": "0.43.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscd-monorepo", - "version": "0.42.0", + "version": "0.43.0", "license": "Apache-2.0", "workspaces": [ "packages/*" diff --git a/package.json b/package.json index e2f7a99a87..c794e7c120 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openscd-monorepo", - "version": "0.42.0", + "version": "0.43.0", "description": "OpenSCD base distribution and plugins", "private": true, "workspaces": [ From 115f22a6f1f9cfe02a4817ec03811bcc53cd1fea Mon Sep 17 00:00:00 2001 From: Nora Blomaard Date: Tue, 28 Oct 2025 10:30:28 +0100 Subject: [PATCH 7/8] fix: update node to 20.x in release workflow (#1716) --- .github/workflows/release-please.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 6248a8d4bb..76ab0723e5 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -25,7 +25,7 @@ jobs: if: ${{ steps.release.outputs.release_created }} - uses: actions/setup-node@v4 with: - node-version: 18 + node-version: "20.x" registry-url: "https://registry.npmjs.org" if: ${{ steps.release.outputs.release_created }} - run: npm ci --include=optional From 2feab99f599572136f36afc00b9ee8f93589c317 Mon Sep 17 00:00:00 2001 From: Nora Blomaard Date: Tue, 28 Oct 2025 10:47:55 +0100 Subject: [PATCH 8/8] fix: update version to 0.43.0-1 --- packages/compas-open-scd/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/compas-open-scd/package.json b/packages/compas-open-scd/package.json index 47da0a36f6..f3429ab510 100644 --- a/packages/compas-open-scd/package.json +++ b/packages/compas-open-scd/package.json @@ -1,6 +1,6 @@ { "name": "compas-open-scd", - "version": "0.42.0-1", + "version": "0.43.0-1", "repository": "https://github.com/openscd/open-scd.git", "description": "OpenSCD CoMPAS Edition", "directory": "packages/compas-open-scd",