diff --git a/.release-please-manifest.json b/.release-please-manifest.json index a35290123c..57c4a5e739 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.41.0" + ".": "0.42.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 17e2c8249d..46abc2fe18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [0.42.0](https://github.com/openscd/open-scd/compare/v0.41.0...v0.42.0) (2025-09-15) + + +### Features + +* Add oscd api with plugin state ([#1696](https://github.com/openscd/open-scd/issues/1696)) ([1c457cf](https://github.com/openscd/open-scd/commit/1c457cf02a404a61b7ff09553223091bc5edd1f6)) + + +### Bug Fixes + +* Connected AP wizard element order ([#1703](https://github.com/openscd/open-scd/issues/1703)) ([cd3b39a](https://github.com/openscd/open-scd/commit/cd3b39ad45b6ddfc5d8c3641a5c120dd95bb5dd6)) +* **Import IED:** Fix order of edits ([#1698](https://github.com/openscd/open-scd/issues/1698)) ([0831fa4](https://github.com/openscd/open-scd/commit/0831fa4e4cde55a21c261b1b4b8b5994868509b0)) +* Settings addon translations ([cd3b39a](https://github.com/openscd/open-scd/commit/cd3b39ad45b6ddfc5d8c3641a5c120dd95bb5dd6)) + ## [0.41.0](https://github.com/openscd/open-scd/compare/v0.40.0...v0.41.0) (2025-08-04) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 15ef579bae..003d24fd74 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,7 +37,7 @@ To develop, follow these steps : 1. Install [↗ Node.js](https://nodejs.org/en/download/package-manager) > [!IMPORTANT] -> `Node.js` version should be set to `20.x.x` as there are incompatibilities with higher version +> `Node.js` version should be set to `18.x.x` as there are incompatibilities with higher version 2. Run `npm ci` in OpenSCD's root folder. @@ -355,4 +355,4 @@ class MyClass { private foo = 1; private bar() {} } -``` \ No newline at end of file +``` diff --git a/docs/core-api/oscd-api.md b/docs/core-api/oscd-api.md new file mode 100644 index 0000000000..ecf84bcbc6 --- /dev/null +++ b/docs/core-api/oscd-api.md @@ -0,0 +1,40 @@ +# OSCD API + +Open scd passes an API object as the property `oscdApi` to every plugin. At the moment the API only includes the plugin state API. Here is an example usage in a Lit based plugin. + +``` +import { OscdApi } from '@openscd/core'; + +class SomePlugin extends LitElement { + + @property() + oscdApi: OscdApi | null = null; + + connectedCallback() { + const pluginState = this.oscdApi?.pluginState.getState(); + + ... + } + + disconnectedCallback() { + this.oscdApi?.pluginState.setState(someStateObject); + } +} +``` + +⚠️ Be aware that not every open scd distribution provides this API, so your plugin should have a null check if you want it to be compatible with other distributions. + +## Plugin state API + +The plugin state API stores an arbitrary object as your plugin's state in memory. Be aware that this state is only persisted during the open scd distribution's runtime and will not be stored in local storage for example. + +``` +interface PluginStateApi { + setState(state: PluginState | null): void; + + getState(): PluginState | null; + + updateState(partialState: Partial): void +} +``` + diff --git a/packages/compas-open-scd/package.json b/packages/compas-open-scd/package.json index 83653cd0e2..47da0a36f6 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.41.0-5", + "version": "0.42.0-1", "repository": "https://github.com/openscd/open-scd.git", "description": "OpenSCD CoMPAS Edition", "directory": "packages/compas-open-scd", diff --git a/packages/compas-open-scd/src/open-scd.ts b/packages/compas-open-scd/src/open-scd.ts index d25ee22312..4274dc225e 100644 --- a/packages/compas-open-scd/src/open-scd.ts +++ b/packages/compas-open-scd/src/open-scd.ts @@ -34,6 +34,7 @@ import { ActionDetail } from '@material/mwc-list'; import { officialPlugins as builtinPlugins } from '../public/js/plugins.js'; import type { PluginSet, Plugin as CorePlugin } from '@openscd/core'; +import { OscdApi } from '@openscd/core'; import { classMap } from 'lit-html/directives/class-map.js'; import { newConfigurePluginEvent, @@ -471,6 +472,7 @@ export class OpenSCD extends LitElement { .nsdoc=${this.nsdoc} .docs=${this.docs} .locale=${this.locale} + .oscdApi=${new OscdApi(tag)} .compasApi=${this.compasApi} class="${classMap({ plugin: true, diff --git a/packages/core/api/api.ts b/packages/core/api/api.ts new file mode 100644 index 0000000000..d01f2544a7 --- /dev/null +++ b/packages/core/api/api.ts @@ -0,0 +1,9 @@ +import { PluginStateApi } from './plugin-state-api.js'; + +export class OscdApi { + public pluginState: PluginStateApi; + + constructor(pluginTag: string) { + this.pluginState = new PluginStateApi(pluginTag); + } +} diff --git a/packages/core/api/plugin-state-api.ts b/packages/core/api/plugin-state-api.ts new file mode 100644 index 0000000000..752d0b2609 --- /dev/null +++ b/packages/core/api/plugin-state-api.ts @@ -0,0 +1,37 @@ +type PluginState = { + [key: string]: unknown +} + +export class PluginStateApi { + private static state: { [tag: string]: PluginState | null } = {}; + private pluginTag: string; + + constructor(tag: string) { + this.pluginTag = tag; + } + + public setState(state: PluginState | null): void { + this.setPluginState(state); + } + + public getState(): PluginState | null { + return this.getPluginState(); + } + + public updateState(partialState: Partial): void { + const pluginState = this.getPluginState(); + const patchedState = { + ...pluginState, + ...partialState + }; + this.setPluginState(patchedState); + } + + private setPluginState(state: PluginState | null): void { + PluginStateApi.state[this.pluginTag] = state; + } + + private getPluginState(): PluginState | null { + return PluginStateApi.state[this.pluginTag] ?? null; + } +} diff --git a/packages/core/foundation.ts b/packages/core/foundation.ts index 80bae0d195..a408b03b73 100644 --- a/packages/core/foundation.ts +++ b/packages/core/foundation.ts @@ -66,3 +66,5 @@ export function crossProduct(...arrays: T[][]): T[][] { [[]] ); } + +export { OscdApi } from './api/api.js'; diff --git a/packages/openscd/src/addons/Settings.ts b/packages/openscd/src/addons/Settings.ts index 5cea7d51e6..4123af8ac9 100644 --- a/packages/openscd/src/addons/Settings.ts +++ b/packages/openscd/src/addons/Settings.ts @@ -7,7 +7,7 @@ import { LitElement, css, } from 'lit-element'; -import { get, registerTranslateConfig, Strings, use } from 'lit-translate'; +import { get, translate, registerTranslateConfig, Strings, use } from 'lit-translate'; import '@material/mwc-button'; import '@material/mwc-dialog'; @@ -204,7 +204,7 @@ export class OscdSettings extends LitElement { @change="${(evt: Event) => this.uploadNsdocFile(evt)}}" /> { const input = ( @@ -360,7 +360,7 @@ export class OscdSettings extends LitElement { render(): TemplateResult { return html`
@@ -368,7 +368,7 @@ export class OscdSettings extends LitElement { fixedMenuPosition id="language" icon="language" - label="${get('settings.language')}" + label="${translate('settings.language')}" > ${Object.keys(this.languageConfig.languages).map( lang => @@ -376,23 +376,23 @@ export class OscdSettings extends LitElement { graphic="icon" value="${lang}" ?selected=${lang === this.settings.language} - >${get(`settings.languages.${lang}`)}${translate(`settings.languages.${lang}`)}` )} - + - + - + ${this.nsdUploadButton ? html`
-

${get('settings.loadNsdTranslations')}

+

${translate('settings.loadNsdTranslations')}

${this.renderFileSelect()}
` : html``} @@ -413,14 +413,14 @@ export class OscdSettings extends LitElement { ${this.renderNsdocItem('IEC 61850-8-1')} - ${get('cancel')} + ${translate('cancel')} - ${get('reset')} + ${translate('reset')} - ${get('save')} + ${translate('save')} diff --git a/packages/openscd/src/open-scd.ts b/packages/openscd/src/open-scd.ts index 8ca47ee017..56318c1b02 100644 --- a/packages/openscd/src/open-scd.ts +++ b/packages/openscd/src/open-scd.ts @@ -45,6 +45,7 @@ import type { Plugin as CorePlugin, EditCompletedEvent, } from '@openscd/core'; +import { OscdApi } from '@openscd/core'; import { HistoryState, historyStateEvent } from './addons/History.js'; @@ -432,6 +433,7 @@ export class OpenSCD extends LitElement { .nsdoc=${this.nsdoc} .docs=${this.docs} .locale=${this.locale} + .oscdApi=${new OscdApi(tag)} class="${classMap({ plugin: true, menu: plugin.kind === 'menu', diff --git a/packages/plugins/src/editors/IED.ts b/packages/plugins/src/editors/IED.ts index 052a1309c0..12b6183c54 100644 --- a/packages/plugins/src/editors/IED.ts +++ b/packages/plugins/src/editors/IED.ts @@ -26,26 +26,30 @@ import { } from '@openscd/open-scd/src/foundation.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'; /** 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; + @state() selectedIEDs: string[] = []; - + @state() selectedLNClasses: string[] = []; - + @state() private get iedList(): Element[] { return this.doc @@ -61,26 +65,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 []; } @@ -101,6 +105,16 @@ export default class IedPlugin extends LitElement { lNClassListOpenedOnce = false; + connectedCallback(): void { + super.connectedCallback(); + this.loadPluginState(); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + this.storePluginState(); + } + protected updated(_changedProperties: PropertyValues): void { super.updated(_changedProperties); @@ -132,6 +146,23 @@ export default class IedPlugin extends LitElement { } } + private loadPluginState(): void { + const stateApi = this.oscdApi?.pluginState; + const selectedIEDs: string[] | null = (stateApi?.getState()?.selectedIEDs as string[]) ?? null; + + if (selectedIEDs) { + this.onSelectionChange(selectedIEDs); + } + } + + private storePluginState(): void { + const stateApi = this.oscdApi?.pluginState; + + if (stateApi) { + stateApi.setState({ selectedIEDs: this.selectedIEDs }); + } + } + private calcSelectedLNClasses(): string[] { const somethingSelected = this.selectedLNClasses.length > 0; const lnClasses = this.lnClassList.map(lnClassInfo => lnClassInfo[0]); @@ -147,6 +178,29 @@ export default class IedPlugin extends LitElement { return selectedLNClasses; } + private onSelectionChange(selectedIeds: string[]): void { + const equalArrays = (first: T[], second: T[]): boolean => { + return ( + first.length === second.length && + first.every((val, index) => val === second[index]) + ); + }; + + const selectionChanged = !equalArrays( + this.selectedIEDs, + selectedIeds + ); + + if (!selectionChanged) { + return; + } + + this.lNClassListOpenedOnce = false; + this.selectedIEDs = selectedIeds; + this.selectedLNClasses = []; + this.requestUpdate('selectedIed'); + } + render(): TemplateResult { const iedList = this.iedList; if (iedList.length > 0) { @@ -158,28 +212,7 @@ export default class IedPlugin extends LitElement { id="iedFilter" icon="developer_board" .header=${get('iededitor.iedSelector')} - @selected-items-changed="${(e: SelectedItemsChangedEvent) => { - const equalArrays = (first: T[], second: T[]): boolean => { - return ( - first.length === second.length && - first.every((val, index) => val === second[index]) - ); - }; - - const selectionChanged = !equalArrays( - this.selectedIEDs, - e.detail.selectedItems - ); - - if (!selectionChanged) { - return; - } - - this.lNClassListOpenedOnce = false; - this.selectedIEDs = e.detail.selectedItems; - this.selectedLNClasses = []; - this.requestUpdate('selectedIed'); - }}" + @selected-items-changed="${(e: SelectedItemsChangedEvent) => this.onSelectionChange(e.detail.selectedItems)}" > ${iedList.map(ied => { const name = getNameAttribute(ied); diff --git a/packages/plugins/src/menu/ImportIEDs.ts b/packages/plugins/src/menu/ImportIEDs.ts index 1ef9e5b4a8..29999af968 100644 --- a/packages/plugins/src/menu/ImportIEDs.ts +++ b/packages/plugins/src/menu/ImportIEDs.ts @@ -425,13 +425,13 @@ export default class ImportingIedPlugin extends LitElement { const dataTypeTemplateActions = addDataTypeTemplates(ied, this.doc); const communicationActions = addCommunicationElements(ied, this.doc); - const actions = communicationActions.concat(dataTypeTemplateActions); - actions.push({ + const insertIed = { new: { parent: this.doc!.querySelector(':root')!, element: ied, }, - }); + }; + const actions = [ ...communicationActions, insertIed, ...dataTypeTemplateActions ]; this.dispatchEvent( newActionEvent({ diff --git a/packages/plugins/src/wizards/connectedap.ts b/packages/plugins/src/wizards/connectedap.ts index 375453894f..dbb1266f96 100644 --- a/packages/plugins/src/wizards/connectedap.ts +++ b/packages/plugins/src/wizards/connectedap.ts @@ -278,13 +278,6 @@ function createConnectedApAction(parent: Element): WizardActor { apName, }); actions.push({ new: { parent, element: connectedAp } }); - actions.push( - ...initSMVElements(doc, connectedAp, { - macGeneratorSmv, - appidGeneratorSmv, - unconnectedSampledValueControl, - }) - ); actions.push( ...initGSEElements(doc, connectedAp, { macGeneratorGse, @@ -292,6 +285,13 @@ function createConnectedApAction(parent: Element): WizardActor { unconnectedGseControl, }) ); + actions.push( + ...initSMVElements(doc, connectedAp, { + macGeneratorSmv, + appidGeneratorSmv, + unconnectedSampledValueControl, + }) + ); return { title: 'Added ConnectedAP', actions }; }); diff --git a/packages/plugins/test/integration/editors/IED.test.ts b/packages/plugins/test/integration/editors/IED.test.ts index 72056ebd19..afa85430d3 100644 --- a/packages/plugins/test/integration/editors/IED.test.ts +++ b/packages/plugins/test/integration/editors/IED.test.ts @@ -17,6 +17,8 @@ import { LNContainer } from '../../../src/editors/ied/ln-container.js'; import { DOContainer } from '../../../src/editors/ied/do-container.js'; import { DAContainer } from '../../../src/editors/ied/da-container.js'; import { MockOpenSCD } from '@openscd/open-scd/test/mock-open-scd.js'; +import { OscdApi } from '@openscd/core'; +import { PluginStateApi } from '../../../../core/dist/api/plugin-state-api.js'; describe('IED Plugin', () => { if (customElements.get('ied-plugin') === undefined) @@ -42,6 +44,7 @@ describe('IED Plugin', () => { describe('with a doc loaded', () => { let doc: XMLDocument; + let oscdApi: OscdApi; describe('containing no IEDs', () => { beforeEach(async () => { @@ -106,9 +109,11 @@ describe('IED Plugin', () => { .then(response => response.text()) .then(str => new DOMParser().parseFromString(str, 'application/xml')); nsdoc = await initializeNsdoc(); + oscdApi = new OscdApi('IED'); + oscdApi.pluginState.setState(null); parent = await fixture( html`` ); element = parent.getActivePlugin(); @@ -370,6 +375,28 @@ describe('IED Plugin', () => { primaryButton.click(); await element.updateComplete; } + + describe('load and store selected IEDs', () => { + it('should store selected IEDs on disconnected', async () => { + await selectIed('IED3'); + element.disconnectedCallback(); + + const api = new OscdApi('IED'); + expect(api.pluginState.getState()).to.deep.equal({ selectedIEDs: ['IED3'] }); + }); + }); + + describe('with stored plugin state', () => { + beforeEach(() => { + oscdApi.pluginState.setState({ selectedIEDs: ['IED3'] }); + }); + + it('should load previously saved IED', () => { + element.connectedCallback(); + + expect(element.selectedIEDs).to.deep.equal(['IED3']); + }); + }); }); }); diff --git a/packages/plugins/test/testfiles/wizards/bugfix_1700_connected_ap_wizard.scd b/packages/plugins/test/testfiles/wizards/bugfix_1700_connected_ap_wizard.scd new file mode 100644 index 0000000000..d73ac5a824 --- /dev/null +++ b/packages/plugins/test/testfiles/wizards/bugfix_1700_connected_ap_wizard.scd @@ -0,0 +1,475 @@ + + +
+ + + 100 + + + + Type:NR6106,Slot:B01,Fiber:4,DspCores:C1R2 + LD0:B01.C1R2.QAL131.qal131 + MU01:B01.C1R2.QAL131.qal132 + RCD:B01.C1R2.QAL131.qal136 + MUSV:B01.C1R2.QAL131.qal137 + MUGO:B01.C1R2.QAL131.qal138 + Type:NR6106AH,Slot:B01 + version:1.07,revision:1.13,tool:PCS-Studio_1.2,cidRuleVersion:2.0 + D030E0AB + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1-A + + + 1-A + + + 1-A + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1-A + + + + 1-A + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/plugins/test/unit/wizards/connectedap-c.test.ts b/packages/plugins/test/unit/wizards/connectedap-c.test.ts index ce20c02be5..7e7da40052 100644 --- a/packages/plugins/test/unit/wizards/connectedap-c.test.ts +++ b/packages/plugins/test/unit/wizards/connectedap-c.test.ts @@ -56,6 +56,44 @@ async function clickListItem( await element.updateComplete; } +describe('regression for bug 1700', () => { + let doc: XMLDocument; + let element: MockWizardEditor; + let parent: Element; + + beforeEach(async () => { + element = ( + await fixture(html``) + ); + + doc = await fetch('/test/testfiles/wizards/bugfix_1700_connected_ap_wizard.scd') + .then(response => response.text()) + .then(str => new DOMParser().parseFromString(str, 'application/xml')); + + parent = doc.querySelector('SubNetwork')!; + const wizard = createConnectedApWizard(parent); + element.dispatchEvent(newWizardEvent(wizard)); + await element.requestUpdate(); + }); + + it('creates GSE and SMV in correct order', async () => { + await clickListItem(element, ['IED1>S1']); + + const connectedAP = parent.querySelector('ConnectedAP[iedName="IED1"][apName="S1"]'); + expect(connectedAP).to.exist; + const children = Array.from(connectedAP!.children); + const firstSMVIndex = children.findIndex(e => e.tagName === 'SMV'); + + for (let i = 0; i < children.length; i++) { + if (i < firstSMVIndex) { + expect(children[i].tagName).to.equal('GSE'); + } else { + expect(children[i].tagName).to.equal('SMV'); + } + } + }); +}); + describe('create wizard for ConnectedAP element', () => { let doc: XMLDocument; let element: MockWizardEditor;