From 71973b0e25a96c5355a1a0752130427d953b48af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20Russ?= Date: Fri, 26 Jul 2024 11:06:08 +0200 Subject: [PATCH 01/22] Preparatory Refactoring - increase readability of a few places - regenerate snapshot --- package-lock.json | 2 +- packages/openscd/src/addons/Layout.ts | 375 +++++++++--------- packages/openscd/src/open-scd.ts | 93 +++-- .../__snapshots__/open-scd.test.snap.js | 12 +- 4 files changed, 255 insertions(+), 227 deletions(-) diff --git a/package-lock.json b/package-lock.json index 842e66ab27..13e72a9740 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30433,7 +30433,7 @@ }, "packages/core": { "name": "@openscd/core", - "version": "0.1.1", + "version": "0.1.2", "license": "Apache-2.0", "dependencies": { "@lit/localize": "^0.11.4", diff --git a/packages/openscd/src/addons/Layout.ts b/packages/openscd/src/addons/Layout.ts index 4080a4fb00..87f5cb2447 100644 --- a/packages/openscd/src/addons/Layout.ts +++ b/packages/openscd/src/addons/Layout.ts @@ -51,6 +51,16 @@ import { EditCompletedEvent } from '@openscd/core'; @customElement('oscd-layout') export class OscdLayout extends LitElement { + + render(): TemplateResult { + return html` + + ${this.renderHeader()} ${this.renderAside()} ${this.renderContent()} + ${this.renderLanding()} ${this.renderPlugging()} + `; + } + + /** The `XMLDocument` to be edited */ @property({ attribute: false }) doc: XMLDocument | null = null; @@ -120,97 +130,13 @@ export class OscdLayout extends LitElement { return this.menuEntries.filter(plugin => plugin.position === 'bottom'); } - get menu(): (MenuItem | 'divider')[] { - const topMenu: (MenuItem | 'divider')[] = []; - const middleMenu: (MenuItem | 'divider')[] = []; - const bottomMenu: (MenuItem | 'divider')[] = []; - const validators: (MenuItem | 'divider')[] = []; - - this.topMenu.forEach(plugin => - topMenu.push({ - icon: plugin.icon || pluginIcons['menu'], - name: plugin.name, - action: ae => { - this.dispatchEvent( - newPendingStateEvent( - (( - (( - (ae.target).items[ae.detail.index].nextElementSibling - )) - )).run() - ) - ); - }, - disabled: (): boolean => plugin.requireDoc! && this.doc === null, - content: plugin.content, - kind: 'top', - }) - ); - - this.middleMenu.forEach(plugin => - middleMenu.push({ - icon: plugin.icon || pluginIcons['menu'], - name: plugin.name, - action: ae => { - this.dispatchEvent( - newPendingStateEvent( - (( - (( - (ae.target).items[ae.detail.index].nextElementSibling - )) - )).run() - ) - ); - }, - disabled: (): boolean => plugin.requireDoc! && this.doc === null, - content: plugin.content, - kind: 'middle', - }) - ); - - this.bottomMenu.forEach(plugin => - bottomMenu.push({ - icon: plugin.icon || pluginIcons['menu'], - name: plugin.name, - action: ae => { - this.dispatchEvent( - newPendingStateEvent( - (( - (( - (ae.target).items[ae.detail.index].nextElementSibling - )) - )).run() - ) - ); - }, - disabled: (): boolean => plugin.requireDoc! && this.doc === null, - content: plugin.content, - kind: 'middle', - }) - ); - this.validators.forEach(plugin => - validators.push({ - icon: plugin.icon || pluginIcons['validator'], - name: plugin.name, - action: ae => { - this.dispatchEvent(newEmptyIssuesEvent(plugin.src)); + get menu(): (MenuItem | 'divider')[] { - this.dispatchEvent( - newPendingStateEvent( - (( - (( - (ae.target).items[ae.detail.index].nextElementSibling - )) - )).validate() - ) - ); - }, - disabled: (): boolean => this.doc === null, - content: plugin.content, - kind: 'validator', - }) - ); + const topMenu = this.generateMenu(this.topMenu, 'top'); + const middleMenu = this.generateMenu(this.middleMenu, 'middle'); + const bottomMenu = this.generateMenu(this.bottomMenu, 'bottom'); + const validators = this.generateValidatorMenus(this.validators); if (middleMenu.length > 0) middleMenu.push('divider'); if (bottomMenu.length > 0) bottomMenu.push('divider'); @@ -295,26 +221,22 @@ export class OscdLayout extends LitElement { // Keyboard Shortcuts private handleKeyPress(e: KeyboardEvent): void { - let handled = false; - const ctrlAnd = (key: string) => - e.key === key && e.ctrlKey && (handled = true); - - if (ctrlAnd('m')) this.menuUI.open = !this.menuUI.open; - if (ctrlAnd('o')) - this.menuUI - .querySelector('mwc-list-item[iconid="folder_open"]') - ?.click(); - if (ctrlAnd('O')) - this.menuUI - .querySelector('mwc-list-item[iconid="create_new_folder"]') - ?.click(); - if (ctrlAnd('s')) - this.menuUI - .querySelector('mwc-list-item[iconid="save"]') - ?.click(); - if (ctrlAnd('P')) this.pluginUI.show(); - - if (handled) e.preventDefault(); + // currently we only handley key shortcuts when users press ctrl + if(!e.ctrlKey){ return } + + const keyFunctionMap: {[key:string]: () => void} = { + 'm': () => this.menuUI.open = !this.menuUI.open, + 'o': () => this.menuUI.querySelector('mwc-list-item[iconid="folder_open"]')?.click(), + 'O': () => this.menuUI.querySelector('mwc-list-item[iconid="create_new_folder"]')?.click(), + 's': () => this.menuUI.querySelector('mwc-list-item[iconid="save"]')?.click(), + 'P': () => this.pluginUI.show(), + } + + const fn = keyFunctionMap[e.key]; + if(!fn){ return; } + + e.preventDefault(); + fn(); } private handleAddPlugin() { @@ -370,7 +292,7 @@ export class OscdLayout extends LitElement { this.shouldValidate = true; await this.validated; - if (!this.shouldValidate) return; + if (!this.shouldValidate){ return; } this.shouldValidate = false; @@ -403,10 +325,59 @@ export class OscdLayout extends LitElement { ); } + + private generateMenu(plugins:Plugin[], kind: 'top' | 'middle' | 'bottom'): (MenuItem | 'divider')[]{ + return plugins.map(plugin => { + return { + icon: plugin.icon || pluginIcons['menu'], + name: plugin.name, + action: ae => { + this.dispatchEvent( + newPendingStateEvent( + (( + (( + (ae.target).items[ae.detail.index].nextElementSibling + )) + )).run() + ) + ); + }, + disabled: (): boolean => plugin.requireDoc! && this.doc === null, + content: plugin.content, + kind: kind, + } + }) + } + + private generateValidatorMenus(plugins: Plugin[]): (MenuItem | 'divider')[] { + return plugins.map(plugin =>{ + return { + icon: plugin.icon || pluginIcons['validator'], + name: plugin.name, + action: ae => { + this.dispatchEvent(newEmptyIssuesEvent(plugin.src)); + + this.dispatchEvent( + newPendingStateEvent( + (( + (( + (ae.target).items[ae.detail.index].nextElementSibling + )) + )).validate() + ) + ); + }, + disabled: (): boolean => this.doc === null, + content: plugin.content, + kind: 'validator', + } + }); + } + private renderMenuItem(me: MenuItem | 'divider'): TemplateResult { - if (me === 'divider') - return html`
  • `; - if (me.actionItem) return html``; + if (me === 'divider') { return html`
  • `; } + if (me.actionItem){ return html``; } + return html` `; - else return html``; + if(me === 'divider' || !me.actionItem){ return html`` } + + return html` + `; } private renderEditorTab({ name, icon }: Plugin): TemplateResult { @@ -453,76 +425,111 @@ export class OscdLayout extends LitElement { `; } - /** Renders a drawer toolbar featuring the scl filename, enabled menu plugins, settings, help, scl history and plug-ins management */ + /** + * Renders a drawer toolbar featuring the scl filename, enabled menu plugins, + * settings, help, scl history and plug-ins management + */ protected renderAside(): TemplateResult { + return html` ${get('menu.title')} - ${this.docName - ? html`${this.docName}` - : ''} + ${renderTitle(this.docName)} ) => { - //FIXME: dirty hack to be fixed in open-scd-core - // if clause not necessary when oscd... components in open-scd not list - if (ae.target instanceof List) - (( - this.menu.filter( - item => item !== 'divider' && !item.actionItem - )[ae.detail.index] - ))?.action?.(ae); - }} + @action=${makeListAction(this.menu)} > ${this.menu.map(this.renderMenuItem)} `; + + function renderTitle(docName?: string){ + if(!docName) return html``; + + return html`${docName}`; + } + + function makeListAction(menuItems : (MenuItem|'divider')[]){ + return function listAction(ae: CustomEvent){ + //FIXME: dirty hack to be fixed in open-scd-core + // if clause not necessary when oscd... components in open-scd not list + if (ae.target instanceof List) + (( + menuItems.filter( + item => item !== 'divider' && !item.actionItem + )[ae.detail.index] + ))?.action?.(ae); + } + } + + } /** Renders the enabled editor plugins and a tab bar to switch between them*/ protected renderContent(): TemplateResult { + + if(!this.doc) return html``; + return html` - ${this.doc - ? html` - (this.activeTab = e.detail.index)} - > - ${this.editors.map(this.renderEditorTab)} - - ${this.editors[this.activeTab]?.content - ? this.editors[this.activeTab].content - : ``}` - : ``} + (this.activeTab = e.detail.index)}> + ${this.editors.map(this.renderEditorTab)} + + ${renderEditorContent(this.editors, this.activeTab)} `; + + function renderEditorContent(editors: Plugin[], activeTab: number){ + const content = editors[activeTab]?.content; + if(!content) { return html`` } + + return html`${content}`; + } } - /** Renders the landing buttons (open project and new project)*/ + /** + * Renders the landing buttons (open project and new project) + * it no document loaded we display the menu item that are in the position + * 'top' and are not disabled + * + * To enable replacement of this part we have to convert it to either an addon + * or a plugin + */ protected renderLanding(): TemplateResult { - return html` ${!this.doc - ? html`
    - ${(this.menu.filter(mi => mi !== 'divider')).map( - (mi: MenuItem, index) => - mi.kind === 'top' && !mi.disabled?.() - ? html` - -
    ${mi.name}
    -
    - ` - : html`` - )} -
    ` - : ``}`; - } + if(this.doc){ return html``; } + + return html` +
    + ${renderMenuItems(this.menu, this.menuUI)} +
    ` + + function renderMenuItems(menuItemsAndDividers: (MenuItem | 'divider')[], menuUI: Drawer){ + + const menuItems = menuItemsAndDividers.filter(mi => mi !== 'divider') as MenuItem[]; + + return menuItems.map((mi: MenuItem, index) => { + if(mi.kind !== 'top' || mi.disabled?.()) { return html``; } + + return html` + +
    ${mi.name}
    +
    + ` + }) + + function clickListItem(index:number) { + const listItem = menuUI.querySelector('mwc-list')!.items[index]; + listItem.click(); + } + + } + } /** Renders the "Add Custom Plug-in" UI*/ + // TODO: this should be its own isolated element protected renderDownloadUI(): TemplateResult { return html` @@ -615,30 +622,34 @@ export class OscdLayout extends LitElement { `; } + // Note: why is the type here if note used? private renderPluginKind( type: PluginKind | MenuPosition, plugins: Plugin[] ): TemplateResult { return html` - ${plugins.map( - plugin => - html` html` + - ${plugin.icon || pluginIcons[plugin.kind]} + + ${plugin.icon || pluginIcons[plugin.kind]} + ${plugin.name} - ` + + ` )} `; } - /** Renders the plug-in management UI (turning plug-ins on/off)*/ + /** + * Renders the plug-in management UI (turning plug-ins on/off) + * TODO: this is big enough to be its own isolated element + */ protected renderPluginUI(): TemplateResult { return html` - ${this.renderHeader()} ${this.renderAside()} ${this.renderContent()} - ${this.renderLanding()} ${this.renderPlugging()} - `; - } + static styles = css` mwc-drawer { diff --git a/packages/openscd/src/open-scd.ts b/packages/openscd/src/open-scd.ts index f6e8673205..34ea9341f2 100644 --- a/packages/openscd/src/open-scd.ts +++ b/packages/openscd/src/open-scd.ts @@ -38,7 +38,7 @@ import './addons/Layout.js'; import { ActionDetail } from '@material/mwc-list'; -import { officialPlugins } from './plugins.js'; +import { officialPlugins as builtinPlugins } from './plugins.js'; import { initializeNsdoc, Nsdoc } from './foundation/nsdoc.js'; import type { PluginSet, @@ -343,7 +343,7 @@ export class OpenSCD extends LitElement { } private resetPlugins(): void { this.storePlugins( - (officialPlugins as Plugin[]).concat(this.parsedPlugins).map(plugin => { + (builtinPlugins as Plugin[]).concat(this.parsedPlugins).map(plugin => { return { src: plugin.src, installed: plugin.default ?? false, @@ -360,48 +360,66 @@ export class OpenSCD extends LitElement { plugins: PluginSet = { menu: [], editor: [] }; get parsedPlugins(): Plugin[] { - return this.plugins.menu - .map((p: CorePlugin) => ({ - ...p, - position: - typeof p.position !== 'number' - ? (p.position as MenuPosition) - : undefined, - kind: 'menu' as PluginKind, - installed: p.active ?? false, - })) - .concat( - this.plugins.editor.map((p: CorePlugin) => ({ - ...p, - position: undefined, - kind: 'editor' as PluginKind, - installed: p.active ?? false, - })) - ); + + const menuPlugins = this.plugins.menu.map((plugin: CorePlugin) => { + let newPosition: MenuPosition | undefined = plugin.position as MenuPosition; + if(typeof plugin.position === 'number') { + newPosition = undefined + } + + return { + ...plugin, + position: newPosition, + kind: 'menu' as PluginKind, + installed: plugin.active ?? false, + } + }) + + const editorPlugins = this.plugins.editor.map((plugin: CorePlugin) => ({ + ...plugin, + position: undefined, + kind: 'editor' as PluginKind, + installed: plugin.active ?? false, + })) + + const allPlugnis = [...menuPlugins, ...editorPlugins] + return allPlugnis } private get sortedStoredPlugins(): Plugin[] { - return this.storedPlugins - .map(plugin => { - if (!plugin.official) return plugin; - const officialPlugin = (officialPlugins as Plugin[]) - .concat(this.parsedPlugins) - .find(needle => needle.src === plugin.src); + + const mergedPlugins = this.storedPlugins.map(plugin => { + if (!plugin.official){ return plugin }; + + const officialPlugin = (builtinPlugins as Plugin[]) + .concat(this.parsedPlugins) + .find(needle => needle.src === plugin.src); + return { ...officialPlugin, ...plugin, }; - }) + }) + + + return mergedPlugins .sort(compareNeedsDoc) .sort(menuCompare); } private get storedPlugins(): Plugin[] { - return ( - JSON.parse(localStorage.getItem('plugins') ?? '[]', (key, value) => - value.src && value.installed ? this.addContent(value) : value - ) - ); + const pluginsConfigStr = localStorage.getItem('plugins') ?? '[]' + const storedPlugins = JSON.parse(pluginsConfigStr) as Plugin[] + + const plugins = storedPlugins.map(plugin => { + const isInstalled = plugin.src && plugin.installed + if(!isInstalled) { return plugin } + + return this.addContent(plugin) + }) + + return plugins + } protected get locale(): string { @@ -420,16 +438,20 @@ export class OpenSCD extends LitElement { private setPlugins(indices: Set) { const newPlugins = this.sortedStoredPlugins.map((plugin, index) => { - return { ...plugin, installed: indices.has(index) }; + return { + ...plugin, + installed: indices.has(index) + }; }); this.storePlugins(newPlugins); } private updatePlugins() { + const stored: Plugin[] = this.storedPlugins; const officialStored = stored.filter(p => p.official); const newOfficial: Array = ( - officialPlugins as Plugin[] + builtinPlugins as Plugin[] ) .concat(this.parsedPlugins) .filter(p => !officialStored.find(o => o.src === p.src)) @@ -440,9 +462,10 @@ export class OpenSCD extends LitElement { official: true as const, }; }); + const oldOfficial = officialStored.filter( p => - !(officialPlugins as Plugin[]) + !(builtinPlugins as Plugin[]) .concat(this.parsedPlugins) .find(o => p.src === o.src) ); diff --git a/packages/openscd/test/integration/__snapshots__/open-scd.test.snap.js b/packages/openscd/test/integration/__snapshots__/open-scd.test.snap.js index 5da7ce13fc..94059f73c8 100644 --- a/packages/openscd/test/integration/__snapshots__/open-scd.test.snap.js +++ b/packages/openscd/test/integration/__snapshots__/open-scd.test.snap.js @@ -294,7 +294,7 @@ snapshots["open-scd renders menu plugins passed down as props and it looks like
    Date: Tue, 20 Aug 2024 10:10:43 +0200 Subject: [PATCH 02/22] fix: 1553 LN LN0 wizards read only attributes (#1568) closes #1553 --- packages/plugins/src/wizards/ln.ts | 4 ++++ packages/plugins/src/wizards/ln0.ts | 3 +++ packages/plugins/test/unit/wizards/ln.test.ts | 12 ++++++------ packages/plugins/test/unit/wizards/ln0.test.ts | 11 +++++------ 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/packages/plugins/src/wizards/ln.ts b/packages/plugins/src/wizards/ln.ts index 21924c2baa..0bf172882b 100644 --- a/packages/plugins/src/wizards/ln.ts +++ b/packages/plugins/src/wizards/ln.ts @@ -25,6 +25,7 @@ export function renderLNWizard( html``, @@ -37,11 +38,13 @@ export function renderLNWizard( html``, html``, ]; diff --git a/packages/plugins/src/wizards/ln0.ts b/packages/plugins/src/wizards/ln0.ts index d3ca8a45d7..ad06a7e1a6 100644 --- a/packages/plugins/src/wizards/ln0.ts +++ b/packages/plugins/src/wizards/ln0.ts @@ -24,6 +24,7 @@ export function renderLN0Wizard( html``, @@ -35,6 +36,7 @@ export function renderLN0Wizard( >`, html``, ]; diff --git a/packages/plugins/test/unit/wizards/ln.test.ts b/packages/plugins/test/unit/wizards/ln.test.ts index a752fec87e..784c7bec10 100644 --- a/packages/plugins/test/unit/wizards/ln.test.ts +++ b/packages/plugins/test/unit/wizards/ln.test.ts @@ -21,9 +21,11 @@ describe('ln wizards', () => { lnClass: 'LN-class', inst: '1', }; - const requiredFields = [ + const readonlyFields = [ 'lnType', + 'prefix', 'lnClass', + 'inst' ]; const ln = ( @@ -56,15 +58,13 @@ describe('ln wizards', () => { }); }); - requiredFields.forEach((field) => { - it(`is a required field ${field}`, async () => { + readonlyFields.forEach((field) => { + it(`is a readonly field ${field}`, async () => { const input = (inputs).find( (textField) => textField.label === field ) as WizardTextField; - await setWizardTextFieldValue(input!, ''); - - expect(input.checkValidity()).to.be.false; + 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 c3fb5c2daf..b5e605059c 100644 --- a/packages/plugins/test/unit/wizards/ln0.test.ts +++ b/packages/plugins/test/unit/wizards/ln0.test.ts @@ -20,9 +20,10 @@ describe('ln0 wizards', () => { lnClass: 'LN0-class', inst: '1', }; - const requiredFields = [ + const readonlyFields = [ 'lnType', 'lnClass', + 'inst' ]; const ln = ( @@ -55,15 +56,13 @@ describe('ln0 wizards', () => { }); }); - requiredFields.forEach((field) => { - it(`is a required field ${field}`, async () => { + readonlyFields.forEach((field) => { + it(`is a readonly field ${field}`, async () => { const input = (inputs).find( (textField) => textField.label === field ) as WizardTextField; - await setWizardTextFieldValue(input!, ''); - - expect(input.checkValidity()).to.be.false; + expect(input.readOnly).to.be.true; }); }); }); From e42b2f2ccc1d883650e7acc63d637632871626d1 Mon Sep 17 00:00:00 2001 From: danyill Date: Tue, 20 Aug 2024 21:56:04 +1200 Subject: [PATCH 03/22] Update chat badge from LF Energy Slack to Zulip --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2258723bf3..e21b41aa09 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Build Status](https://travis-ci.org/openscd/open-scd.svg?branch=main)](https://travis-ci.org/openscd/open-scd) [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fopenscd%2Fopen-scd.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Fopenscd%2Fopen-scd?ref=badge_shield) [![Built with open-wc recommendations](https://img.shields.io/badge/built%20with-open--wc-blue.svg)](https://github.com/open-wc) -[![Slack LF Energy](https://img.shields.io/badge/LF%20Energy%20Slack-%20%23OpenSCD%20chat-purple?logo=slack&color=2aa198&labelColor=6c71c4)](https://lfenergy.slack.com/archives/C03LH7EUP34) +[![Zulip Chat](https://img.shields.io/badge/Zulip%20-%20%23OpenSCD%20chat-purple?logo=zulip&color=2aa198&labelColor=6c71c4)](https://openscd.zulipchat.com/join/k3cyur3wx526tvafkjwubhjn) Open Substation Communication Designer is an editor for SCL files as described in `IEC 61850-6`. From 9094855c03180d3ee30319df930179b5c7b4650c Mon Sep 17 00:00:00 2001 From: danyill Date: Tue, 20 Aug 2024 21:58:08 +1200 Subject: [PATCH 04/22] chore: Add information on Zulip chat to Contributing section. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e21b41aa09..68081943f0 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ To find out more about the development of each packages, such as the base distri ## Contributing -The easiest way to get in touch is to join the `#open-scd` channel kindly hosted on [the LF Energy Slack server](https://lfenergy.slack.com/archives/C03LH7EUP34). +The easiest way to get in touch is to join us on the [Zulip Chat](https://openscd.zulipchat.com/join/k3cyur3wx526tvafkjwubhjn/). If you say "hi" there we will be more than happy to help you find your way around this project. ## Documentation From 054398826b81247d8d094c536f4ab536f96a65d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20Russ?= Date: Mon, 21 Oct 2024 10:20:42 +0200 Subject: [PATCH 05/22] infra: setup playwright The tests are more stable and even quicker. It is also easier to debug them. --- .github/workflows/test-and-build.yml | 1 + .github/workflows/test.yml | 1 + package-lock.json | 292 +++++++++++++++++- package.json | 2 +- packages/core/web-test-runner.config.js | 4 +- .../distribution/web-test-runner.config.mjs | 11 +- packages/openscd/test/unit/Plugging.test.ts | 58 ++-- packages/openscd/web-test-runner.config.mjs | 11 +- packages/plugins/package.json | 11 +- packages/plugins/web-test-runner.config.mjs | 16 +- 10 files changed, 364 insertions(+), 43 deletions(-) diff --git a/.github/workflows/test-and-build.yml b/.github/workflows/test-and-build.yml index 30b91ada8c..3b475e0e11 100644 --- a/.github/workflows/test-and-build.yml +++ b/.github/workflows/test-and-build.yml @@ -17,4 +17,5 @@ jobs: run: | npm i @nx/nx-linux-x64-gnu npm clean-install + npx playwright install chromium --with-deps npm run-script test diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 89318ac3fa..fe51a19b74 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,4 +22,5 @@ jobs: run: | npm i @nx/nx-linux-x64-gnu npm clean-install + npx playwright install chromium --with-deps npm run-script test diff --git a/package-lock.json b/package-lock.json index 13e72a9740..d2f8209d63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12480,6 +12480,19 @@ "node": ">=0.10.0" } }, + "node_modules/default-gateway": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", + "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "execa": "^5.0.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -16385,6 +16398,25 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/internal-ip": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/internal-ip/-/internal-ip-6.2.0.tgz", + "integrity": "sha512-D8WGsR6yDt8uq7vDMu7mjcR+yRMm3dW8yufyChmszWRjcSHuxLBkR3GdS2HZAjodsaGuCvXeEJpueisXJULghg==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-gateway": "^6.0.0", + "ipaddr.js": "^1.9.1", + "is-ip": "^3.1.0", + "p-event": "^4.2.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/internal-ip?sponsor=1" + } + }, "node_modules/internal-slot": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", @@ -16433,6 +16465,26 @@ "node": ">= 12" } }, + "node_modules/ip-regex": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-4.3.0.tgz", + "integrity": "sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-array-buffer": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", @@ -16723,6 +16775,19 @@ "node": ">=8" } }, + "node_modules/is-ip": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-ip/-/is-ip-3.1.0.tgz", + "integrity": "sha512-35vd5necO7IitFPjd/YBeqwWnyDWbuLH9ZXQdMfDA8TEo7pv5X8yfrvVO3xbJbLUlERCMvf6X0hTUamQxCYJ9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-regex": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-lambda": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", @@ -21483,6 +21548,22 @@ "node": ">=8" } }, + "node_modules/p-event": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/p-event/-/p-event-4.2.0.tgz", + "integrity": "sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-timeout": "^3.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", @@ -29120,10 +29201,11 @@ } }, "node_modules/ws": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", - "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8.3.0" }, @@ -32445,6 +32527,7 @@ "@typescript-eslint/parser": "^4.29.2", "@web/dev-server-esbuild": "^0.2.16", "@web/test-runner": "^0.13.22", + "@web/test-runner-playwright": "^0.11.0", "concurrently": "^6.2.1", "deepmerge": "^4.2.2", "es-dev-server": "^2.1.0", @@ -32737,6 +32820,147 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "packages/plugins/node_modules/@web/browser-logs": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@web/browser-logs/-/browser-logs-0.4.0.tgz", + "integrity": "sha512-/EBiDAUCJ2DzZhaFxTPRIznEPeafdLbXShIL6aTu7x73x7ZoxSDv7DGuTsh2rWNMUa4+AKli4UORrpyv6QBOiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "errorstacks": "^2.2.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "packages/plugins/node_modules/@web/dev-server-core": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-0.7.3.tgz", + "integrity": "sha512-GS+Ok6HiqNZOsw2oEv5V2OISZ2s/6icJodyGjUuD3RChr0G5HiESbKf2K8mZV4shTz9sRC9KSQf8qvno2gPKrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/koa": "^2.11.6", + "@types/ws": "^7.4.0", + "@web/parse5-utils": "^2.1.0", + "chokidar": "^4.0.1", + "clone": "^2.1.2", + "es-module-lexer": "^1.0.0", + "get-stream": "^6.0.0", + "is-stream": "^2.0.0", + "isbinaryfile": "^5.0.0", + "koa": "^2.13.0", + "koa-etag": "^4.0.0", + "koa-send": "^5.0.1", + "koa-static": "^5.0.0", + "lru-cache": "^8.0.4", + "mime-types": "^2.1.27", + "parse5": "^6.0.1", + "picomatch": "^2.2.2", + "ws": "^7.5.10" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "packages/plugins/node_modules/@web/dev-server-core/node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true, + "license": "MIT" + }, + "packages/plugins/node_modules/@web/parse5-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-2.1.0.tgz", + "integrity": "sha512-GzfK5disEJ6wEjoPwx8AVNwUe9gYIiwc+x//QYxYDAFKUp4Xb1OJAGLc2l2gVrSQmtPGLKrTRcW90Hv4pEq1qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/parse5": "^6.0.1", + "parse5": "^6.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "packages/plugins/node_modules/@web/parse5-utils/node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true, + "license": "MIT" + }, + "packages/plugins/node_modules/@web/test-runner-core": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/@web/test-runner-core/-/test-runner-core-0.13.4.tgz", + "integrity": "sha512-84E1025aUSjvZU1j17eCTwV7m5Zg3cZHErV3+CaJM9JPCesZwLraIa0ONIQ9w4KLgcDgJFw9UnJ0LbFf42h6tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.11", + "@types/babel__code-frame": "^7.0.2", + "@types/co-body": "^6.1.0", + "@types/convert-source-map": "^2.0.0", + "@types/debounce": "^1.2.0", + "@types/istanbul-lib-coverage": "^2.0.3", + "@types/istanbul-reports": "^3.0.0", + "@web/browser-logs": "^0.4.0", + "@web/dev-server-core": "^0.7.3", + "chokidar": "^4.0.1", + "cli-cursor": "^3.1.0", + "co-body": "^6.1.0", + "convert-source-map": "^2.0.0", + "debounce": "^1.2.0", + "dependency-graph": "^0.11.0", + "globby": "^11.0.1", + "internal-ip": "^6.2.0", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.0.2", + "log-update": "^4.0.0", + "nanocolors": "^0.2.1", + "nanoid": "^3.1.25", + "open": "^8.0.2", + "picomatch": "^2.2.2", + "source-map": "^0.7.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "packages/plugins/node_modules/@web/test-runner-coverage-v8": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@web/test-runner-coverage-v8/-/test-runner-coverage-v8-0.8.0.tgz", + "integrity": "sha512-PskiucYpjUtgNfR2zF2AWqWwjXL7H3WW/SnCAYmzUrtob7X9o/+BjdyZ4wKbOxWWSbJO4lEdGIDLu+8X2Xw+lA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@web/test-runner-core": "^0.13.0", + "istanbul-lib-coverage": "^3.0.0", + "lru-cache": "^8.0.4", + "picomatch": "^2.2.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "packages/plugins/node_modules/@web/test-runner-playwright": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@web/test-runner-playwright/-/test-runner-playwright-0.11.0.tgz", + "integrity": "sha512-s+f43DSAcssKYVOD9SuzueUcctJdHzq1by45gAnSCKa9FQcaTbuYe8CzmxA21g+NcL5+ayo4z+MA9PO4H+PssQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@web/test-runner-core": "^0.13.0", + "@web/test-runner-coverage-v8": "^0.8.0", + "playwright": "^1.22.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "packages/plugins/node_modules/acorn": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", @@ -32780,6 +33004,22 @@ "balanced-match": "^1.0.0" } }, + "packages/plugins/node_modules/chokidar": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", + "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "packages/plugins/node_modules/cli-truncate": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", @@ -32855,6 +33095,13 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true }, + "packages/plugins/node_modules/es-module-lexer": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", + "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==", + "dev": true, + "license": "MIT" + }, "packages/plugins/node_modules/eslint": { "version": "7.32.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", @@ -33169,6 +33416,16 @@ "tslib": "^2.1.0" } }, + "packages/plugins/node_modules/lru-cache": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-8.0.5.tgz", + "integrity": "sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16.14" + } + }, "packages/plugins/node_modules/parse5": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", @@ -33191,6 +33448,20 @@ } ] }, + "packages/plugins/node_modules/readdirp": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", + "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "packages/plugins/node_modules/rxjs": { "version": "6.6.7", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", @@ -33326,6 +33597,21 @@ "node": ">=4.2.0" } }, + "packages/plugins/node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "packages/plugins/node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index a71b3d888e..cb2da2fa3f 100644 --- a/package.json +++ b/package.json @@ -27,4 +27,4 @@ "optionalDependencies": { "@nx/nx-linux-x64-gnu": "18.3.4" } -} +} \ No newline at end of file diff --git a/packages/core/web-test-runner.config.js b/packages/core/web-test-runner.config.js index a88fa3d2f6..334e8ef62d 100644 --- a/packages/core/web-test-runner.config.js +++ b/packages/core/web-test-runner.config.js @@ -20,8 +20,8 @@ const filteredLogs = [ const browsers = [ playwrightLauncher({ product: 'chromium' }), - playwrightLauncher({ product: 'firefox' }), - playwrightLauncher({ product: 'webkit' }), + // playwrightLauncher({ product: 'firefox' }), + // playwrightLauncher({ product: 'webkit' }), ]; function defaultGetImageDiff({ baselineImage, image, options }) { diff --git a/packages/distribution/web-test-runner.config.mjs b/packages/distribution/web-test-runner.config.mjs index 5eea2baf7c..850f1702eb 100644 --- a/packages/distribution/web-test-runner.config.mjs +++ b/packages/distribution/web-test-runner.config.mjs @@ -1,4 +1,4 @@ -// import { playwrightLauncher } from '@web/test-runner-playwright'; +import { playwrightLauncher } from '@web/test-runner-playwright'; import { esbuildPlugin } from '@web/dev-server-esbuild'; export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({ @@ -13,8 +13,13 @@ export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({ * Creating open-scd in the tests leads to error in the browser log - we had to disable the browser log */ browserLogs: false, + browsers: [ + playwrightLauncher({ product: 'chromium' }), + // playwrightLauncher({ product: 'firefox' }), + // playwrightLauncher({ product: 'webkit' }), + ], - /** specify groups for unit and integrations tests + /** specify groups for unit and integrations tests * hint: no --group definition runs all groups */ groups: [ @@ -27,7 +32,7 @@ export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({ files: 'test/integration/**/*.test.ts', }, ], - + /** Compile JS for older browsers. Requires @web/dev-server-esbuild plugin */ // esbuildTarget: 'auto', diff --git a/packages/openscd/test/unit/Plugging.test.ts b/packages/openscd/test/unit/Plugging.test.ts index 60a5605585..38133fd887 100644 --- a/packages/openscd/test/unit/Plugging.test.ts +++ b/packages/openscd/test/unit/Plugging.test.ts @@ -26,8 +26,9 @@ describe('OpenSCD-Plugin', () => { await element.updateComplete; }); - it('stores default plugins on load', () => - expect(element.layout).property('editors').to.have.lengthOf(6)); + it('stores default plugins on load', () =>{ + expect(element.layout).property('editors').to.have.lengthOf(6) + }); it('has Locale property', async () => { expect(element).to.have.property('locale'); @@ -125,26 +126,43 @@ describe('OpenSCD-Plugin', () => { ); }); - it('requires a name and a valid URL to add a plugin', async () => { - primaryAction.click(); - expect(element.layout.pluginDownloadUI).to.have.property('open', true); - src.value = 'http://example.com/plugin.js'; - await src.updateComplete; - primaryAction.click(); - expect(element.layout.pluginDownloadUI).to.have.property('open', true); + describe('requires a name and a valid URL to add a plugin', async () => { - src.value = 'notaURL'; - name.value = 'testName'; - await src.updateComplete; - await name.updateComplete; - primaryAction.click(); - expect(element.layout.pluginDownloadUI).to.have.property('open', true); + it('does not add without user interaction', async () => { + primaryAction.click(); + expect(element.layout.pluginDownloadUI).to.have.property('open', true); + }) + + it('does not add without a name', async () => { + src.value = 'http://example.com/plugin.js'; + await src.updateComplete; + primaryAction.click(); + expect(element.layout.pluginDownloadUI).to.have.property('open', true); + }) + + it('does not add plugin with incorrect url', async () => { + src.value = 'notaURL'; + name.value = 'testName'; + await src.updateComplete; + await name.updateComplete; + primaryAction.click(); + expect(element.layout.pluginDownloadUI).to.have.property('open', true); + }); + + + it('adds a plugin with a name and a valid URL', async () => { + name.value = 'testName'; + await name.updateComplete; + + src.value = 'http://localhost:8080/plugin/plugin.js'; + await src.updateComplete; + + primaryAction.click(); + + expect(element.layout.pluginDownloadUI).to.have.property('open', false); + }).timeout(600_000); - src.value = 'http://example.com/plugin.js'; - await src.updateComplete; - primaryAction.click(); - expect(element.layout.pluginDownloadUI).to.have.property('open', false); }); it('adds a new editor kind plugin on add button click', async () => { @@ -156,6 +174,7 @@ describe('OpenSCD-Plugin', () => { await element.updateComplete; expect(element.layout.editors).to.have.lengthOf(7); }); + it('adds a new menu kind plugin on add button click', async () => { const lengthMenuKindPlugins = element.layout.menuEntries.length; src.value = 'http://example.com/plugin.js'; @@ -167,6 +186,7 @@ describe('OpenSCD-Plugin', () => { await element.updateComplete; expect(element.layout.menuEntries).to.have.lengthOf(lengthMenuKindPlugins + 1); }); + it('sets requireDoc and position for new menu kind plugin', async () => { src.value = 'http://example.com/plugin.js'; name.value = 'testName'; diff --git a/packages/openscd/web-test-runner.config.mjs b/packages/openscd/web-test-runner.config.mjs index 5eea2baf7c..985efeb662 100644 --- a/packages/openscd/web-test-runner.config.mjs +++ b/packages/openscd/web-test-runner.config.mjs @@ -1,4 +1,4 @@ -// import { playwrightLauncher } from '@web/test-runner-playwright'; +import { playwrightLauncher } from '@web/test-runner-playwright'; import { esbuildPlugin } from '@web/dev-server-esbuild'; export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({ @@ -13,8 +13,13 @@ export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({ * Creating open-scd in the tests leads to error in the browser log - we had to disable the browser log */ browserLogs: false, + browsers: [ + playwrightLauncher({ product: 'chromium', launchOptions: { headless: true } }), + // playwrightLauncher({ product: 'firefox' }), + // playwrightLauncher({ product: 'webkit' }), + ], - /** specify groups for unit and integrations tests + /** specify groups for unit and integrations tests * hint: no --group definition runs all groups */ groups: [ @@ -27,7 +32,7 @@ export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({ files: 'test/integration/**/*.test.ts', }, ], - + /** Compile JS for older browsers. Requires @web/dev-server-esbuild plugin */ // esbuildTarget: 'auto', diff --git a/packages/plugins/package.json b/packages/plugins/package.json index 7e26b7a907..9e38d085dd 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -29,14 +29,14 @@ "@material/mwc-switch": "0.22.1", "@material/mwc-textarea": "0.22.1", "@material/mwc-textfield": "0.22.1", + "@openscd/core": "*", + "@openscd/open-scd": "*", + "@openscd/wizards": "*", + "@openscd/xml": "*", "lit": "^2.2.7", "lit-translate": "^1.2.1", "marked": "^4.0.10", - "panzoom": "^9.4.2", - "@openscd/open-scd": "*", - "@openscd/core": "*", - "@openscd/wizards": "*", - "@openscd/xml": "*" + "panzoom": "^9.4.2" }, "scripts": { "clean": "rimraf dist", @@ -76,6 +76,7 @@ "@typescript-eslint/parser": "^4.29.2", "@web/dev-server-esbuild": "^0.2.16", "@web/test-runner": "^0.13.22", + "@web/test-runner-playwright": "^0.11.0", "concurrently": "^6.2.1", "deepmerge": "^4.2.2", "es-dev-server": "^2.1.0", diff --git a/packages/plugins/web-test-runner.config.mjs b/packages/plugins/web-test-runner.config.mjs index 5eea2baf7c..d717d5ef1f 100644 --- a/packages/plugins/web-test-runner.config.mjs +++ b/packages/plugins/web-test-runner.config.mjs @@ -1,5 +1,7 @@ // import { playwrightLauncher } from '@web/test-runner-playwright'; import { esbuildPlugin } from '@web/dev-server-esbuild'; +import { playwrightLauncher } from '@web/test-runner-playwright'; + export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({ /** we run test directly on TypeScript files */ @@ -14,7 +16,7 @@ export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({ */ browserLogs: false, - /** specify groups for unit and integrations tests + /** specify groups for unit and integrations tests * hint: no --group definition runs all groups */ groups: [ @@ -27,7 +29,7 @@ export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({ files: 'test/integration/**/*.test.ts', }, ], - + /** Compile JS for older browsers. Requires @web/dev-server-esbuild plugin */ // esbuildTarget: 'auto', @@ -38,11 +40,11 @@ export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({ // concurrency: 1, /** Browsers to run tests on */ - // browsers: [ - // playwrightLauncher({ product: 'chromium' }), - // playwrightLauncher({ product: 'firefox' }), - // playwrightLauncher({ product: 'webkit' }), - // ], + browsers: [ + playwrightLauncher({ product: 'chromium' }), + // playwrightLauncher({ product: 'firefox' }), + // playwrightLauncher({ product: 'webkit' }), + ], // See documentation for all available options }); From 8b06a375ecfbc6275c5238d4a95383f4e80449b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20Russ?= Date: Wed, 23 Oct 2024 14:17:44 +0200 Subject: [PATCH 06/22] Feat: Editor plugins can be rendered without an active document --- package-lock.json | 204 ++++++++++++++++++ packages/openscd/package.json | 9 +- packages/openscd/src/addons/Layout.ts | 33 ++- packages/openscd/src/open-scd.ts | 2 + packages/openscd/src/plugins.ts | 14 ++ .../__snapshots__/open-scd.test.snap.js | 22 +- packages/openscd/test/unit/Plugging.test.ts | 3 +- packages/openscd/test/unit/foundation.test.ts | 22 +- packages/openscd/web-test-runner.config.mjs | 4 + 9 files changed, 281 insertions(+), 32 deletions(-) diff --git a/package-lock.json b/package-lock.json index d2f8209d63..5809eaa6d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31581,6 +31581,7 @@ "@typescript-eslint/parser": "^4.29.2", "@web/dev-server-esbuild": "^0.2.16", "@web/test-runner": "^0.13.22", + "@web/test-runner-playwright": "^0.11.0", "concurrently": "^6.2.1", "deepmerge": "^4.2.2", "es-dev-server": "^2.1.0", @@ -31873,6 +31874,147 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "packages/openscd/node_modules/@web/browser-logs": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@web/browser-logs/-/browser-logs-0.4.0.tgz", + "integrity": "sha512-/EBiDAUCJ2DzZhaFxTPRIznEPeafdLbXShIL6aTu7x73x7ZoxSDv7DGuTsh2rWNMUa4+AKli4UORrpyv6QBOiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "errorstacks": "^2.2.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "packages/openscd/node_modules/@web/dev-server-core": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-0.7.3.tgz", + "integrity": "sha512-GS+Ok6HiqNZOsw2oEv5V2OISZ2s/6icJodyGjUuD3RChr0G5HiESbKf2K8mZV4shTz9sRC9KSQf8qvno2gPKrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/koa": "^2.11.6", + "@types/ws": "^7.4.0", + "@web/parse5-utils": "^2.1.0", + "chokidar": "^4.0.1", + "clone": "^2.1.2", + "es-module-lexer": "^1.0.0", + "get-stream": "^6.0.0", + "is-stream": "^2.0.0", + "isbinaryfile": "^5.0.0", + "koa": "^2.13.0", + "koa-etag": "^4.0.0", + "koa-send": "^5.0.1", + "koa-static": "^5.0.0", + "lru-cache": "^8.0.4", + "mime-types": "^2.1.27", + "parse5": "^6.0.1", + "picomatch": "^2.2.2", + "ws": "^7.5.10" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "packages/openscd/node_modules/@web/dev-server-core/node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true, + "license": "MIT" + }, + "packages/openscd/node_modules/@web/parse5-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-2.1.0.tgz", + "integrity": "sha512-GzfK5disEJ6wEjoPwx8AVNwUe9gYIiwc+x//QYxYDAFKUp4Xb1OJAGLc2l2gVrSQmtPGLKrTRcW90Hv4pEq1qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/parse5": "^6.0.1", + "parse5": "^6.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "packages/openscd/node_modules/@web/parse5-utils/node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true, + "license": "MIT" + }, + "packages/openscd/node_modules/@web/test-runner-core": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/@web/test-runner-core/-/test-runner-core-0.13.4.tgz", + "integrity": "sha512-84E1025aUSjvZU1j17eCTwV7m5Zg3cZHErV3+CaJM9JPCesZwLraIa0ONIQ9w4KLgcDgJFw9UnJ0LbFf42h6tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.11", + "@types/babel__code-frame": "^7.0.2", + "@types/co-body": "^6.1.0", + "@types/convert-source-map": "^2.0.0", + "@types/debounce": "^1.2.0", + "@types/istanbul-lib-coverage": "^2.0.3", + "@types/istanbul-reports": "^3.0.0", + "@web/browser-logs": "^0.4.0", + "@web/dev-server-core": "^0.7.3", + "chokidar": "^4.0.1", + "cli-cursor": "^3.1.0", + "co-body": "^6.1.0", + "convert-source-map": "^2.0.0", + "debounce": "^1.2.0", + "dependency-graph": "^0.11.0", + "globby": "^11.0.1", + "internal-ip": "^6.2.0", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.0.2", + "log-update": "^4.0.0", + "nanocolors": "^0.2.1", + "nanoid": "^3.1.25", + "open": "^8.0.2", + "picomatch": "^2.2.2", + "source-map": "^0.7.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "packages/openscd/node_modules/@web/test-runner-coverage-v8": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@web/test-runner-coverage-v8/-/test-runner-coverage-v8-0.8.0.tgz", + "integrity": "sha512-PskiucYpjUtgNfR2zF2AWqWwjXL7H3WW/SnCAYmzUrtob7X9o/+BjdyZ4wKbOxWWSbJO4lEdGIDLu+8X2Xw+lA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@web/test-runner-core": "^0.13.0", + "istanbul-lib-coverage": "^3.0.0", + "lru-cache": "^8.0.4", + "picomatch": "^2.2.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "packages/openscd/node_modules/@web/test-runner-playwright": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@web/test-runner-playwright/-/test-runner-playwright-0.11.0.tgz", + "integrity": "sha512-s+f43DSAcssKYVOD9SuzueUcctJdHzq1by45gAnSCKa9FQcaTbuYe8CzmxA21g+NcL5+ayo4z+MA9PO4H+PssQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@web/test-runner-core": "^0.13.0", + "@web/test-runner-coverage-v8": "^0.8.0", + "playwright": "^1.22.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "packages/openscd/node_modules/acorn": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", @@ -31916,6 +32058,22 @@ "balanced-match": "^1.0.0" } }, + "packages/openscd/node_modules/chokidar": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", + "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "packages/openscd/node_modules/cli-truncate": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", @@ -31991,6 +32149,13 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true }, + "packages/openscd/node_modules/es-module-lexer": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", + "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==", + "dev": true, + "license": "MIT" + }, "packages/openscd/node_modules/eslint": { "version": "7.32.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", @@ -32305,6 +32470,16 @@ "tslib": "^2.1.0" } }, + "packages/openscd/node_modules/lru-cache": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-8.0.5.tgz", + "integrity": "sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16.14" + } + }, "packages/openscd/node_modules/parse5": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", @@ -32327,6 +32502,20 @@ } ] }, + "packages/openscd/node_modules/readdirp": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", + "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "packages/openscd/node_modules/rxjs": { "version": "6.6.7", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", @@ -32462,6 +32651,21 @@ "node": ">=4.2.0" } }, + "packages/openscd/node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "packages/openscd/node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/packages/openscd/package.json b/packages/openscd/package.json index 4857ec8a72..fc084cde7c 100644 --- a/packages/openscd/package.json +++ b/packages/openscd/package.json @@ -39,13 +39,13 @@ "@material/mwc-textarea": "0.22.1", "@material/mwc-textfield": "0.22.1", "@material/mwc-top-app-bar-fixed": "0.22.1", + "@openscd/core": "*", + "@openscd/xml": "*", "ace-custom-element": "^1.6.5", "lit": "^2.2.7", "lit-translate": "^1.2.1", "marked": "^4.0.10", - "panzoom": "^9.4.2", - "@openscd/core": "*", - "@openscd/xml": "*" + "panzoom": "^9.4.2" }, "scripts": { "clean": "rimraf build", @@ -85,6 +85,7 @@ "@typescript-eslint/parser": "^4.29.2", "@web/dev-server-esbuild": "^0.2.16", "@web/test-runner": "^0.13.22", + "@web/test-runner-playwright": "^0.11.0", "concurrently": "^6.2.1", "deepmerge": "^4.2.2", "es-dev-server": "^2.1.0", @@ -171,4 +172,4 @@ ], "commitUrlFormat": "https://github.com/openscd/open-scd/commits/{{hash}}" } -} +} \ No newline at end of file diff --git a/packages/openscd/src/addons/Layout.ts b/packages/openscd/src/addons/Layout.ts index 87f5cb2447..ae3c1c038a 100644 --- a/packages/openscd/src/addons/Layout.ts +++ b/packages/openscd/src/addons/Layout.ts @@ -468,18 +468,33 @@ export class OscdLayout extends LitElement { /** Renders the enabled editor plugins and a tab bar to switch between them*/ protected renderContent(): TemplateResult { + const hasActiveDoc = Boolean(this.doc); + + const activeEditors = this.editors + .filter(editor => { + // this is necessary because `requireDoc` can be undefined + // and that is not the same as false + const doesNotRequireDoc = editor.requireDoc === false + return doesNotRequireDoc || hasActiveDoc + }) + .map(this.renderEditorTab) - if(!this.doc) return html``; + const hasActiveEditors = activeEditors.length > 0; + if(!hasActiveEditors){ return html``; } return html` (this.activeTab = e.detail.index)}> - ${this.editors.map(this.renderEditorTab)} + ${activeEditors} - ${renderEditorContent(this.editors, this.activeTab)} + ${renderEditorContent(this.editors, this.activeTab, this.doc)} `; - function renderEditorContent(editors: Plugin[], activeTab: number){ - const content = editors[activeTab]?.content; + function renderEditorContent(editors: Plugin[], activeTab: number, doc: XMLDocument | null){ + const editor = editors[activeTab]; + const requireDoc = editor?.requireDoc + if(requireDoc && !doc) { return html`` } + + const content = editor?.content; if(!content) { return html`` } return html`${content}`; @@ -633,6 +648,14 @@ export class OscdLayout extends LitElement { class="${plugin.official ? 'official' : 'external'}" value="${plugin.src}" ?selected=${plugin.installed} + @request-selected=${(e: CustomEvent<{source: string}>) => { + if(e.detail.source !== 'interaction'){ + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + return false; + } + }} hasMeta left > diff --git a/packages/openscd/src/open-scd.ts b/packages/openscd/src/open-scd.ts index 34ea9341f2..e1ad85f7d8 100644 --- a/packages/openscd/src/open-scd.ts +++ b/packages/openscd/src/open-scd.ts @@ -488,10 +488,12 @@ export class OpenSCD extends LitElement { private addContent(plugin: Omit): Plugin { const tag = pluginTag(plugin.src); + if (!loadedPlugins.has(tag)) { loadedPlugins.add(tag); import(plugin.src).then(mod => customElements.define(tag, mod.default)); } + return { ...plugin, content: staticTagHtml`<${tag} diff --git a/packages/openscd/src/plugins.ts b/packages/openscd/src/plugins.ts index 62f5ed915d..0c33dfa6c1 100644 --- a/packages/openscd/src/plugins.ts +++ b/packages/openscd/src/plugins.ts @@ -9,6 +9,7 @@ export const officialPlugins = [ icon: 'developer_board', default: true, kind: 'editor', + requireDoc: true, }, { name: 'Substation', @@ -16,6 +17,7 @@ export const officialPlugins = [ icon: 'margin', default: true, kind: 'editor', + requireDoc: true, }, { name: 'Single Line Diagram', @@ -23,6 +25,7 @@ export const officialPlugins = [ icon: 'edit', default: false, kind: 'editor', + requireDoc: true, }, { name: 'Subscriber Message Binding (GOOSE)', @@ -30,6 +33,7 @@ export const officialPlugins = [ icon: 'link', default: false, kind: 'editor', + requireDoc: true, }, { name: 'Subscriber Data Binding (GOOSE)', @@ -37,6 +41,7 @@ export const officialPlugins = [ icon: 'link', default: false, kind: 'editor', + requireDoc: true, }, { name: 'Subscriber Later Binding (GOOSE)', @@ -44,6 +49,7 @@ export const officialPlugins = [ icon: 'link', default: true, kind: 'editor', + requireDoc: true, }, { name: 'Subscriber Message Binding (SMV)', @@ -51,6 +57,7 @@ export const officialPlugins = [ icon: 'link', default: false, kind: 'editor', + requireDoc: true, }, { name: 'Subscriber Data Binding (SMV)', @@ -58,6 +65,7 @@ export const officialPlugins = [ icon: 'link', default: false, kind: 'editor', + requireDoc: true, }, { name: 'Subscriber Later Binding (SMV)', @@ -65,6 +73,7 @@ export const officialPlugins = [ icon: 'link', default: true, kind: 'editor', + requireDoc: true, }, { name: 'Communication', @@ -72,6 +81,7 @@ export const officialPlugins = [ icon: 'settings_ethernet', default: true, kind: 'editor', + requireDoc: true, }, { name: '104', @@ -79,6 +89,7 @@ export const officialPlugins = [ icon: 'settings_ethernet', default: false, kind: 'editor', + requireDoc: true, }, { name: 'Templates', @@ -86,6 +97,7 @@ export const officialPlugins = [ icon: 'copy_all', default: true, kind: 'editor', + requireDoc: true, }, { name: 'Publisher', @@ -93,6 +105,7 @@ export const officialPlugins = [ icon: 'publish', default: false, kind: 'editor', + requireDoc: true, }, { name: 'Cleanup', @@ -100,6 +113,7 @@ export const officialPlugins = [ icon: 'cleaning_services', default: false, kind: 'editor', + requireDoc: true, }, { name: 'Open project', diff --git a/packages/openscd/test/integration/__snapshots__/open-scd.test.snap.js b/packages/openscd/test/integration/__snapshots__/open-scd.test.snap.js index 94059f73c8..4509b78bb3 100644 --- a/packages/openscd/test/integration/__snapshots__/open-scd.test.snap.js +++ b/packages/openscd/test/integration/__snapshots__/open-scd.test.snap.js @@ -1,7 +1,7 @@ /* @web/test-runner snapshot v1 */ export const snapshots = {}; -snapshots["open-scd looks like its snapshot"] = +snapshots["open-scd looks like its snapshot"] = ` @@ -17,7 +17,7 @@ snapshots["open-scd looks like its snapshot"] = `; /* end snapshot open-scd looks like its snapshot */ -snapshots["open-scd renders menu plugins passed down as props and it looks like its snapshot"] = +snapshots["open-scd renders menu plugins passed down as props and it looks like its snapshot"] = ` @@ -912,14 +912,13 @@ snapshots["open-scd renders menu plugins passed down as props and it looks like hasmeta="" left="" mwc-list-item="" - selected="" tabindex="-1" - value="http://localhost:8000/plugins/src/menu/Help.js" + value="https://mockup-plugin.url/plugin-bottom.js" > - help + link - Help + Bottom Mock Plugin - link + help - Bottom Mock Plugin + Help @@ -2177,7 +2177,7 @@ snapshots["open-scd renders editor plugins passed down as props and it looks lik `; /* end snapshot open-scd renders editor plugins passed down as props and it looks like its snapshot */ -snapshots["open-scd layout looks like its snapshot"] = +snapshots["open-scd layout looks like its snapshot"] = ` diff --git a/packages/openscd/test/unit/Plugging.test.ts b/packages/openscd/test/unit/Plugging.test.ts index 38133fd887..afeb34c39a 100644 --- a/packages/openscd/test/unit/Plugging.test.ts +++ b/packages/openscd/test/unit/Plugging.test.ts @@ -126,7 +126,6 @@ describe('OpenSCD-Plugin', () => { ); }); - describe('requires a name and a valid URL to add a plugin', async () => { it('does not add without user interaction', async () => { @@ -161,7 +160,7 @@ describe('OpenSCD-Plugin', () => { primaryAction.click(); expect(element.layout.pluginDownloadUI).to.have.property('open', false); - }).timeout(600_000); + }) }); diff --git a/packages/openscd/test/unit/foundation.test.ts b/packages/openscd/test/unit/foundation.test.ts index 8ea76ba66a..081fc52362 100644 --- a/packages/openscd/test/unit/foundation.test.ts +++ b/packages/openscd/test/unit/foundation.test.ts @@ -172,20 +172,22 @@ describe('foundation', () => { }); }); - describe('ifImplemented', () => { - let nonEmpty: HTMLElement; - let empty: HTMLElement; +// skipped becase of flakiness + describe.skip('ifImplemented', () => { - beforeEach(async () => { - nonEmpty = await fixture(html`

    ${ifImplemented('test')}

    `); - empty = await fixture(html`

    ${ifImplemented({})}

    `); + + it('renders non-empty objects into its template', async () => { + const nonEmpty = await fixture(html`

    ${ifImplemented('test')}

    `); + console.log("nonEmpty", nonEmpty.outerHTML); + expect(nonEmpty).dom.to.have.text('test') }); - it('renders non-empty objects into its template', () => - expect(nonEmpty).dom.to.have.text('test')); + it('does not render empty objects into its template', async () => { + const empty = await fixture(html`

    ${ifImplemented({})}

    `); + console.log("empty", empty.outerHTML); + expect(empty).dom.to.be.empty + }); - it('does not render empty objects into its template', () => - expect(empty).dom.to.be.empty); }); describe('isSame', () => { diff --git a/packages/openscd/web-test-runner.config.mjs b/packages/openscd/web-test-runner.config.mjs index 985efeb662..a5ca8467da 100644 --- a/packages/openscd/web-test-runner.config.mjs +++ b/packages/openscd/web-test-runner.config.mjs @@ -31,6 +31,10 @@ export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({ name: 'integration', files: 'test/integration/**/*.test.ts', }, + { + name: 'single', + files: 'test/unit/foundation.test.ts', + }, ], /** Compile JS for older browsers. Requires @web/dev-server-esbuild plugin */ From a5106648367dad831a248b734cd5c34aa1043d89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20Russ?= Date: Fri, 25 Oct 2024 16:23:53 +0200 Subject: [PATCH 07/22] feat: Handle Config Plugin Events --- packages/openscd/src/addons/History.ts | 2 +- packages/openscd/src/addons/Layout.ts | 12 +- packages/openscd/src/open-scd.ts | 133 +++++++++--- packages/openscd/src/plugin.events.ts | 26 +++ packages/openscd/src/plugin.ts | 25 +++ packages/openscd/test/unit/Plugging.test.ts | 216 ++++++++++++++++++++ packages/openscd/web-test-runner.config.mjs | 16 +- 7 files changed, 392 insertions(+), 38 deletions(-) create mode 100644 packages/openscd/src/plugin.events.ts create mode 100644 packages/openscd/src/plugin.ts diff --git a/packages/openscd/src/addons/History.ts b/packages/openscd/src/addons/History.ts index eac9e5b1ab..51ad9af99a 100644 --- a/packages/openscd/src/addons/History.ts +++ b/packages/openscd/src/addons/History.ts @@ -42,7 +42,7 @@ import { import { getFilterIcon, iconColors } from '../icons/icons.js'; -import { Plugin } from '../open-scd.js'; +import { Plugin } from '../plugin.js'; const icons = { info: 'info', diff --git a/packages/openscd/src/addons/Layout.ts b/packages/openscd/src/addons/Layout.ts index ae3c1c038a..03424d0fbf 100644 --- a/packages/openscd/src/addons/Layout.ts +++ b/packages/openscd/src/addons/Layout.ts @@ -13,17 +13,21 @@ import { newPendingStateEvent } from '@openscd/core/foundation/deprecated/waiter import { newSettingsUIEvent } from '@openscd/core/foundation/deprecated/settings.js'; import { MenuItem, - Plugin, Validator, - PluginKind, - MenuPosition, MenuPlugin, - menuPosition, pluginIcons, newResetPluginsEvent, newAddExternalPluginEvent, newSetPluginsEvent, } from '../open-scd.js'; + +import { + MenuPosition, + Plugin, + menuPosition, + PluginKind, +} from "../plugin.js" + import { HistoryUIKind, newEmptyIssuesEvent, diff --git a/packages/openscd/src/open-scd.ts b/packages/openscd/src/open-scd.ts index e1ad85f7d8..02956d59d8 100644 --- a/packages/openscd/src/open-scd.ts +++ b/packages/openscd/src/open-scd.ts @@ -45,6 +45,10 @@ import type { Plugin as CorePlugin, EditCompletedEvent, } from '@openscd/core'; +import { InstalledOfficialPlugin, MenuPosition, PluginKind, Plugin } from "./plugin.js" +import { ConfigurePluginEvent, ConfigurePluginDetail, newConfigurePluginEvent } from './plugin.events.js'; +import { newLogEvent } from '@openscd/core/foundation/deprecated/history'; + // HOSTING INTERFACES @@ -173,28 +177,6 @@ function staticTagHtml( return html(strings, ...args); } -export type PluginKind = 'editor' | 'menu' | 'validator'; -export const menuPosition = ['top', 'middle', 'bottom'] as const; -export type MenuPosition = (typeof menuPosition)[number]; - -export type Plugin = { - name: string; - src: string; - icon?: string; - default?: boolean; - kind: PluginKind; - requireDoc?: boolean; - position?: MenuPosition; - installed: boolean; - official?: boolean; - content?: TemplateResult; -}; - -type InstalledOfficialPlugin = { - src: string; - official: true; - installed: boolean; -}; function withoutContent

    ( plugin: P @@ -278,15 +260,53 @@ export class OpenSCD extends LitElement { if (src.startsWith('blob:')) URL.revokeObjectURL(src); } + /** + * + * @deprecated Use `handleConfigurationPluginEvent` instead + */ + public handleAddExternalPlugin(e: AddExternalPluginEvent){ + this.addExternalPlugin(e.detail.plugin); + const {name, kind} = e.detail.plugin + + const event = newConfigurePluginEvent(name,kind, e.detail.plugin) + + this.handleConfigurationPluginEvent(event) + } + + + public handleConfigurationPluginEvent(e: ConfigurePluginEvent){ + const { name, kind, config } = e.detail; + + const hasPlugin = this.hasPlugin(name, kind); + const hasConfig = config !== null; + const isChangeEvent = hasPlugin && hasConfig; + const isRemoveEvent = hasPlugin && !hasConfig; + const isAddEvent = !hasPlugin && hasConfig; + + // the `&& config`is only because typescript + // cannot infer that `isChangeEvent` and `isAddEvent` implies `config !== null` + if(isChangeEvent && config){ + this.changePlugin(config); + + }else if(isRemoveEvent){ + this.removePlugin(name, kind); + + }else if(isAddEvent && config){ + this.addPlugin(config); + + }else{ + const event = newLogEvent({ + kind: "error", + title: "Invalid plugin configuration event", + message: JSON.stringify({name, kind, config}), + }); + this.dispatchEvent(event); + } + } + connectedCallback(): void { super.connectedCallback(); this.addEventListener('reset-plugins', this.resetPlugins); - this.addEventListener( - 'add-external-plugin', - (e: AddExternalPluginEvent) => { - this.addExternalPlugin(e.detail.plugin); - } - ); this.addEventListener('set-plugins', (e: SetPluginsEvent) => { this.setPlugins(e.detail.indices); }); @@ -320,6 +340,8 @@ export class OpenSCD extends LitElement { .editCount=${this.editCount} > p.name === name && p.kind === kind); + } + + private hasPlugin(name: string, kind: PluginKind): boolean { + return this.findPluginIndex(name, kind) > -1; + } + + private removePlugin(name: string, kind: PluginKind) { + const newPlugins = this.storedPlugins.filter( + p => p.name !== name || p.kind !== kind + ); + this.storePlugins(newPlugins); + } + + private addPlugin(plugin: Plugin) { + const newPlugins = [...this.storedPlugins, plugin]; + this.storePlugins(newPlugins); + } + + /** + * + * @param plugin + * @throws if the plugin is not found + */ + private changePlugin(plugin: Plugin) { + const storedPlugins = this.storedPlugins; + const {name, kind} = plugin; + const pluginIndex = this.findPluginIndex(name, kind); + + if(pluginIndex < 0) { + const event = newLogEvent({ + kind: "error", + title: "Plugin not found, stopping change process", + message: JSON.stringify({name, kind}), + }) + this.dispatchEvent(event); + return; + } + + const pluginToChange = storedPlugins[pluginIndex] + const changedPlugin = {...pluginToChange, ...plugin} + const newPlugins = [...storedPlugins] + newPlugins.splice(pluginIndex, 1, changedPlugin) + + this.storePlugins(newPlugins); + } + private resetPlugins(): void { this.storePlugins( (builtinPlugins as Plugin[]).concat(this.parsedPlugins).map(plugin => { diff --git a/packages/openscd/src/plugin.events.ts b/packages/openscd/src/plugin.events.ts new file mode 100644 index 0000000000..1f1c6111f6 --- /dev/null +++ b/packages/openscd/src/plugin.events.ts @@ -0,0 +1,26 @@ +import { Plugin, PluginKind } from './plugin.js'; + +/** + * The configure plugin event allows the plugin to request that OpenSCD core add, remove, or reconfigure a plugin. + */ +export type ConfigurePluginDetail = { + name: string; + // The API describes only 'menu' and 'editor' kinds b + // but we still use the 'validator' too, so I just use the type PluginKind + kind: PluginKind; + config: Plugin | null; +}; + +export type ConfigurePluginEvent = CustomEvent; + +/** + * The combination of name and kind uniquely identifies the plugin to be configured. + * If config is null, the plugin is removed. Otherwise, the plugin is added or reconfigured. + */ +export function newConfigurePluginEvent(name: string, kind: PluginKind, config: Plugin | null): ConfigurePluginEvent { + return new CustomEvent('oscd-configure-plugin', { + bubbles: true, + composed: true, + detail: { name, kind, config }, + }); +} diff --git a/packages/openscd/src/plugin.ts b/packages/openscd/src/plugin.ts new file mode 100644 index 0000000000..4b9dfba41e --- /dev/null +++ b/packages/openscd/src/plugin.ts @@ -0,0 +1,25 @@ +import { TemplateResult } from 'lit-element'; + +export type Plugin = { + name: string; + src: string; + icon?: string; + default?: boolean; + kind: PluginKind; + requireDoc?: boolean; + position?: MenuPosition; + installed: boolean; + official?: boolean; + content?: TemplateResult; +}; + +export type InstalledOfficialPlugin = { + src: string; + official: true; + installed: boolean; +}; + + +export type PluginKind = 'editor' | 'menu' | 'validator'; +export const menuPosition = ['top', 'middle', 'bottom'] as const; +export type MenuPosition = (typeof menuPosition)[number]; diff --git a/packages/openscd/test/unit/Plugging.test.ts b/packages/openscd/test/unit/Plugging.test.ts index afeb34c39a..51129b7833 100644 --- a/packages/openscd/test/unit/Plugging.test.ts +++ b/packages/openscd/test/unit/Plugging.test.ts @@ -4,6 +4,8 @@ import '../mock-open-scd.js'; import { MockOpenSCD } from '../mock-open-scd.js'; import { TextField } from '@material/mwc-textfield'; +import { Plugin } from '../../src/plugin'; +import { ConfigurePluginDetail, ConfigurePluginEvent, newConfigurePluginEvent } from '../../src/plugin.events'; describe('OpenSCD-Plugin', () => { let element: MockOpenSCD; @@ -214,4 +216,218 @@ describe('OpenSCD-Plugin', () => { expect(element.layout.validators).to.have.lengthOf(3); }); }); + + describe('ConfigurePluginEvent', () => { + + type TestCase = { + desc: string + currentPlugins: Plugin[] + eventDetails: ConfigurePluginDetail + expectedPlugins: Plugin[] + } + + const featureTests: TestCase[] = [ + { + desc: ` + adds plugin, + if a plugin with same name and kind does not exsits + and there is a config + `, + currentPlugins: [], + eventDetails: { + name: "new plugin", + kind: "editor", + config: { + name: "new plugin", + kind: "editor", + src: "https://example.com/new-plugin.js", + installed: false, + }, + }, + expectedPlugins: [ + { + name: "new plugin", + kind: "editor", + src: "https://example.com/new-plugin.js", + installed: false, + } + ] + }, + { + desc: ` + adds plugin, + if a plugin with same exists but with different kind + and there is a config + `, + currentPlugins: [ + { + name: "an existing plugin", + kind: "menu", + src: "https://example.com/new-plugin.js", + installed: false, + } + ], + eventDetails: { + name: "an existing plugin", + kind: "editor", + config: { + name: "an existing plugin", + kind: "editor", + src: "https://example.com/new-plugin.js", + installed: false, + }, + }, + expectedPlugins: [ + { + name: "an existing plugin", + kind: "menu", + src: "https://example.com/new-plugin.js", + installed: false, + }, + { + name: "an existing plugin", + kind: "editor", + src: "https://example.com/new-plugin.js", + installed: false, + } + ] + }, + { + desc: ` + changes plugin, + if a plugin exists with same name and kind, and there is a config + `, + currentPlugins: [ + { + name: "I want to change this plugin", + kind: "editor", + src: "https://example.com/new-plugin.js", + installed: false, + } + ], + eventDetails: { + name: "I want to change this plugin", + kind: "editor", + config: { + name: "I want to change this plugin", + kind: "editor", + src: "https://example.com/changed-url.js", + installed: true, + }, + }, + expectedPlugins: [ + { + name: "I want to change this plugin", + kind: "editor", + src: "https://example.com/changed-url.js", + installed: true, + }, + ] + }, + { + desc: ` + removes plugin, + if it finds it by name and kind and the econfig is 'null' + `, + currentPlugins: [{ + name: "plugin to remove", + kind: "editor", + src: "https://example.com/plugin-to-remove.js", + installed: false, + }], + eventDetails: { + name: "plugin to remove", + kind: "editor", + config: null + }, + expectedPlugins: [] + }, + { + desc: ` + does not remove plugin, + if it does not find it by name + `, + currentPlugins: [{ + name: "plugin to remove", + kind: "editor", + src: "https://example.com/plugin-to-remove.js", + installed: false, + }], + eventDetails: { + name: "wrong name", + kind: "editor", + config: null + }, + expectedPlugins: [{ + name: "plugin to remove", + kind: "editor", + src: "https://example.com/plugin-to-remove.js", + installed: false, + }] + }, + { + desc: ` + does not remove plugin, + if it does not find it by kind + `, + currentPlugins: [{ + name: "plugin to remove, but wrong kind", + kind: "editor", + src: "https://example.com/plugin-to-remove.js", + installed: false, + }], + eventDetails: { + name: "plugin to remove, but wrong kind", + kind: "menu", + config: null + }, + expectedPlugins: [{ + name: "plugin to remove, but wrong kind", + kind: "editor", + src: "https://example.com/plugin-to-remove.js", + installed: false, + }] + }, + ] + + featureTests.forEach(testFeature) + + function testFeature(tc: TestCase) { + it(tc.desc, async () => { + // ARRANGE + + // @ts-ignore: we use the private to arrange the scenario + element.storePlugins(tc.currentPlugins) + await element.updateComplete + + // ACT + const event = newConfigurePluginEvent(tc.eventDetails.name, tc.eventDetails.kind, tc.eventDetails.config) + element.layout.dispatchEvent(event) + await element.updateComplete + + // ASSERT + + // I remove all the keys that we don't have because + // the stored plugins get new keys and + // I could not figure how to compare the two lists + // I've tried to use chai's deep.members and deep.include.members + // and others but non of them worked. + const keys = ["name", "kind", "src", "installed"] + const storedPlugins = element.layout.plugins.map((plugin) => { + Object.keys(plugin).forEach((key) => { + if(!keys.includes(key)) { + delete plugin[key] + } + }) + + return plugin + }) + + const msg = `expected: ${JSON.stringify(tc.expectedPlugins)} but got: ${JSON.stringify(element.layout.plugins)}` + expect(tc.expectedPlugins).to.have.deep.members(storedPlugins, msg) + + }) + } + + }) }); diff --git a/packages/openscd/web-test-runner.config.mjs b/packages/openscd/web-test-runner.config.mjs index a5ca8467da..0bf46459bd 100644 --- a/packages/openscd/web-test-runner.config.mjs +++ b/packages/openscd/web-test-runner.config.mjs @@ -14,7 +14,13 @@ export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({ */ browserLogs: false, browsers: [ - playwrightLauncher({ product: 'chromium', launchOptions: { headless: true } }), + playwrightLauncher({ + product: 'chromium', + launchOptions: { + headless: true, + devtools: true, + } + }), // playwrightLauncher({ product: 'firefox' }), // playwrightLauncher({ product: 'webkit' }), ], @@ -31,10 +37,10 @@ export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({ name: 'integration', files: 'test/integration/**/*.test.ts', }, - { - name: 'single', - files: 'test/unit/foundation.test.ts', - }, + // { + // name: 'single', + // files: 'test/unit/Plugging.test.ts', + // }, ], /** Compile JS for older browsers. Requires @web/dev-server-esbuild plugin */ From d9a4a0c6f6a0c9c86927d80bf5c81b4e9f6fc6d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20Russ?= Date: Wed, 30 Oct 2024 16:00:22 +0100 Subject: [PATCH 08/22] Feat: Allow .fsd file creation --- packages/plugins/src/menu/NewProject.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/plugins/src/menu/NewProject.ts b/packages/plugins/src/menu/NewProject.ts index 3e4dfa902d..042d1dd963 100644 --- a/packages/plugins/src/menu/NewProject.ts +++ b/packages/plugins/src/menu/NewProject.ts @@ -20,13 +20,23 @@ import { } from '@openscd/open-scd/src/schemas.js'; export default class NewProjectPlugin extends LitElement { + private createNewProject( inputs: WizardInputElement[], wizard: Element ): EditorAction[] { - const docName = inputs[0].value?.match(/\.s[sc]d$/i) - ? inputs[0].value - : inputs[0].value + '.scd'; + + let docName = inputs[0].value ?? '' + + const acceptedFileExtension = ['.ssd', '.scd','.fsd']; + const isValidFileFormat = acceptedFileExtension.some((extension) => { + return inputs[0].value?.endsWith(extension); + }) + + if(!isValidFileFormat) { + docName = docName + '.scd'; + } + const version = ( (wizard.shadowRoot!.querySelector('mwc-list')!.selected) .value @@ -39,7 +49,6 @@ export default class NewProjectPlugin extends LitElement { return [{ actions: [], title: '', derived: true }]; } - private newProjectWizard(): Wizard { return [ { From 44a51f05797e8dd6345215c177a2e7b68e189d69 Mon Sep 17 00:00:00 2001 From: Rasul Adasov Date: Tue, 12 Nov 2024 10:43:27 +0100 Subject: [PATCH 09/22] feat: render plugin download UI on event --- packages/openscd/README.md | 1 + packages/openscd/src/addons/Layout.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/packages/openscd/README.md b/packages/openscd/README.md index 71188faf7f..f9b02fb908 100644 --- a/packages/openscd/README.md +++ b/packages/openscd/README.md @@ -29,6 +29,7 @@ Once Node.js is installed on your system, you may get started by entering the fo git clone https://github.com/openscd/open-scd.git cd open-scd npm install +npm run build npm start ``` diff --git a/packages/openscd/src/addons/Layout.ts b/packages/openscd/src/addons/Layout.ts index 03424d0fbf..4b54097f54 100644 --- a/packages/openscd/src/addons/Layout.ts +++ b/packages/openscd/src/addons/Layout.ts @@ -327,6 +327,10 @@ export class OscdLayout extends LitElement { this.requestUpdate(); } ); + + document.addEventListener("open-plugin-download", () => { + this.pluginDownloadUI.show(); + }); } From 14e933ed776ec5592c3c38e84b9884fa41a05e81 Mon Sep 17 00:00:00 2001 From: Christopher Lepski <139237321+clepski@users.noreply.github.com> Date: Thu, 14 Nov 2024 09:35:45 +0100 Subject: [PATCH 10/22] feat: Support edit api v2 (#1581) BREAKING CHANGE: Edit event v1 properties derived and checkValidity will be ignored BREAKING CHANGE: Edit API v1 validation is no longer supported (e.g. edit api v1 checked if an elements id was unique in the document) feat: Edit events v1 will be converted event v2 doc: Added documentation for Edit API v2 --- README.md | 1 + docs/core-api/edit-api.md | 248 ++++++++ packages/core/foundation.ts | 1 - .../core/foundation/deprecated/history.ts | 5 +- packages/core/mixins/Editing.ts | 214 ------- packages/openscd/src/Editing.ts | 470 --------------- packages/openscd/src/addons/Editor.ts | 568 +++++------------- packages/openscd/src/addons/History.ts | 68 ++- packages/openscd/src/addons/Layout.ts | 34 +- .../addons/editor/edit-v1-to-v2-converter.ts | 130 ++++ packages/openscd/src/open-scd.ts | 31 +- packages/openscd/src/translations/de.ts | 1 + packages/openscd/src/translations/en.ts | 1 + .../openscd/test/integration/Editing.test.ts | 24 +- packages/openscd/test/mock-edits.ts | 16 + packages/openscd/test/mock-wizard-editor.ts | 23 +- packages/openscd/test/unit/Editing.test.ts | 465 -------------- packages/openscd/test/unit/Editor.test.ts | 486 +++++++++++++++ packages/openscd/test/unit/Historing.test.ts | 23 +- .../test/unit/edit-v1-to-v2-converter.test.ts | 148 +++++ .../openscd/test/unit/wizard-dialog.test.ts | 30 +- .../GooseSubscriberDataBinding.test.ts | 4 - .../GooseSubscriberLaterBinding.test.ts | 3 - .../GooseSubscriberMessageBinding.test.ts | 10 +- .../editors/SMVSubscriberDataBinding.test.ts | 2 - .../editors/SMVSubscriberLaterBinding.test.ts | 2 - .../SMVSubscriberMessageBinding.test.ts | 6 +- ...nnectedap-editor-wizarding-editing.test.ts | 2 +- ...ubnetwork-editor-wizarding-editing.test.ts | 10 - .../bay-editor-wizarding-editing.test.ts | 7 - ...equipment-editor-wizarding-editing.test.ts | 9 - ...al-equipment-editor-wizard-editing.test.ts | 9 - .../line-editor-wizard-editing.test.ts | 10 - .../process-editor-wizard-editing.test.ts | 12 - .../tapchanger-editor-wizard-editing.test.ts | 14 +- ...rmer-winding-editor-wizard-editing.test.ts | 14 +- ...age-level-editor-wizarding-editing.test.ts | 20 +- .../test/unit/wizards/clientln.test.ts | 3 +- 38 files changed, 1348 insertions(+), 1776 deletions(-) create mode 100644 docs/core-api/edit-api.md delete mode 100644 packages/core/mixins/Editing.ts delete mode 100644 packages/openscd/src/Editing.ts create mode 100644 packages/openscd/src/addons/editor/edit-v1-to-v2-converter.ts create mode 100644 packages/openscd/test/mock-edits.ts delete mode 100644 packages/openscd/test/unit/Editing.test.ts create mode 100644 packages/openscd/test/unit/Editor.test.ts create mode 100644 packages/openscd/test/unit/edit-v1-to-v2-converter.test.ts diff --git a/README.md b/README.md index 68081943f0..6fdb4a6ed8 100644 --- a/README.md +++ b/README.md @@ -43,3 +43,4 @@ How the documentation is organized. A high-level overview of how it’s organized will help you know where to look for certain things: - [⚖️ Decisions](docs/decisions/README.md) documents the decisions we made and why we made them. +- [✏️ Edit event API](docs/core-api/edit-api.md) documents the edit event API. diff --git a/docs/core-api/edit-api.md b/docs/core-api/edit-api.md new file mode 100644 index 0000000000..e5e3a459f9 --- /dev/null +++ b/docs/core-api/edit-api.md @@ -0,0 +1,248 @@ +# Edit Event API + +Open SCD offers an API for editing the scd document which can be used with [Html Custom Events](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent). The main Open SCD components listens to events of the type `oscd-edit`, applies the changes to the `doc` and updates the `editCount` property. + +The edits to the `doc` will be done in place, e.g. the `doc` changes but will keep the same reference. If your plugin needs to react to changes in the doc, you should listen to changes in the `editCount` property. + +## Event factory + +Open SCD core exports a factory function for edit events, so you do not have to build them manually. + +```ts +function newEditEvent( + edit: E, + initiator: Initiator = 'user' +): EditEvent + +type Edit = Insert | Update | Remove | Edit[]; + +type Initiator = 'user' | 'system' | 'undo' | 'redo' | string; + +``` + +Example for remove. + +```ts +import { newEditEvent, Remove } from '@openscd/core'; + +const remove: Remove = { node: someNode }; +const removeEvent = newEditEvent(remove); + +someComponent.dispatchEvent(removeEvent); + +``` + + +### Insert + +Insert events can be used to add new nodes or move existing nodes in the document. Since a node can only have one parent, using an insert on an existing node will replace it's previous parent with the new parent, essentially moving the node to a different position in the xml tree. + +If the reference is not `null`, the node will be inserted before the reference node. The reference has to be a child node of the parent. And if the reference is `null` the node will be added as the last child of the parent. + +```ts +interface Insert { + parent: Node; + node: Node; + reference: Node | null; +} +``` + + +### Remove + +This event will remove the node from the document. + +```ts +interface Remove { + node: Node; +} +``` + + +### Update + +Update can add, remove or change attributes on an existing node. Existing attributes will only be removed, if `null` is passed as value in the event's `attributes` property. + + +```ts +interface Update { + element: Element; + attributes: Partial>; +} + +// Attirubte value + +type AttributeValue = string | null | NamespacedAttributeValue; + +type NamespacedAttributeValue = { + value: string | null; + namespaceURI: string | null; +}; +``` + +Example for adding and changing values. + +```ts + +const update: Update = { + element: elementToUpdate, + attributes: { + name: 'new name', + value: 'new value' + } +}; + +``` + +To remove an existing value pass `null` as value. + +```ts + +const update: Update = { + element: elementToUpdate, + attributes: { + attributeToRemove: null + } +}; + +``` + +Update also supports [Xml namespaces](https://developer.mozilla.org/en-US/docs/Related/IMSC/Namespaces#namespaced_attributes) for attributes. To change namespaced attributes you need to pass an `NamespacedAttributeValue` instead of a plain `string`. + +```ts + +const update: Update = { + element: elementToUpdate, + attributes: { + name: { + value: 'namespaced name', + namespaceURI: 'http://www.iec.ch/61850/2003/SCLcoordinates' + }, + type: { + value: 'namespaced type', + namespaceURI: 'http://www.iec.ch/61850/2003/SCLcoordinates' + }, + } +}; + +``` + +Adding, updating and removing attributes with and without namespaces can be combined in a single `Update`. + +### Complex edits + +Complex edits can be used to apply multiple edits as a single event. This will create a single entry in the history. You can create complex edit events by passing an array of edit events to the `newEditEvent` factory function. + +```ts +import { newEditEvent } from '@openscd/core'; + +const complexEditEvent = newEditEvent([ insert, update, remove ]); + +someComponent.dispatchEvent(complexEditEvent); + +``` + + + +## History + +All edit events with initiator `user` will create a history log entry and can be undone and redone through the history addon. + +## Breaking changes due to migration +Before the edit event API the editor action API was used to edit the `doc`. It is also custom event based and listens to the events of the type `editor-action`. +For backwards compatibility the API is still supported, but it is recommended to use the edit event API instead. Internally editor actions are converted to edit events. +With open SCD version **v0.36.0** and higher some editor action features are no longer supported see [Deprecated Editor Action API](#archives---editor-action-api-deprecated). +* The editor action properties `derived` and `checkValidity` do not have any effect. +* All validation checks have been removed (i.e. check for unique `id` attribute on element before create). +* The `title` for `ComplexAction` does not have any effect. + +--- + +# Archives - Editor Action API (deprecated) + +### Event factory + +```ts + +function newActionEvent( + action: T, + initiator: Initiator = 'user', + eventInitDict?: CustomEventInit>> +): EditorActionEvent + +type SimpleAction = Update | Create | Replace | Delete | Move; +type ComplexAction = { + actions: SimpleAction[]; + title: string; + derived?: boolean; +}; +type EditorAction = SimpleAction | ComplexAction; + +``` + + +### Create + +`Create` actions are converted to `Insert` events. + +```ts +interface Create { + new: { parent: Node; element: Node; reference?: Node | null }; + derived?: boolean; + checkValidity?: () => boolean; +} +``` + +### Move + +`Move` actions are converted to `Insert` events. + +```ts +interface Move { + old: { parent: Element; element: Element; reference?: Node | null }; + new: { parent: Element; reference?: Node | null }; + derived?: boolean; + checkValidity?: () => boolean; +} +``` + + +### Delete + +`Delete` actions are converted to `Remove` events. + +```ts +interface Delete { + old: { parent: Node; element: Node; reference?: Node | null }; + derived?: boolean; + checkValidity?: () => boolean; +} +``` + + +### Update + +`Update` actions are converted to `Update` events. + +```ts +interface Update { + element: Element; + oldAttributes: Record; + newAttributes: Record; + derived?: boolean; + checkValidity?: () => boolean; +} +``` + +### Replace + +`Replace` actions are converted to a complex event with `Remove` and `Insert` events. + +```ts +interface Replace { + old: { element: Element }; + new: { element: Element }; + derived?: boolean; + checkValidity?: () => boolean; +} +``` diff --git a/packages/core/foundation.ts b/packages/core/foundation.ts index 466136ed83..c7cd164a43 100644 --- a/packages/core/foundation.ts +++ b/packages/core/foundation.ts @@ -26,7 +26,6 @@ export type { export { cyrb64 } from './foundation/cyrb64.js'; -export { Editing } from './mixins/Editing.js'; export type { Plugin, PluginSet } from './foundation/plugin.js'; export { newEditCompletedEvent } from './foundation/edit-completed-event.js'; diff --git a/packages/core/foundation/deprecated/history.ts b/packages/core/foundation/deprecated/history.ts index 2c128be2ef..d6c6d6c648 100644 --- a/packages/core/foundation/deprecated/history.ts +++ b/packages/core/foundation/deprecated/history.ts @@ -1,4 +1,4 @@ -import { EditorAction } from './editor'; +import { Edit } from '../edit-event.js'; type InfoEntryKind = 'info' | 'warning' | 'error'; @@ -12,7 +12,8 @@ export interface LogDetailBase { /** The [[`LogEntry`]] for a committed [[`EditorAction`]]. */ export interface CommitDetail extends LogDetailBase { kind: 'action'; - action: EditorAction; + redo: Edit; + undo: Edit; } /** A [[`LogEntry`]] for notifying the user. */ export interface InfoDetail extends LogDetailBase { diff --git a/packages/core/mixins/Editing.ts b/packages/core/mixins/Editing.ts deleted file mode 100644 index c42845491a..0000000000 --- a/packages/core/mixins/Editing.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { LitElement } from 'lit'; - -import { property, state } from 'lit/decorators.js'; - -import { - AttributeValue, - Edit, - EditEvent, - Insert, - isComplex, - isInsert, - isNamespaced, - isRemove, - isUpdate, - LitElementConstructor, - OpenEvent, - Remove, - Update, -} from '../foundation.js'; - -function localAttributeName(attribute: string): string { - return attribute.includes(':') ? attribute.split(':', 2)[1] : attribute; -} - -function handleInsert({ - parent, - node, - reference, -}: Insert): Insert | Remove | [] { - try { - const { parentNode, nextSibling } = node; - parent.insertBefore(node, reference); - if (parentNode) - return { - node, - parent: parentNode, - reference: nextSibling, - }; - return { node }; - } catch (e) { - // do nothing if insert doesn't work on these nodes - return []; - } -} - -function handleUpdate({ element, attributes }: Update): Update { - const oldAttributes = { ...attributes }; - Object.entries(attributes) - .reverse() - .forEach(([name, value]) => { - let oldAttribute: AttributeValue; - if (isNamespaced(value!)) - oldAttribute = { - value: element.getAttributeNS( - value.namespaceURI, - localAttributeName(name) - ), - namespaceURI: value.namespaceURI, - }; - else - oldAttribute = element.getAttributeNode(name)?.namespaceURI - ? { - value: element.getAttribute(name), - namespaceURI: element.getAttributeNode(name)!.namespaceURI!, - } - : element.getAttribute(name); - oldAttributes[name] = oldAttribute; - }); - for (const entry of Object.entries(attributes)) { - try { - const [attribute, value] = entry as [string, AttributeValue]; - if (isNamespaced(value)) { - if (value.value === null) - element.removeAttributeNS( - value.namespaceURI, - localAttributeName(attribute) - ); - else element.setAttributeNS(value.namespaceURI, attribute, value.value); - } else if (value === null) element.removeAttribute(attribute); - else element.setAttribute(attribute, value); - } catch (e) { - // do nothing if update doesn't work on this attribute - delete oldAttributes[entry[0]]; - } - } - return { - element, - attributes: oldAttributes, - }; -} - -function handleRemove({ node }: Remove): Insert | [] { - const { parentNode: parent, nextSibling: reference } = node; - node.parentNode?.removeChild(node); - if (parent) - return { - node, - parent, - reference, - }; - return []; -} - -function handleEdit(edit: Edit): Edit { - if (isInsert(edit)) return handleInsert(edit); - if (isUpdate(edit)) return handleUpdate(edit); - if (isRemove(edit)) return handleRemove(edit); - if (isComplex(edit)) return edit.map(handleEdit).reverse(); - return []; -} - -export type LogEntry = { undo: Edit; redo: Edit }; - -export interface EditingMixin { - doc: XMLDocument; - history: LogEntry[]; - editCount: number; - last: number; - canUndo: boolean; - canRedo: boolean; - docs: Record; - docName: string; - handleOpenDoc(evt: OpenEvent): void; - handleEditEvent(evt: EditEvent): void; - undo(n?: number): void; - redo(n?: number): void; -} - -type ReturnConstructor = new (...args: any[]) => LitElement & EditingMixin; - -/** A mixin for editing a set of [[docs]] using [[EditEvent]]s */ -export function Editing( - Base: TBase -): TBase & ReturnConstructor { - class EditingElement extends Base { - @state() - /** The `XMLDocument` currently being edited */ - get doc(): XMLDocument { - return this.docs[this.docName]; - } - - @state() - history: LogEntry[] = []; - - @state() - editCount: number = 0; - - @state() - get last(): number { - return this.editCount - 1; - } - - @state() - get canUndo(): boolean { - return this.last >= 0; - } - - @state() - get canRedo(): boolean { - return this.editCount < this.history.length; - } - - /** - * The set of `XMLDocument`s currently loaded - * - * @prop {Record} docs - Record of loaded XML documents - */ - @state() - docs: Record = {}; - - /** - * The name of the [[`doc`]] currently being edited - * - * @prop {String} docName - name of the document that is currently being edited - */ - @property({ type: String, reflect: true }) docName = ''; - - handleOpenDoc({ detail: { docName, doc } }: OpenEvent) { - this.docName = docName; - this.docs[this.docName] = doc; - } - - handleEditEvent(event: EditEvent) { - const edit = event.detail.edit; - this.history.splice(this.editCount); - this.history.push({ undo: handleEdit(edit), redo: edit }); - this.editCount += 1; - } - - /** Undo the last `n` [[Edit]]s committed */ - undo(n = 1) { - if (!this.canUndo || n < 1) return; - handleEdit(this.history[this.last!].undo); - this.editCount -= 1; - if (n > 1) this.undo(n - 1); - } - - /** Redo the last `n` [[Edit]]s that have been undone */ - redo(n = 1) { - if (!this.canRedo || n < 1) return; - handleEdit(this.history[this.editCount].redo); - this.editCount += 1; - if (n > 1) this.redo(n - 1); - } - - constructor(...args: any[]) { - super(...args); - - this.addEventListener('oscd-open', this.handleOpenDoc); - this.addEventListener('oscd-edit', event => this.handleEditEvent(event)); - } - } - return EditingElement; -} diff --git a/packages/openscd/src/Editing.ts b/packages/openscd/src/Editing.ts deleted file mode 100644 index e60c4884e8..0000000000 --- a/packages/openscd/src/Editing.ts +++ /dev/null @@ -1,470 +0,0 @@ -import { OpenEvent } from '@openscd/core'; -import { property } from 'lit-element'; -import { get } from 'lit-translate'; - -import { newLogEvent } from '@openscd/core/foundation/deprecated/history.js'; -import { newValidateEvent } from '@openscd/core/foundation/deprecated/validation.js' -import { - Create, - Delete, - Move, - Update, - Replace, - SimpleAction, - EditorAction, - EditorActionEvent, - isCreate, - isDelete, - isMove, - isSimple, - isReplace, - isUpdate, -} from '@openscd/core/foundation/deprecated/editor.js'; -import { OpenDocEvent } from '@openscd/core/foundation/deprecated/open-event.js'; -import { - getReference, - SCLTag, - Mixin, - LitElementConstructor -} from './foundation.js'; - -/** Mixin that edits an `XML` `doc`, listening to [[`EditorActionEvent`]]s */ -export type EditingElement = Mixin; - -/** @typeParam TBase - a type extending `LitElement` - * @returns `Base` with an `XMLDocument` property "`doc`" and an event listener - * applying [[`EditorActionEvent`]]s and dispatching [[`LogEvent`]]s. */ -export function Editing(Base: TBase) { - class EditingElement extends Base { - /** The `XMLDocument` to be edited */ - @property({ attribute: false }) - doc: XMLDocument | null = null; - /** The name of the current [[`doc`]] */ - @property({ type: String }) docName = ''; - /** The UUID of the current [[`doc`]] */ - @property({ type: String }) docId = ''; - - private checkCreateValidity(create: Create): boolean { - if (create.checkValidity !== undefined) return create.checkValidity(); - - if ( - !(create.new.element instanceof Element) || - !(create.new.parent instanceof Element) - ) - return true; - - const invalidNaming = - create.new.element.hasAttribute('name') && - Array.from(create.new.parent.children).some( - elm => - elm.tagName === (create.new.element).tagName && - elm.getAttribute('name') === - (create.new.element).getAttribute('name') - ); - - if (invalidNaming) { - this.dispatchEvent( - newLogEvent({ - kind: 'error', - title: get('editing.error.create', { - name: create.new.element.tagName, - }), - message: get('editing.error.nameClash', { - parent: - create.new.parent instanceof HTMLElement - ? create.new.parent.tagName - : 'Document', - child: create.new.element.tagName, - name: create.new.element.getAttribute('name')!, - }), - }) - ); - - return false; - } - - const invalidId = - create.new.element.hasAttribute('id') && - Array.from( - create.new.parent.ownerDocument.querySelectorAll( - 'LNodeType, DOType, DAType, EnumType' - ) - ).some( - elm => - elm.getAttribute('id') === - (create.new.element).getAttribute('id') - ); - - if (invalidId) { - this.dispatchEvent( - newLogEvent({ - kind: 'error', - title: get('editing.error.create', { - name: create.new.element.tagName, - }), - message: get('editing.error.idClash', { - id: create.new.element.getAttribute('id')!, - }), - }) - ); - - return false; - } - - return true; - } - - private onCreate(action: Create) { - if (!this.checkCreateValidity(action)) return false; - - if ( - action.new.reference === undefined && - action.new.element instanceof Element && - action.new.parent instanceof Element - ) - action.new.reference = getReference( - action.new.parent, - action.new.element.tagName - ); - else action.new.reference = action.new.reference ?? null; - - action.new.parent.insertBefore(action.new.element, action.new.reference); - return true; - } - - private logCreate(action: Create) { - const name = - action.new.element instanceof Element - ? action.new.element.tagName - : get('editing.node'); - - this.dispatchEvent( - newLogEvent({ - kind: 'action', - title: get('editing.created', { name }), - action, - }) - ); - } - - private onDelete(action: Delete) { - if (!action.old.reference) - action.old.reference = action.old.element.nextSibling; - - if (action.old.element.parentNode !== action.old.parent) return false; - - action.old.parent.removeChild(action.old.element); - return true; - } - - private logDelete(action: Delete) { - const name = - action.old.element instanceof Element - ? action.old.element.tagName - : get('editing.node'); - - this.dispatchEvent( - newLogEvent({ - kind: 'action', - title: get('editing.deleted', { name }), - action, - }) - ); - } - - private checkMoveValidity(move: Move): boolean { - if (move.checkValidity !== undefined) return move.checkValidity(); - - const invalid = - move.old.element.hasAttribute('name') && - move.new.parent !== move.old.parent && - Array.from(move.new.parent.children).some( - elm => - elm.tagName === move.old.element.tagName && - elm.getAttribute('name') === move.old.element.getAttribute('name') - ); - - if (invalid) - this.dispatchEvent( - newLogEvent({ - kind: 'error', - title: get('editing.error.move', { - name: move.old.element.tagName, - }), - message: get('editing.error.nameClash', { - parent: move.new.parent.tagName, - child: move.old.element.tagName, - name: move.old.element.getAttribute('name')!, - }), - }) - ); - - return !invalid; - } - - private onMove(action: Move) { - if (!this.checkMoveValidity(action)) return false; - - if (!action.old.reference) - action.old.reference = action.old.element.nextSibling; - - if (action.new.reference === undefined) - action.new.reference = getReference( - action.new.parent, - action.old.element.tagName - ); - - action.new.parent.insertBefore(action.old.element, action.new.reference); - return true; - } - - private logMove(action: Move) { - this.dispatchEvent( - newLogEvent({ - kind: 'action', - title: get('editing.moved', { - name: action.old.element.tagName, - }), - action: action, - }) - ); - } - - private checkReplaceValidity(replace: Replace): boolean { - if (replace.checkValidity !== undefined) return replace.checkValidity(); - - const invalidNaming = - replace.new.element.hasAttribute('name') && - replace.new.element.getAttribute('name') !== - replace.old.element.getAttribute('name') && - Array.from(replace.old.element.parentElement?.children ?? []).some( - elm => - elm.tagName === replace.new.element.tagName && - elm.getAttribute('name') === - replace.new.element.getAttribute('name') - ); - - if (invalidNaming) { - this.dispatchEvent( - newLogEvent({ - kind: 'error', - title: get('editing.error.update', { - name: replace.new.element.tagName, - }), - message: get('editing.error.nameClash', { - parent: replace.old.element.parentElement!.tagName, - child: replace.new.element.tagName, - name: replace.new.element.getAttribute('name')!, - }), - }) - ); - - return false; - } - - const invalidId = - replace.new.element.hasAttribute('id') && - replace.new.element.getAttribute('id') !== - replace.old.element.getAttribute('id') && - Array.from( - replace.new.element.ownerDocument.querySelectorAll( - 'LNodeType, DOType, DAType, EnumType' - ) - ).some( - elm => - elm.getAttribute('id') === - (replace.new.element).getAttribute('id') - ); - - if (invalidId) { - this.dispatchEvent( - newLogEvent({ - kind: 'error', - title: get('editing.error.update', { - name: replace.new.element.tagName, - }), - message: get('editing.error.idClash', { - id: replace.new.element.getAttribute('id')!, - }), - }) - ); - - return false; - } - - return true; - } - - private onReplace(action: Replace) { - if (!this.checkReplaceValidity(action)) return false; - - action.new.element.append(...Array.from(action.old.element.children)); - action.old.element.replaceWith(action.new.element); - return true; - } - - private logUpdate(action: Replace | Update) { - const name = isReplace(action) - ? action.new.element.tagName - : (action as Update).element.tagName; - - this.dispatchEvent( - newLogEvent({ - kind: 'action', - title: get('editing.updated', { - name, - }), - action: action, - }) - ); - } - - private checkUpdateValidity(update: Update): boolean { - if (update.checkValidity !== undefined) return update.checkValidity(); - - if (update.oldAttributes['name'] !== update.newAttributes['name']) { - const invalidNaming = Array.from( - update.element.parentElement?.children ?? [] - ).some( - elm => - elm.tagName === update.element.tagName && - elm.getAttribute('name') === update.newAttributes['name'] - ); - - if (invalidNaming) { - this.dispatchEvent( - newLogEvent({ - kind: 'error', - title: get('editing.error.update', { - name: update.element.tagName, - }), - message: get('editing.error.nameClash', { - parent: update.element.parentElement!.tagName, - child: update.element.tagName, - name: update.newAttributes['name']!, - }), - }) - ); - - return false; - } - } - - const invalidId = - update.newAttributes['id'] && - Array.from( - update.element.ownerDocument.querySelectorAll( - 'LNodeType, DOType, DAType, EnumType' - ) - ).some(elm => elm.getAttribute('id') === update.newAttributes['id']); - - if (invalidId) { - this.dispatchEvent( - newLogEvent({ - kind: 'error', - title: get('editing.error.update', { - name: update.element.tagName, - }), - message: get('editing.error.idClash', { - id: update.newAttributes['id']!, - }), - }) - ); - - return false; - } - - return true; - } - - private onUpdate(action: Update) { - if (!this.checkUpdateValidity(action)) return false; - - Array.from(action.element.attributes).forEach(attr => - action.element.removeAttributeNode(attr) - ); - - Object.entries(action.newAttributes).forEach(([key, value]) => { - if (value !== null && value !== undefined) - action.element.setAttribute(key, value); - }); - - return true; - } - - private onSimpleAction(action: SimpleAction) { - if (isMove(action)) return this.onMove(action as Move); - else if (isCreate(action)) return this.onCreate(action as Create); - else if (isDelete(action)) return this.onDelete(action as Delete); - else if (isReplace(action)) return this.onReplace(action as Replace); - else if (isUpdate(action)) return this.onUpdate(action as Update); - } - - private logSimpleAction(action: SimpleAction) { - if (isMove(action)) this.logMove(action as Move); - else if (isCreate(action)) this.logCreate(action as Create); - else if (isDelete(action)) this.logDelete(action as Delete); - else if (isReplace(action)) this.logUpdate(action as Replace); - else if (isUpdate(action)) this.logUpdate(action as Update); - } - - private async onAction(event: EditorActionEvent) { - if (isSimple(event.detail.action)) { - if (this.onSimpleAction(event.detail.action)) - this.logSimpleAction(event.detail.action); - } else if (event.detail.action.actions.length > 0) { - event.detail.action.actions.forEach(element => - this.onSimpleAction(element) - ); - this.dispatchEvent( - newLogEvent({ - kind: 'action', - title: event.detail.action.title, - action: event.detail.action, - }) - ); - } else return; - - if (!this.doc) return; - - await this.updateComplete; - this.dispatchEvent(newValidateEvent()); - } - - /** - * - * @deprecated [Move to handleOpenDoc instead] - */ - private async onOpenDoc(event: OpenDocEvent) { - this.doc = event.detail.doc; - this.docName = event.detail.docName; - this.docId = event.detail.docId ?? ''; - - await this.updateComplete; - - this.dispatchEvent(newValidateEvent()); - - this.dispatchEvent( - newLogEvent({ - kind: 'info', - title: get('openSCD.loaded', { name: this.docName }), - }) - ); - } - - handleOpenDoc({ detail: { docName, doc } }: OpenEvent) { - this.doc = doc; - this.docName = docName; - } - - constructor(...args: any[]) { - super(...args); - - this.addEventListener('editor-action', this.onAction); - this.addEventListener('open-doc', this.onOpenDoc); - this.addEventListener('oscd-open', this.handleOpenDoc); - } - } - - return EditingElement; -} diff --git a/packages/openscd/src/addons/Editor.ts b/packages/openscd/src/addons/Editor.ts index c1ab8901a9..dea4d2df13 100644 --- a/packages/openscd/src/addons/Editor.ts +++ b/packages/openscd/src/addons/Editor.ts @@ -1,4 +1,4 @@ -import { OpenEvent, newEditCompletedEvent } from '@openscd/core'; +import { OpenEvent, newEditCompletedEvent, newEditEvent } from '@openscd/core'; import { property, LitElement, @@ -9,28 +9,29 @@ import { import { get } from 'lit-translate'; import { - Move, - Create, - Delete, EditorAction, EditorActionEvent, - SimpleAction, - Replace, - Update, } from '@openscd/core/foundation/deprecated/editor.js'; import { newLogEvent } from '@openscd/core/foundation/deprecated/history.js'; import { newValidateEvent } from '@openscd/core/foundation/deprecated/validation.js'; import { OpenDocEvent } from '@openscd/core/foundation/deprecated/open-event.js'; -import { getReference, SCLTag } from '../foundation.js'; + import { - isCreate, - isDelete, - isMove, - isSimple, - isReplace, + AttributeValue, + Edit, + EditEvent, + Insert, + isComplex, + isInsert, + isNamespaced, + isRemove, isUpdate, -} from '@openscd/core/foundation/deprecated/editor.js'; + Remove, + Update, +} from '@openscd/core'; + +import { convertEditV1toV2 } from './editor/edit-v1-to-v2-converter.js'; @customElement('oscd-editor') export class OscdEditor extends LitElement { @@ -47,398 +48,33 @@ export class OscdEditor extends LitElement { }) host!: HTMLElement; - @property({ - type: Number, - }) - editCount = -1; - - private checkCreateValidity(create: Create): boolean { - if (create.checkValidity !== undefined) return create.checkValidity(); - - if ( - !(create.new.element instanceof Element) || - !(create.new.parent instanceof Element) - ) - return true; - - const invalidNaming = - create.new.element.hasAttribute('name') && - Array.from(create.new.parent.children).some( - elm => - elm.tagName === (create.new.element).tagName && - elm.getAttribute('name') === - (create.new.element).getAttribute('name') - ); - - if (invalidNaming) { - this.dispatchEvent( - newLogEvent({ - kind: 'error', - title: get('editing.error.create', { - name: create.new.element.tagName, - }), - message: get('editing.error.nameClash', { - parent: - create.new.parent instanceof HTMLElement - ? create.new.parent.tagName - : 'Document', - child: create.new.element.tagName, - name: create.new.element.getAttribute('name')!, - }), - }) - ); - - return false; - } - - const invalidId = - create.new.element.hasAttribute('id') && - Array.from( - create.new.parent.ownerDocument.querySelectorAll( - 'LNodeType, DOType, DAType, EnumType' - ) - ).some( - elm => - elm.getAttribute('id') === - (create.new.element).getAttribute('id') - ); - - if (invalidId) { - this.dispatchEvent( - newLogEvent({ - kind: 'error', - title: get('editing.error.create', { - name: create.new.element.tagName, - }), - message: get('editing.error.idClash', { - id: create.new.element.getAttribute('id')!, - }), - }) - ); - - return false; - } - - return true; - } - - private onCreate(action: Create) { - if (!this.checkCreateValidity(action)) return false; - - if ( - action.new.reference === undefined && - action.new.element instanceof Element && - action.new.parent instanceof Element - ) - action.new.reference = getReference( - action.new.parent, - action.new.element.tagName - ); - else action.new.reference = action.new.reference ?? null; - - action.new.parent.insertBefore(action.new.element, action.new.reference); - return true; - } - - private logCreate(action: Create) { - const name = - action.new.element instanceof Element - ? action.new.element.tagName - : get('editing.node'); - - this.dispatchEvent( - newLogEvent({ - kind: 'action', - title: get('editing.created', { name }), - action, - }) - ); - } - - private onDelete(action: Delete) { - if (!action.old.reference) - action.old.reference = action.old.element.nextSibling; - - if (action.old.element.parentNode !== action.old.parent) return false; - - action.old.parent.removeChild(action.old.element); - return true; - } - - private logDelete(action: Delete) { - const name = - action.old.element instanceof Element - ? action.old.element.tagName - : get('editing.node'); - - this.dispatchEvent( - newLogEvent({ - kind: 'action', - title: get('editing.deleted', { name }), - action, - }) - ); - } - - private checkMoveValidity(move: Move): boolean { - if (move.checkValidity !== undefined) return move.checkValidity(); - - const invalid = - move.old.element.hasAttribute('name') && - move.new.parent !== move.old.parent && - Array.from(move.new.parent.children).some( - elm => - elm.tagName === move.old.element.tagName && - elm.getAttribute('name') === move.old.element.getAttribute('name') - ); - - if (invalid) - this.dispatchEvent( - newLogEvent({ - kind: 'error', - title: get('editing.error.move', { - name: move.old.element.tagName, - }), - message: get('editing.error.nameClash', { - parent: move.new.parent.tagName, - child: move.old.element.tagName, - name: move.old.element.getAttribute('name')!, - }), - }) - ); - - return !invalid; - } - - private onMove(action: Move) { - if (!this.checkMoveValidity(action)) return false; - - if (!action.old.reference) - action.old.reference = action.old.element.nextSibling; - - if (action.new.reference === undefined) - action.new.reference = getReference( - action.new.parent, - action.old.element.tagName - ); - - action.new.parent.insertBefore(action.old.element, action.new.reference); - return true; - } - - private logMove(action: Move) { - this.dispatchEvent( - newLogEvent({ - kind: 'action', - title: get('editing.moved', { - name: action.old.element.tagName, - }), - action: action, - }) - ); - } - - private checkReplaceValidity(replace: Replace): boolean { - if (replace.checkValidity !== undefined) return replace.checkValidity(); - - const invalidNaming = - replace.new.element.hasAttribute('name') && - replace.new.element.getAttribute('name') !== - replace.old.element.getAttribute('name') && - Array.from(replace.old.element.parentElement?.children ?? []).some( - elm => - elm.tagName === replace.new.element.tagName && - elm.getAttribute('name') === replace.new.element.getAttribute('name') - ); - - if (invalidNaming) { - this.dispatchEvent( - newLogEvent({ - kind: 'error', - title: get('editing.error.update', { - name: replace.new.element.tagName, - }), - message: get('editing.error.nameClash', { - parent: replace.old.element.parentElement!.tagName, - child: replace.new.element.tagName, - name: replace.new.element.getAttribute('name')!, - }), - }) - ); - - return false; - } - - const invalidId = - replace.new.element.hasAttribute('id') && - replace.new.element.getAttribute('id') !== - replace.old.element.getAttribute('id') && - Array.from( - replace.new.element.ownerDocument.querySelectorAll( - 'LNodeType, DOType, DAType, EnumType' - ) - ).some( - elm => - elm.getAttribute('id') === - (replace.new.element).getAttribute('id') - ); - - if (invalidId) { - this.dispatchEvent( - newLogEvent({ - kind: 'error', - title: get('editing.error.update', { - name: replace.new.element.tagName, - }), - message: get('editing.error.idClash', { - id: replace.new.element.getAttribute('id')!, - }), - }) - ); - - return false; - } - - return true; - } - - private onReplace(action: Replace) { - if (!this.checkReplaceValidity(action)) return false; - - action.new.element.append(...Array.from(action.old.element.children)); - action.old.element.replaceWith(action.new.element); - return true; - } - - private logUpdate(action: Replace | Update) { - const name = isReplace(action) - ? action.new.element.tagName - : (action as Update).element.tagName; - - this.dispatchEvent( - newLogEvent({ - kind: 'action', - title: get('editing.updated', { - name, - }), - action: action, - }) - ); - } - - private checkUpdateValidity(update: Update): boolean { - if (update.checkValidity !== undefined) return update.checkValidity(); - - if (update.oldAttributes['name'] !== update.newAttributes['name']) { - const invalidNaming = Array.from( - update.element.parentElement?.children ?? [] - ).some( - elm => - elm.tagName === update.element.tagName && - elm.getAttribute('name') === update.newAttributes['name'] - ); - - if (invalidNaming) { - this.dispatchEvent( - newLogEvent({ - kind: 'error', - title: get('editing.error.update', { - name: update.element.tagName, - }), - message: get('editing.error.nameClash', { - parent: update.element.parentElement!.tagName, - child: update.element.tagName, - name: update.newAttributes['name']!, - }), - }) - ); - - return false; - } + private getLogText(edit: Edit): { title: string, message?: string } { + if (isInsert(edit)) { + const name = edit.node instanceof Element ? + edit.node.tagName : + get('editing.node'); + return { title: get('editing.created', { name }) }; + } else if (isUpdate(edit)) { + const name = edit.element.tagName; + return { title: get('editing.updated', { name }) }; + } else if (isRemove(edit)) { + const name = edit.node instanceof Element ? + edit.node.tagName : + get('editing.node'); + return { title: get('editing.deleted', { name }) }; + } else if (isComplex(edit)) { + const message = edit.map(e => this.getLogText(e)).map(({ title }) => title).join(', '); + return { title: get('editing.complex'), message }; } - const invalidId = - update.newAttributes['id'] && - Array.from( - update.element.ownerDocument.querySelectorAll( - 'LNodeType, DOType, DAType, EnumType' - ) - ).some(elm => elm.getAttribute('id') === update.newAttributes['id']); - - if (invalidId) { - this.dispatchEvent( - newLogEvent({ - kind: 'error', - title: get('editing.error.update', { - name: update.element.tagName, - }), - message: get('editing.error.idClash', { - id: update.newAttributes['id']!, - }), - }) - ); - - return false; - } - - return true; - } - - private onUpdate(action: Update) { - if (!this.checkUpdateValidity(action)) return false; - - Array.from(action.element.attributes).forEach(attr => - action.element.removeAttributeNode(attr) - ); - - Object.entries(action.newAttributes).forEach(([key, value]) => { - if (value !== null && value !== undefined) - action.element.setAttribute(key, value); - }); - - return true; - } - - private onSimpleAction(action: SimpleAction) { - if (isMove(action)) return this.onMove(action as Move); - else if (isCreate(action)) return this.onCreate(action as Create); - else if (isDelete(action)) return this.onDelete(action as Delete); - else if (isReplace(action)) return this.onReplace(action as Replace); - else if (isUpdate(action)) return this.onUpdate(action as Update); + return { title: '' }; } - private logSimpleAction(action: SimpleAction) { - if (isMove(action)) this.logMove(action as Move); - else if (isCreate(action)) this.logCreate(action as Create); - else if (isDelete(action)) this.logDelete(action as Delete); - else if (isReplace(action)) this.logUpdate(action as Replace); - else if (isUpdate(action)) this.logUpdate(action as Update); - } - - private async onAction(event: EditorActionEvent) { - if (isSimple(event.detail.action)) { - if (this.onSimpleAction(event.detail.action)) - this.logSimpleAction(event.detail.action); - } else if (event.detail.action.actions.length > 0) { - event.detail.action.actions.forEach(element => - this.onSimpleAction(element) - ); - this.dispatchEvent( - newLogEvent({ - kind: 'action', - title: event.detail.action.title, - action: event.detail.action, - }) - ); - } else return; - - if (!this.doc) return; + private onAction(event: EditorActionEvent) { + const edit = convertEditV1toV2(event.detail.action); + const initiator = event.detail.initiator; - await this.updateComplete; - this.dispatchEvent(newValidateEvent()); - this.dispatchEvent( - newEditCompletedEvent(event.detail.action, event.detail.initiator) - ); + this.host.dispatchEvent(newEditEvent(edit, initiator)); } /** @@ -470,7 +106,10 @@ export class OscdEditor extends LitElement { connectedCallback(): void { super.connectedCallback(); + // Deprecated editor action API, use 'oscd-edit' instead. this.host.addEventListener('editor-action', this.onAction.bind(this)); + + this.host.addEventListener('oscd-edit', event => this.handleEditEvent(event)); this.host.addEventListener('open-doc', this.onOpenDoc); this.host.addEventListener('oscd-open', this.handleOpenDoc); } @@ -478,4 +117,131 @@ export class OscdEditor extends LitElement { render(): TemplateResult { return html``; } + + async handleEditEvent(event: EditEvent) { + const edit = event.detail.edit; + const undoEdit = handleEdit(edit); + + this.dispatchEvent( + newEditCompletedEvent(event.detail.edit, event.detail.initiator) + ); + + const shouldCreateHistoryEntry = event.detail.initiator !== 'redo' && event.detail.initiator !== 'undo'; + + if (shouldCreateHistoryEntry) { + const { title, message } = this.getLogText(edit); + + this.dispatchEvent(newLogEvent({ + kind: 'action', + title, + message, + redo: edit, + undo: undoEdit, + })); + } + + await this.updateComplete; + this.dispatchEvent(newValidateEvent()); + } +} + +function handleEdit(edit: Edit): Edit { + if (isInsert(edit)) return handleInsert(edit); + if (isUpdate(edit)) return handleUpdate(edit); + if (isRemove(edit)) return handleRemove(edit); + if (isComplex(edit)) return edit.map(handleEdit).reverse(); + return []; +} + +function localAttributeName(attribute: string): string { + return attribute.includes(':') ? attribute.split(':', 2)[1] : attribute; +} + +function handleInsert({ + parent, + node, + reference, +}: Insert): Insert | Remove | [] { + try { + const { parentNode, nextSibling } = node; + + /** + * This is a workaround for converted edit api v1 events, + * because if multiple edits are converted, they are converted before the changes from the previous edits are applied to the document + * so if you first remove an element and then add a clone with changed attributes, the reference will be the element to remove since it hasnt been removed yet + */ + if (!parent.contains(reference)) { + reference = null; + } + + parent.insertBefore(node, reference); + if (parentNode) + return { + node, + parent: parentNode, + reference: nextSibling, + }; + return { node }; + } catch (e) { + // do nothing if insert doesn't work on these nodes + return []; + } } + +function handleUpdate({ element, attributes }: Update): Update { + const oldAttributes = { ...attributes }; + Object.entries(attributes) + .reverse() + .forEach(([name, value]) => { + let oldAttribute: AttributeValue; + if (isNamespaced(value!)) + oldAttribute = { + value: element.getAttributeNS( + value.namespaceURI, + localAttributeName(name) + ), + namespaceURI: value.namespaceURI, + }; + else + oldAttribute = element.getAttributeNode(name)?.namespaceURI + ? { + value: element.getAttribute(name), + namespaceURI: element.getAttributeNode(name)!.namespaceURI!, + } + : element.getAttribute(name); + oldAttributes[name] = oldAttribute; + }); + for (const entry of Object.entries(attributes)) { + try { + const [attribute, value] = entry as [string, AttributeValue]; + if (isNamespaced(value)) { + if (value.value === null) + element.removeAttributeNS( + value.namespaceURI, + localAttributeName(attribute) + ); + else element.setAttributeNS(value.namespaceURI, attribute, value.value); + } else if (value === null) element.removeAttribute(attribute); + else element.setAttribute(attribute, value); + } catch (e) { + // do nothing if update doesn't work on this attribute + delete oldAttributes[entry[0]]; + } + } + return { + element, + attributes: oldAttributes, + }; +} + +function handleRemove({ node }: Remove): Insert | [] { + const { parentNode: parent, nextSibling: reference } = node; + node.parentNode?.removeChild(node); + if (parent) + return { + node, + parent, + reference, + }; + return []; +} \ No newline at end of file diff --git a/packages/openscd/src/addons/History.ts b/packages/openscd/src/addons/History.ts index 51ad9af99a..a6db95729a 100644 --- a/packages/openscd/src/addons/History.ts +++ b/packages/openscd/src/addons/History.ts @@ -35,14 +35,28 @@ import { LogEvent, } from '@openscd/core/foundation/deprecated/history.js'; -import { - newActionEvent, - invert, -} from '@openscd/core/foundation/deprecated/editor.js'; - import { getFilterIcon, iconColors } from '../icons/icons.js'; import { Plugin } from '../plugin.js'; +import { newEditEvent } from '@openscd/core'; + +export const historyStateEvent = 'history-state'; +export interface HistoryState { + editCount: number; + canUndo: boolean; + canRedo: boolean; +} +export type HistoryStateEvent = CustomEvent; + +function newHistoryStateEvent(state: HistoryState): HistoryStateEvent { + return new CustomEvent(historyStateEvent, { detail: state }); +} + +declare global { + interface ElementEventMap { + [historyStateEvent]: HistoryStateEvent; + } +} const icons = { info: 'info', @@ -197,18 +211,20 @@ export class OscdHistory extends LitElement { undo(): boolean { if (!this.canUndo) return false; - const invertedAction = invert( - (this.history[this.editCount]).action - ); - this.dispatchEvent(newActionEvent(invertedAction, 'undo')); - this.editCount = this.previousAction; + + const undoEdit = (this.history[this.editCount]).undo; + this.host.dispatchEvent(newEditEvent(undoEdit, 'undo')); + this.setEditCount(this.previousAction); + return true; } redo(): boolean { if (!this.canRedo) return false; - const nextAction = (this.history[this.nextAction]).action; - this.dispatchEvent(newActionEvent(nextAction, 'redo')); - this.editCount = this.nextAction; + + const redoEdit = (this.history[this.nextAction]).redo; + this.host.dispatchEvent(newEditEvent(redoEdit, 'redo')); + this.setEditCount(this.nextAction); + return true; } @@ -218,21 +234,34 @@ export class OscdHistory extends LitElement { ...detail, }; - if (entry.kind === 'action') { - if (entry.action.derived) return; - entry.action.derived = true; - if (this.nextAction !== -1) this.history.splice(this.nextAction); - this.editCount = this.history.length; + if (this.nextAction !== -1) { + this.history.splice(this.nextAction); } this.history.push(entry); + this.setEditCount(this.history.length - 1); this.requestUpdate('history', []); } private onReset() { this.log = []; this.history = []; - this.editCount = -1; + this.setEditCount(-1); + } + + private setEditCount(count: number): void { + this.editCount = count; + this.dispatchHistoryStateEvent(); + } + + private dispatchHistoryStateEvent(): void { + this.host.dispatchEvent( + newHistoryStateEvent({ + editCount: this.editCount, + canUndo: this.canUndo, + canRedo: this.canRedo + }) + ); } private onInfo(detail: InfoDetail) { @@ -310,6 +339,7 @@ export class OscdHistory extends LitElement { this.historyUIHandler = this.historyUIHandler.bind(this); this.emptyIssuesHandler = this.emptyIssuesHandler.bind(this); this.handleKeyPress = this.handleKeyPress.bind(this); + this.dispatchHistoryStateEvent = this.dispatchHistoryStateEvent.bind(this); document.onkeydown = this.handleKeyPress; } diff --git a/packages/openscd/src/addons/Layout.ts b/packages/openscd/src/addons/Layout.ts index 4b54097f54..572d96facc 100644 --- a/packages/openscd/src/addons/Layout.ts +++ b/packages/openscd/src/addons/Layout.ts @@ -29,6 +29,7 @@ import { } from "../plugin.js" import { + HistoryState, HistoryUIKind, newEmptyIssuesEvent, newHistoryUIEvent, @@ -86,23 +87,15 @@ export class OscdLayout extends LitElement { @property({ type: Object }) host!: HTMLElement; + @property({ type: Object }) + historyState!: HistoryState; + @state() validated: Promise = Promise.resolve(); @state() shouldValidate = false; - @state() - redoCount = 0; - - get canUndo(): boolean { - return this.editCount >= 0; - } - - get canRedo(): boolean { - return this.redoCount > 0; - } - @query('#menu') menuUI!: Drawer; @query('#pluginManager') @@ -156,7 +149,7 @@ export class OscdLayout extends LitElement { action: (): void => { this.dispatchEvent(newUndoEvent()); }, - disabled: (): boolean => !this.canUndo, + disabled: (): boolean => !this.historyState.canUndo, kind: 'static', }, { @@ -166,7 +159,7 @@ export class OscdLayout extends LitElement { action: (): void => { this.dispatchEvent(newRedoEvent()); }, - disabled: (): boolean => !this.canRedo, + disabled: (): boolean => !this.historyState.canRedo, kind: 'static', }, ...validators, @@ -313,21 +306,6 @@ export class OscdLayout extends LitElement { this.handleKeyPress = this.handleKeyPress.bind(this); document.onkeydown = this.handleKeyPress; - this.host.addEventListener( - 'oscd-edit-completed', - (evt: EditCompletedEvent) => { - const initiator = evt.detail.initiator; - - if (initiator === 'undo') { - this.redoCount += 1; - } else if (initiator === 'redo') { - this.redoCount -= 1; - } - - this.requestUpdate(); - } - ); - document.addEventListener("open-plugin-download", () => { this.pluginDownloadUI.show(); }); diff --git a/packages/openscd/src/addons/editor/edit-v1-to-v2-converter.ts b/packages/openscd/src/addons/editor/edit-v1-to-v2-converter.ts new file mode 100644 index 0000000000..f33d76d27f --- /dev/null +++ b/packages/openscd/src/addons/editor/edit-v1-to-v2-converter.ts @@ -0,0 +1,130 @@ +import { + Create, + Delete, + EditorAction, + isCreate, + isDelete, + isMove, + isReplace, + isSimple, + isUpdate, + Move, + Replace, + SimpleAction, + Update +} from '@openscd/core/foundation/deprecated/editor.js'; +import { Edit, Insert, Remove, Update as UpdateV2 } from '@openscd/core'; +import { getReference, SCLTag } from '../../foundation.js'; + + +export function convertEditV1toV2(action: EditorAction): Edit { + if (isSimple(action)) { + return convertSimpleAction(action); + } else { + return action.actions.map(convertSimpleAction); + } +} + +function convertSimpleAction(action: SimpleAction): Edit { + if (isCreate(action)) { + return convertCreate(action); + } else if (isDelete(action)) { + return convertDelete(action); + } else if (isUpdate(action)) { + return convertUpdate(action); + } else if (isMove(action)) { + return convertMove(action); + } else if (isReplace(action)) { + return convertReplace(action); + } + + throw new Error('Unknown action type'); +} + +function convertCreate(action: Create): Insert { + let reference: Node | null = null; + if ( + action.new.reference === undefined && + action.new.element instanceof Element && + action.new.parent instanceof Element + ) { + reference = getReference( + action.new.parent, + action.new.element.tagName + ); + } else { + reference = action.new.reference ?? null; + } + + return { + parent: action.new.parent, + node: action.new.element, + reference + }; +} + +function convertDelete(action: Delete): Remove { + return { + node: action.old.element + }; +} + +function convertUpdate(action: Update): UpdateV2 { + const oldAttributesToRemove: Record = {}; + Array.from(action.element.attributes).forEach(attr => { + oldAttributesToRemove[attr.name] = null; + }); + + const attributes = { + ...oldAttributesToRemove, + ...action.newAttributes + }; + + return { + element: action.element, + attributes + }; +} + +function convertMove(action: Move): Insert { + if (action.new.reference === undefined) { + action.new.reference = getReference( + action.new.parent, + action.old.element.tagName + ); + } + + return { + parent: action.new.parent, + node: action.old.element, + reference: action.new.reference ?? null + } +} + +function convertReplace(action: Replace): Edit { + const oldChildren = action.old.element.children; + // We have to clone the children, because otherwise undoing the action would remove the children from the old element, because append removes the old parent + const copiedChildren = Array.from(oldChildren).map(e => e.cloneNode(true)); + + const newNode = action.new.element.cloneNode(true) as Element; + newNode.append(...Array.from(copiedChildren)); + const parent = action.old.element.parentElement; + + if (!parent) { + throw new Error('Replace action called without parent in old element'); + } + + const reference = action.old.element.nextSibling; + + const remove: Remove = { node: action.old.element }; + const insert: Insert = { + parent, + node: newNode, + reference + }; + + return [ + remove, + insert + ]; +} diff --git a/packages/openscd/src/open-scd.ts b/packages/openscd/src/open-scd.ts index 02956d59d8..5c47f77936 100644 --- a/packages/openscd/src/open-scd.ts +++ b/packages/openscd/src/open-scd.ts @@ -45,11 +45,13 @@ import type { Plugin as CorePlugin, EditCompletedEvent, } from '@openscd/core'; + +import { HistoryState, historyStateEvent } from './addons/History.js'; + import { InstalledOfficialPlugin, MenuPosition, PluginKind, Plugin } from "./plugin.js" import { ConfigurePluginEvent, ConfigurePluginDetail, newConfigurePluginEvent } from './plugin.events.js'; import { newLogEvent } from '@openscd/core/foundation/deprecated/history'; - // HOSTING INTERFACES export interface MenuItem { @@ -227,9 +229,12 @@ export class OpenSCD extends LitElement { /** The UUID of the current [[`doc`]] */ @property({ type: String }) docId = ''; - /** Index of the last [[`EditorAction`]] applied. */ @state() - editCount = -1; + historyState: HistoryState = { + editCount: -1, + canRedo: false, + canUndo: false, + } /** Object containing all *.nsdoc files and a function extracting element's label form them*/ @property({ attribute: false }) @@ -314,15 +319,8 @@ export class OpenSCD extends LitElement { this.updatePlugins(); this.requestUpdate(); - this.addEventListener('oscd-edit-completed', (evt: EditCompletedEvent) => { - const initiator = evt.detail.initiator; - - if (initiator === 'undo') { - this.editCount -= 1; - } else { - this.editCount += 1; - } - + this.addEventListener(historyStateEvent, (e: CustomEvent) => { + this.historyState = e.detail; this.requestUpdate(); }); } @@ -331,13 +329,13 @@ export class OpenSCD extends LitElement { return html` - + @@ -576,7 +575,7 @@ export class OpenSCD extends LitElement { content: staticTagHtml`<${tag} .doc=${this.doc} .docName=${this.docName} - .editCount=${this.editCount} + .editCount=${this.historyState.editCount} .docId=${this.docId} .pluginId=${plugin.src} .nsdoc=${this.nsdoc} diff --git a/packages/openscd/src/translations/de.ts b/packages/openscd/src/translations/de.ts index 81d1e88891..67e314ede6 100644 --- a/packages/openscd/src/translations/de.ts +++ b/packages/openscd/src/translations/de.ts @@ -117,6 +117,7 @@ export const de: Translations = { moved: '{{ name }} verschoben', updated: '{{ name }} bearbeitet', import: '{{name}} importiert', + complex: 'Mehrere Elemente bearbeitet', error: { create: 'Konnte {{ name }} nicht hinzufügen', update: 'Konnte {{ name }} nicht bearbeiten', diff --git a/packages/openscd/src/translations/en.ts b/packages/openscd/src/translations/en.ts index 156be665bb..ca983fd40d 100644 --- a/packages/openscd/src/translations/en.ts +++ b/packages/openscd/src/translations/en.ts @@ -115,6 +115,7 @@ export const en = { moved: 'Moved {{ name }}', updated: 'Edited {{ name }}', import: 'Imported {{name}}', + complex: 'Multiple elements edited', error: { create: 'Could not add {{ name }}', update: 'Could not edit {{ name }}', diff --git a/packages/openscd/test/integration/Editing.test.ts b/packages/openscd/test/integration/Editing.test.ts index 809bcdb746..9ecee22da6 100644 --- a/packages/openscd/test/integration/Editing.test.ts +++ b/packages/openscd/test/integration/Editing.test.ts @@ -209,8 +209,9 @@ describe('Editing-Logging integration', () => { expect(element.parentElement).to.equal(parent); }); - it('can be redone', () => { + it('can be redone', async () => { elm.dispatchEvent( + // Replace: Q01 -> Q03 (new element) newActionEvent({ old: { element }, new: { element: newElement } }) ); @@ -218,30 +219,33 @@ describe('Editing-Logging integration', () => { elm.history.redo(); - expect(newElement.parentElement).to.equal(parent); + const newEle = parent.querySelector('Bay[name="Q03"]')!; + + expect(newEle.parentElement).to.equal(parent); expect(element.parentElement).to.be.null; }); - it('correctly copying child elements between element and newElement for multiple undo/redo', () => { + it('correctly copying child elements between element and newElement for multiple undo/redo', async () => { const originOldChildCount = element.children.length; - const originNewChildCount = newElement.children.length; elm.dispatchEvent( newActionEvent({ old: { element }, new: { element: newElement } }) ); - expect(element.children).to.have.lengthOf(originNewChildCount); - expect(newElement.children).to.have.lengthOf(originOldChildCount); + + let newEle = parent.querySelector('Bay[name="Q03"]')!; + expect(newEle.children).to.have.lengthOf(originOldChildCount); elm.history.undo(); elm.history.redo(); elm.history.undo(); - expect(element.children).to.have.lengthOf(originOldChildCount); - expect(newElement.children).to.have.lengthOf(originNewChildCount); + + const ele = parent.querySelector('Bay[name="Q01"]')!; + expect(ele.children).to.have.lengthOf(originOldChildCount); elm.history.redo(); - expect(element.children).to.have.lengthOf(originNewChildCount); - expect(newElement.children).to.have.lengthOf(originOldChildCount); + newEle = parent.querySelector('Bay[name="Q03"]')!; + expect(newEle.children).to.have.lengthOf(originOldChildCount); }); }); diff --git a/packages/openscd/test/mock-edits.ts b/packages/openscd/test/mock-edits.ts new file mode 100644 index 0000000000..be49a4f387 --- /dev/null +++ b/packages/openscd/test/mock-edits.ts @@ -0,0 +1,16 @@ +import { Edit, Insert, Remove, Update } from '@openscd/core'; + + +const element = document.createElement('test-element'); +const parent = document.createElement('test-parent'); +const reference = document.createElement('test-sibling'); + +parent.appendChild(element); +parent.appendChild(reference); + +export const mockEdits = { + insert: (): Insert => ({ parent, node: element, reference }), + remove: (): Remove => ({ node: element }), + update: (): Update => ({ element, attributes: { test: 'value' } }), + complex: (): Edit[] => [ mockEdits.insert(), mockEdits.remove(), mockEdits.update() ], +} diff --git a/packages/openscd/test/mock-wizard-editor.ts b/packages/openscd/test/mock-wizard-editor.ts index b30adfa324..a1962b0846 100644 --- a/packages/openscd/test/mock-wizard-editor.ts +++ b/packages/openscd/test/mock-wizard-editor.ts @@ -1,22 +1,39 @@ -import { Editing } from '../src/Editing.js'; import { LitElement, customElement, TemplateResult, html, query, + property } from 'lit-element'; import '../src/addons/Wizards.js'; + +import '../src/addons/Editor.js'; + import { OscdWizards } from '../src/addons/Wizards.js'; @customElement('mock-wizard-editor') -export class MockWizardEditor extends Editing(LitElement) { +export class MockWizardEditor extends LitElement { + @property({ type: Object }) doc!: XMLDocument; + @query('oscd-wizards') wizards!: OscdWizards; render(): TemplateResult { - return html``; + return html` + + + + + + `; } get wizardUI() { diff --git a/packages/openscd/test/unit/Editing.test.ts b/packages/openscd/test/unit/Editing.test.ts deleted file mode 100644 index 4b1dfb8351..0000000000 --- a/packages/openscd/test/unit/Editing.test.ts +++ /dev/null @@ -1,465 +0,0 @@ -import { html, fixture, expect } from '@open-wc/testing'; -import { SinonSpy, spy } from 'sinon'; - -import './mock-editor.js'; -import { MockEditor } from './mock-editor.js'; - -import { createUpdateAction, newActionEvent } from '@openscd/core/foundation/deprecated/editor.js'; - -describe('EditingElement', () => { - let elm: MockEditor; - let doc: XMLDocument; - let parent: Element; - let element: Element; - let reference: Node | null; - - let validateEvent: SinonSpy; - - beforeEach(async () => { - doc = await fetch('/test/testfiles/Editing.scd') - .then(response => response.text()) - .then(str => new DOMParser().parseFromString(str, 'application/xml')); - elm = ( - await fixture(html``) - ); - - parent = elm.doc!.querySelector('VoltageLevel[name="E1"]')!; - element = parent.querySelector('Bay[name="Q01"]')!; - reference = element.nextSibling; - - validateEvent = spy(); - window.addEventListener('validate', validateEvent); - }); - - it('creates an element on receiving a Create Action', () => { - elm.dispatchEvent( - newActionEvent({ - new: { - parent, - element: elm.doc!.createElement('newBay'), - reference: null, - }, - }) - ); - expect(elm.doc!.querySelector('newBay')).to.not.be.null; - }); - - it('creates an Node on receiving a Create Action', () => { - const testNode = document.createTextNode('myTestNode'); - - elm.dispatchEvent( - newActionEvent({ - new: { - parent, - element: testNode, - }, - }) - ); - expect(parent.lastChild).to.equal(testNode); - }); - - it('creates the Node based on the reference definition', () => { - const testNode = document.createTextNode('myTestNode'); - - elm.dispatchEvent( - newActionEvent({ - new: { - parent, - element: testNode, - reference: parent.firstChild, - }, - }) - ); - expect(parent.firstChild).to.equal(testNode); - }); - - it('triggers getReference with missing reference on Create Action', () => { - elm.dispatchEvent( - newActionEvent({ - new: { - parent, - element: elm.doc!.createElement('Bay'), - }, - }) - ); - expect(parent.querySelector('Bay')?.nextElementSibling).to.equal( - parent.querySelector('Bay[name="Q01"]') - ); - }); - - it('ignores getReference with existing reference on Create Action', () => { - const newElement = elm.doc!.createElement('Bay'); - newElement?.setAttribute('name', 'Q03'); - - elm.dispatchEvent( - newActionEvent({ - new: { - parent, - element: newElement, - reference: parent.querySelector('Bay[name="Q02"]'), - }, - }) - ); - expect( - parent.querySelector('Bay[name="Q03"]')?.nextElementSibling - ).to.equal(parent.querySelector('Bay[name="Q02"]')); - }); - - it('does not creates an element on name attribute conflict', () => { - const newElement = elm.doc!.createElement('Bay'); - newElement?.setAttribute('name', 'Q01'); - - elm.dispatchEvent( - newActionEvent({ - new: { - parent, - element: newElement, - reference: null, - }, - }) - ); - expect(parent.querySelectorAll('Bay[name="Q01"]').length).to.be.equal(1); - }); - - it('does not creates an element on id attribute conflict', () => { - const newElement = elm.doc!.createElement('DOType'); - newElement?.setAttribute('id', 'testId'); - - elm.dispatchEvent( - newActionEvent({ - new: { - parent: doc.querySelector('DataTypeTemplates')!, - element: newElement, - reference: null, - }, - }) - ); - expect(doc.querySelector('DOType')).to.be.null; - }); - - it('deletes an element on receiving a Delete action', () => { - elm.dispatchEvent( - newActionEvent({ - old: { - parent, - element, - reference, - }, - }) - ); - expect(elm.doc!.querySelector('VoltageLevel[name="E1"] > Bay[name="Q01"]')) - .to.be.null; - }); - - it('deletes a Node on receiving a Delete action', () => { - const testNode = document.createTextNode('myTestNode'); - parent.appendChild(testNode); - expect(testNode.parentNode).to.be.equal(parent); - - elm.dispatchEvent( - newActionEvent({ - old: { - parent, - element: testNode, - }, - }) - ); - - expect(parent.lastChild).to.not.equal(testNode); - expect(testNode.parentNode).to.be.null; - }); - - it('correctly handles incorrect delete action definition', () => { - const testNode = document.createTextNode('myTestNode'); - expect(testNode.parentNode).to.null; - - elm.dispatchEvent( - newActionEvent({ - old: { - parent, - element: testNode, - }, - }) - ); - - expect(parent.lastChild).to.not.equal(testNode); - expect(testNode.parentNode).to.null; - }); - - it('replaces an element on receiving an Replace action', () => { - elm.dispatchEvent( - newActionEvent({ - old: { - element, - }, - new: { - element: elm.doc!.createElement('newBay'), - }, - }) - ); - expect(parent.querySelector('Bay[name="Q01"]')).to.be.null; - expect(parent.querySelector('newBay')).to.not.be.null; - expect(parent.querySelector('newBay')?.nextElementSibling).to.equal( - parent.querySelector('Bay[name="Q02"]') - ); - }); - - it('does not replace an element in case of name conflict', () => { - const newElement = elm.doc!.createElement('Bay'); - newElement?.setAttribute('name', 'Q02'); - - elm.dispatchEvent( - newActionEvent({ - old: { - element, - }, - new: { - element: newElement, - }, - }) - ); - expect(parent.querySelector('Bay[name="Q01"]')).to.not.be.null; - expect( - parent.querySelector('Bay[name="Q01"]')?.nextElementSibling - ).to.equal(parent.querySelector('Bay[name="Q02"]')); - }); - - it('replaces id defined element on receiving Replace action', () => { - expect(doc.querySelector('LNodeType[id="testId"]')).to.not.be.null; - - const newElement = doc.createElement('LNodeType'); - newElement?.setAttribute('id', 'testId3'); - - elm.dispatchEvent( - newActionEvent({ - old: { - element: doc.querySelector('LNodeType[id="testId"]')!, - }, - new: { - element: newElement, - }, - }) - ); - expect(elm.doc!.querySelector('LNodeType[id="testId"]')).to.be.null; - expect(elm.doc!.querySelector('LNodeType[id="testId3"]')).to.not.be.null; - }); - - it('does not replace an element in case of id conflict', () => { - expect(doc.querySelector('LNodeType[id="testId"]')).to.not.be.null; - - const newElement = elm.doc!.createElement('LNodeType'); - newElement?.setAttribute('id', 'testId1'); - - elm.dispatchEvent( - newActionEvent({ - old: { - element: doc.querySelector('LNodeType[id="testId"]')!, - }, - new: { - element: newElement, - }, - }) - ); - expect(elm.doc!.querySelector('LNodeType[id="testId"]')).to.not.be.null; - expect(elm.doc!.querySelector('LNodeType[id="testId1"]')).to.be.null; - }); - - it('moves an element on receiving a Move action', () => { - elm.dispatchEvent( - newActionEvent({ - old: { - parent, - element, - reference, - }, - new: { - parent: elm.doc!.querySelector('VoltageLevel[name="J1"]')!, - reference: null, - }, - }) - ); - expect(parent.querySelector('Bay[name="Q01"]')).to.be.null; - expect(elm.doc!.querySelector('VoltageLevel[name="J1"] > Bay[name="Q01"]')) - .to.not.be.null; - }); - - it('triggers getReference with missing reference on Move action', () => { - elm.dispatchEvent( - newActionEvent({ - old: { - parent, - element, - reference, - }, - new: { - parent: elm.doc!.querySelector('VoltageLevel[name="J1"]')!, - }, - }) - ); - expect(parent.querySelector('Bay[name="Q01"]')).to.be.null; - expect(elm.doc!.querySelector('VoltageLevel[name="J1"] > Bay[name="Q01"]')) - .to.not.be.null; - expect( - elm.doc!.querySelector('VoltageLevel[name="J1"] > Bay[name="Q01"]') - ?.nextElementSibling - ).to.equal(elm.doc!.querySelector('VoltageLevel[name="J1"] > Function')); - }); - - it('does not move an element in case of name conflict', () => { - elm.dispatchEvent( - newActionEvent({ - old: { - parent, - element, - reference, - }, - new: { - parent: elm.doc!.querySelector('VoltageLevel[name="J1"]')!, - reference: null, - }, - }) - ); - expect(parent.querySelector('Bay[name="Q01"]')).to.be.null; - expect(elm.doc!.querySelector('VoltageLevel[name="J1"] > Bay[name="Q01"]')) - .to.not.be.null; - expect( - elm.doc!.querySelector('VoltageLevel[name="J1"] > Bay[name="Q01"]') - ?.nextElementSibling - ).to.be.null; - }); - - it('updates an element on receiving an Update action', () => { - const newAttributes: Record = {}; - newAttributes['name'] = 'Q03'; - - elm.dispatchEvent( - newActionEvent(createUpdateAction(element, newAttributes)) - ); - - expect(element.parentElement).to.equal(parent); - expect(element).to.have.attribute('name', 'Q03'); - expect(element).to.not.have.attribute('desc'); - }); - - it('allows empty string as attribute value', () => { - const newAttributes: Record = {}; - newAttributes['name'] = ''; - - elm.dispatchEvent( - newActionEvent(createUpdateAction(element, newAttributes)) - ); - - expect(element.parentElement).to.equal(parent); - expect(element).to.have.attribute('name', ''); - expect(element).to.not.have.attribute('desc'); - }); - - it('does not update an element in case of name conflict', () => { - const newAttributes: Record = {}; - newAttributes['name'] = 'Q02'; - - elm.dispatchEvent( - newActionEvent(createUpdateAction(element, newAttributes)) - ); - - expect(element.parentElement).to.equal(parent); - expect(element).to.have.attribute('name', 'Q01'); - expect(element).to.have.attribute('desc', 'Bay'); - }); - - it('does not update an element in case of id conflict', () => { - const newAttributes: Record = {}; - newAttributes['id'] = 'testId1'; - - elm.dispatchEvent( - newActionEvent( - createUpdateAction(doc.querySelector('LNodeType')!, newAttributes) - ) - ); - - expect(elm.doc!.querySelector('LNodeType[id="testId"]')).to.exist; - expect(elm.doc!.querySelector('LNodeType[id="testId1"]')).to.not.exist; - }); - - it('carries out subactions sequentially on receiving a ComplexAction', () => { - const child3 = elm.doc!.createElement('newBay'); - elm.dispatchEvent( - newActionEvent({ - title: 'Test complex action', - actions: [ - { - old: { element }, - new: { element: child3 }, - }, - { - old: { - parent, - element: child3, - reference, - }, - new: { - parent: elm.doc!.querySelector('VoltageLevel[name="J1"]')!, - reference: null, - }, - }, - ], - }) - ); - expect(parent.querySelector('Bay[name="Q01"]')).to.be.null; - expect(elm.doc!.querySelector('VoltageLevel[name="J1"] > newBay')).to.not.be - .null; - }); - - it('triggers a validation event on receiving a ComplexAction', async () => { - const child3 = elm.doc!.createElement('newBay'); - elm.dispatchEvent( - newActionEvent({ - title: 'Test complex action', - actions: [ - { - old: { element }, - new: { element: child3 }, - }, - { - old: { - parent, - element: child3, - reference, - }, - new: { - parent: elm.doc!.querySelector('VoltageLevel[name="J1"]')!, - reference: null, - }, - }, - ], - }) - ); - await elm.updateComplete; - - expect(validateEvent).to.be.calledOnce; - }); - - it('does not exchange doc with empty complex action', async () => { - elm.dispatchEvent( - newActionEvent({ - title: 'Test complex action', - actions: [], - }) - ); - await elm.updateComplete; - - expect(doc).to.equal(elm.doc); - }); - - it('does not trigger validation with empty complex action', async () => { - elm.dispatchEvent( - newActionEvent({ - title: 'Test complex action', - actions: [], - }) - ); - await elm.updateComplete; - - expect(validateEvent).to.not.been.called; - }); -}); diff --git a/packages/openscd/test/unit/Editor.test.ts b/packages/openscd/test/unit/Editor.test.ts new file mode 100644 index 0000000000..f26079472a --- /dev/null +++ b/packages/openscd/test/unit/Editor.test.ts @@ -0,0 +1,486 @@ +import { html, fixture, expect } from '@open-wc/testing'; + +import '../../src/addons/Editor.js'; +import { OscdEditor } from '../../src/addons/Editor.js'; +import { Insert, newEditEvent, Remove, Update } from '@openscd/core'; +import { CommitDetail, LogDetail } from '@openscd/core/foundation/deprecated/history.js'; + + +describe('OSCD-Editor', () => { + let element: OscdEditor; + let host: HTMLElement; + let scd: XMLDocument; + + let voltageLevel1: Element; + let voltageLevel2: Element; + let bay1: Element; + let bay2: Element; + let bay4: Element; + let bay5: Element; + let lnode1: Element; + let lnode2: Element; + + const nsXsi = 'urn:example.com'; + const nsTd = 'urn:typedesigner.com'; + + beforeEach(async () => { + scd = new DOMParser().parseFromString( + ` + + + + + + + + + + + + + + + `, + 'application/xml', + ); + + host = document.createElement('div'); + + element = await fixture(html``, { parentNode: host }); + + voltageLevel1 = scd.querySelector('VoltageLevel[name="v1"]')!; + voltageLevel2 = scd.querySelector('VoltageLevel[name="v2"]')!; + bay1 = scd.querySelector('Bay[name="b1"]')!; + bay2 = scd.querySelector('Bay[name="b2"]')!; + bay4 = scd.querySelector('Bay[name="b4"]')!; + bay5 = scd.querySelector('Bay[name="b5"]')!; + lnode1 = scd.querySelector('LNode[name="l1"]')!; + lnode2 = scd.querySelector('LNode[name="l2"]')!; + }); + + describe('Editing', () => { + it('should insert new node', () => { + const newNode = scd.createElement('Bay'); + newNode.setAttribute('name', 'b3'); + + const insert: Insert = { + parent: voltageLevel1, + node: newNode, + reference: null + }; + + host.dispatchEvent(newEditEvent(insert)); + + const newNodeFromScd = scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b3"]'); + + expect(newNodeFromScd).to.deep.equal(newNode); + }); + + it('should insert new node before reference', () => { + const newNode = scd.createElement('Bay'); + newNode.setAttribute('name', 'b3'); + + const insert: Insert = { + parent: voltageLevel1, + node: newNode, + reference: bay1 + }; + + host.dispatchEvent(newEditEvent(insert)); + + const newNodeFromScd = scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b3"]'); + + expect(newNodeFromScd?.nextSibling).to.deep.equal(bay1); + }); + + it('should move node when inserting existing node', () => { + const insertMove: Insert = { + parent: voltageLevel1, + node: bay2, + reference: null + }; + + host.dispatchEvent(newEditEvent(insertMove)); + + expect(scd.querySelector('VoltageLevel[name="v2"] > Bay[name="b2"]')).to.be.null; + expect(scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b2"]')).to.deep.equal(bay2); + }); + + it('should remove node', () => { + const remove: Remove = { + node: bay1 + }; + + host.dispatchEvent(newEditEvent(remove)); + + expect(scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b1"]')).to.be.null; + }); + + describe('Update', () => { + it('should add new attributes and leave old attributes', () => { + const bay1NewAttributes = { + desc: 'new description', + type: 'Superbay' + }; + + const oldAttributes = elementAttributesToMap(bay1); + + const update: Update = { + element: bay1, + attributes: bay1NewAttributes + }; + + host.dispatchEvent(newEditEvent(update)); + + const updatedElement = scd.querySelector('Bay[name="b1"]')!; + + const expectedAttributes = { + ...oldAttributes, + ...bay1NewAttributes + }; + + expect(elementAttributesToMap(updatedElement)).to.deep.equal(expectedAttributes); + }); + + it('should remove attribute with null value', () => { + const bay1NewAttributes = { + kind: null + }; + + const update: Update = { + element: bay1, + attributes: bay1NewAttributes + }; + + host.dispatchEvent(newEditEvent(update)); + + const updatedElement = scd.querySelector('Bay[name="b1"]')!; + + expect(updatedElement.getAttribute('kind')).to.be.null; + }); + + it('should change, add and remove attributes in one update', () => { + const bay1NewAttributes = { + name: 'b5', + kind: null, + desc: 'new description' + }; + + const oldAttributes = elementAttributesToMap(bay1); + + const update: Update = { + element: bay1, + attributes: bay1NewAttributes + }; + + host.dispatchEvent(newEditEvent(update)); + + const updatedElement = scd.querySelector(`Bay[name="${bay1NewAttributes.name}"]`)!; + + const { kind, ...expectedAttributes } = { + ...oldAttributes, + ...bay1NewAttributes + }; + + expect(elementAttributesToMap(updatedElement)).to.deep.equal(expectedAttributes); + }); + + describe('namespaced attributes', () => { + it('should update attribute with namespace', () => { + const update: Update = { + element: lnode1, + attributes: { + type: { value: 'newType', namespaceURI: 'xsi' } + } + }; + + host.dispatchEvent(newEditEvent(update)); + + expect(lnode1.getAttributeNS('xsi', 'type')).to.equal('newType'); + }); + + it('should handle multiple namespaces', () => { + const update: Update = { + element: lnode1, + attributes: { + type: { value: 'newTypeXSI', namespaceURI: nsXsi } + } + }; + + host.dispatchEvent(newEditEvent(update)); + + const update2: Update = { + element: lnode1, + attributes: { + type: { value: 'newTypeTD', namespaceURI: nsTd } + } + }; + + host.dispatchEvent(newEditEvent(update2)); + + expect(lnode1.getAttributeNS(nsXsi, 'type')).to.equal('newTypeXSI'); + expect(lnode1.getAttributeNS(nsTd, 'type')).to.equal('newTypeTD'); + }); + + it('should remove namespaced attribute', () => { + const update: Update = { + element: lnode2, + attributes: { + type: { value: null, namespaceURI: nsXsi } + } + }; + + host.dispatchEvent(newEditEvent(update)); + + expect(lnode2.getAttributeNS(nsXsi, 'type')).to.be.null; + expect(lnode2.getAttributeNS(nsTd, 'type')).to.equal('typeTD'); + }); + + it('should add and remove multiple normal and namespaced attributes', () => { + const update: Update = { + element: lnode2, + attributes: { + type: { value: null, namespaceURI: nsXsi }, + kind: { value: 'td-kind', namespaceURI: nsTd }, + normalAttribute: 'normalValue', + lnClass: null + } + }; + + host.dispatchEvent(newEditEvent(update)); + + expect(lnode2.getAttributeNS(nsXsi, 'type')).to.be.null; + expect(lnode2.getAttributeNS(nsTd, 'kind')).to.equal('td-kind'); + expect(lnode2.getAttribute('normalAttribute')).to.equal('normalValue'); + expect(lnode2.getAttribute('lnClass')).to.be.null; + }); + }); + + describe('Complex action', () => { + it('should apply each edit from a complex edit', () => { + const newNode = scd.createElement('Bay'); + newNode.setAttribute('name', 'b3'); + + const insert: Insert = { + parent: voltageLevel1, + node: newNode, + reference: bay1 + }; + + const remove: Remove = { + node: bay2 + }; + + const update: Update = { + element: bay1, + attributes: { + desc: 'new description' + } + }; + + host.dispatchEvent(newEditEvent([insert, remove, update])); + + expect(scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b3"]')).to.deep.equal(newNode); + expect(scd.querySelector('VoltageLevel[name="v2"] > Bay[name="b2"]')).to.be.null; + expect(scd.querySelector('Bay[name="b1"]')?.getAttribute('desc')).to.equal('new description'); + }); + }); + + describe('log edits', () => { + let log: LogDetail[] = []; + beforeEach(() => { + log = []; + + element.addEventListener('log', (e: CustomEvent) => { + log.push(e.detail); + }); + }); + + it('should log edit for user event', () => { + const remove: Remove = { + node: bay2, + }; + + host.dispatchEvent(newEditEvent(remove, 'user')); + + expect(log).to.have.lengthOf(1); + const logEntry = log[0] as CommitDetail; + expect(logEntry.kind).to.equal('action'); + expect(logEntry.title).to.equal('[editing.deleted]'); + expect(logEntry.redo).to.deep.equal(remove); + }); + + it('should not log edit for undo or redo event', () => { + const remove: Remove = { + node: bay2, + }; + + host.dispatchEvent(newEditEvent(remove, 'redo')); + host.dispatchEvent(newEditEvent(remove, 'undo')); + + expect(log).to.have.lengthOf(0); + }); + + describe('validate after edit', () => { + let hasTriggeredValidate = false; + beforeEach(() => { + hasTriggeredValidate = false; + + element.addEventListener('validate', () => { + hasTriggeredValidate = true; + }); + }); + + it('should dispatch validate event after edit', async () => { + const remove: Remove = { + node: bay2, + }; + + host.dispatchEvent(newEditEvent(remove)); + + await element.updateComplete; + + expect(hasTriggeredValidate).to.be.true; + }); + }); + }); + }); + }); + + describe('Undo/Redo', () => { + let log: CommitDetail[] = []; + beforeEach(() => { + log = []; + + element.addEventListener('log', (e: CustomEvent) => { + log.push(e.detail as CommitDetail); + }); + }); + + it('should undo insert', () => { + const newNode = scd.createElement('Bay'); + newNode.setAttribute('name', 'b3'); + + const insert: Insert = { + parent: voltageLevel1, + node: newNode, + reference: null + }; + + host.dispatchEvent(newEditEvent(insert)); + + const undoInsert = log[0].undo as Remove; + + host.dispatchEvent(newEditEvent(undoInsert)); + + expect(scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b3"]')).to.be.null; + }); + + it('should undo remove', () => { + const remove: Remove = { + node: bay4 + }; + + host.dispatchEvent(newEditEvent(remove)); + + const undoRemove = log[0].undo as Insert; + + host.dispatchEvent(newEditEvent(undoRemove)); + + const bay4FromScd = scd.querySelector('VoltageLevel[name="v2"] > Bay[name="b4"]'); + expect(bay4FromScd).to.deep.equal(bay4); + }); + + it('should undo update', () => { + const update: Update = { + element: bay1, + attributes: { + desc: 'new description', + kind: 'superbay' + } + }; + + host.dispatchEvent(newEditEvent(update)); + + const undoUpdate = log[0].undo as Update; + + host.dispatchEvent(newEditEvent(undoUpdate)); + + expect(bay1.getAttribute('desc')).to.be.null; + expect(bay1.getAttribute('kind')).to.equal('bay'); + }); + + it('should redo previously undone action', () => { + const newNode = scd.createElement('Bay'); + newNode.setAttribute('name', 'b3'); + + const insert: Insert = { + parent: voltageLevel1, + node: newNode, + reference: null + }; + + host.dispatchEvent(newEditEvent(insert)); + + const undoIsert = log[0].undo; + const redoInsert = log[0].redo; + + host.dispatchEvent(newEditEvent(undoIsert)); + + expect(scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b3"]')).to.be.null; + + host.dispatchEvent(newEditEvent(redoInsert)); + + expect(scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b3"]')).to.deep.equal(newNode); + }); + + it('should undo and redo complex edit', () => { + const newNode = scd.createElement('Bay'); + newNode.setAttribute('name', 'b3'); + + const insert: Insert = { + parent: voltageLevel1, + node: newNode, + reference: bay1 + }; + + const remove: Remove = { + node: bay2 + }; + + const update: Update = { + element: bay1, + attributes: { + desc: 'new description' + } + }; + + host.dispatchEvent(newEditEvent([insert, remove, update])); + + const undoComplex = log[0].undo; + const redoComplex = log[0].redo; + + host.dispatchEvent(newEditEvent(undoComplex)); + + expect(scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b3"]')).to.be.null; + expect(scd.querySelector('VoltageLevel[name="v2"] > Bay[name="b2"]')).to.deep.equal(bay2); + expect(bay1.getAttribute('desc')).to.be.null; + + host.dispatchEvent(newEditEvent(redoComplex)); + + expect(scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b3"]')).to.deep.equal(newNode); + expect(scd.querySelector('VoltageLevel[name="v2"] > Bay[name="b2"]')).to.be.null; + expect(bay1.getAttribute('desc')).to.equal('new description'); + }); + }); +}); + +function elementAttributesToMap(element: Element): Record { + const attributes: Record = {}; + Array.from(element.attributes).forEach(attr => { + attributes[attr.name] = attr.value; + }); + + return attributes; +} + diff --git a/packages/openscd/test/unit/Historing.test.ts b/packages/openscd/test/unit/Historing.test.ts index f85ac3346a..c93869afba 100644 --- a/packages/openscd/test/unit/Historing.test.ts +++ b/packages/openscd/test/unit/Historing.test.ts @@ -1,7 +1,7 @@ import { expect, fixture, html } from '@open-wc/testing'; import '../mock-open-scd.js'; -import { MockAction } from './mock-actions.js'; +import { mockEdits } from '../mock-edits.js'; import { MockOpenSCD } from '../mock-open-scd.js'; import { @@ -107,7 +107,8 @@ describe('HistoringElement', () => { newLogEvent({ kind: 'action', title: 'test MockAction', - action: MockAction.cre, + redo: mockEdits.insert(), + undo: mockEdits.insert() }) ); element.requestUpdate(); @@ -126,18 +127,6 @@ describe('HistoringElement', () => { it('has no next action', () => expect(element).to.have.property('nextAction', -1)); - it('does not log derived actions', () => { - expect(element).property('history').to.have.lengthOf(1); - element.dispatchEvent( - newLogEvent({ - kind: 'action', - title: 'test MockAction', - action: (element.history[0]).action, - }) - ); - expect(element).property('history').to.have.lengthOf(1); - }); - it('can reset its log', () => { element.dispatchEvent(newLogEvent({ kind: 'reset' })); expect(element).property('log').to.be.empty; @@ -160,7 +149,8 @@ describe('HistoringElement', () => { newLogEvent({ kind: 'action', title: 'test MockAction', - action: MockAction.del, + redo: mockEdits.remove(), + undo: mockEdits.remove() }) ); }); @@ -189,7 +179,8 @@ describe('HistoringElement', () => { newLogEvent({ kind: 'action', title: 'test MockAction', - action: MockAction.mov, + redo: mockEdits.insert(), + undo: mockEdits.insert() }) ); await element.updateComplete; diff --git a/packages/openscd/test/unit/edit-v1-to-v2-converter.test.ts b/packages/openscd/test/unit/edit-v1-to-v2-converter.test.ts new file mode 100644 index 0000000000..6676a1ffed --- /dev/null +++ b/packages/openscd/test/unit/edit-v1-to-v2-converter.test.ts @@ -0,0 +1,148 @@ +import { html, fixture, expect } from '@open-wc/testing'; + +import { + Create, + Delete, + EditorAction, + isCreate, + isDelete, + isMove, + isReplace, + isSimple, + isUpdate, + Move, + Replace, + SimpleAction, + Update, + createUpdateAction +} from '@openscd/core/foundation/deprecated/editor.js'; +import { Edit, Insert, Remove, Update as UpdateV2 } from '@openscd/core'; + +import { convertEditV1toV2 } from '../../src/addons/editor/edit-v1-to-v2-converter.js'; + + +describe('edit-v1-to-v2-converter', () => { + const doc = new DOMParser().parseFromString( + ` + + + + + + `, + 'application/xml' + ); + const substation = doc.querySelector('Substation')!; + const substation2 = doc.querySelector('Substation[name="sub2"]')!; + const bay = doc.querySelector('Bay')!; + + it('should convert delete to remove', () => { + const deleteAction: Delete = { + old: { + parent: substation, + element: bay + } + }; + + const remove = convertEditV1toV2(deleteAction); + + const expectedRemove: Remove = { + node: bay + }; + + expect(remove).to.deep.equal(expectedRemove); + }); + + it('should convert create to insert', () => { + const newBay = doc.createElement('Bay'); + newBay.setAttribute('name', 'bay2'); + + const createAction: Create = { + new: { + parent: substation, + element: newBay + } + }; + + const insert = convertEditV1toV2(createAction); + + const expectedInsert: Insert = { + parent: substation, + node: newBay, + reference: null + }; + + expect(insert).to.deep.equal(expectedInsert); + }); + + it('should convert update to updateV2', () => { + const newAttributes = { + name: 'newBayName', + }; + const updateAction = createUpdateAction(bay, newAttributes); + + const updateV2 = convertEditV1toV2(updateAction); + + const expectedUpdateV2: UpdateV2 = { + element: bay, + attributes: { + ...newAttributes, + desc: null + } + }; + + expect(updateV2).to.deep.equal(expectedUpdateV2); + }); + + it('should convert move to insert', () => { + const moveAction: Move = { + old: { + parent: substation, + element: bay, + reference: null + }, + new: { + parent: substation2, + reference: null + } + }; + + const insert = convertEditV1toV2(moveAction); + + const expectedInsert: Insert = { + parent: substation2, + node: bay, + reference: null + }; + + expect(insert).to.deep.equal(expectedInsert); + }); + + it('should convert replace to complex action with remove and insert', () => { + const ied = doc.createElement('IED'); + ied.setAttribute('name', 'ied'); + + const replace: Replace = { + old: { + element: bay + }, + new: { + element: ied + } + }; + + const [ remove, insert ] = convertEditV1toV2(replace) as Edit[]; + + const expectedRemove: Remove = { + node: bay + }; + const expectedInsert: Insert = { + parent: substation, + node: ied, + reference: bay.nextSibling + }; + + expect(remove).to.deep.equal(expectedRemove); + expect(insert).to.deep.equal(expectedInsert); + }); +}); diff --git a/packages/openscd/test/unit/wizard-dialog.test.ts b/packages/openscd/test/unit/wizard-dialog.test.ts index 3419c35a0b..458a19705b 100644 --- a/packages/openscd/test/unit/wizard-dialog.test.ts +++ b/packages/openscd/test/unit/wizard-dialog.test.ts @@ -1,17 +1,15 @@ import { html, fixture, expect } from '@open-wc/testing'; -import './mock-editor.js'; - import { Button } from '@material/mwc-button'; import '../../src/wizard-textfield.js'; import '../../src/wizard-dialog.js'; import { WizardDialog } from '../../src/wizard-dialog.js'; -import { WizardInputElement } from '../../src/foundation.js'; +import { checkValidity, WizardInputElement } from '../../src/foundation.js'; import { WizardCheckbox } from '../../src/wizard-checkbox.js'; import { WizardSelect } from '../../src/wizard-select.js'; import { WizardTextField } from '../../src/wizard-textfield.js'; -import { EditorAction } from '@openscd/core/foundation/deprecated/editor.js'; +import { ComplexAction, Create, Delete, EditorAction } from '@openscd/core/foundation/deprecated/editor.js'; describe('wizard-dialog', () => { let element: WizardDialog; @@ -230,9 +228,7 @@ describe('wizard-dialog', () => { let host: Element; beforeEach(async () => { - element = await fixture( - html`` - ).then(elm => elm.querySelector('wizard-dialog')!); + element = await fixture(html``); localStorage.setItem('mode', 'pro'); element.requestUpdate(); await element.updateComplete; @@ -274,6 +270,9 @@ describe('wizard-dialog', () => { }); it('commits the code action on primary button click', async () => { + let editorAction: ComplexAction; + element.addEventListener('editor-action', (action) => editorAction = action.detail.action as ComplexAction); + element.dialog ?.querySelector('ace-editor') ?.setAttribute('value', ''); @@ -282,7 +281,22 @@ describe('wizard-dialog', () => { ?.querySelector