Skip to content

Commit 3f5a741

Browse files
committed
feat(homecore-ui iter 4): live per-field validation + inline server errors
CRUD increment 4/6. The form now shows validity feedback on every keystroke instead of only on Create click, makes the warning vs error distinction visible (amber vs red), and propagates backend 4xx responses into the form's own error surface. frontend/src/components/EntityForm.ts (~80 LOC delta): - Three new @State fields tracking per-field validity: _idValid, _stateValid, _attrsValid (each is `{ok:true} | {ok:false, level: 'err'|'warn', msg}` or null when untouched). - Pure validators outside the class so they can be unit-tested: validateEntityId, validateState, validateAttrs. - validateEntityId now warns (amber, not red) if the domain prefix is outside the standard HA set. KNOWN_DOMAINS lists ~40 standard domains (sensor, light, switch, binary_sensor, climate, cover, fan, media_player, lock, camera, vacuum, climate, scene, script, automation, input_*, person, device_tracker, zone, weather, etc.) + homecore-native domain. Unknown domains create entities anyway (backend regex still passes them) but the operator sees the soft signal. - Sigils render below each field: ✓ green when ok, ✗ red on err, ! amber on warn. Field borders adopt the level color via .invalid / .warn classes. - New public method `isValid()` so the host can bind a disabled state on its Save button (unused for now; ready for a follow-up). - New public method `setSubmitError(msg)` so the host can surface server-side rejection text inline in the form's red error block, not just at the page top. frontend/src/pages/Dashboard.ts (small delta): - `_onSubmit()` now calls `this._form?.setSubmitError(null)` before each attempt to clear stale text, and on non-2xx responses it surfaces the server's body text inline via `setSubmitError`. Page-top error block is no longer hijacked for form errors. Browser-verified end-to-end (real homecore-server :8123): entity_id field: BadID → red border + "must match domain.snake_case…" light.kitchen_test → green ✓ "entity_id OK" madeup_domain.foo → amber border + "unknown domain 'madeup_domain' — HA-standard…" state field: empty → red ✗ required "on" → green ✓ attributes field: empty → green ✓ (defaults to {}) [1,2,3] → red ✗ "must be a JSON object…" {"key": → red ✗ "JSON parse: Unexpected end of JSON input" {"friendly_name":"Test"} → green ✓ Server-error inline: Force 401 via wrong token → form red block shows "server rejected (401): unauthorized" Successful create: still works, toast still shown, 0 console errors. Co-Authored-By: claude-flow <ruv@ruv.net>
1 parent c0bb6f4 commit 3f5a741

2 files changed

Lines changed: 130 additions & 5 deletions

File tree

frontend/src/components/EntityForm.ts

Lines changed: 119 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,63 @@ import { customElement, property, state } from 'lit/decorators.js';
2222

2323
const ENTITY_ID_RE = /^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/;
2424

