From e7215a16e57656aa1fe6874ba260fc8844dd665c Mon Sep 17 00:00:00 2001 From: ruv Date: Tue, 26 May 2026 14:33:01 -0400 Subject: [PATCH] feat(homecore-ui iter 1): Modal + EntityForm + Add Entity flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First CRUD increment. Click "+ Add entity" on the Dashboard toolbar → modal opens → form with entity_id / state / attributes fields → Create validates client-side then POSTs /api/states/ → modal closes, toast confirms, dashboard refreshes. New components: frontend/src/components/Modal.ts (~110 LOC) — reusable accessible overlay. open property; closes on Escape and backdrop click. Heading prop; default + footer slots. frontend/src/components/EntityForm.ts (~130 LOC) — three-field form with public requestSubmit()/requestCancel() methods. Client-side validation: - entity_id matches /^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/ - state non-empty - attributes parses as a JSON object (rejects array/scalar) Emits hc-entity-submit / hc-entity-cancel events for host to handle. Footer buttons live in the host (modal slot=footer). frontend/src/pages/Dashboard.ts (+60 LOC) — toolbar with "+ Add entity" button, modal state, POST handler that wraps fetch with bearer token, success toast (3 s), refresh(). Browser-verified end-to-end (real homecore-server :8123): - Toolbar button visible: Y - Modal opens: Y - 3/3 validation paths fire correctly: BadID → "entity_id must match domain.snake_case" blank state → "state must not be empty" [1,2,3] attrs → "attributes must be a JSON object" - Successful create: light.test_bulb POSTed; modal closes; toast "Created light.test_bulb = on"; grid count went 10 → 11 - Persistence: hard reload, count stays - 0 console errors (Lit dev-mode notices excluded) Note: TypeScript caught a name collision — `attributes` is reserved on HTMLElement (NamedNodeMap). Renamed the Lit @property to `entityAttrs` so the class extends LitElement cleanly. Co-Authored-By: claude-flow --- frontend/src/components/EntityForm.ts | 143 ++++++++++++++++++++++++++ frontend/src/components/Modal.ts | 112 ++++++++++++++++++++ frontend/src/pages/Dashboard.ts | 79 +++++++++++++- 3 files changed, 329 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/EntityForm.ts create mode 100644 frontend/src/components/Modal.ts diff --git a/frontend/src/components/EntityForm.ts b/frontend/src/components/EntityForm.ts new file mode 100644 index 00000000..716150ab --- /dev/null +++ b/frontend/src/components/EntityForm.ts @@ -0,0 +1,143 @@ +/** + * `` — create / edit form for a single entity. + * + * Props: + * .entityId — pre-populated when editing; empty for create + * .state — pre-populated state value + * .attributes — pre-populated JSON object + * .editing — true to lock entity_id (HA wire-compat doesn't rename) + * + * Emits: + * hc-entity-submit detail: { entity_id, state, attributes } + * hc-entity-cancel + * + * Validation (client-side; backend validates again): + * - entity_id matches /^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/ + * - state is non-empty + * - attributes parses as a JSON object (not array, not scalar) + */ + +import { LitElement, html, css } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +const ENTITY_ID_RE = /^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/; + +@customElement('hc-entity-form') +export class EntityForm extends LitElement { + @property({ type: String }) entityId = ''; + @property({ type: String }) state = ''; + @property({ type: Object }) entityAttrs: Record = {}; + @property({ type: Boolean }) editing = false; + + @state() private _attrs = ''; + @state() private _err: string | null = null; + + static styles = css` + :host { display: block; font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); color: var(--hc-text, #e6eaee); } + label { display: block; margin: 12px 0 4px; font-size: 12px; color: var(--hc-text-muted, #7b899d); } + input, textarea { + width: 100%; box-sizing: border-box; + padding: 8px 10px; background: hsl(220 25% 10%); + border: 1px solid var(--hc-border, #2a323e); border-radius: 6px; + color: var(--hc-text, #e6eaee); + font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); + font-size: 13px; + } + 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; } + 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; } + button { + padding: 8px 16px; + border: 1px solid var(--hc-border, #2a323e); + border-radius: 6px; + background: hsl(220 25% 14%); + color: var(--hc-text, #e6eaee); + font-size: 13px; + font-weight: 500; + cursor: pointer; + font-family: inherit; + } + button.primary { background: var(--hc-primary, #19d4e5); color: var(--hc-primary-fg, #0b0e13); border-color: var(--hc-primary, #19d4e5); font-weight: 600; } + button:hover { background: hsl(220 20% 18%); } + button.primary:hover { background: hsl(185 80% 55%); } + `; + + protected updated(changed: Map): void { + if (changed.has('entityAttrs')) { + this._attrs = JSON.stringify(this.entityAttrs, null, 2); + } + } + + /** Public — call from host to trigger validation + emit submit event. */ + public requestSubmit(): void { this._submit(); } + + /** Public — call from host to dispatch cancel. */ + public requestCancel(): void { this._cancel(); } + + private _submit() { + const id = this.entityId.trim(); + if (!ENTITY_ID_RE.test(id)) { + this._err = `entity_id must match domain.snake_case (got "${id}")`; + return; + } + const stateVal = this.state.trim(); + if (!stateVal) { + this._err = 'state must not be empty'; + return; + } + let attrs: Record = {}; + if (this._attrs.trim()) { + try { + const parsed = JSON.parse(this._attrs); + if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) { + this._err = 'attributes must be a JSON object (not array, not scalar)'; + return; + } + attrs = parsed as Record; + } catch (e) { + this._err = `attributes JSON parse failed: ${e instanceof Error ? e.message : String(e)}`; + return; + } + } + this._err = null; + this.dispatchEvent(new CustomEvent('hc-entity-submit', { + detail: { entity_id: id, state: stateVal, attributes: attrs }, + bubbles: true, composed: true, + })); + } + + private _cancel() { + this._err = null; + this.dispatchEvent(new CustomEvent('hc-entity-cancel', { bubbles: true, composed: true })); + } + + render() { + return html` +
{ e.preventDefault(); this._submit(); }}> + + (this.entityId = (e.target as HTMLInputElement).value)} + placeholder="light.kitchen_ceiling" /> +
format: domain.snake_case — domain like sensor / light / switch / binary_sensor
+ + + (this.state = (e.target as HTMLInputElement).value)} + placeholder="on / off / 42 / 14.5 / detected" /> + + + +
optional; leave blank for {}
+ + ${this._err ? html`
${this._err}
` : ''} +
+ `; + } +} + +declare global { interface HTMLElementTagNameMap { 'hc-entity-form': EntityForm; } } diff --git a/frontend/src/components/Modal.ts b/frontend/src/components/Modal.ts new file mode 100644 index 00000000..e98ed60f --- /dev/null +++ b/frontend/src/components/Modal.ts @@ -0,0 +1,112 @@ +/** + * `` — minimal accessible overlay modal. + * + * Open / close by setting the `open` property. Closes on Escape and + * on backdrop click. Content goes in the default slot; an optional + * named "footer" slot is rendered below the content. + * + * Emits `hc-modal-close` on close so the host can clean up. + */ + +import { LitElement, html, css } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +@customElement('hc-modal') +export class Modal extends LitElement { + @property({ type: Boolean, reflect: true }) open = false; + @property({ type: String }) heading = ''; + + static styles = css` + :host { display: contents; } + .backdrop { + position: fixed; + inset: 0; + background: hsl(220 25% 4% / 0.65); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + padding: 16px; + } + .dialog { + background: var(--hc-bg, #0b0e13); + border: 1px solid var(--hc-border, #2a323e); + border-radius: 10px; + box-shadow: 0 24px 64px hsl(220 25% 2% / 0.6); + width: min(560px, calc(100vw - 32px)); + max-height: calc(100vh - 32px); + display: flex; + flex-direction: column; + overflow: hidden; + font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); + color: var(--hc-text, #e6eaee); + } + header { + padding: 14px 18px; + border-bottom: 1px solid var(--hc-border, #2a323e); + display: flex; + align-items: center; + justify-content: space-between; + font-weight: 600; + font-size: 15px; + } + button.close { + background: transparent; + border: none; + color: var(--hc-text-muted, #7b899d); + cursor: pointer; + font-size: 18px; + line-height: 1; + padding: 4px 8px; + border-radius: 4px; + } + button.close:hover { background: hsl(220 20% 14%); color: var(--hc-text, #e6eaee); } + .body { padding: 16px 18px; overflow-y: auto; } + .footer { + padding: 12px 18px; + border-top: 1px solid var(--hc-border, #2a323e); + display: flex; + justify-content: flex-end; + gap: 8px; + } + `; + + connectedCallback(): void { + super.connectedCallback(); + this._onKey = this._onKey.bind(this); + window.addEventListener('keydown', this._onKey); + } + disconnectedCallback(): void { + window.removeEventListener('keydown', this._onKey); + super.disconnectedCallback(); + } + + private _onKey(e: KeyboardEvent) { + if (this.open && e.key === 'Escape') this._close(); + } + + private _close() { + this.open = false; + this.dispatchEvent(new CustomEvent('hc-modal-close', { bubbles: true, composed: true })); + } + + render() { + if (!this.open) return html``; + return html` +
{ if (e.target === e.currentTarget) this._close(); }}> + +
+ `; + } +} + +declare global { interface HTMLElementTagNameMap { 'hc-modal': Modal; } } diff --git a/frontend/src/pages/Dashboard.ts b/frontend/src/pages/Dashboard.ts index 8ea3a93b..f93741a5 100644 --- a/frontend/src/pages/Dashboard.ts +++ b/frontend/src/pages/Dashboard.ts @@ -9,10 +9,13 @@ */ import { LitElement, html, css } from 'lit'; -import { customElement, state } from 'lit/decorators.js'; +import { customElement, state, query } from 'lit/decorators.js'; import { HomecoreClient } from '../api/client.js'; import type { ApiConfig, StateView } from '../api/types.js'; +import '../components/Modal.js'; +import '../components/EntityForm.js'; +import type { EntityForm } from '../components/EntityForm.js'; function resolveToken(): string { if (typeof localStorage !== 'undefined') { @@ -66,12 +69,41 @@ export class Dashboard extends LitElement { font-size: 13px; white-space: pre-wrap; } + .toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; } + .toolbar .grow { flex: 1; } + button.add { + padding: 7px 14px; + background: var(--hc-primary, #19d4e5); + color: var(--hc-primary-fg, #0b0e13); + border: none; border-radius: 6px; + font-size: 13px; font-weight: 600; + cursor: pointer; + font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); + } + button.add:hover { background: hsl(185 80% 55%); } + button.btn { + padding: 7px 14px; + background: hsl(220 25% 14%); + color: var(--hc-text, #e6eaee); + border: 1px solid var(--hc-border, #2a323e); + border-radius: 6px; + font-size: 13px; + cursor: pointer; + font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); + } + button.btn:hover { background: hsl(220 20% 18%); } + button.primary { background: var(--hc-primary, #19d4e5); color: var(--hc-primary-fg, #0b0e13); border-color: var(--hc-primary, #19d4e5); font-weight: 600; } + .toast { padding: 8px 12px; background: hsl(165 60% 16%); color: hsl(165 60% 80%); border-radius: 6px; font-size: 12px; margin-bottom: 12px; } `; @state() private states: StateView[] = []; @state() private config: ApiConfig | null = null; @state() private error: string | null = null; @state() private loading = true; + @state() private modalOpen = false; + @state() private submitToast: string | null = null; + + @query('hc-entity-form') private _form?: EntityForm; private client = new HomecoreClient({ token: resolveToken() }); private pollTimer: number | undefined; @@ -103,8 +135,30 @@ export class Dashboard extends LitElement { } } + private async _onSubmit(e: CustomEvent<{ entity_id: string; state: string; attributes: Record }>) { + const { entity_id, state, attributes } = e.detail; + try { + const resp = await fetch(`/api/states/${encodeURIComponent(entity_id)}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${resolveToken()}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ state, attributes }), + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`); + this.modalOpen = false; + this.submitToast = `Created ${entity_id} = ${state}`; + window.setTimeout(() => (this.submitToast = null), 3000); + await this.refresh(); + } catch (err) { + // Form-level error stays in the form; surface at top too for visibility. + this.error = err instanceof Error ? err.message : String(err); + } + } + render() { - if (this.error) { + if (this.error && this.states.length === 0) { return html`
backend unreachable — ${this.error}\n\n hint: make sure homecore-server is running on :8123 and that the token in localStorage["homecore.token"] is accepted. @@ -116,6 +170,11 @@ export class Dashboard extends LitElement { const v = this.config?.version ?? '?'; const loc = this.config?.location_name ?? 'Home'; return html` + ${this.submitToast ? html`
${this.submitToast}
` : ''} +
+ + +
${loc} HOMECORE v${v} @@ -123,15 +182,25 @@ export class Dashboard extends LitElement {
${this.states.length === 0 ? html`
- No entities registered yet. Run - bash scripts/homecore-seed.sh to populate - ~10 demo entities, or connect a plugin / integration. + No entities registered yet. Click + Add entity + above, run bash scripts/homecore-seed.sh, + or boot homecore-server without + --no-seed-entities.
` : html`
${this.states.map( (s) => html`` )}
`} + + (this.modalOpen = false)}> + this._onSubmit(e as CustomEvent)} + @hc-entity-cancel=${() => (this.modalOpen = false)}> + + + `; } }