diff --git a/components/add-data-object-dialog.spec.ts b/components/add-data-object-dialog.spec.ts index 7a691f5..7f9aa23 100644 --- a/components/add-data-object-dialog.spec.ts +++ b/components/add-data-object-dialog.spec.ts @@ -44,9 +44,10 @@ describe('AddDataObjectDialog', () => { it('renders dialog', () => { expect(dialog.createDOdialog.open).to.be.false; dialog.show(); - expect(dialog.shadowRoot?.querySelector('md-dialog')).to.exist; - expect(dialog.shadowRoot?.querySelector('md-filled-select')).to.exist; - expect(dialog.shadowRoot?.querySelector('md-outlined-text-field')).to.exist; + expect(dialog.shadowRoot?.querySelector('oscd-dialog')).to.exist; + expect(dialog.shadowRoot?.querySelector('oscd-filled-select')).to.exist; + expect(dialog.shadowRoot?.querySelector('oscd-outlined-text-field')).to + .exist; cdClasses.forEach(cdClass => { expect(dialog.shadowRoot?.textContent).to.include(cdClass); }); diff --git a/components/add-data-object-dialog.ts b/components/add-data-object-dialog.ts index 6e1a3bf..f5adc37 100644 --- a/components/add-data-object-dialog.ts +++ b/components/add-data-object-dialog.ts @@ -3,12 +3,11 @@ import { LitElement, html, css } from 'lit'; import { property, state, query } from 'lit/decorators.js'; import { Tree, TreeNode } from '@openenergytools/tree-grid'; import { ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; -import { MdDialog } from '@scopedelement/material-web/dialog/MdDialog.js'; -import { MdOutlinedTextField } from '@scopedelement/material-web/textfield/MdOutlinedTextField.js'; -import { MdFilledSelect } from '@scopedelement/material-web/select/MdOutlineSelect.js'; -import { MdSelectOption } from '@scopedelement/material-web/select/MdSelectOption.js'; -import { MdOutlinedButton } from '@scopedelement/material-web/button/outlined-button.js'; -import { MdTextButton } from '@scopedelement/material-web/button/text-button.js'; +import { OscdDialog } from '@omicronenergy/oscd-ui/dialog/OscdDialog.js'; +import { OscdOutlinedTextField } from '@omicronenergy/oscd-ui/textfield/OscdOutlinedTextField.js'; +import { OscdFilledSelect } from '@omicronenergy/oscd-ui/select/OscdFilledSelect.js'; +import { OscdSelectOption } from '@omicronenergy/oscd-ui/select/OscdSelectOption.js'; +import { OscdTextButton } from '@omicronenergy/oscd-ui/button/OscdTextButton.js'; import { debounce } from '../utils/debounce.js'; // eslint-disable-next-line no-shadow @@ -23,16 +22,15 @@ const firstTextBlockRegExp = /[A-Za-z]+/; export class AddDataObjectDialog extends ScopedElementsMixin(LitElement) { static scopedElements = { - 'md-outlined-button': MdOutlinedButton, - 'md-dialog': MdDialog, - 'md-outlined-text-field': MdOutlinedTextField, - 'md-text-button': MdTextButton, - 'md-select-option': MdSelectOption, - 'md-filled-select': MdFilledSelect, + 'oscd-dialog': OscdDialog, + 'oscd-outlined-text-field': OscdOutlinedTextField, + 'oscd-text-button': OscdTextButton, + 'oscd-select-option': OscdSelectOption, + 'oscd-filled-select': OscdFilledSelect, }; static styles = css` - md-dialog { + oscd-dialog { min-width: 350px; } .dialog-content { @@ -41,11 +39,11 @@ export class AddDataObjectDialog extends ScopedElementsMixin(LitElement) { gap: 16px; margin-top: 8px; } - md-filled-select, - md-outlined-text-field { + oscd-filled-select, + oscd-outlined-text-field { width: 100%; } - md-text-button { + oscd-text-button { text-transform: uppercase; } `; @@ -69,17 +67,17 @@ export class AddDataObjectDialog extends ScopedElementsMixin(LitElement) { @state() errorText = ''; - @query('md-dialog') - createDOdialog!: MdDialog; + @query('oscd-dialog') + createDOdialog!: OscdDialog; @query('#cdc-type') - cdcType!: MdFilledSelect; + cdcType!: OscdFilledSelect; @query('#do-name') - doName!: MdOutlinedTextField; + doName!: OscdOutlinedTextField; @query('#namespace') - namespace!: MdOutlinedTextField; + namespace!: OscdOutlinedTextField; private namespaceDefaultValue = 'User-Defined'; @@ -251,7 +249,7 @@ export class AddDataObjectDialog extends ScopedElementsMixin(LitElement) { /* eslint-disable class-methods-use-this */ private resetErrorText(e: Event): void { - const target = e.target as MdOutlinedTextField; + const target = e.target as OscdOutlinedTextField; if (target.errorText && target.checkValidity()) { target.errorText = ''; target.error = false; @@ -260,7 +258,7 @@ export class AddDataObjectDialog extends ScopedElementsMixin(LitElement) { render() { return html` - +
Add Data Object
- ${this.cdClasses.map( cdClass => - html`${cdClass}${cdClass}` )} - - + - + + >
- CloseClose - AddAdd
-
+ `; } } diff --git a/components/delete-lnodetype-dialog.spec.ts b/components/delete-lnodetype-dialog.spec.ts index 18039a6..824f803 100644 --- a/components/delete-lnodetype-dialog.spec.ts +++ b/components/delete-lnodetype-dialog.spec.ts @@ -40,8 +40,9 @@ describe('DeleteDialog', () => { await deleteDialog.updateComplete; await deleteDialog.dialog.updateComplete; - const buttons = - deleteDialog.shadowRoot?.querySelectorAll('md-outlined-button'); + const buttons = deleteDialog.shadowRoot?.querySelectorAll( + 'oscd-outlined-button' + ); const deleteButton = Array.from(buttons || []).find( btn => btn.textContent?.trim() === 'Delete' ) as HTMLElement; @@ -75,8 +76,9 @@ describe('DeleteDialog', () => { deleteDialog.show(); await deleteDialog.updateComplete; - const buttons = - deleteDialog.shadowRoot?.querySelectorAll('md-outlined-button'); + const buttons = deleteDialog.shadowRoot?.querySelectorAll( + 'oscd-outlined-button' + ); const cancelButton = Array.from(buttons || []).find( btn => btn.textContent?.trim() === 'Cancel' ) as HTMLElement; diff --git a/components/delete-lnodetype-dialog.ts b/components/delete-lnodetype-dialog.ts index 62ab811..3ee7a90 100644 --- a/components/delete-lnodetype-dialog.ts +++ b/components/delete-lnodetype-dialog.ts @@ -2,13 +2,13 @@ import { ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; import { LitElement, html, css } from 'lit'; import { query, property } from 'lit/decorators.js'; -import { MdDialog } from '@scopedelement/material-web/dialog/dialog.js'; -import { MdOutlinedButton } from '@scopedelement/material-web/button/MdOutlinedButton.js'; +import { OscdDialog } from '@omicronenergy/oscd-ui/dialog/OscdDialog.js'; +import { OscdOutlinedButton } from '@omicronenergy/oscd-ui/button/OscdOutlinedButton.js'; export class DeleteDialog extends ScopedElementsMixin(LitElement) { static scopedElements = { - 'md-dialog': MdDialog, - 'md-outlined-button': MdOutlinedButton, + 'oscd-dialog': OscdDialog, + 'oscd-outlined-button': OscdOutlinedButton, }; @property() @@ -17,8 +17,8 @@ export class DeleteDialog extends ScopedElementsMixin(LitElement) { @property() lnodeTypeId: string = ''; - @query('md-dialog') - dialog!: MdDialog; + @query('oscd-dialog') + dialog!: OscdDialog; get open() { return this.dialog?.open ?? false; @@ -43,23 +43,25 @@ export class DeleteDialog extends ScopedElementsMixin(LitElement) { render() { return html` - +
Confirm delete
Are you sure you want to delete Logical Node Type ${this.lnodeTypeId}? This action may have severe consequences.
- CancelCancel - DeleteDelete
-
+ `; } diff --git a/components/lnodetype-sidebar.ts b/components/lnodetype-sidebar.ts index 7e499fb..b204e6c 100644 --- a/components/lnodetype-sidebar.ts +++ b/components/lnodetype-sidebar.ts @@ -3,17 +3,17 @@ import { LitElement, html, css } from 'lit'; import { property, state } from 'lit/decorators.js'; import { ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; -import { MdOutlinedButton } from '@scopedelement/material-web/button/outlined-button.js'; -import { MdOutlinedTextField } from '@scopedelement/material-web/textfield/MdOutlinedTextField.js'; -import { MdList } from '@scopedelement/material-web/list/MdList.js'; -import { MdListItem } from '@scopedelement/material-web/list/MdListItem.js'; +import { OscdFilledButton } from '@omicronenergy/oscd-ui/button/OscdFilledButton.js'; +import { OscdOutlinedTextField } from '@omicronenergy/oscd-ui/textfield/OscdOutlinedTextField.js'; +import { OscdList } from '@omicronenergy/oscd-ui/list/OscdList.js'; +import { OscdListItem } from '@omicronenergy/oscd-ui/list/OscdListItem.js'; export class LNodeTypeSidebar extends ScopedElementsMixin(LitElement) { static scopedElements = { - 'md-outlined-button': MdOutlinedButton, - 'md-outlined-textfield': MdOutlinedTextField, - 'md-list': MdList, - 'md-list-item': MdListItem, + 'oscd-filled-button': OscdFilledButton, + 'oscd-outlined-textfield': OscdOutlinedTextField, + 'oscd-list': OscdList, + 'oscd-list-item': OscdListItem, }; @property({ type: Array }) @@ -88,13 +88,13 @@ export class LNodeTypeSidebar extends ScopedElementsMixin(LitElement) { render() { return html``; } updated(changedProperties: Map) { super.updated?.(changedProperties); - // Scroll md-list to top when lNodeTypes changes + // Scroll oscd-list to top when lNodeTypes changes if (changedProperties.has('lNodeTypes')) { - const mdList = this.renderRoot.querySelector('md-list'); - if (mdList) mdList.scrollTop = 0; + const oscdList = this.renderRoot.querySelector('oscd-list'); + if (oscdList) oscdList.scrollTop = 0; } } @@ -142,35 +142,35 @@ export class LNodeTypeSidebar extends ScopedElementsMixin(LitElement) { min-height: 0; padding: 1rem; overflow: hidden; - background-color: #fcf6e5; + background-color: var(--oscd-base3); } - md-list { + oscd-list { min-height: 0; max-height: calc(100vh - var(--header-height) - 1rem - 134px); overflow-y: auto; scrollbar-width: thin; padding: 0; } - md-list::-webkit-scrollbar { + oscd-list::-webkit-scrollbar { width: 8px; } - md-list::-webkit-scrollbar-thumb { + oscd-list::-webkit-scrollbar-thumb { border-radius: 4px; } - md-list::-webkit-scrollbar-track { + oscd-list::-webkit-scrollbar-track { background: transparent; } - md-list-item { + oscd-list-item { box-sizing: border-box; } - md-list-item[selected] { + oscd-list-item[selected] { background: var(--md-sys-color-primary); } - md-list-item[selected] span[slot='headline'], - md-list-item[selected] span[slot='supporting-text'] { + oscd-list-item[selected] span[slot='headline'], + oscd-list-item[selected] span[slot='supporting-text'] { color: var(--md-sys-color-on-primary, #ffffff); } - md-outlined-textfield { + oscd-outlined-textfield { width: 100%; } .actions { diff --git a/components/settings-dialog.spec.ts b/components/settings-dialog.spec.ts index a30a814..b404251 100644 --- a/components/settings-dialog.spec.ts +++ b/components/settings-dialog.spec.ts @@ -7,7 +7,7 @@ describe('SettingsDialog', () => { let element: SettingsDialog; const getRadio = (value: 'update' | 'swap') => - element.shadowRoot!.querySelector(`md-radio[value="${value}"]`) as any; + element.shadowRoot!.querySelector(`oscd-radio[value="${value}"]`) as any; beforeEach(async () => { localStorage.clear(); @@ -53,7 +53,7 @@ describe('SettingsDialog', () => { await element.updateComplete; const confirmButton = element.shadowRoot!.querySelector( - 'md-text-button:last-child' + 'oscd-text-button:last-child' ) as any; confirmButton.click(); @@ -68,7 +68,7 @@ describe('SettingsDialog', () => { await element.updateComplete; const cancelButton = element.shadowRoot!.querySelector( - 'md-text-button:first-child' + 'oscd-text-button:first-child' ) as any; cancelButton.click(); diff --git a/components/settings-dialog.ts b/components/settings-dialog.ts index a53a047..fa57567 100644 --- a/components/settings-dialog.ts +++ b/components/settings-dialog.ts @@ -2,9 +2,9 @@ import { ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; import { LitElement, html, css } from 'lit'; import { query, state } from 'lit/decorators.js'; -import { MdDialog } from '@scopedelement/material-web/dialog/dialog.js'; -import { MdTextButton } from '@scopedelement/material-web/button/text-button.js'; -import { MdRadio } from '@scopedelement/material-web/radio/radio.js'; +import { OscdDialog } from '@omicronenergy/oscd-ui/dialog/OscdDialog.js'; +import { OscdTextButton } from '@omicronenergy/oscd-ui/button/OscdTextButton.js'; +import { OscdRadio } from '@omicronenergy/oscd-ui/radio/OscdRadio.js'; import { TEMPLATE_UPDATE_SETTING_STORAGE_KEY } from '../foundation/constants.js'; // eslint-disable-next-line no-shadow @@ -15,13 +15,13 @@ export enum UpdateSetting { export class SettingsDialog extends ScopedElementsMixin(LitElement) { static scopedElements = { - 'md-dialog': MdDialog, - 'md-text-button': MdTextButton, - 'md-radio': MdRadio, + 'oscd-dialog': OscdDialog, + 'oscd-text-button': OscdTextButton, + 'oscd-radio': OscdRadio, }; - @query('md-dialog') - dialog!: MdDialog; + @query('oscd-dialog') + dialog!: OscdDialog; @state() private updateSetting: UpdateSetting = UpdateSetting.Update; @@ -63,7 +63,7 @@ export class SettingsDialog extends ScopedElementsMixin(LitElement) { } private handleRadioChange(event: Event) { - const target = event.target as MdRadio; + const target = event.target as OscdRadio; if (target.checked) { this.updateSetting = target.value as UpdateSetting; } @@ -81,44 +81,44 @@ export class SettingsDialog extends ScopedElementsMixin(LitElement) { render() { return html` - this.dialog?.close()}> + this.dialog?.close()}>
LNodeType update behaviour
- + Cancel - - + + Save - +
-
+ `; } static styles = css` - md-dialog { + oscd-dialog { --md-dialog-container-max-width: 400px; } @@ -147,7 +147,7 @@ export class SettingsDialog extends ScopedElementsMixin(LitElement) { flex: 1; } - md-text-button { + oscd-text-button { text-transform: uppercase; } `; diff --git a/foundation/utils.spec.ts b/foundation/utils.spec.ts index 638051d..4a69f68 100644 --- a/foundation/utils.spec.ts +++ b/foundation/utils.spec.ts @@ -1,6 +1,7 @@ /* eslint-disable no-unused-expressions */ import { expect } from '@open-wc/testing'; -import { removeDOsNotInSelection } from './utils.js'; +import { removeDOsNotInSelection, computeOrphanedRemoves } from './utils.js'; +import { nsdSpeced } from '../scl-template-update.testfiles.js'; describe('foundation/utils', () => { describe('removeDOsNotInSelection', () => { @@ -85,4 +86,83 @@ describe('foundation/utils', () => { expect(result.tagName).to.equal('LNodeType'); }); }); + + describe('computeOrphanedRemoves', () => { + const mmxuId = 'MMXU$oscd$_c53e78191fabefa3'; + let dataTypeTemplates: Element; + + beforeEach(() => { + dataTypeTemplates = new DOMParser() + .parseFromString(nsdSpeced, 'application/xml') + .querySelector('DataTypeTemplates')!; + }); + + function makeNewMmxu(doNames: string[]): Element { + const doElements = doNames + .map(name => { + const existing = dataTypeTemplates.querySelector( + `LNodeType[id="${mmxuId}"] > DO[name="${name}"]` + ); + return existing ? existing.outerHTML : ''; + }) + .join(''); + return new DOMParser().parseFromString( + `${doElements}`, + 'application/xml' + ).documentElement; + } + + it('returns the old LNodeType as the first remove', () => { + const result = computeOrphanedRemoves(dataTypeTemplates, mmxuId, [ + makeNewMmxu(['Beh']), + ]); + + expect(result).to.have.length.greaterThan(0); + expect((result[0] as any).node as Element).to.equal( + dataTypeTemplates.querySelector(`LNodeType[id="${mmxuId}"]`) + ); + }); + + it('cascades orphans when A DO is removed: removes A DOType and all its exclusive descendants', () => { + const result = computeOrphanedRemoves(dataTypeTemplates, mmxuId, [ + makeNewMmxu(['Beh']), + ]); + + const orphanedIds = result + .slice(1) // skip old LNodeType remove + .map((edit: any) => edit.node.getAttribute('id')) + .filter(id => id !== null); + expect(orphanedIds).to.include('A$oscd$_41824603f63b26ac'); + expect(orphanedIds).to.include('phsA$oscd$_995aad120f12c815'); + expect(orphanedIds).to.include('cVal$oscd$_80272042468595d1'); + expect(orphanedIds).to.include('units$oscd$_3f2e10def85bfeac'); + expect(orphanedIds).to.include('mag$oscd$_ed49c2f7a55ad05a'); + expect(orphanedIds).to.include('SIUnit$oscd$_39f6ca400c633081'); + }); + + it('does not orphan the Beh DOType which is shared with LLN0', () => { + const result = computeOrphanedRemoves(dataTypeTemplates, mmxuId, [ + makeNewMmxu(['Beh']), + ]); + + const orphanedIds = result + .slice(1) // skip old LNodeType remove + .map((edit: any) => edit.node.getAttribute('id')) + .filter(id => id !== null); + + expect(orphanedIds).to.not.include('Beh$oscd$_c6ed035c8137b35a'); + expect(orphanedIds).to.not.include('stVal$oscd$_48ba16345b8e7f5b'); + }); + + it('returns only the old LNodeType when the new version keeps all DOs', () => { + const result = computeOrphanedRemoves(dataTypeTemplates, mmxuId, [ + makeNewMmxu(['Beh', 'A']), + ]); + + expect(result).to.have.lengthOf(1); + expect((result[0] as any).node as Element).to.equal( + dataTypeTemplates.querySelector(`LNodeType[id="${mmxuId}"]`) + ); + }); + }); }); diff --git a/foundation/utils.ts b/foundation/utils.ts index 1c01287..bffef73 100644 --- a/foundation/utils.ts +++ b/foundation/utils.ts @@ -1,4 +1,5 @@ import { Tree, TreeSelection } from '@openenergytools/tree-grid'; +import { EditV2 } from '@openscd/oscd-api'; export function getLNodeTypes(doc: XMLDocument | undefined): Element[] { return Array.from( @@ -69,3 +70,64 @@ export function removeDOsNotInSelection( return clonedLNodeType; } + +/** + * Builds the Remove edits needed when an LNodeType is replaced. + * Clones the DataTypeTemplates once, adds the incoming new nodes, removes + * the old LNodeType, then cascades to find which pre-existing sub-types are + * now unreferenced. Returns the old LNodeType remove plus all orphan removes. + * @param dataTypeTemplates The live DataTypeTemplates element to base the remove calculations on + * @param oldLNodeTypeId The ID of the LNodeType being replaced + * @param newNodes The new nodes being added (cloned before passing in) + * @returns An array of EditV2 objects representing the nodes to remove + */ +export function computeOrphanedRemoves( + dataTypeTemplates: Element, + oldLNodeTypeId: string, + newNodes: Element[] +): EditV2[] { + const subTypes = Array.from( + dataTypeTemplates.querySelectorAll( + ':scope > DOType, :scope > DAType, :scope > EnumType' + ) + ); + const subTypeIds = subTypes + .map(el => el.getAttribute('id')) + .filter(id => id !== null); + const preEditIds = new Set(subTypeIds); + + const dttClone = dataTypeTemplates.cloneNode(true) as Element; + newNodes.forEach(n => dttClone.appendChild(n.cloneNode(true))); + dttClone + .querySelector(`:scope > LNodeType[id="${oldLNodeTypeId}"]`) + ?.remove(); + + const remainingIds = new Set(preEditIds); + const orphanIds: string[] = []; + let changed = true; + + while (changed) { + changed = false; + for (const id of remainingIds) { + if (!dttClone.querySelector(`:scope *[type="${id}"]`)) { + orphanIds.push(id); + remainingIds.delete(id); + dttClone.querySelector(`:scope > *[id="${id}"]`)?.remove(); + changed = true; + } + } + } + + const results: EditV2[] = []; + const oldLN = dataTypeTemplates.querySelector( + `:scope > LNodeType[id="${oldLNodeTypeId}"]` + ); + if (oldLN) results.push({ node: oldLN as Node }); + orphanIds.forEach(id => { + const node = dataTypeTemplates.querySelector( + `:scope > *[id="${id}"]` + ) as Node | null; + if (node) results.push({ node }); + }); + return results; +} diff --git a/index.html b/index.html index b6c02b0..eb87359 100644 --- a/index.html +++ b/index.html @@ -14,14 +14,40 @@ /> - +