From c0bb6f4fc77461e3981dd8f019f8964cf876235d Mon Sep 17 00:00:00 2001 From: ruv Date: Tue, 26 May 2026 15:03:40 -0400 Subject: [PATCH] feat(homecore iter 3): DELETE /api/states/ + confirm modal in UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/components/StateCard.ts | 35 +++++++++++++++++++++- frontend/src/pages/Dashboard.ts | 45 +++++++++++++++++++++++++++- v2/crates/homecore-api/src/app.rs | 7 ++++- v2/crates/homecore-api/src/rest.rs | 15 ++++++++++ 4 files changed, 99 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/StateCard.ts b/frontend/src/components/StateCard.ts index 90fdfff9..e076ad2a 100644 --- a/frontend/src/components/StateCard.ts +++ b/frontend/src/components/StateCard.ts @@ -38,8 +38,27 @@ export class StateCard extends LitElement { border-color: hsl(185 80% 50% / 0.4); } - .card { cursor: pointer; } + .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; @@ -121,6 +140,11 @@ export class StateCard extends LitElement { @click=${this._onClick} @keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this._onClick(); } }} aria-label="Edit ${entity_id}"> +
${this.iconSvg ? html`
` @@ -141,6 +165,15 @@ export class StateCard extends LitElement { 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 { diff --git a/frontend/src/pages/Dashboard.ts b/frontend/src/pages/Dashboard.ts index d0ace6f5..3d7a7fae 100644 --- a/frontend/src/pages/Dashboard.ts +++ b/frontend/src/pages/Dashboard.ts @@ -103,6 +103,7 @@ export class Dashboard extends LitElement { @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; @@ -146,6 +147,29 @@ export class Dashboard extends LitElement { 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 }>) { const { entity_id, state, attributes } = e.detail; const wasEditing = this.editingState !== null; @@ -200,12 +224,31 @@ export class Dashboard extends LitElement { --no-seed-entities.
` : html`
this._openEdit(e as CustomEvent)}> + @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`` )}
`} + (this.deletingState = null)}> +

+ Permanently remove + ${this.deletingState?.entity_id ?? ''} + from the state machine? +
+ + This is immediate. To restore, re-create the entity via "+ Add entity". + +

+ + +
+ { this.modalOpen = false; this.editingState = null; }}> diff --git a/v2/crates/homecore-api/src/app.rs b/v2/crates/homecore-api/src/app.rs index 37a48167..35b7b969 100644 --- a/v2/crates/homecore-api/src/app.rs +++ b/v2/crates/homecore-api/src/app.rs @@ -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)) diff --git a/v2/crates/homecore-api/src/rest.rs b/v2/crates/homecore-api/src/rest.rs index 9ca6dc28..b2965fd3 100644 --- a/v2/crates/homecore-api/src/rest.rs +++ b/v2/crates/homecore-api/src/rest.rs @@ -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, + Path(entity_id): Path, +) -> ApiResult { + 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,