25+
/**
26+
* Known Home Assistant domain prefixes. We don't reject unknown domains
27+
* (the API accepts any matching the regex), but unknown ones get a
28+
* warning so the operator sees what's standard. Add new domains here
29+
* as integrations land.
30+
*/
31+
const KNOWN_DOMAINS = new Set([
32+
'sensor', 'binary_sensor', 'switch', 'light', 'climate', 'cover',
33+
'fan', 'media_player', 'lock', 'camera', 'vacuum', 'humidifier',
34+
'water_heater', 'scene', 'script', 'automation', 'input_boolean',
35+
'input_number', 'input_text', 'input_select', 'input_datetime',
36+
'person', 'device_tracker', 'zone', 'sun', 'weather', 'calendar',
37+
'remote', 'siren', 'select', 'number', 'text', 'button',
38+
'homeassistant', 'homecore', 'group', 'notify', 'tts', 'alarm_control_panel',
39+
]);
40+
41+
type FieldValidity = { ok: true } | { ok: false; level: 'err' | 'warn'; msg: string };
42+
43+
function validateEntityId(id: string): FieldValidity {
44+
const trimmed = id.trim();
45+
if (!trimmed) return { ok: false, level: 'err', msg: 'required' };
46+
if (!ENTITY_ID_RE.test(trimmed)) {
47+
return {
48+
ok: false,
49+
level: 'err',
50+
msg: 'must match domain.snake_case (lowercase, digits, underscores)',
51+
};
52+
}
53+
const domain = trimmed.split('.')[0]!;
54+
if (!KNOWN_DOMAINS.has(domain)) {
55+
return {
56+
ok: false,
57+
level: 'warn',
58+
msg: `unknown domain "${domain}" — HA-standard domains include sensor / light / switch / binary_sensor / climate`,
59+
};
60+
}
61+
return { ok: true };
62+
}
63+
64+
function validateState(s: string): FieldValidity {
65+
if (!s.trim()) return { ok: false, level: 'err', msg: 'required' };
66+
return { ok: true };
67+
}
68+
69+
function validateAttrs(raw: string): FieldValidity {
70+
if (!raw.trim()) return { ok: true }; // empty = {}
71+
try {
72+
const parsed = JSON.parse(raw);
73+
if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) {
74+
return { ok: false, level: 'err', msg: 'must be a JSON object (not array, not scalar)' };
75+
}
76+
return { ok: true };
77+
} catch (e) {
78+
return { ok: false, level: 'err', msg: `JSON parse: ${e instanceof Error ? e.message : String(e)}` };
79+
}
80+
}
81+
2582
@customElement('hc-entity-form')
2683
export class EntityForm extends LitElement {
2784
@property({ type: String }) entityId = '';
@@ -31,6 +88,10 @@ export class EntityForm extends LitElement {
3188

3289
@state() private _attrs = '';
3390
@state() private _err: string | null = null;
91+
/** Per-field live validity. `null` = haven't typed yet (no decoration). */
92+
@state() private _idValid: FieldValidity | null = null;
93+
@state() private _stateValid: FieldValidity | null = null;
94+
@state() private _attrsValid: FieldValidity | null = null;
3495

3596
static styles = css`
3697
:host { display: block; font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); color: var(--hc-text, #e6eaee); }
@@ -45,6 +106,14 @@ export class EntityForm extends LitElement {
45106
}
46107
input:focus, textarea:focus { outline: 2px solid hsl(185 80% 50% / 0.5); border-color: var(--hc-primary, #19d4e5); }
47108
input[disabled] { opacity: 0.5; cursor: not-allowed; }
109+
input.invalid, textarea.invalid { border-color: hsl(0 60% 50%); }
110+
input.warn, textarea.warn { border-color: hsl(38 80% 55%); }
111+
.field-status { font-size: 11px; margin-top: 4px; display: flex; align-items: center; gap: 6px; }
112+
.field-status.ok { color: hsl(150 60% 55%); }
113+
.field-status.err { color: hsl(0 70% 70%); }
114+
.field-status.warn { color: hsl(38 80% 65%); }
115+
.field-status .sigil { display: inline-block; width: 12px; text-align: center; font-weight: 700; }
116+
button.primary[disabled] { background: hsl(220 15% 20%); color: var(--hc-text-muted, #7b899d); border-color: var(--hc-border, #2a323e); cursor: not-allowed; }
48117
textarea { min-height: 90px; resize: vertical; }
49118
.hint { font-size: 11px; color: var(--hc-text-muted, #7b899d); margin-top: 4px; }
50119
.err { margin-top: 10px; padding: 10px; border: 1px solid #b35a5a; border-radius: 6px; background: hsl(0 35% 12%); color: #f0c0c0; font-size: 12px; }
@@ -70,6 +139,47 @@ export class EntityForm extends LitElement {
70139
}
71140
}
72141

142+
/** Allow the host (Dashboard) to surface a server-side error inline. */
143+
public setSubmitError(msg: string | null): void {
144+
this._err = msg;
145+
}
146+
147+
/** True iff every field is valid (warnings are OK, errors block). Public so the host can bind a disabled state on the submit button. */
148+
public isValid(): boolean {
149+
const checks = [
150+
validateEntityId(this.entityId),
151+
validateState(this.state),
152+
validateAttrs(this._attrs),
153+
];
154+
return !checks.some((c) => !c.ok && c.level === 'err');
155+
}
156+
157+
private _onIdInput(v: string) {
158+
this.entityId = v;
159+
this._idValid = validateEntityId(v);
160+
}
161+
private _onStateInput(v: string) {
162+
this.state = v;
163+
this._stateValid = validateState(v);
164+
}
165+
private _onAttrsInput(v: string) {
166+
this._attrs = v;
167+
this._attrsValid = validateAttrs(v);
168+
}
169+
170+
private _statusLine(label: string, v: FieldValidity | null) {
171+
if (v === null) return html``;
172+
if (v.ok) return html`<div class="field-status ok"><span class="sigil"></span>${label} OK</div>`;
173+
return html`<div class="field-status ${v.level}">
174+
<span class="sigil">${v.level === 'warn' ? '!' : '✗'}</span>${v.msg}
175+
</div>`;
176+
}
177+
178+
private _fieldClass(v: FieldValidity | null): string {
179+
if (v === null || v.ok) return '';
180+
return v.level;
181+
}
182+
73183
/** Public — call from host to trigger validation + emit submit event. */
74184
public requestSubmit(): void { this._submit(); }
75185

@@ -118,21 +228,27 @@ export class EntityForm extends LitElement {
118228
<form @submit=${(e: Event) => { e.preventDefault(); this._submit(); }}>
119229
<label for="eid">entity_id</label>
120230
<input id="eid" .value=${this.entityId}
231+
class=${this._fieldClass(this._idValid)}
121232
?disabled=${this.editing}
122-
@input=${(e: Event) => (this.entityId = (e.target as HTMLInputElement).value)}
233+
@input=${(e: Event) => this._onIdInput((e.target as HTMLInputElement).value)}
123234
placeholder="light.kitchen_ceiling" />
124235
<div class="hint">format: <code>domain.snake_case</code> — domain like sensor / light / switch / binary_sensor</div>
236+
${this._statusLine('entity_id', this._idValid)}
125237
126238
<label for="state">state</label>
127239
<input id="state" .value=${this.state}
128-
@input=${(e: Event) => (this.state = (e.target as HTMLInputElement).value)}
240+
class=${this._fieldClass(this._stateValid)}
241+
@input=${(e: Event) => this._onStateInput((e.target as HTMLInputElement).value)}
129242
placeholder="on / off / 42 / 14.5 / detected" />
243+
${this._statusLine('state', this._stateValid)}
130244
131245
<label for="attrs">attributes (JSON object)</label>
132246
<textarea id="attrs" .value=${this._attrs}
133-
@input=${(e: Event) => (this._attrs = (e.target as HTMLTextAreaElement).value)}
247+
class=${this._fieldClass(this._attrsValid)}
248+
@input=${(e: Event) => this._onAttrsInput((e.target as HTMLTextAreaElement).value)}
134249
placeholder='{ "friendly_name": "Kitchen Ceiling", "brightness": 230 }'></textarea>
135250
<div class="hint">optional; leave blank for <code>{}</code></div>
251+
${this._statusLine('attributes', this._attrsValid)}
136252
137253
${this._err ? html`<div class="err">${this._err}</div>` : ''}
138254
</form>

frontend/src/pages/Dashboard.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,8 @@ export class Dashboard extends LitElement {
173173
private async _onSubmit(e: CustomEvent<{ entity_id: string; state: string; attributes: Record<string, unknown> }>) {
174174
const { entity_id, state, attributes } = e.detail;
175175
const wasEditing = this.editingState !== null;
176+
// Clear any previous server-side error before the next attempt.
177+
this._form?.setSubmitError(null);
176178
try {
177179
const resp = await fetch(`/api/states/${encodeURIComponent(entity_id)}`, {
178180
method: 'POST',
@@ -182,14 +184,21 @@ export class Dashboard extends LitElement {
182184
},
183185
body: JSON.stringify({ state, attributes }),
184186
});
185-
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
187+
if (!resp.ok) {
188+
// Surface the server message inline in the form, not at
189+
// the top of the page — the form is what the user is
190+
// looking at.
191+
const body = await resp.text();
192+
this._form?.setSubmitError(`server rejected (${resp.status}): ${body || resp.statusText}`);
193+
return;
194+
}
186195
this.modalOpen = false;
187196
this.editingState = null;
188197
this.submitToast = `${wasEditing ? 'Updated' : 'Created'} ${entity_id} = ${state}`;
189198
window.setTimeout(() => (this.submitToast = null), 3000);
190199
await this.refresh();
191200
} catch (err) {
192-
this.error = err instanceof Error ? err.message : String(err);
201+
this._form?.setSubmitError(err instanceof Error ? err.message : String(err));
193202
}
194203
}
195204

0 commit comments

Comments
 (0)