diff --git a/localization/de.xlf b/localization/de.xlf index b5187aa..19d9fe3 100644 --- a/localization/de.xlf +++ b/localization/de.xlf @@ -46,6 +46,26 @@ Your editing history will be displayed here. Hier wird Ihre Bearbeitungshistorie angezeigt. + + Filename + Dateiname + + + Extension + Dateiendung + + + Close file + Datei schließen + + + Cancel + Abbrechen + + + Rename + Umbenennen + Close Schließen diff --git a/open-scd.editing.spec.ts b/open-scd.editing.spec.ts index ed35142..df9ee9f 100644 --- a/open-scd.editing.spec.ts +++ b/open-scd.editing.spec.ts @@ -48,7 +48,7 @@ export namespace util { `; - const testDocStrings = [ + export const testDocStrings = [ sclDocString, ` @@ -210,7 +210,13 @@ export namespace util { } } -describe('Editing Element', () => { +function newTestDoc() { + const docString = + util.testDocStrings[Math.floor(Math.random() * util.testDocStrings.length)]; + return new DOMParser().parseFromString(docString, 'application/xml'); +} + +describe('open-scd', () => { let editor: OpenSCD; let sclDoc: XMLDocument; @@ -222,13 +228,76 @@ describe('Editing Element', () => { ); }); - it('loads a document on OpenDocEvent', async () => { + it('loads a non-SCL document on OpenDocEvent', async () => { + editor.dispatchEvent(newOpenEvent(sclDoc, 'test.xml')); + await editor.updateComplete; + expect(editor.docs).to.have.property('test.xml', sclDoc); + }); + + it('opens an SCL document for editing on OpenDocEvent', async () => { editor.dispatchEvent(newOpenEvent(sclDoc, 'test.scd')); await editor.updateComplete; expect(editor.doc).to.equal(sclDoc); expect(editor.docName).to.equal('test.scd'); }); + describe('with an SCL document loaded', () => { + beforeEach(async () => { + editor.dispatchEvent(newOpenEvent(sclDoc, 'test.scd')); + await editor.updateComplete; + }); + + it('allows the user to change the current doc name', async () => { + editor.shadowRoot + ?.querySelector('mwc-icon-button[icon=edit]') + ?.click(); + const dialog = editor.editFileUI; + await dialog.updateComplete; + const textfield = dialog.querySelector('mwc-textfield')!; + textfield.value = 'newName'; + const select = dialog.querySelector('mwc-select')!; + select.value = 'cid'; + await textfield.updateComplete; + await select.updateComplete; + dialog + .querySelector('mwc-button[slot="primaryAction"]') + ?.click(); + await editor.updateComplete; + expect(editor).to.have.property('docName', 'newName.cid'); + expect(editor).to.have.property('doc', sclDoc); + }); + }); + + it('allows the user to close the current doc', async () => { + editor.shadowRoot + ?.querySelector('mwc-icon-button[icon=edit]') + ?.click(); + const dialog = editor.editFileUI; + await dialog.updateComplete; + dialog + .querySelector('mwc-button[icon="delete"]') + ?.click(); + await editor.updateComplete; + expect(editor).to.have.property('docName'); + expect(editor).to.have.property('doc'); + }); + + describe('with several documents loaded', () => { + beforeEach(async () => { + for (let i = 0; i < Math.floor(Math.random() * 10) + 1; i += 1) + editor.dispatchEvent(newOpenEvent(newTestDoc(), `test${i}.scd`)); + }); + + it('allows the user to switch documents', async () => { + const oldDocName = editor.docName; + editor.fileMenuButtonUI?.click(); + await editor.fileMenuUI.updateComplete; + (editor.fileMenuUI.firstElementChild as HTMLButtonElement).click(); + await editor.updateComplete; + expect(editor).to.not.have.property('docName', oldDocName); + }); + }); + it('inserts an element on Insert', () => { const parent = sclDoc.documentElement; const node = sclDoc.createElement('test'); diff --git a/open-scd.spec.ts b/open-scd.spec.ts index ec59386..54bec5a 100644 --- a/open-scd.spec.ts +++ b/open-scd.spec.ts @@ -48,31 +48,31 @@ describe('with editor plugins loaded', () => { }); it('passes attribute docname', async () => { - editor.dispatchEvent(newOpenEvent(doc, 'test.xml')); + editor.dispatchEvent(newOpenEvent(doc, 'test.scd')); await editor.updateComplete; - const plugin = editor.shadowRoot?.querySelector('*[docName="test.xml"]'); + const plugin = editor.shadowRoot?.querySelector('*[docName="test.scd"]'); expect(plugin?.tagName).to.exist.and.to.satisfy(isOscdPlugin); }); it('passes property doc', async () => { - editor.dispatchEvent(newOpenEvent(doc, 'test.xml')); + editor.dispatchEvent(newOpenEvent(doc, 'test.scd')); await editor.updateComplete; - const plugin = editor.shadowRoot?.querySelector('*[docname="test.xml"]'); + const plugin = editor.shadowRoot?.querySelector('*[docname="test.scd"]'); expect(plugin).to.have.property('docs'); }); it('passes property editCount', async () => { - editor.dispatchEvent(newOpenEvent(doc, 'test.xml')); + editor.dispatchEvent(newOpenEvent(doc, 'test.scd')); await editor.updateComplete; - const plugin = editor.shadowRoot?.querySelector('*[docname="test.xml"]'); + const plugin = editor.shadowRoot?.querySelector('*[docname="test.scd"]'); expect(plugin).to.have.property('editCount', 0); }); it('updated passed editCount property on edit events', async () => { - editor.dispatchEvent(newOpenEvent(doc, 'test.xml')); + editor.dispatchEvent(newOpenEvent(doc, 'test.scd')); await editor.updateComplete; editor.dispatchEvent( @@ -83,7 +83,7 @@ describe('with editor plugins loaded', () => { ); await editor.updateComplete; - const plugin = editor.shadowRoot?.querySelector('*[docname="test.xml"]'); + const plugin = editor.shadowRoot?.querySelector('*[docname="test.scd"]'); expect(plugin).to.have.property('editCount', 1); }); }); @@ -111,31 +111,31 @@ describe('with menu plugins loaded', () => { }); it('passes attribute docname', async () => { - editor.dispatchEvent(newOpenEvent(doc, 'test.xml')); + editor.dispatchEvent(newOpenEvent(doc, 'test.scd')); await editor.updateComplete; - const plugin = editor.shadowRoot?.querySelector('*[docName="test.xml"]'); + const plugin = editor.shadowRoot?.querySelector('*[docName="test.scd"]'); expect(plugin?.tagName).to.exist.and.to.satisfy(isOscdPlugin); }); it('passes property doc', async () => { - editor.dispatchEvent(newOpenEvent(doc, 'test.xml')); + editor.dispatchEvent(newOpenEvent(doc, 'test.scd')); await editor.updateComplete; - const plugin = editor.shadowRoot?.querySelector('*[docname="test.xml"]'); + const plugin = editor.shadowRoot?.querySelector('*[docname="test.scd"]'); expect(plugin).to.have.property('docs'); }); it('passes property editCount', async () => { - editor.dispatchEvent(newOpenEvent(doc, 'test.xml')); + editor.dispatchEvent(newOpenEvent(doc, 'test.scd')); await editor.updateComplete; - const plugin = editor.shadowRoot?.querySelector('*[docname="test.xml"]'); + const plugin = editor.shadowRoot?.querySelector('*[docname="test.scd"]'); expect(plugin).to.have.property('editCount', 0); }); it('updated passed editCount property on edit events', async () => { - editor.dispatchEvent(newOpenEvent(doc, 'test.xml')); + editor.dispatchEvent(newOpenEvent(doc, 'test.scd')); await editor.updateComplete; editor.dispatchEvent( @@ -146,7 +146,7 @@ describe('with menu plugins loaded', () => { ); await editor.updateComplete; - const plugin = editor.shadowRoot?.querySelector('*[docname="test.xml"]'); + const plugin = editor.shadowRoot?.querySelector('*[docname="test.scd"]'); expect(plugin).to.have.property('editCount', 1); }); }); diff --git a/open-scd.test.ts b/open-scd.test.ts index 71ec820..8eda043 100644 --- a/open-scd.test.ts +++ b/open-scd.test.ts @@ -75,7 +75,7 @@ allLocales.forEach(lang => it(`displays a current document title`, async () => { await editor.updateComplete; - editor.dispatchEvent(newOpenEvent(doc, 'test.xml')); + editor.dispatchEvent(newOpenEvent(doc, 'test.scd')); await editor.updateComplete; await timeout(20); await visualDiff(editor, `document-name-${lang}`); @@ -208,7 +208,7 @@ allLocales.forEach(lang => }, { name: 'Test Menu Plugin 2', - src: 'data:text/javascript;charset=utf-8,export%20default%20class%20TestPlugin%20extends%20HTMLElement%20%7B%0D%0A%20%20async%20run%28%29%20%7B%0D%0A%20%20%20%20this.dispatchEvent%28new%20CustomEvent%28%27oscd-open%27%2C%20%7Bdetail%3A%20%7BdocName%3A%20%27testDoc%27%2C%20doc%3A%20window.document%7D%2C%20bubbles%3A%20true%2C%20composed%3A%20true%7D%29%29%3B%0D%0A%20%20%7D%0D%0A%7D', + src: 'data:text/javascript;charset=utf-8,export%20default%20class%20TestPlugin%20extends%20HTMLElement%20%7B%0D%0A%20%20async%20run%28%29%20%7B%0D%0A%20%20%20%20this.dispatchEvent%28new%20CustomEvent%28%27oscd-open%27%2C%20%7Bdetail%3A%20%7BdocName%3A%20%27testDoc.scd%27%2C%20doc%3A%20window.document%7D%2C%20bubbles%3A%20true%2C%20composed%3A%20true%7D%29%29%3B%0D%0A%20%20%7D%0D%0A%7D', icon: 'polymer', active: true, requireDoc: false, @@ -259,7 +259,7 @@ allLocales.forEach(lang => await editor.updateComplete; await timeout(200); - expect(editor.docName).to.equal('testDoc'); + expect(editor.docName).to.equal('testDoc.scd'); await editor.updateComplete; await visualDiff(editor, `menu-plugins-triggered-${lang}`); }); @@ -311,7 +311,7 @@ allLocales.forEach(lang => }); it('displays more tabs with a doc loaded', async () => { - editor.dispatchEvent(newOpenEvent(doc, 'test.xml')); + editor.dispatchEvent(newOpenEvent(doc, 'test.scd')); await editor.updateComplete; await visualDiff(editor, `editor-plugins-with-doc-${lang}`); }); diff --git a/open-scd.ts b/open-scd.ts index 20ee272..3623b42 100644 --- a/open-scd.ts +++ b/open-scd.ts @@ -10,11 +10,19 @@ import '@material/mwc-drawer'; import '@material/mwc-icon'; import '@material/mwc-icon-button'; import '@material/mwc-list'; +import '@material/mwc-menu'; +import '@material/mwc-select'; import '@material/mwc-tab-bar'; +import '@material/mwc-textfield'; import '@material/mwc-top-app-bar-fixed'; -import type { ActionDetail } from '@material/mwc-list'; +import type { ActionDetail, SingleSelectedEvent } from '@material/mwc-list'; import type { Dialog } from '@material/mwc-dialog'; import type { Drawer } from '@material/mwc-drawer'; +import type { IconButton } from '@material/mwc-icon-button'; +import type { ListItemBase } from '@material/mwc-list/mwc-list-item-base.js'; +import type { Menu } from '@material/mwc-menu'; +import type { Select } from '@material/mwc-select'; +import type { TextField } from '@material/mwc-textfield'; import { allLocales, sourceLocale, targetLocales } from './locales.js'; @@ -142,6 +150,27 @@ export class OpenSCD extends LitElement { /** The name of the [[`doc`]] currently being edited */ @property({ type: String, reflect: true }) docName = ''; + /** The file endings of editable files */ + @property({ type: Array, reflect: true }) editable = [ + 'cid', + 'icd', + 'iid', + 'scd', + 'sed', + 'ssd', + ]; + + isEditable(docName: string): boolean { + return !!this.editable.find(ext => + docName.toLowerCase().endsWith(`.${ext}`) + ); + } + + @state() + get editableDocs(): string[] { + return Object.keys(this.docs).filter(name => this.isEditable(name)); + } + #loadedPlugins = new Map(); @state() @@ -173,8 +202,8 @@ export class OpenSCD extends LitElement { } handleOpenDoc({ detail: { docName, doc } }: OpenEvent) { - this.docName = docName; - this.docs[this.docName] = doc; + this.docs[docName] = doc; + if (this.isEditable(docName)) this.docName = docName; } handleEditEvent(event: EditEvent) { @@ -203,9 +232,24 @@ export class OpenSCD extends LitElement { @query('#log') logUI!: Dialog; + @query('#editFile') + editFileUI!: Dialog; + @query('#menu') menuUI!: Drawer; + @query('#fileName') + fileNameUI!: TextField; + + @query('#fileExtension') + fileExtensionUI!: Select; + + @query('#fileMenu') + fileMenuUI!: Menu; + + @query('#fileMenuButton') + fileMenuButtonUI?: IconButton; + @property({ type: String, reflect: true }) get locale() { return getLocale() as LocaleTag; @@ -379,7 +423,42 @@ export class OpenSCD extends LitElement { ${renderActionItem(this.controls.menu, 'navigationIcon')} -
${this.docName}
+
+ ${this.editableDocs.length > 1 + ? html` this.fileMenuUI.show()} + >` + : nothing} + ${this.docName} + ${this.docName + ? html` this.editFileUI.show()} + >` + : nothing} + { + const item = this.fileMenuUI.selected as ListItemBase | null; + if (!item) return; + this.docName = this.editableDocs[index]; + item.selected = false; + this.fileMenuUI.layout(); + }} + > + ${this.editableDocs.map( + name => html`${name}` + )} + +
${this.#actions.map(op => renderActionItem(op))} !p.isDisabled()).length @@ -411,6 +490,78 @@ export class OpenSCD extends LitElement { : nothing}
+ { + if (!detail) return; + if (detail.action === 'remove') { + delete this.docs[this.docName]; + this.docName = this.editableDocs[0] || ''; + } + }} + > + { + const name = `${value}.${this.fileExtensionUI.value}`; + if (name in this.docs && name !== this.docName) + return { + valid: false, + }; + return {}; + }} + > + this.fileNameUI.reportValidity()} + > + ${this.editable.map( + ext => + html`${ext}` + )} + + + ${msg('Close file')} + + + ${msg('Cancel')} + + { + const valid = this.fileNameUI.checkValidity(); + if (!valid) { + this.fileNameUI.reportValidity(); + return; + } + const newDocName = `${this.fileNameUI.value}.${this.fileExtensionUI.value}`; + if (this.docs[newDocName]) return; + this.docs[newDocName] = this.doc; + delete this.docs[this.docName]; + this.docName = newDocName; + this.editFileUI.close(); + }} + trailingIcon + > + ${msg('Rename')} + + ${this.renderHistory()}