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>
This commit is contained in:
parent
89190b6c2d
commit
c0bb6f4fc7
|
|
@ -38,8 +38,27 @@ export class StateCard extends LitElement {
|
||||||
border-color: hsl(185 80% 50% / 0.4);
|
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; }
|
.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 {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -121,6 +140,11 @@ export class StateCard extends LitElement {
|
||||||
@click=${this._onClick}
|
@click=${this._onClick}
|
||||||
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this._onClick(); } }}
|
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this._onClick(); } }}
|
||||||
aria-label="Edit ${entity_id}">
|
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">
|
<div class="header">
|
||||||
${this.iconSvg
|
${this.iconSvg
|
||||||
? html`<div class="icon-wrap" .innerHTML=${this.iconSvg}></div>`
|
? html`<div class="icon-wrap" .innerHTML=${this.iconSvg}></div>`
|
||||||
|
|
@ -141,6 +165,15 @@ export class StateCard extends LitElement {
|
||||||
detail: { state: this.state }, bubbles: true, composed: true,
|
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 {
|
declare global {
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@ export class Dashboard extends LitElement {
|
||||||
@state() private modalOpen = false;
|
@state() private modalOpen = false;
|
||||||
@state() private submitToast: string | null = null;
|
@state() private submitToast: string | null = null;
|
||||||
@state() private editingState: StateView | null = null; // null = create mode
|
@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;
|
@query('hc-entity-form') private _form?: EntityForm;
|
||||||
|
|
||||||
|
|
@ -146,6 +147,29 @@ export class Dashboard extends LitElement {
|
||||||
this.modalOpen = true;
|
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> }>) {
|
private async _onSubmit(e: CustomEvent<{ entity_id: string; state: string; attributes: Record<string, unknown> }>) {
|
||||||
const { entity_id, state, attributes } = e.detail;
|
const { entity_id, state, attributes } = e.detail;
|
||||||
const wasEditing = this.editingState !== null;
|
const wasEditing = this.editingState !== null;
|
||||||
|
|
@ -200,12 +224,31 @@ export class Dashboard extends LitElement {
|
||||||
<code>--no-seed-entities</code>.
|
<code>--no-seed-entities</code>.
|
||||||
</div>`
|
</div>`
|
||||||
: html`<div class="grid"
|
: html`<div class="grid"
|
||||||
@hc-state-card-click=${(e: Event) => 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(
|
${this.states.map(
|
||||||
(s) => html`<hc-state-card .state=${s}></hc-state-card>`
|
(s) => html`<hc-state-card .state=${s}></hc-state-card>`
|
||||||
)}
|
)}
|
||||||
</div>`}
|
</div>`}
|
||||||
|
|
||||||
|
<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}
|
<hc-modal .open=${this.modalOpen}
|
||||||
heading=${this.editingState ? `Edit ${this.editingState.entity_id}` : 'Add entity'}
|
heading=${this.editingState ? `Edit ${this.editingState.entity_id}` : 'Add entity'}
|
||||||
@hc-modal-close=${() => { this.modalOpen = false; this.editingState = null; }}>
|
@hc-modal-close=${() => { this.modalOpen = false; this.editingState = null; }}>
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,12 @@ pub fn router(state: SharedState) -> Router {
|
||||||
.route("/api/", get(rest::api_root))
|
.route("/api/", get(rest::api_root))
|
||||||
.route("/api/config", get(rest::get_config))
|
.route("/api/config", get(rest::get_config))
|
||||||
.route("/api/states", get(rest::get_states))
|
.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", get(rest::get_services))
|
||||||
.route("/api/services/:domain/:service", post(rest::call_service))
|
.route("/api/services/:domain/:service", post(rest::call_service))
|
||||||
.route("/api/websocket", get(ws::websocket_handler))
|
.route("/api/websocket", get(ws::websocket_handler))
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,21 @@ pub struct SetStateRequest {
|
||||||
pub attributes: serde_json::Value,
|
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(
|
pub async fn set_state(
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
State(s): State<SharedState>,
|
State(s): State<SharedState>,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue