Skip to content
Open
19 changes: 16 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/core/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
dist/
node_modules/
doc/
coverage/
26 changes: 26 additions & 0 deletions packages/core/api/editor/subject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export type Subscriber<T> = (value: T) => void;
export type Unsubscriber<T> = () => Subscriber<T>;

export class Subject<T> {
private subscribers: Subscriber<T>[] = [];

public next(value: T): void {
this.subscribers.forEach(s => s(value));
}

public subscribe(subscriber: Subscriber<T>): Unsubscriber<T> {
this.subscribers.push(subscriber);

return () => {
this.unsubscribe(subscriber);
return subscriber;
}
}

public unsubscribe(subscriber: Subscriber<T>): void {
const indexToRemove = this.subscribers.findIndex(s => s === subscriber);
if (indexToRemove > -1) {
this.subscribers.splice(indexToRemove, 1);
}
}
}
92 changes: 92 additions & 0 deletions packages/core/api/editor/xml-editor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { Transactor, TransactedCallback, Commit, CommitOptions } from '@openscd/oscd-api/dist/Transactor.js';
import { EditV2 } from '@openscd/oscd-api/dist/editv2.js';

import { Subject } from './subject.js';
import { handleEditV2 } from '../../foundation.js';

export interface OscdCommit<C> extends Commit<C> {
time: number;
}

export class XMLEditor implements Transactor<EditV2> {
public past: OscdCommit<EditV2>[] = [];
public future: OscdCommit<EditV2>[] = [];

private commitSubject = new Subject<OscdCommit<EditV2>>();
private undoSubject = new Subject<OscdCommit<EditV2>>();
private redoSubject = new Subject<OscdCommit<EditV2>>();

get canUndo(): boolean {
return this.past.length > 0;
}

get canRedo(): boolean {
return this.future.length > 0;
}

reset(): void {
this.past = [];
this.future = [];
}

commit(change: EditV2, { title, squash }: CommitOptions = {}): OscdCommit<EditV2> {
const commit: OscdCommit<EditV2> =
squash && this.past.length
? this.past[this.past.length - 1]
: { undo: [], redo: [], time: Date.now() };
// TODO: Fix type once issue is fixed https://github.com/openscd/oscd-api/issues/57
const undo = handleEditV2(change as any);
// typed as per https://github.com/microsoft/TypeScript/issues/49280#issuecomment-1144181818 recommendation:
commit.undo.unshift(...[undo].flat(Infinity as 1));
commit.redo.push(...[change].flat(Infinity as 1));
if (title) commit.title = title;
if (squash && this.past.length) this.past.pop();
this.past.push(commit);
this.future = [];
this.commitSubject.next(commit);
return commit;
};

undo(): OscdCommit<EditV2> | undefined {
const commit = this.past.pop();
if (!commit) return;
// TODO: Fix type once issue is fixed https://github.com/openscd/oscd-api/issues/57
handleEditV2(commit.undo as any);
this.future.unshift(commit);
this.undoSubject.next(commit);
return commit;
};

redo(): OscdCommit<EditV2> | undefined {
const commit = this.future.shift();
if (!commit) return;
// TODO: Fix type once issue is fixed https://github.com/openscd/oscd-api/issues/57
handleEditV2(commit.redo as any);
this.past.push(commit);
this.redoSubject.next(commit);
return commit;
};

subscribe(txCallback: TransactedCallback<EditV2>): () => TransactedCallback<EditV2> {
return this.commitSubject.subscribe(txCallback) as () => TransactedCallback<EditV2>;
};

subscribeUndo(txCallback: TransactedCallback<EditV2>): () => TransactedCallback<EditV2> {
return this.undoSubject.subscribe(txCallback) as () => TransactedCallback<EditV2>;
}

subscribeRedo(txCallback: TransactedCallback<EditV2>): () => TransactedCallback<EditV2> {
return this.redoSubject.subscribe(txCallback) as () => TransactedCallback<EditV2>;
}

subscribeUndoRedo(txCallback: TransactedCallback<EditV2>): () => TransactedCallback<EditV2> {
const unsubscribeUndo = this.subscribeUndo(txCallback);
const unsubscribeRedo = this.subscribeRedo(txCallback);

return () => {
unsubscribeUndo();
unsubscribeRedo();
return txCallback;
}
}
}
2 changes: 2 additions & 0 deletions packages/core/foundation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,5 @@ export function crossProduct<T>(...arrays: T[][]): T[][] {
}

export { OscdApi } from './api/api.js';

