Skip to content

Commit

Permalink
feat: integrate EditV1 types and oscd-edit event
Browse files Browse the repository at this point in the history
  • Loading branch information
JakobVogelsang committed Oct 21, 2024
1 parent a86c1dc commit bcd6086
Show file tree
Hide file tree
Showing 7 changed files with 274 additions and 22 deletions.
26 changes: 25 additions & 1 deletion Editor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ import {
xmlAttributeName,
} from "./testHelpers.js";

import { newEditEventV2 } from "./edit-event.js";
import { newEditEventV2 } from "./edit-event-v2.js";

import { EditV2, isSetAttributes, isSetTextContent } from "./handleEdit.js";

import { Editor } from "./Editor.js";
import { newEditEvent } from "./edit-event.js";

customElements.define("editor-element", Editor);

Expand Down Expand Up @@ -94,6 +95,29 @@ describe("Utility function to handle EditV2 edits", () => {
expect(element.getAttribute("ens3:attr2")).to.equal("value3");
});

it("updates an element's attributes on Update", () => {
const element = sclDoc.querySelector("Substation")!;
editor.dispatchEvent(
newEditEvent({
element,
attributes: {
name: "A2",
desc: null,
["__proto__"]: "a string", // covers a rare edge case branch
"myns:attr": {
value: "namespaced value",
namespaceURI: "http://example.org/myns",
},
},
})
);

expect(element).to.have.attribute("name", "A2");
expect(element).to.not.have.attribute("desc");
expect(element).to.not.have.attribute("__proto__"); // cannot convert this key
expect(element).to.have.attribute("myns:attr", "namespaced value");
});

