Skip to content

Commit edc133c

Browse files
authored
feat(editors/cleanup): unreferenced DataSet (openscd#568)
* Initial efforts * Tidying and type definitions * Tidy up last lot of types * Revert snowpack to version 3.2.1 * Revert "Revert snowpack to version 3.2.1" This reverts commit 1ccfe02. * Remove vaadin-grid * Fix up package-lock.json for node version * Implement using mwc-list * Translations and tidying of code * Remove Cleanup editor from defaults * Add open-scd integration test snapshot and unit test file * Respond to feedback: * Convert ids to classes * Show number of datasets and number to be removed * Ensure button is disabled if no datasets selected and simplify logic * Update snapshot * Restore deleted snapshots * Include LogControl in checks More fully qualify references * Ensure security of dataset references * Improve dataset sorting * Improve styling * Fix functionality regression, update tooltip, add LogControl to testfile * Improve tests * Make button outlined and adjust location * Update snapshots * Respond to and incorporate review comments * Tidying * Rebase leftovers and error in translations * Respond to review comments * Fix broken import * Update snapshots
1 parent f94b042 commit edc133c

File tree

10 files changed

+1229
-0
lines changed

10 files changed

+1229
-0
lines changed

public/js/plugins.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,13 @@ export const officialPlugins = [
133133
requireDoc: true,
134134
position: 'middle'
135135
},
136+
{
137+
name: 'Cleanup',
138+
src: '/src/editors/Cleanup.js',
139+
icon: 'cleaning_services',
140+
default: false,
141+
kind: 'editor',
142+
},
136143
{
137144
name: 'Help',
138145
src: '/src/menu/Help.js',

src/editors/Cleanup.ts

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
'use strict';
2+
3+
import {
4+
css,
5+
html,
6+
LitElement,
7+
property,
8+
TemplateResult,
9+
query,
10+
queryAll,
11+
} from 'lit-element';
12+
import { translate } from 'lit-translate';
13+
14+
import '@material/mwc-button';
15+
import { Button } from '@material/mwc-button';
16+
import { List, MWCListIndex } from '@material/mwc-list';
17+
import { ListItem } from '@material/mwc-list/mwc-list-item.js';
18+
import '@material/mwc-list/mwc-check-list-item.js';
19+
import '../filtered-list.js';
20+
21+
import {
22+
Delete,
23+
identity,
24+
isPublic,
25+
newSubWizardEvent,
26+
newActionEvent,
27+
} from '../foundation.js';
28+
29+
import { editDataSetWizard } from '../wizards/dataset.js';
30+
import { styles } from './templates/foundation.js';
31+
32+
/** An editor [[`plugin`]] for cleaning SCL references and definitions. */
33+
export default class Cleanup extends LitElement {
34+
/** The document being edited as provided to plugins by [[`OpenSCD`]]. */
35+
@property()
36+
doc!: XMLDocument;
37+
@property()
38+
disableDataSetClean = false;
39+
@property()
40+
unreferencedDataSets: Element[] = [];
41+
@property()
42+
selectedItems: MWCListIndex | [] = [];
43+
44+
@query('.cleanupUnreferencedDataSetsDeleteButton')
45+
_cleanUnreferencedDataSetsButton!: Button;
46+
@query('.cleanupUnreferencedDataSetsList')
47+
_cleanUnreferencedDataSetsList: List | undefined;
48+
@queryAll('mwc-check-list-item')
49+
_cleanUnreferencedDataSetItems: ListItem[] | undefined;
50+
51+
/**
52+
* Set a class variable for selected items to allow processing and UI interaction
53+
*/
54+
private getSelectedUnreferencedDataSetItems() {
55+
this.selectedItems = (<List>(
56+
this.shadowRoot!.querySelector('.cleanupUnreferencedDataSetsList')
57+
)).index;
58+
}
59+
60+
/**
61+
* Clean datasets as requested by removing DataSet elements specified by the user from the SCL file
62+
* @returns an actions array to support undo/redo
63+
*/
64+
public cleanDataSets(cleanItems: Element[]): Delete[] {
65+
const actions: Delete[] = [];
66+
if (cleanItems) {
67+
cleanItems.forEach(item => {
68+
actions.push({
69+
old: {
70+
parent: <Element>item.parentElement!,
71+
element: item,
72+
reference: <Node | null>item!.nextSibling,
73+
},
74+
});
75+
});
76+
}
77+
return actions;
78+
}
79+
80+
async firstUpdated(): Promise<void> {
81+
this._cleanUnreferencedDataSetsList?.addEventListener('selected', () => {
82+
this.getSelectedUnreferencedDataSetItems();
83+
});
84+
}
85+
86+
/**
87+
* Render a user selectable table of unreferenced datasets if any exist, otherwise indicate this is not an issue.
88+
* @returns html for table and action button.
89+
*/
90+
private renderUnreferencedDataSets() {
91+
const unreferencedDataSets: Element[] = [];
92+
Array.from(this.doc?.querySelectorAll('DataSet') ?? [])
93+
.filter(isPublic)
94+
.forEach(dataSet => {
95+
const parent = dataSet.parentElement;
96+
const name = dataSet.getAttribute('name');
97+
const isReferenced = Array.from(
98+
parent?.querySelectorAll(
99+
'GSEControl, ReportControl, SampledValueControl, LogControl'
100+
) ?? []
101+
).some(cb => cb.getAttribute('datSet') === name);
102+
103+
if (parent && (!name || !isReferenced))
104+
unreferencedDataSets.push(dataSet);
105+
});
106+
107+
this.unreferencedDataSets = unreferencedDataSets.sort((a, b) => {
108+
// sorting using the identity ensures sort order includes IED
109+
const aId = identity(a);
110+
const bId = identity(b);
111+
if (aId < bId) {
112+
return -1;
113+
}
114+
if (aId > bId) {
115+
return 1;
116+
}
117+
// names must be equal
118+
return 0;
119+
});
120+
121+
return html`
122+
<h1>
123+
${translate('cleanup.unreferencedDataSets.title')}
124+
(${unreferencedDataSets.length})
125+
<abbr slot="action">
126+
<mwc-icon-button
127+
icon="info"
128+
title="${translate('cleanup.unreferencedDataSets.tooltip')}"
129+
>
130+
</mwc-icon-button>
131+
</abbr>
132+
</h1>
133+
<filtered-list multi class="cleanupUnreferencedDataSetsList"
134+
>${Array.from(
135+
unreferencedDataSets.map(
136+
item =>
137+
html`<mwc-check-list-item twoline left value="${identity(item)}"
138+
><span class="unreferencedDataSet"
139+
>${item.getAttribute('name')!}
140+
</span>
141+
<span>
142+
<mwc-icon-button
143+
label="Edit"
144+
icon="edit"
145+
class="editUnreferencedDataSet"
146+
@click=${(e: MouseEvent) => {
147+
e.stopPropagation();
148+
e.target?.dispatchEvent(
149+
newSubWizardEvent(() => editDataSetWizard(item))
150+
);
151+
}}
152+
></mwc-icon-button>
153+
</span>
154+
<span slot="secondary"
155+
>${item.closest('IED')?.getAttribute('name')}
156+
(${item.closest('IED')?.getAttribute('manufacturer') ??
157+
'No manufacturer defined'})
158+
-
159+
${item.closest('IED')?.getAttribute('type') ??
160+
'No Type Defined'}</span
161+
>
162+
</mwc-check-list-item>`
163+
)
164+
)}
165+
</filtered-list>
166+
<footer>
167+
<mwc-button
168+
outlined
169+
icon="delete"
170+
class="cleanupUnreferencedDataSetsDeleteButton"
171+
label="${translate('cleanup.unreferencedDataSets.deleteButton')} (${(<
172+
Set<number>
173+
>this.selectedItems).size || '0'})"
174+
?disabled=${(<Set<number>>this.selectedItems).size === 0 ||
175+
(Array.isArray(this.selectedItems) && !this.selectedItems.length)}
176+
@click=${(e: MouseEvent) => {
177+
const cleanItems = Array.from(
178+
(<Set<number>>this.selectedItems).values()
179+
).map(index => this.unreferencedDataSets[index]);
180+
const deleteActions = this.cleanDataSets(cleanItems);
181+
deleteActions.forEach(deleteAction =>
182+
e.target?.dispatchEvent(newActionEvent(deleteAction))
183+
);
184+
}}
185+
></mwc-button>
186+
</footer>
187+
`;
188+
}
189+
190+
render(): TemplateResult {
191+
return html`
192+
<div class="cleanupUnreferencedDataSets">
193+
<section tabindex="0">${this.renderUnreferencedDataSets()}</section>
194+
</div>
195+
`;
196+
}
197+
198+
static styles = css`
199+
${styles}
200+
201+
:host {
202+
width: 100vw;
203+
}
204+
205+
.cleanupUnreferencedDataSets {
206+
display: grid;
207+
grid-gap: 12px;
208+
padding: 8px 12px 16px;
209+
box-sizing: border-box;
210+
grid-template-columns: repeat(auto-fit, minmax(316px, 50%));
211+
}
212+
213+
@media (max-width: 387px) {
214+
.cleanupUnreferencedDataSets {
215+
grid-template-columns: repeat(auto-fit, minmax(196px, auto));
216+
}
217+
}
218+
219+
.editUnreferencedDataSet {
220+
--mdc-icon-size: 16px;
221+
}
222+
223+
.cleanupUnreferencedDataSetsDeleteButton {
224+
float: right;
225+
margin-bottom: 10px;
226+
margin-right: 10px;
227+
}
228+
229+
footer {
230+
display: flex;
231+
align-items: center;
232+
justify-content: flex-end;
233+
}
234+
`;
235+
}

src/translations/de.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,14 @@ export const de: Translations = {
499499
report: {
500500
wizard: { location: 'Ablageort der Reports wählen' },
501501
},
502+
cleanup: {
503+
unreferencedDataSets: {
504+
title: 'Nicht referenzierte Datensätze',
505+
deleteButton: 'Ausgewählten Datensatz entfernen',
506+
tooltip:
507+
'DatenSätze ohne Verweis auf einen zugehörigen GOOSE-, Log-, Report- oder Sampled Value Control Block',
508+
},
509+
},
502510
controlblock: {
503511
action: {
504512
edit: '{{type}} "{{name}}" in IED {{iedName}} bearbeitet',

src/translations/en.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,13 @@ export const en = {
495495
report: {
496496
wizard: { location: 'Select Report Control Location' },
497497
},
498+
cleanup: {
499+
unreferencedDataSets: {
500+
title: 'Unreferenced Datasets',
501+
deleteButton: 'Remove Selected Datasets',
502+
tooltip: 'Datasets without a reference to an associated GOOSE, Log, Report or Sampled Value Control Block'
503+
},
504+
},
498505
controlblock: {
499506
action: {
500507
edit: 'Edited {{type}} "{{name}}" in IED {{iedName}}',

test/integration/__snapshots__/open-scd.test.snap.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,21 @@ snapshots["open-scd looks like its snapshot"] =
652652
</mwc-icon>
653653
Templates
654654
</mwc-check-list-item>
655+
<mwc-check-list-item
656+
aria-disabled="false"
657+
class="official"
658+
graphic="control"
659+
hasmeta=""
660+
left=""
661+
mwc-list-item=""
662+
tabindex="-1"
663+
value="/src/editors/Cleanup.js"
664+
>
665+
<mwc-icon slot="meta">
666+
cleaning_services
667+
</mwc-icon>
668+
Cleanup
669+
</mwc-check-list-item>
655670
<mwc-list-item
656671
aria-disabled="false"
657672
graphic="avatar"
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
'use strict';
2+
import { html, fixture, expect } from '@open-wc/testing';
3+
4+
import { Editing } from '../../../src/Editing.js';
5+
import { Wizarding } from '../../../src/Wizarding.js';
6+
7+
import Cleanup from '../../../src/editors/Cleanup.js';
8+
9+
describe('Cleanup', () => {
10+
customElements.define('cleanup-plugin', Wizarding(Editing(Cleanup)));
11+
let element: Cleanup;
12+
13+
beforeEach(async () => {
14+
element = await fixture(html`<cleanup-plugin></cleanup-plugin>`);
15+
});
16+
17+
describe('without a doc loaded', () => {
18+
it('looks like the latest snapshot', async () => {
19+
await expect(element).shadowDom.to.equalSnapshot();
20+
});
21+
});
22+
23+
describe('Unreferenced DataSets', () => {
24+
let doc: Document;
25+
beforeEach(async () => {
26+
doc = await fetch('/test/testfiles/cleanup.scd')
27+
.then(response => response.text())
28+
.then(str => new DOMParser().parseFromString(str, 'application/xml'));
29+
element = await fixture(
30+
html`<cleanup-plugin .doc="${doc}"></cleanup-plugin>`
31+
);
32+
await element.updateComplete;
33+
});
34+
35+
it('creates two Delete Actions', async () => {
36+
// select all items and update list
37+
const checkbox = element
38+
.shadowRoot!.querySelector('.cleanupUnreferencedDataSetsList')!
39+
.shadowRoot!.querySelector('mwc-formfield')!
40+
.querySelector('mwc-checkbox')!;
41+
await checkbox.click();
42+
element._cleanUnreferencedDataSetsList?.layout();
43+
const cleanItems = Array.from(
44+
(<Set<number>>element._cleanUnreferencedDataSetsList!.index).values()
45+
).map(index => element.unreferencedDataSets[index]);
46+
const deleteActions = element.cleanDataSets(cleanItems);
47+
expect(deleteActions.length).to.equal(2);
48+
});
49+
50+
it('correctly removes the datasets from the SCL file', async () => {
51+
// select all items and update list
52+
const checkbox = element
53+
.shadowRoot!.querySelector('.cleanupUnreferencedDataSetsList')!
54+
.shadowRoot!.querySelector('mwc-formfield')!
55+
.querySelector('mwc-checkbox')!;
56+
await checkbox.click();
57+
element._cleanUnreferencedDataSetsList?.layout();
58+
await element._cleanUnreferencedDataSetsButton.click();
59+
// the correct number of DataSets should remain
60+
const remainingDataSetCountCheck =
61+
doc.querySelectorAll(
62+
':root > IED > AccessPoint > Server > LDevice > LN0 > DataSet, :root > IED > AccessPoint > Server > LDevice > LN > DataSet'
63+
).length === 6;
64+
// those DataSets selected had best be gone
65+
const datasetsCorrectlyRemoved =
66+
doc.querySelectorAll(
67+
'DataSet[name="GooseDataSet2"], DataSet[name="PhsMeas2"]'
68+
).length === 0;
69+
expect(remainingDataSetCountCheck && datasetsCorrectlyRemoved).to.equal(
70+
true
71+
);
72+
});
73+
});
74+
});

0 commit comments

Comments
 (0)