Skip to content

Commit

Permalink
feat: add UpdateNS edit type
Browse files Browse the repository at this point in the history
  • Loading branch information
ca-d committed Apr 30, 2024
1 parent 9a918fb commit 4e36a57
Show file tree
Hide file tree
Showing 3 changed files with 196 additions and 9 deletions.
98 changes: 94 additions & 4 deletions foundation/edit-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,31 @@ 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 Value = string | null;
export type AttributeValue = Value | NamespacedAttributeValue;
/**
* Intent to set or remove (if null) attributes on element
* @deprecated - use `UpdateNS` for updating namespaced attributes instead.
*/
export type Update = {
element: Element;
attributes: Partial<Record<string, AttributeValue>>;
};

/** Intent to set or remove (if null) attributes on element */
export type UpdateNS = {
element: Element;
attributes: Partial<Record<string, Value>>;
attributesNS: Partial<Record<string, Partial<Record<string, Value>>>>;
};

/** Intent to remove a node from its ownerDocument */
export type Remove = {
node: Node;
};

/** Represents the user's intent to change an XMLDocument */
export type Edit = Insert | Update | Remove | Edit[];
export type Edit = Insert | Update | UpdateNS | Remove | Edit[];

export function isComplex(edit: Edit): edit is Edit[] {
return edit instanceof Array;
Expand All @@ -39,7 +50,11 @@ export function isNamespaced(
}

export function isUpdate(edit: Edit): edit is Update {
return (edit as Update).element !== undefined;
return 'element' in edit && !('attributesNS' in edit);
}

export function isUpdateNS(edit: Edit): edit is UpdateNS {
return 'element' in edit && 'attributesNS' in edit;
}

export function isRemove(edit: Edit): edit is Remove {
Expand All @@ -66,6 +81,19 @@ declare global {

/** EDIT HANDLING */

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

function localAttributeName(attribute: string): string {
return attribute.includes(':') ? attribute.split(':', 2)[1] : attribute;
}
Expand Down Expand Up @@ -137,6 +165,67 @@ function handleUpdate({ element, attributes }: Update): Update {
};
}

function handleUpdateNS({
element,
attributes,
attributesNS,
}: UpdateNS): UpdateNS {
const oldAttributes = { ...attributes };
const oldAttributesNS = { ...attributesNS };
Object.keys(attributes)
.reverse()
.forEach(name => {
oldAttributes[name] = element.getAttribute(name);
});
for (const entry of Object.entries(attributes)) {
try {
const [name, value] = entry as [string, Value];
if (value === null) element.removeAttribute(name);
else element.setAttribute(name, value);
} catch (e) {
// do nothing if update doesn't work on this attribute
delete oldAttributes[entry[0]];
}
}
Object.entries(attributesNS).forEach(([ns, attrs]) => {
Object.keys(attrs!)
.reverse()
.forEach(name => {
oldAttributesNS[ns] = {
...oldAttributesNS[ns],
[name]: element.getAttributeNS(ns, name),
};
});
});
for (const nsEntry of Object.entries(attributesNS)) {
const [ns, attrs] = nsEntry as [string, Partial<Record<string, Value>>];
for (const entry of Object.entries(attrs)) {
try {
const [name, value] = entry as [string, Value];
if (value === null) element.removeAttributeNS(ns, name);
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[entry[0]];
}
}
}
/*
*/
return {
element,
attributes: oldAttributes,
attributesNS: oldAttributesNS,
};
}

function handleRemove({ node }: Remove): Insert | [] {
const { parentNode: parent, nextSibling: reference } = node;
node.parentNode?.removeChild(node);
Expand All @@ -152,6 +241,7 @@ function handleRemove({ node }: Remove): Insert | [] {
export function handleEdit(edit: Edit): Edit {
if (isInsert(edit)) return handleInsert(edit);
if (isUpdate(edit)) return handleUpdate(edit);
if (isUpdateNS(edit)) return handleUpdateNS(edit);
if (isRemove(edit)) return handleRemove(edit);
if (isComplex(edit)) return edit.map(handleEdit).reverse();
return [];
Expand Down
105 changes: 101 additions & 4 deletions open-scd.editing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
constant,
constantFrom,
dictionary,
object as objectArbitrary,
oneof,
property,
record,
Expand All @@ -31,6 +32,7 @@ import {
import type { OpenSCD } from './open-scd.js';

import './open-scd.js';
import { UpdateNS, Value } from './foundation/edit-event.js';

export namespace util {
export const xmlAttributeName =
Expand All @@ -43,7 +45,7 @@ export namespace util {
}

export const sclDocString = `<?xml version="1.0" encoding="UTF-8"?>
<SCL version="2007" revision="B" xmlns="http://www.iec.ch/61850/2003/SCL">
<SCL version="2007" revision="B" xmlns="http://www.iec.ch/61850/2003/SCL" xmlns:ens1="http://example.org/somePreexistingExtensionNamespace">
<Substation name="A1" desc="test substation"></Substation>
</SCL>`;
const testDocStrings = [
Expand Down Expand Up @@ -117,10 +119,34 @@ export namespace util {
return record({ element, attributes });
}

export function updateNS(nodes: Node[]): Arbitrary<UpdateNS> {
const element = <Arbitrary<Element>>(
constantFrom(...nodes.filter(nd => nd.nodeType === Node.ELEMENT_NODE))
);
const attributes = dictionary(
stringArbitrary(),
oneof(stringArbitrary(), constant(null))
);
// object() instead of nested dictionary() necessary for performance reasons
const attributesNS = objectArbitrary({
key: webUrl(),
values: [stringArbitrary(), constant(null)],
maxDepth: 1,
}).map(
aNS =>
Object.fromEntries(
Object.entries(aNS).filter(
([_, attrs]) => attrs && !(typeof attrs === 'string')
)
) as Partial<Record<string, Partial<Record<string, Value>>>>
);
return record({ element, attributes, attributesNS });
}

export function simpleEdit(
nodes: Node[]
): Arbitrary<Insert | Update | Remove> {
return oneof(remove(nodes), insert(nodes), update(nodes));
return oneof(remove(nodes), insert(nodes), update(nodes), updateNS(nodes));
}

export function complexEdit(nodes: Node[]): Arbitrary<Edit[]> {
Expand Down Expand Up @@ -242,6 +268,43 @@ describe('Editing Element', () => {
expect(element).to.have.attribute('myns:attr', 'namespaced value');
});

it("updates an element's attributes on UpdateNS", () => {
const element = sclDoc.querySelector('Substation')!;
editor.dispatchEvent(
newEditEvent({
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',
},
},
})
);
expect(element).to.have.attribute('name', 'A2');
expect(element).to.not.have.attribute('desc');
expect(element).to.have.attribute('__proto__', 'a string');
expect(element).to.have.attribute('myns:attr', 'value1');
expect(element).to.have.attribute('myns:attr2', 'value1');
expect(element).to.have.attribute('ens2:attr', 'value2');
expect(element).to.have.attribute('ens2:attr2', 'value2');
expect(element).to.have.attribute('ens3:attr', 'value3');
expect(element).to.have.attribute('ens3:attr2', 'value3');
});

it('processes complex edits in the given order', () => {
const parent = sclDoc.documentElement;
const reference = sclDoc.querySelector('Substation');
Expand Down Expand Up @@ -341,6 +404,40 @@ describe('Editing Element', () => {
)
));

it('updates default- and foreign-namespace attributes on UpdateNS events', () =>
assert(
property(
util.testDocs.chain(([{ nodes }]) => util.updateNS(nodes)),
edit => {
editor.dispatchEvent(newEditEvent(edit));
return (
Object.entries(edit.attributes)
.filter(([name]) => util.xmlAttributeName.test(name))
.map(entry => entry as [string, Value])
.every(
([name, value]) => edit.element.getAttribute(name) === value
) &&
Object.entries(edit.attributesNS)
.map(entry => entry as [string, Record<string, Value>])
.every(([ns, attributes]) =>
Object.entries(attributes)
.filter(([name]) => util.xmlAttributeName.test(name))
.map(entry => entry as [string, Value])
.every(
([name, value]) =>
edit.element.getAttributeNS(
ns,
name.includes(':')
? <string>name.split(':', 2)[1]
: name
) === value
)
)
);
}
)
)).timeout(20000);

it('removes elements on Remove edit events', () =>
assert(
property(
Expand Down Expand Up @@ -373,7 +470,7 @@ describe('Editing Element', () => {
return true;
}
)
));
)).timeout(20000);

it('redoes up to n edits on redo(n) call', () =>
assert(
Expand All @@ -396,6 +493,6 @@ describe('Editing Element', () => {
return oldDoc1 === newDoc1 && oldDoc2 === newDoc2;
}
)
));
)).timeout(20000);
});
});
2 changes: 1 addition & 1 deletion open-scd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ allLocales.forEach(lang =>
newEditEvent([
{ parent, node, reference },
{ parent, node, reference: null },
'invalid edit' as unknown as Edit,
{ invalid: 'edit' } as unknown as Edit,
])
);

Expand Down

0 comments on commit 4e36a57

Please sign in to comment.