it("sets an element's textContent on SetTextContent", () => {
const element = sclDoc.querySelector("SCL")!;

Expand Down
20 changes: 19 additions & 1 deletion Editor.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { LitElement } from "lit";
import { state } from "lit/decorators.js";

import { EditEventV2 } from "./edit-event.js";
import { EditEventV2 } from "./edit-event-v2.js";

import { EditV2, handleEdit } from "./handleEdit.js";

import { EditEvent } from "./edit-event.js";
import { convertEdit } from "./convertEditToEditV2.js";

export type LogEntry = {
undo: EditV2;
redo: EditV2;
Expand Down Expand Up @@ -95,6 +98,17 @@ export class Editor extends LitElement {
this.updateVersion();
}

handleEditEventV1(event: EditEvent) {
const edit = event.detail;
const editV2 = convertEdit(edit);

this.history.splice(this.editCount); // cut history at editCount

this.history.push({ undo: handleEdit(editV2), redo: editV2 });

this.editCount = this.history.length;
}

/** Undo the last `n` [[Edit]]s committed */
undo(n = 1) {
if (!this.canUndo || n < 1) return;
Expand All @@ -116,6 +130,10 @@ export class Editor extends LitElement {
constructor() {
super();

this.addEventListener("oscd-edit", (event) =>
this.handleEditEventV1(event)
);

this.addEventListener("oscd-edit-v2", (event) =>
this.handleEditEvent(event)
);
Expand Down
104 changes: 104 additions & 0 deletions convertEditToEditV2.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { expect } from "@open-wc/testing";

import { Insert, Remove, Update } from "./edit-event.js";

import { convertEdit } from "./convertEditToEditV2.js";
import {
isInsert as isInsertV2,
isRemove as isRemoveV2,
isSetAttributes,
SetAttributes,
} from "./handleEdit.js";

const doc = new DOMParser().parseFromString(
'<SCL><Substation name="AA1"></Substation></SCL>',
"application/xml"
);

const subSt = doc.querySelector("Substation")!;

const removeV1: Remove = { node: subSt };

const insertV1: Insert = {
parent: subSt,
node: doc.createAttribute("VoltageLevel"),
reference: null,
};

const update: Update = {
element: subSt,
attributes: {
name: "A2",
desc: null,
["__proto__"]: "a string",
"myns:attr": {
value: "value1",
namespaceURI: "http://example.org/myns",
},
"myns:attr2": {
value: "value1",
namespaceURI: "http://example.org/myns",
},
attr: {
value: "value2",
namespaceURI: "http://example.org/myns2",
},
attr2: {
value: "value2",
namespaceURI: "http://example.org/myns2",
},
attr3: {
value: "value3",
namespaceURI: null,
},
},
};

const setAttributes: SetAttributes = {
element: subSt,
attributes: {
name: "A2",
desc: null,
},
attributesNS: {
"http://example.org/myns": {
"myns:attr": "value1",
"myns:attr2": "value1",
},
"http://example.org/myns2": {
attr: "value2",
attr2: "value2",
},
},
};

const invalidEdit = { someWrongKey: "someValue" } as unknown as Update;

describe("convertEditToEditV2", () => {
it("passes through a Remove edit", () =>
expect(convertEdit(removeV1)).to.deep.equal(removeV1));

it("passes through a Insert edit", () =>
expect(convertEdit(insertV1)).to.deep.equal(insertV1));

it("converts Update to SetAttributes", () =>
expect(convertEdit(update)).to.deep.equal(setAttributes));

it("converts complex edits", () => {
const editsV1 = [removeV1, insertV1, update];

const editsV2 = convertEdit(editsV1);

const [removeV2, insertV2, updateV2] = Array.from(
editsV2 as Array<Remove | Insert | SetAttributes>
);

expect(removeV1).to.deep.equal(removeV2);
expect(insertV1).to.deep.equal(insertV2);
expect(updateV2).to.deep.equal(setAttributes);
});

it("return empty array for invalid edit", () => {
expect(convertEdit(invalidEdit)).to.be.an("array").that.is.empty;
});
});
41 changes: 41 additions & 0 deletions convertEditToEditV2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {
Edit,
isComplex,
isInsert,
isNamespaced,
isUpdate,
isRemove,
Update,
} from "./edit-event.js";

import { EditV2 } from "./handleEdit.js";

function convertUpdate(edit: Update): EditV2 {
const attributes: Partial<Record<string, string | null>> = {};
const attributesNS: Partial<
Record<string, Partial<Record<string, string | null>>>
> = {};

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;
});

return { element: edit.element, attributes, attributesNS };
}

export function convertEdit(edit: Edit): EditV2 {
if (isRemove(edit)) return edit as EditV2;
if (isInsert(edit)) return edit as EditV2;
if (isUpdate(edit)) return convertUpdate(edit);
if (isComplex(edit)) return edit.map(convertEdit);

return [];
}
33 changes: 33 additions & 0 deletions edit-event-v2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { EditV2 } from "./handleEdit.js";

export type EditDetailV2<E extends EditV2 = EditV2> = {
edit: E;
title?: string;
squash?: boolean;
};

export type EditEventV2<E extends EditV2 = EditV2> = CustomEvent<
EditDetailV2<E>
>;

export type EditEventOptions = {
title?: string;
squash?: boolean;
};

export function newEditEventV2<E extends EditV2>(
edit: E,
options?: EditEventOptions
): EditEventV2<E> {
return new CustomEvent<EditDetailV2<E>>("oscd-edit-v2", {
composed: true,
bubbles: true,
detail: { ...options, edit },
});
}

declare global {
interface ElementEventMap {
["oscd-edit-v2"]: EditEventV2;
}
}
70 changes: 51 additions & 19 deletions edit-event.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,65 @@
import { EditV2 } from "./handleEdit.js";

export type EditDetailV2<E extends EditV2 = EditV2> = {
edit: E;
title?: string;
squash?: boolean;
/** Intent to `parent.insertBefore(node, reference)` */
export type Insert = {
parent: Node;
node: Node;
reference: Node | null;
};

export type EditEventV2<E extends EditV2 = EditV2> = CustomEvent<
EditDetailV2<E>
>;
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<Record<string, AttributeValue>>;
};

export type EditEventOptions = {
title?: string;
squash?: boolean;
/** Intent to remove a node from its ownerDocument */
export type Remove = {
node: Node;
};

export function newEditEventV2<E extends EditV2>(
edit: E,
options?: EditEventOptions
): EditEventV2<E> {
return new CustomEvent<EditDetailV2<E>>("oscd-edit-v2", {
/** 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 function isRemove(edit: Edit): edit is Remove {
return (
(edit as Insert).parent === undefined && (edit as Remove).node !== undefined
);
}

export type EditEvent<E extends Edit = Edit> = CustomEvent<E>;

export function newEditEvent<E extends Edit>(edit: E): EditEvent<E> {
return new CustomEvent<E>("oscd-edit", {
composed: true,
bubbles: true,
detail: { ...options, edit },
detail: edit,
});
}

declare global {
interface ElementEventMap {
["oscd-edit-v2"]: EditEventV2;
["oscd-edit"]: EditEvent;
}
}
2 changes: 1 addition & 1 deletion index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@ export {

export { Editor } from "./Editor.js";

export { newEditEventV2 } from "./edit-event.js";
export { newEditEventV2 } from "./edit-event-v2.js";

0 comments on commit bcd6086

Please sign in to comment.