Skip to content

Commit c0bb6f4

Browse files
committed
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>
1 parent 89190b6 commit c0bb6f4

4 files changed

Lines changed: 99 additions & 3 deletions

File tree

frontend/src/components/StateCard.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,27 @@ export class StateCard extends LitElement {
3838
border-color: hsl(185 80% 50% / 0.4);
3939
}
4040
41-
.card { cursor: pointer; }
41+
.card { cursor: pointer; position: relative; }
4242
.card:focus-visible { outline: 2px solid var(--hc-primary, #19d4e5); outline-offset: 2px; }
43+
button.delete {
44+
position: absolute;
45+
top: 0.5rem; right: 0.5rem;
46+
width: 24px; height: 24px;
47+
border: none;
48+
border-radius: 4px;
49+
background: transparent;
50+
color: var(--hc-text-muted, #7b899d);
51+
cursor: pointer;
52+
font-size: 16px;
53+
line-height: 1;
54+
padding: 0;
55+
opacity: 0;
56+
transition: opacity 150ms, background 150ms, color 150ms;
57+
}
58+
.card:hover button.delete,
59+
.card:focus-within button.delete { opacity: 1; }
60+
button.delete:hover { background: hsl(0 50% 30%); color: hsl(0 80% 88%); }
61+
button.delete:focus-visible { opacity: 1; outline: 2px solid hsl(0 60% 55%); }
4362
4463
.header {
4564
display: flex;
@@ -121,6 +140,11 @@ export class StateCard extends LitElement {
121140
@click=${this._onClick}
122141
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this._onClick(); } }}
123142
aria-label="Edit ${entity_id}">
143+
<button class="delete" type="button"
144+
@click=${this._onDelete}
145+
@keydown=${(e: KeyboardEvent) => { e.stopPropagation(); }}
146+
aria-label="Delete ${entity_id}"
147+
title="Delete ${entity_id}">×</button>
124148
<div class="header">
125149
${this.iconSvg
126150
? html`<div class="icon-wrap" .innerHTML=${this.iconSvg}></div>`
@@ -141,6 +165,15 @@ export class StateCard extends LitElement {
141165
detail: { state: this.state }, bubbles: true, composed: true,
142166
}));
143167
}
168+
169+
private _onDelete(e: Event) {
170+
// Stop propagation so the parent card's click handler (which would
171+
// open the edit modal) doesn't also fire.
172+
e.stopPropagation();
173+
this.dispatchEvent(new CustomEvent('hc-state-card-delete', {
174+
detail: { state: this.state }, bubbles: true, composed: true,
175+
}));
176+
}
144177
}
145178

