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 3947aa1085..539dacb337 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8260,6 +8260,12 @@
"lit-html": "^1.0.0"
}
},
+ "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
@@ -35833,6 +35839,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/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",
diff --git a/packages/openscd/src/translations/de.ts b/packages/openscd/src/translations/de.ts
index b4cef871fd..c3be050ffb 100644
--- a/packages/openscd/src/translations/de.ts
+++ b/packages/openscd/src/translations/de.ts
@@ -229,6 +229,8 @@ export const de: Translations = {
missing: 'Kein IED vorhanden',
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',
@@ -248,11 +250,57 @@ 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',
+ },
+ 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',
+ },
+ },
+ 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',
@@ -269,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 ecf7cb4ca0..9d98b081b1 100644
--- a/packages/openscd/src/translations/en.ts
+++ b/packages/openscd/src/translations/en.ts
@@ -226,6 +226,8 @@ export const en = {
missing: 'No IED',
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',
@@ -245,11 +247,57 @@ 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',
+ },
+ 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',
+ },
+ },
+ 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',
@@ -266,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/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/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.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``;
+ if (iedList.length === 0) {
+ return html`
+ ${translate('iededitor.missing')}
+
`;
}
- return html`
- ${get('iededitor.missing')}
-
`;
+ return html``;
+ }
+
+ 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/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/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
new file mode 100644
index 0000000000..e53ec724c0
--- /dev/null
+++ b/packages/plugins/src/editors/ied/create-ied-dialog.ts
@@ -0,0 +1,134 @@
+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 !== '',
+ };
+ }}
+ required
+ autoValidate
+ helper=${translate('iededitor.createDialog.iedName')}
+ dialogInitialFocus
+ style="width: 100%; margin-bottom: 16px;"
+ @input=${(e: Event) => {
+ this.newIedName = (e.target as HTMLInputElement).value;
+ }}
+ >
+
+
+ ${translate('close')}
+
+
+ ${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..5471655460 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,139 @@ 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;
+}
+
+/**
+ * Create an AccessPoint element for an IED.
+ * @param doc - The XML document to create the AccessPoint in.
+ * @param name - The name for the new AccessPoint.
+ * @returns The created AccessPoint element.
+ */
+export function createAccessPoint(doc: XMLDocument, name: string): Element {
+ return createElement(doc, 'AccessPoint', { name });
+}
+
+/**
+ * Create a ServerAt element pointing to an existing AccessPoint.
+ * @param doc - The XML document to create the ServerAt in.
+ * @param apName - The name of the AccessPoint that contains the Server to reference.
+ * @param desc - Optional description for the ServerAt element.
+ * @returns The created ServerAt element.
+ */
+export function createServerAt(
+ doc: XMLDocument,
+ apName: string,
+ desc?: string
+): Element {
+ const attributes: Record = { 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.
@@ -146,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..e15f711cb0 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,16 @@ 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 { newActionEvent } from '@openscd/core/foundation/deprecated/editor.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 +43,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,17 +85,57 @@ 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));
+ }
+
+ 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()}
+ >
this.openEditWizard()}
>
+
+ this.addLnDialog.show()}
+ >
+
${lnElements.length > 0
? html`
+ this.handleAddLN(data)}
+ >
`;
}
diff --git a/packages/plugins/src/editors/ied/ln-container.ts b/packages/plugins/src/editors/ied/ln-container.ts
index 190b65a295..17164298b7 100644
--- a/packages/plugins/src/editors/ied/ln-container.ts
+++ b/packages/plugins/src/editors/ied/ln-container.ts
@@ -1,13 +1,14 @@
import { customElement, html, query, TemplateResult } from 'lit-element';
import { nothing } from 'lit-html';
import { until } from 'lit-html/directives/until';
-import { get } from 'lit-translate';
+import { get, translate } from 'lit-translate';
import {
getInstanceAttribute,
getNameAttribute,
newWizardEvent,
} from '@openscd/open-scd/src/foundation.js';
+import { newActionEvent } from '@openscd/core/foundation/deprecated/editor.js';
import { IconButtonToggle } from '@material/mwc-icon-button-toggle';
import '@openscd/open-scd/src/action-pane.js';
@@ -65,30 +66,44 @@ export class LNContainer extends Container {
if (wizard) this.dispatchEvent(newWizardEvent(wizard));
}
+ private removeElement(): void {
+ if (this.element.tagName === 'LN') {
+ this.dispatchEvent(
+ newActionEvent({
+ old: { parent: this.element.parentElement!, element: this.element },
+ })
+ );
+ }
+ }
+
render(): TemplateResult {
const doElements = this.getDOElements();
return html`
${doElements.length > 0
- ? html`
- this.openEditWizard()}}"
- >
-
-
- this.requestUpdate()}
- >
- `
+ ? html`${this.element.tagName === 'LN'
+ ? html` this.removeElement()}
+ >`
+ : nothing}
+ this.openEditWizard()}}"
+ >
+
+
+ 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 7568ddc545..4e948638c2 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,42 @@ 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',
+ inst: '',
+ 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/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/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` {
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"] =
-`
+
+
+
`;
/* end snapshot IED Plugin with a doc loaded containing IEDs looks like the latest snapshot */
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/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__/access-point-container.test.snap.js b/packages/plugins/test/unit/editors/ied/__snapshots__/access-point-container.test.snap.js
index 5e54f5ccb8..bf39170ee5 100644
--- a/packages/plugins/test/unit/editors/ied/__snapshots__/access-point-container.test.snap.js
+++ b/packages/plugins/test/unit/editors/ied/__snapshots__/access-point-container.test.snap.js
@@ -5,6 +5,18 @@ snapshots["access-point-container with Server Elements looks like the latest sna
`
+
+
+
+
@@ -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__/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..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 ldevice-container LDevice Element with LN Elements and all LN Elements displayed looks like the latest snapshot */
@@ -52,6 +67,12 @@ 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 */
@@ -89,6 +119,12 @@ 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 */
@@ -106,6 +151,12 @@ 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__/ln-container.test.snap.js b/packages/plugins/test/unit/editors/ied/__snapshots__/ln-container.test.snap.js
index a1bd1625d6..97cf57023a 100644
--- a/packages/plugins/test/unit/editors/ied/__snapshots__/ln-container.test.snap.js
+++ b/packages/plugins/test/unit/editors/ied/__snapshots__/ln-container.test.snap.js
@@ -66,6 +66,12 @@ snapshots["looks like the latest snapshot with a LN0 element and child elements
snapshots["looks like the latest snapshot with a LN element."] =
`
+
+
+
+
+
+
+
+
+
+
`;
/* 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,
+ });
+ });
+});
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');
+ });
+ });
});
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;
+ });
+ });
});
diff --git a/packages/plugins/test/unit/wizards/__snapshots__/accesspoint.test.snap.js b/packages/plugins/test/unit/wizards/__snapshots__/accesspoint.test.snap.js
new file mode 100644
index 0000000000..a758b118fb
--- /dev/null
+++ b/packages/plugins/test/unit/wizards/__snapshots__/accesspoint.test.snap.js
@@ -0,0 +1,93 @@
+/* @web/test-runner snapshot v1 */
+export const snapshots = {};
+
+snapshots["Wizards for SCL element AccessPoint edit AccessPoint looks like the latest snapshot"] =
+`
+
+
+
+
+
+
+
+
+
+
+
+`;
+/* 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;