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