diff --git a/frontend/src/components/EntityForm.ts b/frontend/src/components/EntityForm.ts index 716150ab..09a59f35 100644 --- a/frontend/src/components/EntityForm.ts +++ b/frontend/src/components/EntityForm.ts @@ -22,6 +22,63 @@ import { customElement, property, state } from 'lit/decorators.js'; const ENTITY_ID_RE = /^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/; +/** + * Known Home Assistant domain prefixes. We don't reject unknown domains + * (the API accepts any matching the regex), but unknown ones get a + * warning so the operator sees what's standard. Add new domains here + * as integrations land. + */ +const KNOWN_DOMAINS = new Set([ + 'sensor', 'binary_sensor', 'switch', 'light', 'climate', 'cover', + 'fan', 'media_player', 'lock', 'camera', 'vacuum', 'humidifier', + 'water_heater', 'scene', 'script', 'automation', 'input_boolean', + 'input_number', 'input_text', 'input_select', 'input_datetime', + 'person', 'device_tracker', 'zone', 'sun', 'weather', 'calendar', + 'remote', 'siren', 'select', 'number', 'text', 'button', + 'homeassistant', 'homecore', 'group', 'notify', 'tts', 'alarm_control_panel', +]); + +type FieldValidity = { ok: true } | { ok: false; level: 'err' | 'warn'; msg: string }; + +function validateEntityId(id: string): FieldValidity { + const trimmed = id.trim(); + if (!trimmed) return { ok: false, level: 'err', msg: 'required' }; + if (!ENTITY_ID_RE.test(trimmed)) { + return { + ok: false, + level: 'err', + msg: 'must match domain.snake_case (lowercase, digits, underscores)', + }; + } + const domain = trimmed.split('.')[0]!; + if (!KNOWN_DOMAINS.has(domain)) { + return { + ok: false, + level: 'warn', + msg: `unknown domain "${domain}" — HA-standard domains include sensor / light / switch / binary_sensor / climate`, + }; + } + return { ok: true }; +} + +function validateState(s: string): FieldValidity { + if (!s.trim()) return { ok: false, level: 'err', msg: 'required' }; + return { ok: true }; +} + +function validateAttrs(raw: string): FieldValidity { + if (!raw.trim()) return { ok: true }; // empty = {} + try { + const parsed = JSON.parse(raw); + if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) { + return { ok: false, level: 'err', msg: 'must be a JSON object (not array, not scalar)' }; + } + return { ok: true }; + } catch (e) { + return { ok: false, level: 'err', msg: `JSON parse: ${e instanceof Error ? e.message : String(e)}` }; + } +} + @customElement('hc-entity-form') export class EntityForm extends LitElement { @property({ type: String }) entityId = ''; @@ -31,6 +88,10 @@ export class EntityForm extends LitElement { @state() private _attrs = ''; @state() private _err: string | null = null; + /** Per-field live validity. `null` = haven't typed yet (no decoration). */ + @state() private _idValid: FieldValidity | null = null; + @state() private _stateValid: FieldValidity | null = null; + @state() private _attrsValid: FieldValidity | null = null; static styles = css` :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 { } input:focus, textarea:focus { outline: 2px solid hsl(185 80% 50% / 0.5); border-color: var(--hc-primary, #19d4e5); } input[disabled] { opacity: 0.5; cursor: not-allowed; } + input.invalid, textarea.invalid { border-color: hsl(0 60% 50%); } + input.warn, textarea.warn { border-color: hsl(38 80% 55%); } + .field-status { font-size: 11px; margin-top: 4px; display: flex; align-items: center; gap: 6px; } + .field-status.ok { color: hsl(150 60% 55%); } + .field-status.err { color: hsl(0 70% 70%); } + .field-status.warn { color: hsl(38 80% 65%); } + .field-status .sigil { display: inline-block; width: 12px; text-align: center; font-weight: 700; } + button.primary[disabled] { background: hsl(220 15% 20%); color: var(--hc-text-muted, #7b899d); border-color: var(--hc-border, #2a323e); cursor: not-allowed; } textarea { min-height: 90px; resize: vertical; } .hint { font-size: 11px; color: var(--hc-text-muted, #7b899d); margin-top: 4px; } .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 { } } + /** Allow the host (Dashboard) to surface a server-side error inline. */ + public setSubmitError(msg: string | null): void { + this._err = msg; + } + + /** True iff every field is valid (warnings are OK, errors block). Public so the host can bind a disabled state on the submit button. */ + public isValid(): boolean { + const checks = [ + validateEntityId(this.entityId), + validateState(this.state), + validateAttrs(this._attrs), + ]; + return !checks.some((c) => !c.ok && c.level === 'err'); + } + + private _onIdInput(v: string) { + this.entityId = v; + this._idValid = validateEntityId(v); + } + private _onStateInput(v: string) { + this.state = v; + this._stateValid = validateState(v); + } + private _onAttrsInput(v: string) { + this._attrs = v; + this._attrsValid = validateAttrs(v); + } + + private _statusLine(label: string, v: FieldValidity | null) { + if (v === null) return html``; + if (v.ok) return html`
${label} OK
`; + return html`
+ ${v.level === 'warn' ? '!' : '✗'}${v.msg} +
`; + } + + private _fieldClass(v: FieldValidity | null): string { + if (v === null || v.ok) return ''; + return v.level; + } + /** Public — call from host to trigger validation + emit submit event. */ public requestSubmit(): void { this._submit(); } @@ -118,21 +228,27 @@ export class EntityForm extends LitElement {
{ e.preventDefault(); this._submit(); }}> (this.entityId = (e.target as HTMLInputElement).value)} + @input=${(e: Event) => this._onIdInput((e.target as HTMLInputElement).value)} placeholder="light.kitchen_ceiling" />
format: domain.snake_case — domain like sensor / light / switch / binary_sensor
+ ${this._statusLine('entity_id', this._idValid)} (this.state = (e.target as HTMLInputElement).value)} + class=${this._fieldClass(this._stateValid)} + @input=${(e: Event) => this._onStateInput((e.target as HTMLInputElement).value)} placeholder="on / off / 42 / 14.5 / detected" /> + ${this._statusLine('state', this._stateValid)}
optional; leave blank for {}
+ ${this._statusLine('attributes', this._attrsValid)} ${this._err ? html`
${this._err}
` : ''}
diff --git a/frontend/src/pages/Dashboard.ts b/frontend/src/pages/Dashboard.ts index 3d7a7fae..321e53b1 100644 --- a/frontend/src/pages/Dashboard.ts +++ b/frontend/src/pages/Dashboard.ts @@ -173,6 +173,8 @@ export class Dashboard extends LitElement { private async _onSubmit(e: CustomEvent<{ entity_id: string; state: string; attributes: Record }>) { const { entity_id, state, attributes } = e.detail; const wasEditing = this.editingState !== null; + // Clear any previous server-side error before the next attempt. + this._form?.setSubmitError(null); try { const resp = await fetch(`/api/states/${encodeURIComponent(entity_id)}`, { method: 'POST', @@ -182,14 +184,21 @@ export class Dashboard extends LitElement { }, body: JSON.stringify({ state, attributes }), }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`); + if (!resp.ok) { + // Surface the server message inline in the form, not at + // the top of the page — the form is what the user is + // looking at. + const body = await resp.text(); + this._form?.setSubmitError(`server rejected (${resp.status}): ${body || resp.statusText}`); + return; + } this.modalOpen = false; this.editingState = null; this.submitToast = `${wasEditing ? 'Updated' : 'Created'} ${entity_id} = ${state}`; window.setTimeout(() => (this.submitToast = null), 3000); await this.refresh(); } catch (err) { - this.error = err instanceof Error ? err.message : String(err); + this._form?.setSubmitError(err instanceof Error ? err.message : String(err)); } }