Skip to content

Commit fe0892d

Browse files
authored
refactor: replace wizard event by own dialog to edit and create (sub)functions (#10)
1 parent 6b1add6 commit fe0892d

File tree

4 files changed

+429
-195
lines changed

4 files changed

+429
-195
lines changed
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { expect, fixture, html } from '@open-wc/testing';
2+
import { testScl } from '../scl-bay-template.testfiles.js';
3+
import './edit-function-dialog.js';
4+
import type EditFunctionDialog from './edit-function-dialog.js';
5+
6+
describe('EditFunctionDialog', () => {
7+
let dialog: EditFunctionDialog;
8+
let doc: XMLDocument;
9+
let parent: Element;
10+
let saveButton: HTMLButtonElement;
11+
12+
beforeEach(async () => {
13+
doc = new DOMParser().parseFromString(testScl, 'application/xml');
14+
parent = doc.querySelector('Function')!;
15+
16+
dialog = await fixture(
17+
html`<edit-function-dialog-e4d2f8b7></edit-function-dialog-e4d2f8b7>`
18+
);
19+
dialog.parent = parent;
20+
dialog.elTagName = 'SubFunction';
21+
saveButton = dialog.shadowRoot?.querySelector(
22+
'[data-testid="add-subfunction-btn"]'
23+
) as HTMLButtonElement;
24+
});
25+
26+
it('should reset fields on dialog close', () => {
27+
dialog.nameTextField.value = 'CBR';
28+
dialog.descTextField.value = 'CBR Description';
29+
dialog.typeTextField.value = 'CBR Type';
30+
(dialog as any).onDialogClosed();
31+
expect(dialog.nameTextField.value).to.equal('');
32+
expect(dialog.descTextField.value).to.equal(null);
33+
expect(dialog.typeTextField.value).to.equal(null);
34+
});
35+
36+
it('should validate name as required', () => {
37+
saveButton.click();
38+
const mdField = dialog.nameTextField.shadowRoot?.querySelector(
39+
'md-filled-text-field'
40+
) as HTMLInputElement;
41+
expect(mdField.validationMessage).to.equal('Name is required.');
42+
});
43+
44+
it('should validate name as unique', () => {
45+
dialog.requireUniqueName = true;
46+
dialog.siblings = Array.from(parent.querySelectorAll('SubFunction'));
47+
dialog.nameTextField.value = 'General';
48+
49+
saveButton.click();
50+
51+
const mdField = dialog.nameTextField.shadowRoot?.querySelector(
52+
'md-filled-text-field'
53+
) as HTMLInputElement;
54+
expect(mdField.validationMessage).to.match(/Name must be unique/i);
55+
});
56+
57+
describe('Add mode', () => {
58+
beforeEach(() => {
59+
dialog.element = undefined;
60+
dialog.open = true;
61+
});
62+
63+
it('should render dialog with correct heading', () => {
64+
const mwcDialog = dialog.shadowRoot?.querySelector('mwc-dialog');
65+
expect((mwcDialog as any).heading).to.equal('Add SubFunction');
66+
});
67+
68+
it('should dispatch insert event on save', () => {
69+
dialog.nameTextField.value = 'ETH';
70+
dialog.descTextField.value = 'Description of ETH';
71+
dialog.typeTextField.value = 'Type of ETH';
72+
dialog.addEventListener('oscd-edit', e => {
73+
const insert = (e as CustomEvent).detail;
74+
expect(insert.parent).to.equal(parent);
75+
expect(insert.node.getAttribute('name')).to.equal('ETH');
76+
expect(insert.node.getAttribute('desc')).to.equal('Description of ETH');
77+
expect(insert.node.getAttribute('type')).to.equal('Type of ETH');
78+
});
79+
(dialog as any).onSave();
80+
});
81+
});
82+
83+
describe('Edit mode', () => {
84+
beforeEach(async () => {
85+
dialog.element = parent.querySelector('SubFunction') as Element;
86+
dialog.open = true;
87+
});
88+
89+
it('should render dialog with correct heading', () => {
90+
const mwcDialog = dialog.shadowRoot?.querySelector('mwc-dialog');
91+
expect((mwcDialog as any).heading).to.equal('Edit SubFunction');
92+
});
93+
94+
it('should populate fields with existing values', () => {
95+
expect(dialog.nameTextField.value).to.equal('General');
96+
expect(dialog.descTextField.value).to.equal(null);
97+
expect(dialog.typeTextField.value).to.equal(null);
98+
});
99+
100+
it('should dispatch update event on save', () => {
101+
dialog.nameTextField.value = 'General Edited';
102+
dialog.descTextField.value = 'Edited Description';
103+
dialog.typeTextField.value = 'Edited Type';
104+
dialog.addEventListener('oscd-edit', e => {
105+
const update = (e as CustomEvent).detail;
106+
expect(update.element).to.equal(dialog.element);
107+
expect(update.attributes.name).to.equal('General Edited');
108+
expect(update.attributes.desc).to.equal('Edited Description');
109+
expect(update.attributes.type).to.equal('Edited Type');
110+
});
111+
(dialog as any).onSave();
112+
});
113+
});
114+
});

components/edit-function-dialog.ts

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import { LitElement, html, css } from 'lit';
2+
import { property, customElement, query } from 'lit/decorators.js';
3+
import { newEditEvent, Insert } from '@openscd/open-scd-core';
4+
import { ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js';
5+
import { SclTextField } from '@openenergytools/scl-text-field';
6+
import { Dialog } from '@material/mwc-dialog';
7+
import { Button } from '@material/mwc-button';
8+
9+
@customElement('edit-function-dialog-e4d2f8b7')
10+
export default class EditFunctionDialog extends ScopedElementsMixin(
11+
LitElement
12+
) {
13+
static scopedElements = {
14+
'scl-text-field': SclTextField,
15+
'mwc-dialog': Dialog,
16+
'mwc-button': Button,
17+
};
18+
19+
@property({ attribute: false })
20+
element?: Element;
21+
22+
@property({ attribute: false })
23+
parent!: Element;
24+
25+
@property()
26+
elTagName?: string;
27+
28+
@property()
29+
open = false;
30+
31+
@property()
32+
requireUniqueName = false;
33+
34+
@property({ attribute: false })
35+
siblings: Element[] = [];
36+
37+
@query('#edit-function-name') nameTextField!: SclTextField;
38+
39+
@query('#edit-function-desc') descTextField!: SclTextField;
40+
41+
@query('#edit-function-type') typeTextField!: SclTextField;
42+
43+
private get isEdit() {
44+
return !!this.element;
45+
}
46+
47+
// Workaround for SclTextField: clears and nulls the value,
48+
// ensuring the field is visually and logically reset.
49+
// eslint-disable-next-line class-methods-use-this
50+
private clearNullableField(field: SclTextField) {
51+
const f = field;
52+
f.value = '';
53+
f.reset();
54+
f.value = null;
55+
}
56+
57+
private resetDialog() {
58+
this.nameTextField.value = '';
59+
this.nameTextField.reset();
60+
this.clearNullableField(this.descTextField);
61+
this.clearNullableField(this.typeTextField);
62+
}
63+
64+
private onDialogClosed() {
65+
this.resetDialog();
66+
this.dispatchEvent(new CustomEvent('close'));
67+
}
68+
69+
private isNameUnique(name: string): boolean {
70+
if (!this.requireUniqueName) return true;
71+
return !this.siblings.some(
72+
el =>
73+
el !== this.element &&
74+
el.getAttribute('name')?.trim().toLowerCase() === name.toLowerCase()
75+
);
76+
}
77+
78+
private validateName(nameField: SclTextField): boolean {
79+
const name = nameField.value?.trim();
80+
if (!name) {
81+
nameField.setCustomValidity('Name is required.');
82+
nameField.reportValidity();
83+
return false;
84+
}
85+
if (!this.isNameUnique(name)) {
86+
nameField.setCustomValidity('Name must be unique.');
87+
nameField.reportValidity();
88+
return false;
89+
}
90+
nameField.setCustomValidity('');
91+
nameField.reportValidity();
92+
return true;
93+
}
94+
95+
private createElementNode(name: string, desc: string, type: string) {
96+
const doc = this.parent.ownerDocument;
97+
if (!doc) throw new Error('Parent element is not attached to a document.');
98+
const el = doc.createElement(this.elTagName!);
99+
if (!el) return;
100+
el.setAttribute('name', name);
101+
if (desc) el.setAttribute('desc', desc);
102+
if (type) el.setAttribute('type', type);
103+
const insert: Insert = {
104+
parent: this.parent,
105+
node: el,
106+
reference: null,
107+
};
108+
this.dispatchEvent(newEditEvent(insert));
109+
}
110+
111+
private updateElementNode(name: string, desc: string, type: string) {
112+
if (!this.element) throw new Error('No element to update.');
113+
const update = {
114+
element: this.element,
115+
attributes: {
116+
name,
117+
desc: desc || null,
118+
type: type || null,
119+
},
120+
};
121+
this.dispatchEvent(newEditEvent(update));
122+
}
123+
124+
private onSave() {
125+
const name = this.nameTextField.value?.trim();
126+
const desc = this.descTextField.value?.trim() ?? '';
127+
const type = this.typeTextField.value?.trim() ?? '';
128+
if (!this.validateName(this.nameTextField)) return;
129+
if (this.isEdit && this.element) {
130+
this.updateElementNode(name!, desc, type);
131+
} else {
132+
this.createElementNode(name!, desc, type);
133+
}
134+
this.onDialogClosed();
135+
}
136+
137+
render() {
138+
const heading = this.isEdit
139+
? `Edit ${this.element?.tagName ?? 'Element'}`
140+
: `Add ${this.elTagName ?? 'Element'}`;
141+
return html`
142+
<mwc-dialog
143+
.open=${this.open}
144+
heading=${heading}
145+
@closed=${this.onDialogClosed}
146+
>
147+
<div class="dialog-content">
148+
<scl-text-field
149+
id="edit-function-name"
150+
label="name"
151+
required
152+
.value=${this.element?.getAttribute('name') ?? ''}
153+
></scl-text-field>
154+
<scl-text-field
155+
id="edit-function-desc"
156+
label="desc"
157+
nullable
158+
.value=${this.element?.getAttribute('desc') ?? null}
159+
@input=${(e: InputEvent) => {
160+
const field = e.target as SclTextField;
161+
if (field.value === null) {
162+
this.clearNullableField(field);
163+
}
164+
}}
165+
></scl-text-field>
166+
<scl-text-field
167+
id="edit-function-type"
168+
label="type"
169+
nullable
170+
.value=${this.element?.getAttribute('type') ?? null}
171+
@input=${(e: InputEvent) => {
172+
const field = e.target as SclTextField;
173+
if (field.value === null) {
174+
this.clearNullableField(field);
175+
}
176+
}}
177+
></scl-text-field>
178+
</div>
179+
<mwc-button
180+
class="close-btn"
181+
slot="secondaryAction"
182+
dialogAction="close"
183+
>
184+
Close
185+
</mwc-button>
186+
<mwc-button
187+
data-testid="add-subfunction-btn"
188+
slot="primaryAction"
189+
icon="save"
190+
@click=${this.onSave}
191+
>
192+
Save
193+
</mwc-button>
194+
</mwc-dialog>
195+
`;
196+
}
197+
198+
static styles = css`
199+
:host {
200+
--md-switch-selected-hover-handle-color: #000;
201+
--md-switch-selected-pressed-handle-color: #000;
202+
--md-switch-selected-focus-handle-color: #000;
203+
--md-sys-color-primary: var(--oscd-primary);
204+
}
205+
.dialog-content {
206+
display: flex;
207+
flex-direction: column;
208+
gap: 16px;
209+
}
210+
.close-btn {
211+
--mdc-theme-primary: var(--oscd-error);
212+
}
213+
`;
214+
}

function-editor.spec.ts

Lines changed: 0 additions & 42 deletions
This file was deleted.

0 commit comments

Comments
 (0)