Compare commits

..

3 Commits

Author SHA1 Message Date
ruv 3f5a7411db 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>
2026-05-26 15:12:48 -04:00
ruv c0bb6f4fc7 feat(homecore iter 3): DELETE /api/states/<id> + confirm modal in UI
CRUD increment 3/6. Full delete path lands end-to-end.

Backend (homecore-api):
  rest.rs +18 LOC — new `delete_state` handler. Idempotent (matches HA's
    removal semantics): returns 204 No Content whether the entity existed
    or not. 4xx only for malformed entity_id or auth failure.
  app.rs +6 LOC — adds `.delete(rest::delete_state)` to the
    /api/states/:entity_id route alongside existing GET + POST.

Backend curl smoke:
  POST /api/states/sensor.test_delete         201
  DELETE /api/states/sensor.test_delete       204
  GET /api/states/sensor.test_delete          404

Frontend:
  components/StateCard.ts +25 LOC — small `×` delete button in the
    card's top-right corner. opacity 0 by default, fades in on hover
    or keyboard focus. dispatches `hc-state-card-delete` (NOT
    `hc-state-card-click`) with stopPropagation so the card's own
    click-to-edit handler doesn't also fire.

  pages/Dashboard.ts +45 LOC — deletingState (StateView | null), a
    confirm modal that names the entity_id in the body, Cancel /
    Delete buttons in the footer (Delete styled in muted red),
    `_confirmDelete()` dispatches DELETE with bearer, toast on
    success, grid refresh.

Browser-verified end-to-end on real homecore-server :8123:
  - Hover card → × button visible
  - Click × → DELETE confirm modal (NOT edit modal — stopPropagation works)
  - Modal names entity_id in code block
  - Cancel: entity preserved, modal closes
  - Delete: backend GET-after-DELETE returns 404, grid card vanishes,
    toast "Deleted sensor.delete_target"
  - 0 unexpected console errors (1 expected 404 from verification fetch)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-26 15:03:40 -04:00
ruv 89190b6c2d feat(homecore-ui iter 2): Edit Entity modal + shadow-DOM focus delegation
CRUD increment 2/6 — clicking any state card on the Dashboard opens
the Add Entity modal in EDIT mode: pre-populated, entity_id locked,
"Save" primary button, idempotent POST to /api/states/<id> (backend
returns 200 if existed, 201 if created — same handler).