export { XMLEditor } from './api/editor/xml-editor.js';
3 changes: 0 additions & 3 deletions packages/core/foundation/deprecated/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,6 @@ export interface LogDetailBase {
/** The [[`LogEntry`]] for a committed [[`EditorAction`]]. */
export interface CommitDetail extends LogDetailBase {
kind: 'action';
redo: EditV2;
undo: EditV2;
squash?: boolean;
}
/** A [[`LogEntry`]] for notifying the user. */
export interface InfoDetail extends LogDetailBase {
Expand Down
5 changes: 4 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,16 @@
"clean": "rimraf .tsbuildinfo dist",
"build": "tsc -b",
"doc": "typedoc --out doc foundation.ts",
"test": "web-test-runner --coverage",
"prepublish": "npm run lint && npm run build && npm run doc",
"lint": "eslint --ext .ts,.html . --ignore-path .gitignore && prettier \"**/*.ts\" --check --ignore-path .gitignore",
"format": "eslint --ext .ts,.html . --fix --ignore-path .gitignore && prettier \"**/*.ts\" --write --ignore-path .gitignore"
},
"dependencies": {
"@lit/localize": "^0.11.4",
"@open-wc/lit-helpers": "^0.5.1",
"lit": "^2.2.7"
"lit": "^2.2.7",
"@openscd/oscd-api": "^0.1.5"
},
"devDependencies": {
"@custom-elements-manifest/analyzer": "^0.6.3",
Expand All @@ -52,6 +54,7 @@
"@typescript-eslint/parser": "^5.30.7",
"@web/dev-server": "^0.1.32",
"@web/test-runner": "next",
"@web/dev-server-esbuild": "^0.2.16",
"@web/test-runner-playwright": "^0.8.10",
"@web/test-runner-visual-regression": "^0.6.6",
"concurrently": "^7.3.0",
Expand Down
63 changes: 63 additions & 0 deletions packages/core/test/subject.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { expect } from '@open-wc/testing';

import { Subject } from '../api/editor/subject.js';

describe('Subject', () => {
let subject: Subject<string>;

let subOneValues: string[];
let subTwoValues: string[];

beforeEach(() => {
subject = new Subject<string>();

subOneValues = [];
subTwoValues = [];
});

it('should call subscribers on next', () => {
const subscriberOne = (v: string) => subOneValues.push(v);
const subscriberTwo = (v: string) => subTwoValues.push(v);

subject.subscribe(subscriberOne);

subject.next('first');

expect(subOneValues).to.deep.equal([ 'first' ]);
expect(subTwoValues).to.deep.equal([]);

subject.subscribe(subscriberTwo);

subject.next('second');

expect(subOneValues).to.deep.equal([ 'first', 'second' ]);
expect(subTwoValues).to.deep.equal([ 'second' ]);
});

it('should remove correct subscriber on unsubscribe', () => {
const subscriberOne = (v: string) => subOneValues.push(v);
const subscriberTwo = (v: string) => subTwoValues.push(v);

const unsubscribeOne = subject.subscribe(subscriberOne);
const unsubscribeTwo = subject.subscribe(subscriberTwo);

subject.next('first');

expect(subOneValues).to.deep.equal([ 'first' ]);
expect(subTwoValues).to.deep.equal([ 'first' ]);

unsubscribeOne();

subject.next('second');

expect(subOneValues).to.deep.equal([ 'first' ]);
expect(subTwoValues).to.deep.equal([ 'first', 'second' ]);

unsubscribeTwo();

subject.next('third');

expect(subOneValues).to.deep.equal([ 'first' ]);
expect(subTwoValues).to.deep.equal([ 'first', 'second' ]);
});
});
107 changes: 107 additions & 0 deletions packages/core/test/xml-editor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { expect } from '@open-wc/testing';
import { EditV2 } from '@openscd/oscd-api/dist/editv2.js';

import { OscdCommit, XMLEditor } from '../api/editor/xml-editor.js';
import { RemoveV2 } from '../foundation.js';

describe('XMLEditor', () => {
let editor: XMLEditor;
let scd: XMLDocument;
let subscriberValues: OscdCommit<EditV2>[];

let substation: Element;
let voltageLevel: Element;
let bay1: Element;

beforeEach(() => {
editor = new XMLEditor();

subscriberValues = [];

scd = new DOMParser().parseFromString(
`<Substation name="s1">
<VoltageLevel name="v1">
<Bay name="b1" kind="bay">
<LNode name="l1" />
</Bay>
</VoltageLevel>
</Substation>`,
'application/xml'
);

substation = scd.querySelector('Substation')!;
voltageLevel = scd.querySelector('VoltageLevel')!;
bay1 = scd.querySelector('Bay')!;
});

it('should call subscriber on commit', () => {
editor.subscribe(c => subscriberValues.push(c as any));

const deleteBay: RemoveV2 = {
node: bay1
};

editor.commit(deleteBay);

const [ commit ] = subscriberValues;
expect(commit.redo).to.deep.equal([ deleteBay ]);
});

it('should set title in commit', () => {
const title = 'Important change';

const deleteBay: RemoveV2 = {
node: bay1
};

editor.commit(deleteBay, { title });

const [ commit ] = editor.past;
expect(commit.title).to.equal(title);
});

it('should undo and redo changes', () => {
const deleteBay: RemoveV2 = {
node: bay1
};

editor.commit(deleteBay);

const bayAfterDelete = scd.querySelector('Bay');
expect(bayAfterDelete).to.be.null;

editor.undo();

const bayAfterUndo = scd.querySelector('Bay');
expect(bayAfterUndo).to.equal(bay1);

editor.redo();

const bayAfterRedo = scd.querySelector('Bay');
expect(bayAfterRedo).to.be.null;
});

it('should call subscribers on undo and redo', () => {
const undos = [];
const redos = [];

editor.subscribeUndo(c => undos.push(c));
editor.subscribeRedo(c => redos.push(c));

const deleteBay: RemoveV2 = {
node: bay1
};

editor.commit(deleteBay);

editor.undo();

const [ lastUndo ] = undos;
expect(lastUndo.redo).to.deep.equal([ deleteBay ]);

editor.redo();

const [ lastRedo ] = redos;
expect(lastRedo.redo).to.deep.equal([ deleteBay ]);
});
});
Loading
Loading