diff --git a/.github/workflows/test-and-build.yml b/.github/workflows/test-and-build.yml index 3b475e0e11..3f107044f3 100644 --- a/.github/workflows/test-and-build.yml +++ b/.github/workflows/test-and-build.yml @@ -3,7 +3,7 @@ on: pull_request jobs: test-and-build: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Checkout uses: actions/checkout@v2.3.1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fe51a19b74..12bfc92f87 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,7 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Checkout uses: actions/checkout@v2.3.1 diff --git a/docs/core-api/edit-api.md b/docs/core-api/edit-api.md index e5e3a459f9..3029221682 100644 --- a/docs/core-api/edit-api.md +++ b/docs/core-api/edit-api.md @@ -1,6 +1,6 @@ -# Edit Event API +# Edit Event API v2 -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. +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-v2`, 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. @@ -8,6 +8,116 @@ The edits to the `doc` will be done in place, e.g. the `doc` changes but will ke Open SCD core exports a factory function for edit events, so you do not have to build them manually. +```ts +function newEditEventV2( + edit: E, + options?: EditEventOptionsV2 +): EditEventV2 + +type EditV2 = InsertV2 | SetAttributesV2 | SetTextContentV2 | RemoveV2 | EditV2[]; + +interface EditEventOptionsV2 = { + title?: string; + squash?: boolean; + createHistoryEntry?: boolean; +}; +``` + +### EditEventOptionsV2 + +* `title` set a title to be shown in the history. +* `squash` squash edit with previous history entry, this is useful if you want to create multiple edits based on an user action, but need the updated `doc` before applying each edit. Defaults to `false`. +* `createHistoryEntry` decides whether a history for the `edit` should be created. Defaults to `true`. + +### 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 InsertV2 { + parent: Node; + node: Node; + reference: Node | null; +} +``` + +### Remove + +This event will remove the node from the document. + +```ts +interface RemoveV2 { + node: Node; +} +``` + +### SetAttributes + +Sets attributes for the element, can set both regular and namespaced attributes. + +```ts +interface SetAttributesV2 { + element: Element; + attributes: Partial>; + attributesNS: Partial>>>; +} +``` + +To set a namespaced attribute see the following example. Here we are setting the attribute `exa:type` for the namespace `https://example.com` to `secondary`. + +```ts +const setNamespacedAttributes: SetAttributesV2 = { + element, + attributes: {}, + attributesNS: { + "https://example.com": { + "exa:type": "secondary" + } + } +} +``` + +### SetTextContent + +Sets the text content of the element, removes any other children. To remove text content you can pass `null` as value for `textContent`. + +```ts +interface SetTextContentV2 { + element: Element; + textContent: string; +} +``` + +### 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 `newEditEventV2` factory function. + +```ts +import { newEditEventV2 } from '@openscd/core'; + +const complexEditEvent = newEditEventV2([ insert, update, remove ]); + +someComponent.dispatchEvent(complexEditEvent); + +``` + +## History + +All edit events with the option `createHistoryEntry` will create a history log entry and can be undone and redone through the history addon. + + +# Archives + +## Edit Event API v1 (deprecated) + +The edit event API v1 is still available and listens to events of the type `oscd-edit`. + +## 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, @@ -158,7 +268,7 @@ With open SCD version **v0.36.0** and higher some editor action features are no --- -# Archives - Editor Action API (deprecated) +## Editor Action API (deprecated) ### Event factory diff --git a/packages/core/foundation.ts b/packages/core/foundation.ts index c7cd164a43..4b34b7b25d 100644 --- a/packages/core/foundation.ts +++ b/packages/core/foundation.ts @@ -13,7 +13,7 @@ export { isNamespaced, isUpdate, isRemove, -} from './foundation/edit-event.js'; +} from './foundation/deprecated/edit-event.js'; export type { EditEvent, Edit, @@ -22,7 +22,31 @@ export type { NamespacedAttributeValue, Update, Remove, +} from './foundation/deprecated/edit-event.js'; + +export type { + EditV2, + InsertV2, + RemoveV2, + SetTextContentV2, + SetAttributesV2, +} from './foundation/edit.js'; +export { + isEditV2, + isRemoveV2, + isInsertV2, + isComplexV2, + isSetAttributesV2, + isSetTextContentV2 +} from './foundation/edit.js'; +export type { + EditEventV2, + EditEventOptionsV2, + EditDetailV2 } from './foundation/edit-event.js'; +export { newEditEventV2 } from './foundation/edit-event.js'; + +export { handleEditV2 } from './foundation/handle-edit.js'; export { cyrb64 } from './foundation/cyrb64.js'; diff --git a/packages/core/foundation/deprecated/edit-event.ts b/packages/core/foundation/deprecated/edit-event.ts new file mode 100644 index 0000000000..4abf0dae56 --- /dev/null +++ b/packages/core/foundation/deprecated/edit-event.ts @@ -0,0 +1,119 @@ +/** + * @deprecated Use the new edit event V2 API instead. + */ +export type Initiator = 'user' | 'system' | 'undo' | 'redo' | string; + +/** + * @deprecated Use the new edit event V2 API instead. + */ +export type Insert = { + parent: Node; + node: Node; + reference: Node | null; +}; + +/** + * @deprecated Use the new edit event V2 API instead. + */ +export type NamespacedAttributeValue = { + value: string | null; + namespaceURI: string | null; +}; +/** + * @deprecated Use the new edit event V2 API instead. + */ +export type AttributeValue = string | null | NamespacedAttributeValue; +/** + * @deprecated Use the new edit event V2 API instead. + */ +export type Update = { + element: Element; + attributes: Partial>; +}; + +/** + * @deprecated Use the new edit event V2 API instead. + */ +export type Remove = { + node: Node; +}; + +/** + * @deprecated Use the new edit event V2 API instead. + */ +export type Edit = Insert | Update | Remove | Edit[]; + +/** + * @deprecated Use the new edit event V2 API instead. + */ +export function isComplex(edit: Edit): edit is Edit[] { + return edit instanceof Array; +} + +/** + * @deprecated Use the new edit event V2 API instead. + */ +export function isInsert(edit: Edit): edit is Insert { + return (edit as Insert).parent !== undefined; +} + +/** + * @deprecated Use the new edit event V2 API instead. + */ +export function isNamespaced( + value: AttributeValue +): value is NamespacedAttributeValue { + return value !== null && typeof value !== 'string'; +} + +/** + * @deprecated Use the new edit event V2 API instead. + */ +export function isUpdate(edit: Edit): edit is Update { + return (edit as Update).element !== undefined; +} + +/** + * @deprecated Use the new edit event V2 API instead. + */ +export function isRemove(edit: Edit): edit is Remove { + return ( + (edit as Insert).parent === undefined && (edit as Remove).node !== undefined + ); +} + +/** + * @deprecated Use the new edit event V2 API instead. + */ +export interface EditEventDetail { + edit: E; + initiator: Initiator; +} + +/** + * @deprecated Use the new edit event V2 API instead. + */ +export type EditEvent = CustomEvent; + +/** + * @deprecated Use the new edit event V2 API instead. + */ +export function newEditEvent( + edit: E, + initiator: Initiator = 'user' +): EditEvent { + return new CustomEvent('oscd-edit', { + composed: true, + bubbles: true, + detail: { + edit: edit, + initiator: initiator, + }, + }); +} + +declare global { + interface ElementEventMap { + ['oscd-edit']: EditEvent; + } +} diff --git a/packages/core/foundation/deprecated/editor.ts b/packages/core/foundation/deprecated/editor.ts index 5aa7350bac..5f7b53c2dc 100644 --- a/packages/core/foundation/deprecated/editor.ts +++ b/packages/core/foundation/deprecated/editor.ts @@ -1,4 +1,4 @@ -import { Initiator } from '../edit-event.js'; +import { Initiator } from './edit-event.js'; /** Inserts `new.element` to `new.parent` before `new.reference`. */ export interface Create { diff --git a/packages/core/foundation/deprecated/history.ts b/packages/core/foundation/deprecated/history.ts index d6c6d6c648..b78f919432 100644 --- a/packages/core/foundation/deprecated/history.ts +++ b/packages/core/foundation/deprecated/history.ts @@ -1,4 +1,4 @@ -import { Edit } from '../edit-event.js'; +import { EditV2 } from '../edit.js'; type InfoEntryKind = 'info' | 'warning' | 'error'; @@ -12,8 +12,9 @@ export interface LogDetailBase { /** The [[`LogEntry`]] for a committed [[`EditorAction`]]. */ export interface CommitDetail extends LogDetailBase { kind: 'action'; - redo: Edit; - undo: Edit; + redo: EditV2; + undo: EditV2; + squash?: boolean; } /** A [[`LogEntry`]] for notifying the user. */ export interface InfoDetail extends LogDetailBase { diff --git a/packages/core/foundation/edit-completed-event.ts b/packages/core/foundation/edit-completed-event.ts index b2128ded9a..ed8ad8df08 100644 --- a/packages/core/foundation/edit-completed-event.ts +++ b/packages/core/foundation/edit-completed-event.ts @@ -1,4 +1,4 @@ -import { Edit, Initiator } from './edit-event.js'; +import { Edit, Initiator } from './deprecated/edit-event.js'; import { EditorAction } from './deprecated/editor.js'; diff --git a/packages/core/foundation/edit-event.ts b/packages/core/foundation/edit-event.ts index 1cd6649932..79b1a62155 100644 --- a/packages/core/foundation/edit-event.ts +++ b/packages/core/foundation/edit-event.ts @@ -1,78 +1,35 @@ -export type Initiator = 'user' | 'system' | 'undo' | 'redo' | string; +import { EditV2 } from './edit.js'; -/** Intent to `parent.insertBefore(node, reference)` */ -export type Insert = { - parent: Node; - node: Node; - reference: Node | null; -}; - -export type NamespacedAttributeValue = { - value: string | null; - namespaceURI: string | null; -}; -export type AttributeValue = string | null | NamespacedAttributeValue; -/** Intent to set or remove (if null) attributes on element */ -export type Update = { - element: Element; - attributes: Partial>; -}; - -/** Intent to remove a node from its ownerDocument */ -export type Remove = { - node: Node; +export type EditDetailV2 = EditEventOptionsV2 & { + edit: E; }; -/** Represents the user's intent to change an XMLDocument */ -export type Edit = Insert | Update | Remove | Edit[]; - -export function isComplex(edit: Edit): edit is Edit[] { - return edit instanceof Array; -} - -export function isInsert(edit: Edit): edit is Insert { - return (edit as Insert).parent !== undefined; -} - -export function isNamespaced( - value: AttributeValue -): value is NamespacedAttributeValue { - return value !== null && typeof value !== 'string'; -} - -export function isUpdate(edit: Edit): edit is Update { - return (edit as Update).element !== undefined; -} +export type EditEventV2 = CustomEvent< + EditDetailV2 +>; -export function isRemove(edit: Edit): edit is Remove { - return ( - (edit as Insert).parent === undefined && (edit as Remove).node !== undefined - ); +type BaseEditEventOptionsV2 = { + title?: string; + squash?: boolean; } -export interface EditEventDetail { - edit: E; - initiator: Initiator; -} - -export type EditEvent = CustomEvent; +export type EditEventOptionsV2 = BaseEditEventOptionsV2 & { + createHistoryEntry?: boolean; +}; -export function newEditEvent( +export function newEditEventV2( edit: E, - initiator: Initiator = 'user' -): EditEvent { - return new CustomEvent('oscd-edit', { + options?: EditEventOptionsV2 +): EditEventV2 { + return new CustomEvent>('oscd-edit-v2', { composed: true, bubbles: true, - detail: { - edit: edit, - initiator: initiator, - }, + detail: { ...options, edit }, }); } declare global { interface ElementEventMap { - ['oscd-edit']: EditEvent; + ['oscd-edit-v2']: EditEventV2; } } diff --git a/packages/core/foundation/edit.ts b/packages/core/foundation/edit.ts new file mode 100644 index 0000000000..cce63f144d --- /dev/null +++ b/packages/core/foundation/edit.ts @@ -0,0 +1,79 @@ +/** Intent to `parent.insertBefore(node, reference)` */ +export type InsertV2 = { + parent: Node; + node: Node; + reference: Node | null; +}; + +/** Intent to remove a `node` from its `ownerDocument` */ +export type RemoveV2 = { + node: Node; +}; + +/** Intent to set the `textContent` of `element` */ +export type SetTextContentV2 = { + element: Element; + textContent: string; +}; + +/** Intent to set or remove (if `null`) `attributes`(-`NS`) on `element` */ +export type SetAttributesV2 = { + element: Element; + attributes: Partial>; + attributesNS: Partial>>>; +}; + +/** Intent to change some XMLDocuments */ +export type EditV2 = + | InsertV2 + | SetAttributesV2 + | SetTextContentV2 + | RemoveV2 + | EditV2[]; + +export function isComplexV2(edit: EditV2): edit is EditV2[] { + return edit instanceof Array; +} + +export function isSetTextContentV2(edit: EditV2): edit is SetTextContentV2 { + return ( + (edit as SetTextContentV2).element !== undefined && + (edit as SetTextContentV2).textContent !== undefined + ); +} + +export function isRemoveV2(edit: EditV2): edit is RemoveV2 { + return ( + (edit as InsertV2).parent === undefined && (edit as RemoveV2).node !== undefined + ); +} + +export function isSetAttributesV2(edit: EditV2): edit is SetAttributesV2 { + return ( + (edit as SetAttributesV2).element !== undefined && + (edit as SetAttributesV2).attributes !== undefined && + (edit as SetAttributesV2).attributesNS !== undefined + ); +} + +export function isInsertV2(edit: EditV2): edit is InsertV2 { + return ( + (edit as InsertV2).parent !== undefined && + (edit as InsertV2).node !== undefined && + (edit as InsertV2).reference !== undefined + ); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function isEditV2(edit: any): edit is EditV2 { + if (isComplexV2(edit)) { + return !edit.some((e) => !isEditV2(e)); + } + + return ( + isSetAttributesV2(edit) || + isSetTextContentV2(edit) || + isInsertV2(edit) || + isRemoveV2(edit) + ); +} diff --git a/packages/core/foundation/handle-edit.ts b/packages/core/foundation/handle-edit.ts new file mode 100644 index 0000000000..9122dc5d02 --- /dev/null +++ b/packages/core/foundation/handle-edit.ts @@ -0,0 +1,189 @@ +import { + EditV2, + InsertV2, + isComplexV2, + isInsertV2, + isRemoveV2, + isSetAttributesV2, + isSetTextContentV2, + RemoveV2, + SetAttributesV2, + SetTextContentV2, +} from './edit.js'; + +function handleSetTextContent({ + element, + textContent, +}: SetTextContentV2): (SetTextContentV2 | InsertV2)[] { + const { childNodes } = element; + + const restoreChildNodes: InsertV2[] = Array.from(childNodes).map((node) => ({ + parent: element, + node, + reference: null, + })); + + element.textContent = textContent; + + const undoTextContent: SetTextContentV2 = { element, textContent: '' }; + + return [undoTextContent, ...restoreChildNodes]; +} + +function uniqueNSPrefix(element: Element, ns: string): string { + let i = 1; + const attributes = Array.from(element.attributes); + const hasSamePrefix = (attribute: Attr) => + attribute.prefix === `ens${i}` && attribute.namespaceURI !== ns; + const nsOrNull = new Set([null, ns]); + const differentNamespace = (prefix: string) => + !nsOrNull.has(element.lookupNamespaceURI(prefix)); + while (differentNamespace(`ens${i}`) || attributes.find(hasSamePrefix)) + i += 1; + return `ens${i}`; +} + +const xmlAttributeName = /^(?!xml|Xml|xMl|xmL|XMl|xML|XmL|XML)[A-Za-z_][A-Za-z0-9-_.]*(:[A-Za-z_][A-Za-z0-9-_.]*)?$/; + +function validName(name: string): boolean { + return xmlAttributeName.test(name); +} + +function handleSetAttributes({ + element, + attributes, + attributesNS, +}: SetAttributesV2): SetAttributesV2 { + const oldAttributes = { ...attributes }; + const oldAttributesNS = { ...attributesNS }; + + // save element's non-prefixed attributes for undo + Object.keys(attributes) + .reverse() + .forEach((name) => { + oldAttributes[name] = element.getAttribute(name); + }); + + // change element's non-prefixed attributes + for (const entry of Object.entries(attributes)) { + try { + const [name, value] = entry as [string, string | null]; + if (value === null) element.removeAttribute(name); + else element.setAttribute(name, value); + } catch (_e) { + // undo nothing if update didn't work on this attribute + delete oldAttributes[entry[0]]; + } + } + + // save element's namespaced attributes for undo + Object.entries(attributesNS).forEach(([ns, attrs]) => { + Object.keys(attrs!) + .filter(validName) + .reverse() + .forEach((name) => { + oldAttributesNS[ns] = { + ...oldAttributesNS[ns], + [name]: element.getAttributeNS(ns, name.split(':').pop()!), + }; + }); + Object.keys(attrs!) + .filter((name) => !validName(name)) + .forEach((name) => { + delete oldAttributesNS[ns]![name]; + }); + }); + + // change element's namespaced attributes + for (const nsEntry of Object.entries(attributesNS)) { + const [ns, attrs] = nsEntry as [ + string, + Partial>, + ]; + for (const entry of Object.entries(attrs).filter(([name]) => + validName(name), + )) { + try { + const [name, value] = entry as [string, string | null]; + if (value === null) { + element.removeAttributeNS(ns, name.split(':').pop()!); + } else { + let qualifiedName = name; + if (!qualifiedName.includes(':')) { + let prefix = element.lookupPrefix(ns); + if (!prefix) prefix = uniqueNSPrefix(element, ns); + qualifiedName = `${prefix}:${name}`; + } + element.setAttributeNS(ns, qualifiedName, value); + } + } catch (_e) { + delete oldAttributesNS[ns]![entry[0]]; + } + } +} + +return { + element, + attributes: oldAttributes, + attributesNS: oldAttributesNS, +}; +} + +function handleRemove({ node }: RemoveV2): InsertV2 | [] { + const { parentNode: parent, nextSibling: reference } = node; + node.parentNode?.removeChild(node); + if (parent) + return { + node, + parent, + reference, + }; + return []; +} + +function handleInsert({ + parent, + node, + reference, +}: InsertV2): InsertV2 | RemoveV2 | [] { + 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) { + // undo: move child node back to original place + return { + node, + parent: parentNode, + reference: nextSibling, + }; + } + + // undo: remove orphaned node + return { node }; + } catch (_e) { + // undo nothing if insert doesn't work on these nodes + return []; + } +} + +/** Applies an Edit, returning the corresponding 'undo' Edit. */ +export function handleEditV2(edit: EditV2): EditV2 { + if (isInsertV2(edit)) return handleInsert(edit); + if (isRemoveV2(edit)) return handleRemove(edit); + if (isSetAttributesV2(edit)) return handleSetAttributes(edit); + if (isSetTextContentV2(edit)) return handleSetTextContent(edit); + if (isComplexV2(edit)) return edit.map((edit) => handleEditV2(edit)).reverse(); + + return []; +} diff --git a/packages/openscd/src/addons/Editor.ts b/packages/openscd/src/addons/Editor.ts index a2ea531dae..70a07fca52 100644 --- a/packages/openscd/src/addons/Editor.ts +++ b/packages/openscd/src/addons/Editor.ts @@ -1,4 +1,17 @@ -import { OpenEvent, newEditCompletedEvent, newEditEvent } from '@openscd/core'; +import { + EditV2, + EditEventV2, + OpenEvent, + newEditCompletedEvent, + newEditEvent, + handleEditV2, + isInsertV2, + isRemoveV2, + isSetAttributesV2, + isSetTextContentV2, + isComplexV2, + newEditEventV2 +} from '@openscd/core'; import { property, LitElement, @@ -31,6 +44,7 @@ import { Update, } from '@openscd/core'; +import { convertEditActiontoV1 } from './editor/edit-action-to-v1-converter.js'; import { convertEditV1toV2 } from './editor/edit-v1-to-v2-converter.js'; @customElement('oscd-editor') @@ -48,21 +62,21 @@ export class OscdEditor extends LitElement { }) host!: HTMLElement; - private getLogText(edit: Edit): { title: string, message?: string } { - if (isInsert(edit)) { + private getLogText(edit: EditV2): { title: string, message?: string } { + if (isInsertV2(edit)) { const name = edit.node instanceof Element ? edit.node.tagName : get('editing.node'); return { title: get('editing.created', { name }) }; - } else if (isUpdate(edit)) { + } else if (isSetAttributesV2(edit) || isSetTextContentV2(edit)) { const name = edit.element.tagName; return { title: get('editing.updated', { name }) }; - } else if (isRemove(edit)) { + } else if (isRemoveV2(edit)) { const name = edit.node instanceof Element ? edit.node.tagName : get('editing.node'); return { title: get('editing.deleted', { name }) }; - } else if (isComplex(edit)) { + } else if (isComplexV2(edit)) { const message = edit.map(e => this.getLogText(e)).map(({ title }) => title).join(', '); return { title: get('editing.complex'), message }; } @@ -71,10 +85,26 @@ export class OscdEditor extends LitElement { } private onAction(event: EditorActionEvent) { - const edit = convertEditV1toV2(event.detail.action); - const initiator = event.detail.initiator; + const edit = convertEditActiontoV1(event.detail.action); + const editV2 = convertEditV1toV2(edit); - this.host.dispatchEvent(newEditEvent(edit, initiator)); + this.host.dispatchEvent(newEditEventV2(editV2)); + } + + handleEditEvent(event: EditEvent) { + /** + * This is a compatibility fix for plugins based on open energy tools edit events + * because their edit event look slightly different + * see https://github.com/OpenEnergyTools/open-scd-core/blob/main/foundation/edit-event-v1.ts for details + */ + if (isOpenEnergyEditEvent(event)) { + event = convertOpenEnergyEditEventToEditEvent(event); + } + + const edit = event.detail.edit; + const editV2 = convertEditV1toV2(edit); + + this.host.dispatchEvent(newEditEventV2(editV2)); } /** @@ -108,8 +138,10 @@ export class OscdEditor extends LitElement { // Deprecated editor action API, use 'oscd-edit' instead. this.host.addEventListener('editor-action', this.onAction.bind(this)); - + // Deprecated edit event API, use 'oscd-edit-v2' instead. this.host.addEventListener('oscd-edit', event => this.handleEditEvent(event)); + + this.host.addEventListener('oscd-edit-v2', event => this.handleEditEventV2(event)); this.host.addEventListener('open-doc', this.onOpenDoc); this.host.addEventListener('oscd-open', this.handleOpenDoc); } @@ -118,34 +150,23 @@ export class OscdEditor extends LitElement { return html``; } - async handleEditEvent(event: EditEvent) { - /** - * This is a compatibility fix for plugins based on open energy tools edit events - * because their edit event look slightly different - * see https://github.com/OpenEnergyTools/open-scd-core/blob/main/foundation/edit-event-v1.ts for details - */ - if (isOpenEnergyEditEvent(event)) { - event = convertOpenEnergyEditEventToEditEvent(event); - } - + async handleEditEventV2(event: EditEventV2) { const edit = event.detail.edit; - const undoEdit = handleEdit(edit); - this.dispatchEvent( - newEditCompletedEvent(event.detail.edit, event.detail.initiator) - ); + const undoEdit = handleEditV2(edit); - const shouldCreateHistoryEntry = event.detail.initiator !== 'redo' && event.detail.initiator !== 'undo'; + const shouldCreateHistoryEntry = event.detail.createHistoryEntry !== false; if (shouldCreateHistoryEntry) { const { title, message } = this.getLogText(edit); this.dispatchEvent(newLogEvent({ kind: 'action', - title, + title: event.detail.title ?? title, message, redo: edit, undo: undoEdit, + squash: event.detail.squash })); } @@ -154,107 +175,6 @@ export class OscdEditor extends LitElement { } } -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 []; -} - function isOpenEnergyEditEvent(event: CustomEvent): boolean { const eventDetail = event.detail as Edit; return isComplex(eventDetail) || isInsert(eventDetail) || isUpdate(eventDetail) || isRemove(eventDetail); diff --git a/packages/openscd/src/addons/History.ts b/packages/openscd/src/addons/History.ts index a6db95729a..2b330facdf 100644 --- a/packages/openscd/src/addons/History.ts +++ b/packages/openscd/src/addons/History.ts @@ -38,7 +38,7 @@ import { import { getFilterIcon, iconColors } from '../icons/icons.js'; import { Plugin } from '../plugin.js'; -import { newEditEvent } from '@openscd/core'; +import { EditV2, isComplexV2, newEditEventV2 } from '@openscd/core'; export const historyStateEvent = 'history-state'; export interface HistoryState { @@ -213,7 +213,7 @@ export class OscdHistory extends LitElement { if (!this.canUndo) return false; const undoEdit = (this.history[this.editCount]).undo; - this.host.dispatchEvent(newEditEvent(undoEdit, 'undo')); + this.host.dispatchEvent(newEditEventV2(undoEdit, { createHistoryEntry: false })); this.setEditCount(this.previousAction); return true; @@ -222,7 +222,7 @@ export class OscdHistory extends LitElement { if (!this.canRedo) return false; const redoEdit = (this.history[this.nextAction]).redo; - this.host.dispatchEvent(newEditEvent(redoEdit, 'redo')); + this.host.dispatchEvent(newEditEventV2(redoEdit, { createHistoryEntry: false })); this.setEditCount(this.nextAction); return true; @@ -238,11 +238,60 @@ export class OscdHistory extends LitElement { this.history.splice(this.nextAction); } - this.history.push(entry); + this.addHistoryEntry(entry); this.setEditCount(this.history.length - 1); this.requestUpdate('history', []); } + private addHistoryEntry(entry: CommitEntry) { + const shouldSquash = Boolean(entry.squash) && this.history.length > 0; + + if (shouldSquash) { + const previousEntry = this.history.pop() as CommitEntry; + const squashedEntry = this.squashHistoryEntries(entry, previousEntry); + this.history.push(squashedEntry); + } else { + this.history.push(entry); + } + } + + private squashHistoryEntries(current: CommitEntry, previous: CommitEntry): CommitEntry { + const undo = this.squashUndo(current.undo, previous.undo); + const redo = this.squashRedo(current.redo, previous.redo); + + return { + ...current, + undo, + redo + }; + } + + private squashUndo(current: EditV2, previous: EditV2): EditV2 { + const isCurrentComplex = isComplexV2(current); + const isPreviousComplex = isComplexV2(previous); + + const previousUndos: EditV2[] = (isPreviousComplex ? previous : [ previous ]) as EditV2[]; + const currentUndos: EditV2[] = (isCurrentComplex ? current : [ current ]) as EditV2[]; + + return [ + ...currentUndos, + ...previousUndos + ]; + } + + private squashRedo(current: EditV2, previous: EditV2): EditV2 { + const isCurrentComplex = isComplexV2(current); + const isPreviousComplex = isComplexV2(previous); + + const previousRedos: EditV2[] = (isPreviousComplex ? previous : [ previous ]) as EditV2[]; + const currentRedos: EditV2[] = (isCurrentComplex ? current : [ current ]) as EditV2[]; + + return [ + ...previousRedos, + ...currentRedos + ]; + } + private onReset() { this.log = []; this.history = []; diff --git a/packages/openscd/src/addons/editor/edit-action-to-v1-converter.ts b/packages/openscd/src/addons/editor/edit-action-to-v1-converter.ts new file mode 100644 index 0000000000..8dadf86bed --- /dev/null +++ b/packages/openscd/src/addons/editor/edit-action-to-v1-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 convertEditActiontoV1(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/addons/editor/edit-v1-to-v2-converter.ts b/packages/openscd/src/addons/editor/edit-v1-to-v2-converter.ts index f33d76d27f..fd5909f4e5 100644 --- a/packages/openscd/src/addons/editor/edit-v1-to-v2-converter.ts +++ b/packages/openscd/src/addons/editor/edit-v1-to-v2-converter.ts @@ -1,130 +1,36 @@ -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 - ); +import { Edit, EditV2, isComplex, isInsert, isNamespaced, isRemove, isUpdate, Update } from '@openscd/core'; + +export function convertEditV1toV2(edit: Edit): EditV2 { + if (isComplex(edit)) { + return edit.map(convertEditV1toV2); + } else if (isRemove(edit)) { + return edit as EditV2; + } else if (isInsert(edit)) { + return edit as EditV2; + } else if (isUpdate(edit)) { + return convertUpdate(edit); } else { - reference = action.new.reference ?? null; + throw new Error('Unknown edit type'); } - - 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; +function convertUpdate(edit: Update): EditV2 { + const attributes: Partial> = {}; + const attributesNS: Partial< + Record>> + > = {}; + + Object.entries(edit.attributes).forEach(([key, value]) => { + if (isNamespaced(value!)) { + const ns = value.namespaceURI; + if (!ns) return; + + if (!attributesNS[ns]) { + attributesNS[ns] = {}; + } + attributesNS[ns]![key] = value.value; + } else attributes[key] = value; }); - 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 - ]; + return { element: edit.element, attributes, attributesNS }; } diff --git a/packages/openscd/test/unit/Editor.test.ts b/packages/openscd/test/unit/Editor.test.ts index f26079472a..0b1cbd6d18 100644 --- a/packages/openscd/test/unit/Editor.test.ts +++ b/packages/openscd/test/unit/Editor.test.ts @@ -2,7 +2,17 @@ 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 { + Insert, + InsertV2, + newEditEvent, + newEditEventV2, + Remove, + Update, + SetAttributesV2, + SetTextContentV2, + RemoveV2 +} from '@openscd/core'; import { CommitDetail, LogDetail } from '@openscd/core/foundation/deprecated/history.js'; @@ -17,6 +27,7 @@ describe('OSCD-Editor', () => { let bay2: Element; let bay4: Element; let bay5: Element; + let bayWithoutTextContent: Element; let lnode1: Element; let lnode2: Element; @@ -39,6 +50,7 @@ describe('OSCD-Editor', () => { + `, 'application/xml', @@ -54,6 +66,7 @@ describe('OSCD-Editor', () => { bay2 = scd.querySelector('Bay[name="b2"]')!; bay4 = scd.querySelector('Bay[name="b4"]')!; bay5 = scd.querySelector('Bay[name="b5"]')!; + bayWithoutTextContent = scd.querySelector('Bay[name="bWithoutTextContent"]')!; lnode1 = scd.querySelector('LNode[name="l1"]')!; lnode2 = scd.querySelector('LNode[name="l2"]')!; }); @@ -63,13 +76,13 @@ describe('OSCD-Editor', () => { const newNode = scd.createElement('Bay'); newNode.setAttribute('name', 'b3'); - const insert: Insert = { + const insert: InsertV2 = { parent: voltageLevel1, node: newNode, reference: null }; - host.dispatchEvent(newEditEvent(insert)); + host.dispatchEvent(newEditEventV2(insert)); const newNodeFromScd = scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b3"]'); @@ -80,13 +93,13 @@ describe('OSCD-Editor', () => { const newNode = scd.createElement('Bay'); newNode.setAttribute('name', 'b3'); - const insert: Insert = { + const insert: InsertV2 = { parent: voltageLevel1, node: newNode, reference: bay1 }; - host.dispatchEvent(newEditEvent(insert)); + host.dispatchEvent(newEditEventV2(insert)); const newNodeFromScd = scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b3"]'); @@ -94,13 +107,13 @@ describe('OSCD-Editor', () => { }); it('should move node when inserting existing node', () => { - const insertMove: Insert = { + const insertMove: InsertV2 = { parent: voltageLevel1, node: bay2, reference: null }; - host.dispatchEvent(newEditEvent(insertMove)); + host.dispatchEvent(newEditEventV2(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); @@ -111,12 +124,12 @@ describe('OSCD-Editor', () => { node: bay1 }; - host.dispatchEvent(newEditEvent(remove)); + host.dispatchEvent(newEditEventV2(remove)); expect(scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b1"]')).to.be.null; }); - describe('Update', () => { + describe('SetAttributes', () => { it('should add new attributes and leave old attributes', () => { const bay1NewAttributes = { desc: 'new description', @@ -125,12 +138,13 @@ describe('OSCD-Editor', () => { const oldAttributes = elementAttributesToMap(bay1); - const update: Update = { + const update: SetAttributesV2 = { element: bay1, - attributes: bay1NewAttributes + attributes: bay1NewAttributes, + attributesNS: {} }; - host.dispatchEvent(newEditEvent(update)); + host.dispatchEvent(newEditEventV2(update)); const updatedElement = scd.querySelector('Bay[name="b1"]')!; @@ -147,12 +161,13 @@ describe('OSCD-Editor', () => { kind: null }; - const update: Update = { + const update: SetAttributesV2 = { element: bay1, - attributes: bay1NewAttributes + attributes: bay1NewAttributes, + attributesNS: {} }; - host.dispatchEvent(newEditEvent(update)); + host.dispatchEvent(newEditEventV2(update)); const updatedElement = scd.querySelector('Bay[name="b1"]')!; @@ -168,12 +183,13 @@ describe('OSCD-Editor', () => { const oldAttributes = elementAttributesToMap(bay1); - const update: Update = { + const update: SetAttributesV2 = { element: bay1, - attributes: bay1NewAttributes + attributes: bay1NewAttributes, + attributesNS: {} }; - host.dispatchEvent(newEditEvent(update)); + host.dispatchEvent(newEditEventV2(update)); const updatedElement = scd.querySelector(`Bay[name="${bay1NewAttributes.name}"]`)!; @@ -187,67 +203,77 @@ describe('OSCD-Editor', () => { describe('namespaced attributes', () => { it('should update attribute with namespace', () => { - const update: Update = { + const update: SetAttributesV2 = { element: lnode1, - attributes: { - type: { value: 'newType', namespaceURI: 'xsi' } + attributes: { }, + attributesNS: { + [nsXsi]: { type: 'newType' } } }; - host.dispatchEvent(newEditEvent(update)); + host.dispatchEvent(newEditEventV2(update)); - expect(lnode1.getAttributeNS('xsi', 'type')).to.equal('newType'); + expect(lnode1.getAttributeNS(nsXsi, 'type')).to.equal('newType'); }); it('should handle multiple namespaces', () => { - const update: Update = { + const update: SetAttributesV2 = { element: lnode1, - attributes: { - type: { value: 'newTypeXSI', namespaceURI: nsXsi } + attributes: { }, + attributesNS: { + [nsXsi]: { type: 'newTypeXSI' } } }; - host.dispatchEvent(newEditEvent(update)); + host.dispatchEvent(newEditEventV2(update)); - const update2: Update = { + const update2: SetAttributesV2 = { element: lnode1, - attributes: { - type: { value: 'newTypeTD', namespaceURI: nsTd } + attributes: { }, + attributesNS: { + [nsTd]: { type: 'newTypeTD' } } }; - host.dispatchEvent(newEditEvent(update2)); + host.dispatchEvent(newEditEventV2(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 = { + const update: SetAttributesV2 = { element: lnode2, - attributes: { - type: { value: null, namespaceURI: nsXsi } + attributes: { }, + attributesNS: { + [nsXsi]: { type: null } } }; - host.dispatchEvent(newEditEvent(update)); + host.dispatchEvent(newEditEventV2(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 = { + const update: SetAttributesV2 = { element: lnode2, attributes: { - type: { value: null, namespaceURI: nsXsi }, - kind: { value: 'td-kind', namespaceURI: nsTd }, normalAttribute: 'normalValue', lnClass: null + }, + attributesNS: { + [nsXsi]: { + type: null + }, + [nsTd]: { + kind: 'td-kind' + } } }; - host.dispatchEvent(newEditEvent(update)); + host.dispatchEvent(newEditEventV2(update)); expect(lnode2.getAttributeNS(nsXsi, 'type')).to.be.null; expect(lnode2.getAttributeNS(nsTd, 'kind')).to.equal('td-kind'); @@ -256,29 +282,43 @@ describe('OSCD-Editor', () => { }); }); + describe('SetTextContent', () => { + it('should set text content', () => { + const update: SetTextContentV2 = { + element: bay1, + textContent: 'new text' + }; + + host.dispatchEvent(newEditEventV2(update)); + + expect(bay1.textContent).to.equal('new text'); + }); + }); + describe('Complex action', () => { it('should apply each edit from a complex edit', () => { const newNode = scd.createElement('Bay'); newNode.setAttribute('name', 'b3'); - const insert: Insert = { + const insert: InsertV2 = { parent: voltageLevel1, node: newNode, reference: bay1 }; - const remove: Remove = { + const remove: RemoveV2 = { node: bay2 }; - const update: Update = { + const update: SetAttributesV2 = { element: bay1, attributes: { desc: 'new description' - } + }, + attributesNS: {} }; - host.dispatchEvent(newEditEvent([insert, remove, update])); + host.dispatchEvent(newEditEventV2([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; @@ -296,12 +336,12 @@ describe('OSCD-Editor', () => { }); }); - it('should log edit for user event', () => { - const remove: Remove = { + it('should log edit by default', () => { + const remove: RemoveV2 = { node: bay2, }; - host.dispatchEvent(newEditEvent(remove, 'user')); + host.dispatchEvent(newEditEventV2(remove)); expect(log).to.have.lengthOf(1); const logEntry = log[0] as CommitDetail; @@ -310,17 +350,6 @@ describe('OSCD-Editor', () => { 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(() => { @@ -332,11 +361,11 @@ describe('OSCD-Editor', () => { }); it('should dispatch validate event after edit', async () => { - const remove: Remove = { + const remove: RemoveV2 = { node: bay2, }; - host.dispatchEvent(newEditEvent(remove)); + host.dispatchEvent(newEditEventV2(remove)); await element.updateComplete; @@ -361,75 +390,108 @@ describe('OSCD-Editor', () => { const newNode = scd.createElement('Bay'); newNode.setAttribute('name', 'b3'); - const insert: Insert = { + const insert: InsertV2 = { parent: voltageLevel1, node: newNode, reference: null }; - host.dispatchEvent(newEditEvent(insert)); + host.dispatchEvent(newEditEventV2(insert)); - const undoInsert = log[0].undo as Remove; + const undoInsert = log[0].undo as RemoveV2; - host.dispatchEvent(newEditEvent(undoInsert)); + host.dispatchEvent(newEditEventV2(undoInsert)); expect(scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b3"]')).to.be.null; }); it('should undo remove', () => { - const remove: Remove = { + const remove: RemoveV2 = { node: bay4 }; - host.dispatchEvent(newEditEvent(remove)); + host.dispatchEvent(newEditEventV2(remove)); - const undoRemove = log[0].undo as Insert; + const undoRemove = log[0].undo as InsertV2; - host.dispatchEvent(newEditEvent(undoRemove)); + host.dispatchEvent(newEditEventV2(undoRemove)); const bay4FromScd = scd.querySelector('VoltageLevel[name="v2"] > Bay[name="b4"]'); expect(bay4FromScd).to.deep.equal(bay4); }); - it('should undo update', () => { - const update: Update = { + it('should undo set attributes', () => { + const update: SetAttributesV2 = { element: bay1, attributes: { desc: 'new description', kind: 'superbay' - } + }, + attributesNS: {} }; - host.dispatchEvent(newEditEvent(update)); + host.dispatchEvent(newEditEventV2(update)); - const undoUpdate = log[0].undo as Update; + const undoUpdate = log[0].undo as SetAttributesV2; - host.dispatchEvent(newEditEvent(undoUpdate)); + host.dispatchEvent(newEditEventV2(undoUpdate)); expect(bay1.getAttribute('desc')).to.be.null; expect(bay1.getAttribute('kind')).to.equal('bay'); }); + it('should undo set textcontent', () => { + const update: SetTextContentV2 = { + element: bayWithoutTextContent, + textContent: 'new text' + }; + + host.dispatchEvent(newEditEventV2(update)); + + const undoUpdate = log[0].undo as SetTextContentV2; + + host.dispatchEvent(newEditEventV2(undoUpdate)); + + expect(bayWithoutTextContent.textContent).to.be.empty; + }); + + it('should restore children when undoing set textcontent', () => { + const update: SetTextContentV2 = { + element: bay2, + textContent: 'new text' + }; + + host.dispatchEvent(newEditEventV2(update)); + + expect(bay2.children).to.be.empty; + + const undoUpdate = log[0].undo as SetTextContentV2; + + host.dispatchEvent(newEditEventV2(undoUpdate)); + + expect(bay2.children[0]).to.deep.equal(lnode2); + }); + it('should redo previously undone action', () => { const newNode = scd.createElement('Bay'); newNode.setAttribute('name', 'b3'); - const insert: Insert = { + const insert: InsertV2 = { parent: voltageLevel1, node: newNode, reference: null }; - host.dispatchEvent(newEditEvent(insert)); + host.dispatchEvent(newEditEventV2(insert)); const undoIsert = log[0].undo; const redoInsert = log[0].redo; - host.dispatchEvent(newEditEvent(undoIsert)); + host.dispatchEvent(newEditEventV2(undoIsert)); expect(scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b3"]')).to.be.null; - host.dispatchEvent(newEditEvent(redoInsert)); + host.dispatchEvent(newEditEventV2(redoInsert)); expect(scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b3"]')).to.deep.equal(newNode); }); @@ -438,35 +500,36 @@ describe('OSCD-Editor', () => { const newNode = scd.createElement('Bay'); newNode.setAttribute('name', 'b3'); - const insert: Insert = { + const insert: InsertV2 = { parent: voltageLevel1, node: newNode, reference: bay1 }; - const remove: Remove = { + const remove: RemoveV2 = { node: bay2 }; - const update: Update = { + const update: SetAttributesV2 = { element: bay1, attributes: { desc: 'new description' - } + }, + attributesNS: {} }; - host.dispatchEvent(newEditEvent([insert, remove, update])); + host.dispatchEvent(newEditEventV2([insert, remove, update])); const undoComplex = log[0].undo; const redoComplex = log[0].redo; - host.dispatchEvent(newEditEvent(undoComplex)); + host.dispatchEvent(newEditEventV2(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)); + host.dispatchEvent(newEditEventV2(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; diff --git a/packages/openscd/test/unit/edit-action-to-v1-converter.test.ts b/packages/openscd/test/unit/edit-action-to-v1-converter.test.ts new file mode 100644 index 0000000000..b7ef2bc2e4 --- /dev/null +++ b/packages/openscd/test/unit/edit-action-to-v1-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 { convertEditActiontoV1 } from '../../src/addons/editor/edit-action-to-v1-converter.js'; + + +describe('edit-action-to-v1-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 = convertEditActiontoV1(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 = convertEditActiontoV1(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 = convertEditActiontoV1(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 = convertEditActiontoV1(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 ] = convertEditActiontoV1(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/edit-v1-to-v2-converter.test.ts b/packages/openscd/test/unit/edit-v1-to-v2-converter.test.ts index 6676a1ffed..da9bf79415 100644 --- a/packages/openscd/test/unit/edit-v1-to-v2-converter.test.ts +++ b/packages/openscd/test/unit/edit-v1-to-v2-converter.test.ts @@ -16,15 +16,17 @@ import { Update, createUpdateAction } from '@openscd/core/foundation/deprecated/editor.js'; -import { Edit, Insert, Remove, Update as UpdateV2 } from '@openscd/core'; +import { Edit, Insert, InsertV2, Remove, Update as UpdateV1, RemoveV2, SetAttributesV2 } from '@openscd/core'; -import { convertEditV1toV2 } from '../../src/addons/editor/edit-v1-to-v2-converter.js'; +import { convertEditV1toV2 } from '../../src/addons/editor/edit-v1-to-v2-converter'; describe('edit-v1-to-v2-converter', () => { + const nsXsi = 'urn:example.com'; + const doc = new DOMParser().parseFromString( ` - + @@ -35,114 +37,90 @@ describe('edit-v1-to-v2-converter', () => { 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 - } + + it('should keep remove as is', () => { + const remove: Remove = { + node: bay }; - - const remove = convertEditV1toV2(deleteAction); - - const expectedRemove: Remove = { + + const removeV2 = convertEditV1toV2(remove); + + const expectedRemoveV2: RemoveV2 = { node: bay }; - - expect(remove).to.deep.equal(expectedRemove); + + expect(removeV2).to.deep.equal(expectedRemoveV2); }); - it('should convert create to insert', () => { + it('should keep insert as is', () => { const newBay = doc.createElement('Bay'); newBay.setAttribute('name', 'bay2'); - const createAction: Create = { - new: { - parent: substation, - element: newBay - } - }; - - const insert = convertEditV1toV2(createAction); - - const expectedInsert: Insert = { + const insert: Insert = { + node: newBay, parent: substation, + reference: null + }; + + const insertV2 = convertEditV1toV2(insert); + + const expectedInsertV2: InsertV2 = { node: newBay, + parent: substation, reference: null }; - - expect(insert).to.deep.equal(expectedInsert); + + expect(insertV2).to.deep.equal(expectedInsertV2); }); - - it('should convert update to updateV2', () => { + + it('should convert update to set attributes', () => { const newAttributes = { name: 'newBayName', }; - const updateAction = createUpdateAction(bay, newAttributes); - - const updateV2 = convertEditV1toV2(updateAction); - - const expectedUpdateV2: UpdateV2 = { + const update: UpdateV1 = { element: bay, - attributes: { - ...newAttributes, - desc: null - } + attributes: newAttributes + } + + const setAttributesV2 = convertEditV1toV2(update); + + const expectedSetAttributesV2: SetAttributesV2 = { + element: bay, + attributes: newAttributes, + attributesNS: {} }; - - expect(updateV2).to.deep.equal(expectedUpdateV2); + + expect(setAttributesV2).to.deep.equal(expectedSetAttributesV2); }); - it('should convert move to insert', () => { - const moveAction: Move = { - old: { - parent: substation, - element: bay, - reference: null - }, - new: { - parent: substation2, - reference: null + it('shoudl convert update with namespaced attributes', () => { + const newAttributes = { + name: 'newBayName', + type: { + value: 'new value', + namespaceURI: nsXsi } }; - const insert = convertEditV1toV2(moveAction); - - const expectedInsert: Insert = { - parent: substation2, - node: bay, - reference: null - }; - - expect(insert).to.deep.equal(expectedInsert); - }); + const update: UpdateV1 = { + element: bay, + attributes: newAttributes + } - it('should convert replace to complex action with remove and insert', () => { - const ied = doc.createElement('IED'); - ied.setAttribute('name', 'ied'); + const setAttributesV2 = convertEditV1toV2(update); - const replace: Replace = { - old: { - element: bay + const expectedSetAttributesV2: SetAttributesV2 = { + element: bay, + attributes: { + name: 'newBayName' }, - new: { - element: ied + attributesNS: { + [nsXsi]: { + type: 'new value' + } } }; - 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); + expect(setAttributesV2).to.deep.equal(expectedSetAttributesV2); }); });