frontend/src/components/StateCard.ts:
  - card div is now role="button" tabindex=0, dispatches
    `hc-state-card-click` on click + Enter/Space keydown
  - aria-label="Edit <entity_id>" for screen readers
  - shadowRootOptions delegatesFocus=true so the outer Tab sequence
    can reach the inner focusable div (caught by browser agent —
    without this Tab couldn't pierce the shadow root)

frontend/src/pages/Dashboard.ts:
  - new state: editingState (null = create, StateView = edit)
  - _openEdit() catches `hc-state-card-click` from the grid container
  - modal heading switches: "Add entity" ↔ "Edit <entity_id>"
  - primary button text switches: "Create" ↔ "Save"
  - EntityForm receives .editing=true so entity_id input is disabled
  - submit toast reads "Updated" or "Created" depending on mode

Browser-verified end-to-end (real homecore-server :8123, 12 entities):
  - Click `light.kitchen_ceiling` → modal opens with all 4 attributes
    (brightness=230, color_temp_kelvin=4000, friendly_name,
    supported_color_modes) pre-populated
  - Change state to "off", click Save → toast "Updated
    light.kitchen_ceiling = off", grid card reflects new state
  - Backend curl confirms /api/states/light.kitchen_ceiling.state = "off"
  - Enter key on focused card opens the modal too
  - 0 console errors

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-26 14:48:49 -04:00
5 changed files with 272 additions and 15 deletions
+119 -3
View File
@@ -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`<div class="field-status ok"><span class="sigil">✓</span>${label} OK</div>`;
return html`<div class="field-status ${v.level}">
<span class="sigil">${v.level === 'warn' ? '!' : '✗'}</span>${v.msg}
</div>`;
}
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 {
<form @submit=${(e: Event) => { e.preventDefault(); this._submit(); }}>
<label for="eid">entity_id</label>
<input id="eid" .value=${this.entityId}
class=${this._fieldClass(this._idValid)}
?disabled=${this.editing}
@input=${(e: Event) => (this.entityId = (e.target as HTMLInputElement).value)}
@input=${(e: Event) => this._onIdInput((e.target as HTMLInputElement).value)}
placeholder="light.kitchen_ceiling" />
<div class="hint">format: <code>domain.snake_case</code> — domain like sensor / light / switch / binary_sensor</div>
${this._statusLine('entity_id', this._idValid)}
<label for="state">state</label>
<input id="state" .value=${this.state}
@input=${(e: Event) => (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)}
<label for="attrs">attributes (JSON object)</label>
<textarea id="attrs" .value=${this._attrs}
@input=${(e: Event) => (this._attrs = (e.target as HTMLTextAreaElement).value)}
class=${this._fieldClass(this._attrsValid)}
@input=${(e: Event) => this._onAttrsInput((e.target as HTMLTextAreaElement).value)}
placeholder='{ "friendly_name": "Kitchen Ceiling", "brightness": 230 }'></textarea>
<div class="hint">optional; leave blank for <code>{}</code></div>
${this._statusLine('attributes', this._attrsValid)}
${this._err ? html`<div class="err">${this._err}</div>` : ''}
</form>
+52 -1
View File
@@ -9,6 +9,12 @@ import type { StateView } from '../api/types.js';
@customElement('hc-state-card')
export class StateCard extends LitElement {
// `delegatesFocus` lets Tab key traversal from the light DOM reach the
// role="button" element inside this card's shadow root. Without it the
// user can only activate the card via mouse click or by JS-focusing the
// inner div; with it, the natural tab sequence flows through every card.
static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true };
@property({ type: Object }) state!: StateView;
/** Optional: icon SVG string (use `iconSvg()` from lucide.ts) */
@property({ type: String }) iconSvg?: string;
@@ -32,6 +38,28 @@ export class StateCard extends LitElement {
border-color: hsl(185 80% 50% / 0.4);
}
.card { cursor: pointer; position: relative; }
.card:focus-visible { outline: 2px solid var(--hc-primary, #19d4e5); outline-offset: 2px; }
button.delete {
position: absolute;
top: 0.5rem; right: 0.5rem;
width: 24px; height: 24px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--hc-text-muted, #7b899d);
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0;
opacity: 0;
transition: opacity 150ms, background 150ms, color 150ms;
}
.card:hover button.delete,
.card:focus-within button.delete { opacity: 1; }
button.delete:hover { background: hsl(0 50% 30%); color: hsl(0 80% 88%); }
button.delete:focus-visible { opacity: 1; outline: 2px solid hsl(0 60% 55%); }
.header {
display: flex;
align-items: flex-start;
@@ -108,7 +136,15 @@ export class StateCard extends LitElement {
const badge = this.badgeClass(state);
return html`
<div class="card" part="card">
<div class="card" part="card" role="button" tabindex="0"
@click=${this._onClick}
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this._onClick(); } }}
aria-label="Edit ${entity_id}">
<button class="delete" type="button"
@click=${this._onDelete}
@keydown=${(e: KeyboardEvent) => { e.stopPropagation(); }}
aria-label="Delete ${entity_id}"
title="Delete ${entity_id}">×</button>
<div class="header">
${this.iconSvg
? html`<div class="icon-wrap" .innerHTML=${this.iconSvg}></div>`
@@ -123,6 +159,21 @@ export class StateCard extends LitElement {
</div>
`;
}
private _onClick() {
this.dispatchEvent(new CustomEvent('hc-state-card-click', {
detail: { state: this.state }, bubbles: true, composed: true,
}));
}
private _onDelete(e: Event) {
// Stop propagation so the parent card's click handler (which would
// open the edit modal) doesn't also fire.
e.stopPropagation();
this.dispatchEvent(new CustomEvent('hc-state-card-delete', {
detail: { state: this.state }, bubbles: true, composed: true,
}));
}
}
declare global {
+80 -10
View File
@@ -102,6 +102,8 @@ export class Dashboard extends LitElement {
@state() private loading = true;
@state() private modalOpen = false;
@state() private submitToast: string | null = null;
@state() private editingState: StateView | null = null; // null = create mode
@state() private deletingState: StateView | null = null; // null = no confirm
@query('hc-entity-form') private _form?: EntityForm;
@@ -135,8 +137,44 @@ export class Dashboard extends LitElement {
}
}
private _openCreate() {
this.editingState = null;
this.modalOpen = true;
}
private _openEdit(e: CustomEvent<{ state: StateView }>) {
this.editingState = e.detail.state;
this.modalOpen = true;
}
private _openDeleteConfirm(e: CustomEvent<{ state: StateView }>) {
this.deletingState = e.detail.state;
}
private async _confirmDelete() {
const target = this.deletingState;
if (!target) return;
try {
const resp = await fetch(`/api/states/${encodeURIComponent(target.entity_id)}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${resolveToken()}` },
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
this.deletingState = null;
this.submitToast = `Deleted ${target.entity_id}`;
window.setTimeout(() => (this.submitToast = null), 3000);
await this.refresh();
} catch (err) {
this.error = err instanceof Error ? err.message : String(err);
this.deletingState = null;
}
}
private async _onSubmit(e: CustomEvent<{ entity_id: string; state: string; attributes: Record<string, unknown> }>) {
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',
@@ -146,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.submitToast = `Created ${entity_id} = ${state}`;
this.editingState = null;
this.submitToast = `${wasEditing ? 'Updated' : '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);
this._form?.setSubmitError(err instanceof Error ? err.message : String(err));
}
}
@@ -173,7 +218,7 @@ export class Dashboard extends LitElement {
${this.submitToast ? html`<div class="toast">${this.submitToast}</div>` : ''}
<div class="toolbar">
<span class="grow"></span>
<button class="add" @click=${() => (this.modalOpen = true)}>+ Add entity</button>
<button class="add" @click=${this._openCreate}>+ Add entity</button>
</div>
<div class="meta">
<span><strong>${loc}</strong></span>
@@ -187,19 +232,44 @@ export class Dashboard extends LitElement {
or boot <code>homecore-server</code> without
<code>--no-seed-entities</code>.
</div>`
: html`<div class="grid">
: html`<div class="grid"
@hc-state-card-click=${(e: Event) => this._openEdit(e as CustomEvent)}
@hc-state-card-delete=${(e: Event) => this._openDeleteConfirm(e as CustomEvent)}>
${this.states.map(
(s) => html`<hc-state-card .state=${s}></hc-state-card>`
)}
</div>`}
<hc-modal .open=${this.modalOpen} heading="Add entity"
@hc-modal-close=${() => (this.modalOpen = false)}>
<hc-modal .open=${this.deletingState !== null}
heading="Delete entity"
@hc-modal-close=${() => (this.deletingState = null)}>
<p style="margin:0 0 12px 0; line-height:1.5;">
Permanently remove
<code style="background:hsl(220 25% 14%); padding:2px 6px; border-radius:4px;">${this.deletingState?.entity_id ?? ''}</code>
from the state machine?
<br>
<span style="color:var(--hc-text-muted,#7b899d); font-size:12px;">
This is immediate. To restore, re-create the entity via "+ Add entity".
</span>
</p>
<button slot="footer" class="btn" @click=${() => (this.deletingState = null)}>Cancel</button>
<button slot="footer" class="btn"
style="background:hsl(0 50% 25%); border-color:hsl(0 50% 35%); color:hsl(0 60% 88%);"
@click=${this._confirmDelete}>Delete</button>
</hc-modal>
<hc-modal .open=${this.modalOpen}
heading=${this.editingState ? `Edit ${this.editingState.entity_id}` : 'Add entity'}
@hc-modal-close=${() => { this.modalOpen = false; this.editingState = null; }}>
<hc-entity-form
.entityId=${this.editingState?.entity_id ?? ''}
.state=${this.editingState?.state ?? ''}
.entityAttrs=${this.editingState?.attributes ?? {}}
.editing=${this.editingState !== null}
@hc-entity-submit=${(e: Event) => this._onSubmit(e as CustomEvent)}
@hc-entity-cancel=${() => (this.modalOpen = false)}></hc-entity-form>
@hc-entity-cancel=${() => { this.modalOpen = false; this.editingState = null; }}></hc-entity-form>
<button slot="footer" class="btn" @click=${() => this._form?.requestCancel()}>Cancel</button>
<button slot="footer" class="btn primary" @click=${() => this._form?.requestSubmit()}>Create</button>
<button slot="footer" class="btn primary" @click=${() => this._form?.requestSubmit()}>${this.editingState ? 'Save' : 'Create'}</button>
</hc-modal>
`;
}
+6 -1
View File
@@ -28,7 +28,12 @@ pub fn router(state: SharedState) -> Router {
.route("/api/", get(rest::api_root))
.route("/api/config", get(rest::get_config))
.route("/api/states", get(rest::get_states))
.route("/api/states/:entity_id", get(rest::get_state).post(rest::set_state))
.route(
"/api/states/:entity_id",
get(rest::get_state)
.post(rest::set_state)
.delete(rest::delete_state),
)
.route("/api/services", get(rest::get_services))
.route("/api/services/:domain/:service", post(rest::call_service))
.route("/api/websocket", get(ws::websocket_handler))
+15
View File
@@ -92,6 +92,21 @@ pub struct SetStateRequest {
pub attributes: serde_json::Value,
}
/// DELETE /api/states/:entity_id — remove an entity from the state
/// machine. Idempotent: returns 204 whether or not the entity existed,
/// matching HA's removal semantics. 4xx only for malformed entity_id or
/// auth failure.
pub async fn delete_state(
headers: HeaderMap,
State(s): State<SharedState>,
Path(entity_id): Path<String>,
) -> ApiResult<StatusCode> {
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
let id = EntityId::parse(entity_id).map_err(|e| ApiError::BadRequest(e.to_string()))?;
s.homecore().states().remove(&id);
Ok(StatusCode::NO_CONTENT)
}
pub async fn set_state(
headers: HeaderMap,
State(s): State<SharedState>,