146179
declare global {

frontend/src/pages/Dashboard.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export class Dashboard extends LitElement {
103103
@state() private modalOpen = false;
104104
@state() private submitToast: string | null = null;
105105
@state() private editingState: StateView | null = null; // null = create mode
106+
@state() private deletingState: StateView | null = null; // null = no confirm
106107

107108
@query('hc-entity-form') private _form?: EntityForm;
108109

@@ -146,6 +147,29 @@ export class Dashboard extends LitElement {
146147
this.modalOpen = true;
147148
}
148149

150+
private _openDeleteConfirm(e: CustomEvent<{ state: StateView }>) {
151+
this.deletingState = e.detail.state;
152+
}
153+
154+
private async _confirmDelete() {
155+
const target = this.deletingState;
156+
if (!target) return;
157+
try {
158+
const resp = await fetch(`/api/states/${encodeURIComponent(target.entity_id)}`, {
159+
method: 'DELETE',
160+
headers: { 'Authorization': `Bearer ${resolveToken()}` },
161+
});
162+
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
163+
this.deletingState = null;
164+
this.submitToast = `Deleted ${target.entity_id}`;
165+
window.setTimeout(() => (this.submitToast = null), 3000);
166+
await this.refresh();
167+
} catch (err) {
168+
this.error = err instanceof Error ? err.message : String(err);
169+
this.deletingState = null;
170+
}
171+
}
172+
149173
private async _onSubmit(e: CustomEvent<{ entity_id: string; state: string; attributes: Record<string, unknown> }>) {
150174
const { entity_id, state, attributes } = e.detail;
151175
const wasEditing = this.editingState !== null;
@@ -200,12 +224,31 @@ export class Dashboard extends LitElement {
200224
<code>--no-seed-entities</code>.
201225
</div>`
202226
: html`<div class="grid"
203-
@hc-state-card-click=${(e: Event) => this._openEdit(e as CustomEvent)}>
227+
@hc-state-card-click=${(e: Event) => this._openEdit(e as CustomEvent)}
228+
@hc-state-card-delete=${(e: Event) => this._openDeleteConfirm(e as CustomEvent)}>
204229
${this.states.map(
205230
(s) => html`<hc-state-card .state=${s}></hc-state-card>`
206231
)}
207232
</div>`}
208233
234+
<hc-modal .open=${this.deletingState !== null}
235+
heading="Delete entity"
236+
@hc-modal-close=${() => (this.deletingState = null)}>
237+
<p style="margin:0 0 12px 0; line-height:1.5;">
238+
Permanently remove
239+
<code style="background:hsl(220 25% 14%); padding:2px 6px; border-radius:4px;">${this.deletingState?.entity_id ?? ''}</code>
240+
from the state machine?
241+
<br>
242+
<span style="color:var(--hc-text-muted,#7b899d); font-size:12px;">
243+
This is immediate. To restore, re-create the entity via "+ Add entity".
244+
</span>
245+
</p>
246+
<button slot="footer" class="btn" @click=${() => (this.deletingState = null)}>Cancel</button>
247+
<button slot="footer" class="btn"
248+
style="background:hsl(0 50% 25%); border-color:hsl(0 50% 35%); color:hsl(0 60% 88%);"
249+
@click=${this._confirmDelete}>Delete</button>
250+
</hc-modal>
251+
209252
<hc-modal .open=${this.modalOpen}
210253
heading=${this.editingState ? `Edit ${this.editingState.entity_id}` : 'Add entity'}
211254
@hc-modal-close=${() => { this.modalOpen = false; this.editingState = null; }}>

v2/crates/homecore-api/src/app.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@ pub fn router(state: SharedState) -> Router {
2828
.route("/api/", get(rest::api_root))
2929
.route("/api/config", get(rest::get_config))
3030
.route("/api/states", get(rest::get_states))
31-
.route("/api/states/:entity_id", get(rest::get_state).post(rest::set_state))
31+
.route(
32+
"/api/states/:entity_id",
33+
get(rest::get_state)
34+
.post(rest::set_state)
35+
.delete(rest::delete_state),
36+
)
3237
.route("/api/services", get(rest::get_services))
3338
.route("/api/services/:domain/:service", post(rest::call_service))
3439
.route("/api/websocket", get(ws::websocket_handler))

v2/crates/homecore-api/src/rest.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,21 @@ pub struct SetStateRequest {
9292
pub attributes: serde_json::Value,
9393
}
9494

95+
/// DELETE /api/states/:entity_id — remove an entity from the state
96+
/// machine. Idempotent: returns 204 whether or not the entity existed,
97+
/// matching HA's removal semantics. 4xx only for malformed entity_id or
98+
/// auth failure.
99+
pub async fn delete_state(
100+
headers: HeaderMap,
101+
State(s): State<SharedState>,
102+
Path(entity_id): Path<String>,
103+
) -> ApiResult<StatusCode> {
104+
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
105+
let id = EntityId::parse(entity_id).map_err(|e| ApiError::BadRequest(e.to_string()))?;
106+
s.homecore().states().remove(&id);
107+
Ok(StatusCode::NO_CONTENT)
108+
}
109+
95110
pub async fn set_state(
96111
headers: HeaderMap,
97112
State(s): State<SharedState>,

0 commit comments

Comments
 (0)