Skip to content

Commit e43ee6a

Browse files
authored
feat: API compliant editor (openscd#1719)
1 parent 396bb13 commit e43ee6a

22 files changed

Lines changed: 553 additions & 609 deletions

package-lock.json

Lines changed: 16 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/core/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
dist/
44
node_modules/
55
doc/
6+
coverage/
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
export type Subscriber<T> = (value: T) => void;
2+
export type Unsubscriber<T> = () => Subscriber<T>;
3+
4+
export class Subject<T> {
5+
private subscribers: Subscriber<T>[] = [];
6+
7+
public next(value: T): void {
8+
this.subscribers.forEach(s => s(value));
9+
}
10+
11+
public subscribe(subscriber: Subscriber<T>): Unsubscriber<T> {
12+
this.subscribers.push(subscriber);
13+
14+
return () => {
15+
this.unsubscribe(subscriber);
16+
return subscriber;
17+
}
18+
}
19+
20+
public unsubscribe(subscriber: Subscriber<T>): void {
21+
const indexToRemove = this.subscribers.findIndex(s => s === subscriber);
22+
if (indexToRemove > -1) {
23+
this.subscribers.splice(indexToRemove, 1);
24+
}
25+
}
26+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { Transactor, TransactedCallback, Commit, CommitOptions } from '@openscd/oscd-api/dist/Transactor.js';
2+
import { EditV2 } from '@openscd/oscd-api/dist/editv2.js';
3+
4+
import { Subject } from './subject.js';
5+
import { handleEditV2 } from '../../foundation.js';
6+
7+
export interface OscdCommit<C> extends Commit<C> {
8+
time: number;
9+
}
10+
11+
export class XMLEditor implements Transactor<EditV2> {
12+
public past: OscdCommit<EditV2>[] = [];
13+
public future: OscdCommit<EditV2>[] = [];
14+
15+
private commitSubject = new Subject<OscdCommit<EditV2>>();
16+
private undoSubject = new Subject<OscdCommit<EditV2>>();
17+
private redoSubject = new Subject<OscdCommit<EditV2>>();
18+
19+
get canUndo(): boolean {
20+
return this.past.length > 0;
21+
}
22+
23+
get canRedo(): boolean {
24+
return this.future.length > 0;
25+
}
26+
27+
reset(): void {
28+
this.past = [];
29+
this.future = [];
30+
}
31+
32+
commit(change: EditV2, { title, squash }: CommitOptions = {}): OscdCommit<EditV2> {
33+
const commit: OscdCommit<EditV2> =
34+
squash && this.past.length
35+
? this.past[this.past.length - 1]
36+
: { undo: [], redo: [], time: Date.now() };
37+
// TODO: Fix type once issue is fixed https://github.com/openscd/oscd-api/issues/57
38+
const undo = handleEditV2(change as any);
39+
// typed as per https://github.com/microsoft/TypeScript/issues/49280#issuecomment-1144181818 recommendation:
40+
commit.undo.unshift(...[undo].flat(Infinity as 1));
41+
commit.redo.push(...[change].flat(Infinity as 1));
42+
if (title) commit.title = title;
43+
if (squash && this.past.length) this.past.pop();
44+
this.past.push(commit);
45+
this.future = [];
46+
this.commitSubject.next(commit);
47+
return commit;
48+
};
49+
50+
undo(): OscdCommit<EditV2> | undefined {
51+
const commit = this.past.pop();
52+
if (!commit) return;
53+
// TODO: Fix type once issue is fixed https://github.com/openscd/oscd-api/issues/57
54+
handleEditV2(commit.undo as any);
55+
this.future.unshift(commit);
56+
this.undoSubject.next(commit);
57+
return commit;
58+
};
59+
60+
redo(): OscdCommit<EditV2> | undefined {
61+
const commit = this.future.shift();
62+
if (!commit) return;
63+
// TODO: Fix type once issue is fixed https://github.com/openscd/oscd-api/issues/57
64+
handleEditV2(commit.redo as any);
65+
this.past.push(commit);
66+
this.redoSubject.next(commit);
67+
return commit;
68+
};
69+
70+
subscribe(txCallback: TransactedCallback<EditV2>): () => TransactedCallback<EditV2> {
71+
return this.commitSubject.subscribe(txCallback) as () => TransactedCallback<EditV2>;
72+
};
73+
74+
subscribeUndo(txCallback: TransactedCallback<EditV2>): () => TransactedCallback<EditV2> {
75+
return this.undoSubject.subscribe(txCallback) as () => TransactedCallback<EditV2>;
76+
}
77+
78+
subscribeRedo(txCallback: TransactedCallback<EditV2>): () => TransactedCallback<EditV2> {
79+
return this.redoSubject.subscribe(txCallback) as () => TransactedCallback<EditV2>;
80+
}
81+
82+
subscribeUndoRedo(txCallback: TransactedCallback<EditV2>): () => TransactedCallback<EditV2> {
83+
const unsubscribeUndo = this.subscribeUndo(txCallback);
84+
const unsubscribeRedo = this.subscribeRedo(txCallback);
85+
86+
return () => {
87+
unsubscribeUndo();
88+
unsubscribeRedo();
89+
return txCallback;
90+
}
91+
}
92+
}

packages/core/foundation.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,5 @@ export function crossProduct<T>(...arrays: T[][]): T[][] {
6868
}
6969

7070
export { OscdApi } from './api/api.js';
71+
72+
export { XMLEditor } from './api/editor/xml-editor.js';

packages/core/foundation/deprecated/history.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,6 @@ export interface LogDetailBase {
1212
/** The [[`LogEntry`]] for a committed [[`EditorAction`]]. */
1313
export interface CommitDetail extends LogDetailBase {
1414
kind: 'action';
15-
redo: EditV2;
16-
undo: EditV2;
17-
squash?: boolean;
1815
}
1916
/** A [[`LogEntry`]] for notifying the user. */
2017
export interface InfoDetail extends LogDetailBase {

packages/core/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,16 @@
3131
"clean": "rimraf .tsbuildinfo dist",
3232
"build": "tsc -b",
3333
"doc": "typedoc --out doc foundation.ts",
34+
"test": "web-test-runner --coverage",
3435
"prepublish": "npm run lint && npm run build && npm run doc",
3536
"lint": "eslint --ext .ts,.html . --ignore-path .gitignore && prettier \"**/*.ts\" --check --ignore-path .gitignore",
3637
"format": "eslint --ext .ts,.html . --fix --ignore-path .gitignore && prettier \"**/*.ts\" --write --ignore-path .gitignore"
3738
},
3839
"dependencies": {
3940
"@lit/localize": "^0.11.4",
4041
"@open-wc/lit-helpers": "^0.5.1",
41-
"lit": "^2.2.7"
42+
"lit": "^2.2.7",
43+
"@openscd/oscd-api": "^0.1.5"
4244
},
4345
"devDependencies": {
4446
"@custom-elements-manifest/analyzer": "^0.6.3",
@@ -52,6 +54,7 @@
5254
"@typescript-eslint/parser": "^5.30.7",
5355
"@web/dev-server": "^0.1.32",
5456
"@web/test-runner": "next",
57+
"@web/dev-server-esbuild": "^0.2.16",
5558
"@web/test-runner-playwright": "^0.8.10",
5659
"@web/test-runner-visual-regression": "^0.6.6",
5760
"concurrently": "^7.3.0",

packages/core/test/subject.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { expect } from '@open-wc/testing';
2+
3+
import { Subject } from '../api/editor/subject.js';
4+
5+
describe('Subject', () => {
6+
let subject: Subject<string>;
7+
8+
let subOneValues: string[];
9+
let subTwoValues: string[];
10+
11+
beforeEach(() => {
12+
subject = new Subject<string>();
13+
14+
subOneValues = [];
15+
subTwoValues = [];
16+
});
17+
18+
it('should call subscribers on next', () => {
19+
const subscriberOne = (v: string) => subOneValues.push(v);
20+
const subscriberTwo = (v: string) => subTwoValues.push(v);
21+
22+
subject.subscribe(subscriberOne);
23+
24+
subject.next('first');
25+
26+
expect(subOneValues).to.deep.equal([ 'first' ]);
27+
expect(subTwoValues).to.deep.equal([]);
28+
29+
subject.subscribe(subscriberTwo);
30+
31+
subject.next('second');
32+
33+
expect(subOneValues).to.deep.equal([ 'first', 'second' ]);
34+
expect(subTwoValues).to.deep.equal([ 'second' ]);
35+
});
36+
37+
it('should remove correct subscriber on unsubscribe', () => {
38+
const subscriberOne = (v: string) => subOneValues.push(v);
39+
const subscriberTwo = (v: string) => subTwoValues.push(v);
40+
41+
const unsubscribeOne = subject.subscribe(subscriberOne);
42+
const unsubscribeTwo = subject.subscribe(subscriberTwo);
43+
44+
subject.next('first');
45+
46+
expect(subOneValues).to.deep.equal([ 'first' ]);
47+
expect(subTwoValues).to.deep.equal([ 'first' ]);
48+
49+
unsubscribeOne();
50+
51+
subject.next('second');
52+
53+
expect(subOneValues).to.deep.equal([ 'first' ]);
54+
expect(subTwoValues).to.deep.equal([ 'first', 'second' ]);
55+
56+
unsubscribeTwo();
57+
58+
subject.next('third');
59+
60+
expect(subOneValues).to.deep.equal([ 'first' ]);
61+
expect(subTwoValues).to.deep.equal([ 'first', 'second' ]);
62+
});
63+
});
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { expect } from '@open-wc/testing';
2+
import { EditV2 } from '@openscd/oscd-api/dist/editv2.js';
3+
4+
import { OscdCommit, XMLEditor } from '../api/editor/xml-editor.js';
5+
import { RemoveV2 } from '../foundation.js';
6+
7+
describe('XMLEditor', () => {
8+
let editor: XMLEditor;
9+
let scd: XMLDocument;
10+
let subscriberValues: OscdCommit<EditV2>[];
11+
12+
let substation: Element;
13+
let voltageLevel: Element;
14+
let bay1: Element;
15+
16+
beforeEach(() => {
17+
editor = new XMLEditor();
18+
19+
subscriberValues = [];
20+
21+
scd = new DOMParser().parseFromString(
22+
`<Substation name="s1">
23+
<VoltageLevel name="v1">
24+
<Bay name="b1" kind="bay">
25+
<LNode name="l1" />
26+
</Bay>
27+
</VoltageLevel>
28+
</Substation>`,
29+
'application/xml'
30+
);
31+
32+
substation = scd.querySelector('Substation')!;
33+
voltageLevel = scd.querySelector('VoltageLevel')!;
34+
bay1 = scd.querySelector('Bay')!;
35+
});
36+
37+
it('should call subscriber on commit', () => {
38+
editor.subscribe(c => subscriberValues.push(c as any));
39+
40+
const deleteBay: RemoveV2 = {
41+
node: bay1
42+
};
43+
44+
editor.commit(deleteBay);
45+
46+
const [ commit ] = subscriberValues;
47+
expect(commit.redo).to.deep.equal([ deleteBay ]);
48+
});
49+
50+
it('should set title in commit', () => {
51+
const title = 'Important change';
52+
53+
const deleteBay: RemoveV2 = {
54+
node: bay1
55+
};
56+
57+
editor.commit(deleteBay, { title });
58+
59+
const [ commit ] = editor.past;
60+
expect(commit.title).to.equal(title);
61+
});
62+
63+
it('should undo and redo changes', () => {
64+
const deleteBay: RemoveV2 = {
65+
node: bay1
66+
};
67+
68+
editor.commit(deleteBay);
69+
70+
const bayAfterDelete = scd.querySelector('Bay');
71+
expect(bayAfterDelete).to.be.null;
72+
73+
editor.undo();
74+
75+
const bayAfterUndo = scd.querySelector('Bay');
76+
expect(bayAfterUndo).to.equal(bay1);
77+
78+
editor.redo();
79+
80+
const bayAfterRedo = scd.querySelector('Bay');
81+
expect(bayAfterRedo).to.be.null;
82+
});
83+
84+
it('should call subscribers on undo and redo', () => {
85+
const undos = [];
86+
const redos = [];
87+
88+
editor.subscribeUndo(c => undos.push(c));
89+
editor.subscribeRedo(c => redos.push(c));
90+
91+
const deleteBay: RemoveV2 = {
92+
node: bay1
93+
};
94+
95+
editor.commit(deleteBay);
96+
97+
editor.undo();
98+
99+
const [ lastUndo ] = undos;
100+
expect(lastUndo.redo).to.deep.equal([ deleteBay ]);
101+
102+
editor.redo();
103+
104+
const [ lastRedo ] = redos;
105+
expect(lastRedo.redo).to.deep.equal([ deleteBay ]);
106+
});
107+
});

0 commit comments

Comments
 (0)