Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,28 @@ const removeNode: Remove = {node: toBeRemovedNode}
handleEdit(removeNode)
```

You can also use the exported `EditV2Editor` class which keeps track of an undo/redo
history. This class is a wrapper around the `handleEdit` function and provides
a more user-friendly API. The `EditV2Editor` class can be used as follows:

You can also use the exported `Editor` class that inherits form `LitElement` that listens on the edit as specified in [OpenSCD Core API](https://github.com/OpenEnergyTools/open-scd-core/blob/main/API.md).
```ts
import { EditV2Editor } from '@omicronenergy/oscd-editor';

const editor = new EditV2Editor();

const removeNode: Remove = {node: toBeRemovedNode}
editor.handleEdit(removeNode);

expect(toBeRemovedNode.parentNode).to.not.exist;

editor.undo(); // undo the latest edit

expect(toBeRemovedNode.parentNode).to.exist;

editor.redo(); // redo the most recently undone edit

expect(toBeRemovedNode.parentNode).to.not.exist;
```

## Linting and formatting

Expand Down Expand Up @@ -41,4 +60,4 @@ To run the tests in interactive watch mode run:

This project is licensed under the Apache License 2.0.

© 2024 Jakob Vogelsang
© 2025 Jakob Vogelsang, OMICRON electronics GmbH
44 changes: 44 additions & 0 deletions Transactor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
export type CommitOptions = {
/** An optional human-readable description of the committed change */
title?: string;
/**
* If true, the commit will be squashed into the previous commit,
* overriding the previous title if a title is also provided.
*/
squash?: boolean;
};
/** Record of changes committed */
export interface Commit<Change> {
/** The inverse of the changes that were committed */
undo: Change[];
/** The changes that were committed */
redo: Change[];
/** An optional human-readable description of the committed changes */
title?: string;
}
export type TransactedCallback<Change> = (txRecord: Commit<Change>) => void;
/**
* Transactor is an interface that defines a transaction manager for managing
* changes in a system. It provides methods to commit changes, undo and redo
* changes, and subscribe to transaction events.
* @template Change - The type of changes that can be committed.
*/
export interface Transactor<Change> {
/** Commits a change, returning the resultant record. */
commit(change: Change, options?: CommitOptions): Commit<Change>;
/** Undoes the most reset `past` `Commit`, if any, returning it. */
undo(): Commit<Change> | undefined;
/** Redoes the most recent `future` `Commit`, if any, returning it. */
redo(): Commit<Change> | undefined;
/** All changes that have been committed and not yet undone. */
past: Commit<Change>[];
/** All changes that have been undone and can be redone. */
future: Commit<Change>[];
/**
* Registers `txCallback`, which will be called on every new `commit`.
* @returns a function that will **unsubscribe** the callback, returning it.
*/
subscribe(
txCallback: TransactedCallback<Change>,
): () => TransactedCallback<Change>;
}
299 changes: 299 additions & 0 deletions XMLEditor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
/* eslint-disable @typescript-eslint/no-unused-expressions */
import { expect } from "@open-wc/testing";

import { assert, property } from "fast-check";

import {
sclDocString,
testDocs,
UndoRedoTestCase,
undoRedoTestCases,
} from "./testHelpers.js";

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

import { XMLEditor } from "./XMLEditor.js";
import { Commit, Transactor } from "./Transactor.js";

describe("XMLEditor", () => {
let editor: Transactor<EditV2>;
let sclDoc: XMLDocument;

beforeEach(() => {
editor = new XMLEditor();
sclDoc = new DOMParser().parseFromString(sclDocString, "application/xml");
});

it("inserts an element on Insert", () => {
const parent = sclDoc.documentElement;
const node = sclDoc.createElement("test");
const reference = sclDoc.querySelector("Substation");
editor.commit({ parent, node, reference });
expect(sclDoc.documentElement.querySelector("test")).to.have.property(
"nextSibling",
reference,
);
});

it("removes an element on Remove", () => {
const node = sclDoc.querySelector("Substation")!;
editor.commit({ node });

expect(sclDoc.querySelector("Substation")).to.not.exist;
});

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

expect(element.getAttribute("name")).to.equal("A2");
expect(element.getAttribute("desc")).to.be.null;
expect(element.getAttribute("__proto__")).to.equal("a string");
expect(element.getAttribute("myns:attr")).to.equal("value1");
expect(element.getAttribute("myns:attr2")).to.equal("value1");
});

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

const newTextContent = "someNewTextContent";
editor.commit({
element,
textContent: newTextContent,
});

expect(element.textContent).to.equal(newTextContent);
});

it("records a commit history", () => {
const node = sclDoc.querySelector("Substation")!;
const edit = { node };
editor.commit(edit);
expect(editor.past).to.have.lengthOf(1);
expect(editor.past[0])
.to.exist.and.property("redo")
.to.have.lengthOf(1)
.and.to.include(edit);
});

it("records a given title in the commit history", () => {
const node = sclDoc.querySelector("SCL")!;

editor.commit(
{
node,
},
{ title: "delete everything" },
);

expect(editor.past[editor.past.length - 1]).to.have.property(
"title",
"delete everything",
);
});

it("squashes multiple edits into a single undoable edit", () => {
const element = sclDoc.querySelector("Substation")!;

const edit1: EditV2 = {
element,
attributes: {
name: "A2",
desc: null,
["__proto__"]: "a string", // covers a rare edge case branch
},
attributesNS: {
"http://example.org/myns": {
"myns:attr": "value1",
"myns:attr2": "value1",
},
"http://example.org/myns2": {
attr: "value2",
attr2: "value2",
},
"http://example.org/myns3": {
attr: "value3",
attr2: "value3",
},
},
};

const edit2: EditV2 = {
element,
textContent: "someNewTextContent",
};

editor.commit(edit1, {});
editor.commit(edit2, { squash: true });

const history = editor.past;
expect(history).to.have.length(1);

expect((history[0].undo as EditV2[])[0]).to.satisfy(isSetTextContent);
expect((history[0].undo as EditV2[])[1]).to.satisfy(isSetAttributes);
});

it("processes complex edits in the given order", () => {
const parent = sclDoc.documentElement;
const reference = sclDoc.querySelector("Substation");
const node1 = sclDoc.createElement("test1");
const node2 = sclDoc.createElement("test2");
editor.commit(
[
{ parent, node: node1, reference },
{ parent, node: node2, reference },
],
{},
);
expect(sclDoc.documentElement.querySelector("test1")).to.have.property(
"nextSibling",
node2,
);
expect(sclDoc.documentElement.querySelector("test2")).to.have.property(
"nextSibling",
reference,
);
});

it("undoes a committed edit on undo() call", () => {
const node = sclDoc.querySelector("Substation")!;

const commit = editor.commit({ node });
const undone = editor.undo();

expect(undone).to.exist.and.to.equal(commit);
expect(sclDoc.querySelector("Substation")).to.exist;
});

it("redoes an undone edit on redo() call", () => {
const node = sclDoc.querySelector("Substation")!;

const commit = editor.commit({ node });
editor.undo();
const redone = editor.redo();

expect(redone).to.exist.and.to.equal(commit);
expect(sclDoc.querySelector("Substation")).to.be.null;
});

it("undoes nothing at the beginning of the history", () => {
const node = sclDoc.querySelector("Substation")!;

editor.commit({ node });
editor.undo();
const secondUndo = editor.undo();

expect(secondUndo).to.not.exist;
});

it("redoes nothing at the end of the history", () => {
const node = sclDoc.querySelector("Substation")!;

editor.commit({ node });
const redo = editor.redo();

expect(redo).to.not.exist;
});

it("allows the user to subscribe to commits and to unsubscribe", () => {
const node = sclDoc.querySelector("Substation")!;
const edit = { node };
let committed: Commit<EditV2> | undefined;
let called = 0;
const callback = (commit: Commit<EditV2>) => {
committed = commit;
called++;
};
const unsubscribe = editor.subscribe(callback);
editor.commit(edit, { title: "test" });
expect(committed).to.exist.and.to.have.property("redo").to.include(edit);
expect(committed).to.have.property("title", "test");
expect(called).to.equal(1);
expect(editor.past).to.have.lengthOf(1);

editor.undo();
expect(called).to.equal(1);
expect(editor.past).to.have.lengthOf(0);
expect(editor.future).to.have.lengthOf(1);

editor.redo();
expect(called).to.equal(1);
expect(editor.past).to.have.lengthOf(1);
expect(editor.future).to.have.lengthOf(0);

const unsubscribed = unsubscribe();
expect(unsubscribed).to.equal(callback);

editor.commit(edit, { title: "some other title, not test" });
expect(committed).to.have.property("title", "test");
expect(called).to.equal(1);
expect(editor.past).to.have.lengthOf(2);
});

describe("generally", () => {
it("undoes up to n edits on undo(n) call", () =>
assert(
property(
testDocs.chain((docs) => undoRedoTestCases(...docs)),
({ doc1, doc2, edits, squash }: UndoRedoTestCase) => {
const [oldDoc1, oldDoc2] = [doc1, doc2].map((doc) =>
doc.cloneNode(true),
);
edits.forEach((a: EditV2) => {
try {
editor.commit(a, { squash });
} catch (e) {
console.log("error", e);
}
});
while (editor.past.length) editor.undo();
expect(doc1).to.satisfy((doc: XMLDocument) =>
doc.isEqualNode(oldDoc1),
);
expect(doc2).to.satisfy((doc: XMLDocument) =>
doc.isEqualNode(oldDoc2),
);
return true;
},
),
)).timeout(20000);

it("redoes up to n edits on redo(n) call", () =>
assert(
property(
testDocs.chain((docs) => undoRedoTestCases(...docs)),
({ doc1, doc2, edits }: UndoRedoTestCase) => {
edits.forEach((a: EditV2) => {
editor.commit(a);
});
const [oldDoc1, oldDoc2] = [doc1, doc2].map((doc) =>
new XMLSerializer().serializeToString(doc),
);

while (editor.past.length) editor.undo();
while (editor.future.length) editor.redo();
const [newDoc1, newDoc2] = [doc1, doc2].map((doc) =>
new XMLSerializer().serializeToString(doc),
);
return oldDoc1 === newDoc1 && oldDoc2 === newDoc2;
},
),
)).timeout(20000);
});
});
Loading