Skip to content

Commit e7215a1

Browse files
committed
feat(homecore-ui iter 1): Modal + EntityForm + Add Entity flow
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/<id> → 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 <ruv@ruv.net>
1 parent 0979fac commit e7215a1

3 files changed

Lines changed: 329 additions & 5 deletions

File tree

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* `<hc-entity-form>` — create / edit form for a single entity.
3+
*
4+
* Props:
5+
* .entityId — pre-populated when editing; empty for create
6+
* .state — pre-populated state value
7+
* .attributes — pre-populated JSON object
8+
* .editing — true to lock entity_id (HA wire-compat doesn't rename)
9+
*
10+
* Emits:
11+
* hc-entity-submit detail: { entity_id, state, attributes }
12+
* hc-entity-cancel
13+
*
14+
* Validation (client-side; backend validates again):
15+
* - entity_id matches /^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/
16+
* - state is non-empty
17+
* - attributes parses as a JSON object (not array, not scalar)
18+
*/
19+
20+
import { LitElement, html, css } from 'lit';
21+
import { customElement, property, state } from 'lit/decorators.js';
22+
23+
const ENTITY_ID_RE = /^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/;
24+
25+
@customElement('hc-entity-form')
26+
export class EntityForm extends LitElement {
27+
@property({ type: String }) entityId = '';
28+
@property({ type: String }) state = '';
29+
@property({ type: Object }) entityAttrs: Record<string, unknown> = {};
30+
@property({ type: Boolean }) editing = false;
31+
32+
@state() private _attrs = '';
33+
@state() private _err: string | null = null;
34+
35+
static styles = css`
36+
:host { display: block; font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); color: var(--hc-text, #e6eaee); }
37+
label { display: block; margin: 12px 0 4px; font-size: 12px; color: var(--hc-text-muted, #7b899d); }
38+
input, textarea {
39+
width: 100%; box-sizing: border-box;
40+
padding: 8px 10px; background: hsl(220 25% 10%);
41+
border: 1px solid var(--hc-border, #2a323e); border-radius: 6px;
42+
color: var(--hc-text, #e6eaee);
43+
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
44+
font-size: 13px;
45+
}
46+
input:focus, textarea:focus { outline: 2px solid hsl(185 80% 50% / 0.5); border-color: var(--hc-primary, #19d4e5); }
47+
input[disabled] { opacity: 0.5; cursor: not-allowed; }
48+
textarea { min-height: 90px; resize: vertical; }
49+
.hint { font-size: 11px; color: var(--hc-text-muted, #7b899d); margin-top: 4px; }
50+
.err { margin-top: 10px; padding: 10px; border: 1px solid #b35a5a; border-radius: 6px; background: hsl(0 35% 12%); color: #f0c0c0; font-size: 12px; }
51+
button {
52+
padding: 8px 16px;
53+
border: 1px solid var(--hc-border, #2a323e);
54+
border-radius: 6px;
55+
background: hsl(220 25% 14%);
56+
color: var(--hc-text, #e6eaee);
57+
font-size: 13px;
58+
font-weight: 500;
59+
cursor: pointer;
60+
font-family: inherit;
61+
}
62+
button.primary { background: var(--hc-primary, #19d4e5); color: var(--hc-primary-fg, #0b0e13); border-color: var(--hc-primary, #19d4e5); font-weight: 600; }
63+
button:hover { background: hsl(220 20% 18%); }
64+
button.primary:hover { background: hsl(185 80% 55%); }
65+
`;
66+
67+
protected updated(changed: Map<string, unknown>): void {
68+
if (changed.has('entityAttrs')) {
69+
this._attrs = JSON.stringify(this.entityAttrs, null, 2);
70+
}
71+
}
72+
73+
/** Public — call from host to trigger validation + emit submit event. */
74+
public requestSubmit(): void { this._submit(); }
75+
76+
/** Public — call from host to dispatch cancel. */
77+
public requestCancel(): void { this._cancel(); }
78+
79+
private _submit() {
80+
const id = this.entityId.trim();
81+
if (!ENTITY_ID_RE.test(id)) {
82+
this._err = `entity_id must match domain.snake_case (got "${id}")`;
83+
return;
84+
}
85+
const stateVal = this.state.trim();
86+
if (!stateVal) {
87+
this._err = 'state must not be empty';
88+
return;
89+
}
90+
let attrs: Record<string, unknown> = {};
91+
if (this._attrs.trim()) {
92+
try {
93+
const parsed = JSON.parse(this._attrs);
94+
if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) {
95+
this._err = 'attributes must be a JSON object (not array, not scalar)';
96+
return;
97+
}
98+
attrs = parsed as Record<string, unknown>;
99+
} catch (e) {
100+
this._err = `attributes JSON parse failed: ${e instanceof Error ? e.message : String(e)}`;
101+
return;
102+
}
103+
}
104+
this._err = null;
105+
this.dispatchEvent(new CustomEvent('hc-entity-submit', {
106+
detail: { entity_id: id, state: stateVal, attributes: attrs },
107+
bubbles: true, composed: true,
108+
}));
109+
}
110+
111+
private _cancel() {
112+
this._err = null;
113+
this.dispatchEvent(new CustomEvent('hc-entity-cancel', { bubbles: true, composed: true }));
114+
}
115+
116+
render() {
117+
return html`
118+
<form @submit=${(e: Event) => { e.preventDefault(); this._submit(); }}>
119+
<label for="eid">entity_id</label>
120+
<input id="eid" .value=${this.entityId}
121+
?disabled=${this.editing}
122+
@input=${(e: Event) => (this.entityId = (e.target as HTMLInputElement).value)}
123+
placeholder="light.kitchen_ceiling" />
124+
<div class="hint">format: <code>domain.snake_case</code> — domain like sensor / light / switch / binary_sensor</div>
125+
126+
<label for="state">state</label>
127+
<input id="state" .value=${this.state}
128+
@input=${(e: Event) => (this.state = (e.target as HTMLInputElement).value)}
129+
placeholder="on / off / 42 / 14.5 / detected" />
130+
131+
<label for="attrs">attributes (JSON object)</label>
132+
<textarea id="attrs" .value=${this._attrs}
133+
@input=${(e: Event) => (this._attrs = (e.target as HTMLTextAreaElement).value)}
134+
placeholder='{ "friendly_name": "Kitchen Ceiling", "brightness": 230 }'></textarea>
135+
<div class="hint">optional; leave blank for <code>{}</code></div>
136+
137+
${this._err ? html`<div class="err">${this._err}</div>` : ''}
138+
</form>
139+
`;
140+
}
141+
}
142+
143+
declare global { interface HTMLElementTagNameMap { 'hc-entity-form': EntityForm; } }

frontend/src/components/Modal.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* `<hc-modal>` — minimal accessible overlay modal.
3+
*
4+
* Open / close by setting the `open` property. Closes on Escape and
5+
* on backdrop click. Content goes in the default slot; an optional
6+
* named "footer" slot is rendered below the content.
7+
*
8+
* Emits `hc-modal-close` on close so the host can clean up.
9+
*/
10+
11+
import { LitElement, html, css } from 'lit';
12+
import { customElement, property } from 'lit/decorators.js';
13+
14+
@customElement('hc-modal')
15+
export class Modal extends LitElement {
16+
@property({ type: Boolean, reflect: true }) open = false;
17+
@property({ type: String }) heading = '';
18+
19+
static styles = css`
20+
:host { display: contents; }
21+
.backdrop {
22+
position: fixed;
23+
inset: 0;
24+
background: hsl(220 25% 4% / 0.65);
25+
backdrop-filter: blur(4px);
26+
-webkit-backdrop-filter: blur(4px);
27+
display: flex;
28+
align-items: center;
29+
justify-content: center;
30+
z-index: 100;
31+
padding: 16px;
32+
}
33+
.dialog {
34+
background: var(--hc-bg, #0b0e13);
35+
border: 1px solid var(--hc-border, #2a323e);
36+
border-radius: 10px;
37+
box-shadow: 0 24px 64px hsl(220 25% 2% / 0.6);
38+
width: min(560px, calc(100vw - 32px));
39+
max-height: calc(100vh - 32px);
40+
display: flex;
41+
flex-direction: column;
42+
overflow: hidden;
43+
font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif);
44+
color: var(--hc-text, #e6eaee);
45+
}
46+
header {
47+
padding: 14px 18px;
48+
border-bottom: 1px solid var(--hc-border, #2a323e);
49+
display: flex;
50+
align-items: center;
51+
justify-content: space-between;
52+
font-weight: 600;
53+
font-size: 15px;
54+
}
55+
button.close {
56+
background: transparent;
57+
border: none;
58+
color: var(--hc-text-muted, #7b899d);
59+
cursor: pointer;
60+
font-size: 18px;
61+
line-height: 1;
62+
padding: 4px 8px;
63+
border-radius: 4px;
64+
}
65+
button.close:hover { background: hsl(220 20% 14%); color: var(--hc-text, #e6eaee); }
66+
.body { padding: 16px 18px; overflow-y: auto; }
67+
.footer {
68+
padding: 12px 18px;
69+
border-top: 1px solid var(--hc-border, #2a323e);
70+
display: flex;
71+
justify-content: flex-end;
72+
gap: 8px;
73+
}
74+
`;
75+
76+
connectedCallback(): void {
77+
super.connectedCallback();
78+
this._onKey = this._onKey.bind(this);
79+
window.addEventListener('keydown', this._onKey);
80+
}
81+
disconnectedCallback(): void {
82+
window.removeEventListener('keydown', this._onKey);
83+
super.disconnectedCallback();
84+
}
85+
86+
private _onKey(e: KeyboardEvent) {
87+
if (this.open && e.key === 'Escape') this._close();
88+
}
89+
90+
private _close() {
91+
this.open = false;
92+
this.dispatchEvent(new CustomEvent('hc-modal-close', { bubbles: true, composed: true }));
93+
}
94+
95+
render() {
96+
if (!this.open) return html``;
97+
return html`
98+
<div class="backdrop" @click=${(e: Event) => { if (e.target === e.currentTarget) this._close(); }}>
99+
<div class="dialog" role="dialog" aria-modal="true" aria-label=${this.heading}>
100+
<header>
101+
<span>${this.heading}</span>
102+
<button class="close" @click=${this._close} aria-label="Close">×</button>
103+
</header>
104+
<div class="body"><slot></slot></div>
105+
<div class="footer"><slot name="footer"></slot></div>
106+
</div>
107+
</div>
108+
`;
109+
}
110+
}
111+
112+
declare global { interface HTMLElementTagNameMap { 'hc-modal': Modal; } }

frontend/src/pages/Dashboard.ts

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@
99
*/
1010

1111
import { LitElement, html, css } from 'lit';
12-
import { customElement, state } from 'lit/decorators.js';
12+
import { customElement, state, query } from 'lit/decorators.js';
1313

1414
import { HomecoreClient } from '../api/client.js';
1515
import type { ApiConfig, StateView } from '../api/types.js';
16+
import '../components/Modal.js';
17+
import '../components/EntityForm.js';
18+
import type { EntityForm } from '../components/EntityForm.js';
1619

1720
function resolveToken(): string {
1821
if (typeof localStorage !== 'undefined') {
@@ -66,12 +69,41 @@ export class Dashboard extends LitElement {
6669
font-size: 13px;
6770
white-space: pre-wrap;
6871
}
72+
.toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
73+
.toolbar .grow { flex: 1; }
74+
button.add {
75+
padding: 7px 14px;
76+
background: var(--hc-primary, #19d4e5);
77+
color: var(--hc-primary-fg, #0b0e13);
78+
border: none; border-radius: 6px;
79+
font-size: 13px; font-weight: 600;
80+
cursor: pointer;
81+
font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif);
82+
}
83+
button.add:hover { background: hsl(185 80% 55%); }
84+
button.btn {
85+
padding: 7px 14px;
86+
background: hsl(220 25% 14%);
87+
color: var(--hc-text, #e6eaee);
88+
border: 1px solid var(--hc-border, #2a323e);
89+
border-radius: 6px;
90+
font-size: 13px;
91+
cursor: pointer;
92+
font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif);
93+
}
94+
button.btn:hover { background: hsl(220 20% 18%); }
95+
button.primary { background: var(--hc-primary, #19d4e5); color: var(--hc-primary-fg, #0b0e13); border-color: var(--hc-primary, #19d4e5); font-weight: 600; }
96+
.toast { padding: 8px 12px; background: hsl(165 60% 16%); color: hsl(165 60% 80%); border-radius: 6px; font-size: 12px; margin-bottom: 12px; }
6997
`;
7098

7199
@state() private states: StateView[] = [];
72100
@state() private config: ApiConfig | null = null;
73101
@state() private error: string | null = null;
74102
@state() private loading = true;
103+
@state() private modalOpen = false;
104+
@state() private submitToast: string | null = null;
105+
106+
@query('hc-entity-form') private _form?: EntityForm;
75107

76108
private client = new HomecoreClient({ token: resolveToken() });
77109
private pollTimer: number | undefined;
@@ -103,8 +135,30 @@ export class Dashboard extends LitElement {
103135
}
104136
}
105137

138+
private async _onSubmit(e: CustomEvent<{ entity_id: string; state: string; attributes: Record<string, unknown> }>) {
139+
const { entity_id, state, attributes } = e.detail;
140+
try {
141+
const resp = await fetch(`/api/states/${encodeURIComponent(entity_id)}`, {
142+
method: 'POST',
143+
headers: {
144+
'Authorization': `Bearer ${resolveToken()}`,
145+
'Content-Type': 'application/json',
146+
},
147+
body: JSON.stringify({ state, attributes }),
148+
});
149+
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
150+
this.modalOpen = false;
151+
this.submitToast = `Created ${entity_id} = ${state}`;
152+
window.setTimeout(() => (this.submitToast = null), 3000);
153+
await this.refresh();
154+
} catch (err) {
155+
// Form-level error stays in the form; surface at top too for visibility.
156+
this.error = err instanceof Error ? err.message : String(err);
157+
}
158+
}
159+
106160
render() {
107-
if (this.error) {
161+
if (this.error && this.states.length === 0) {
108162
return html`<div class="err">backend unreachable — ${this.error}\n\n
109163
hint: make sure homecore-server is running on :8123 and that
110164
the token in localStorage["homecore.token"] is accepted.
@@ -116,22 +170,37 @@ export class Dashboard extends LitElement {
116170
const v = this.config?.version ?? '?';
117171
const loc = this.config?.location_name ?? 'Home';
118172
return html`
173+
${this.submitToast ? html`<div class="toast">${this.submitToast}</div>` : ''}
174+
<div class="toolbar">
175+
<span class="grow"></span>
176+
<button class="add" @click=${() => (this.modalOpen = true)}>+ Add entity</button>
177+
</div>
119178
<div class="meta">
120179
<span><strong>${loc}</strong></span>
121180
<span>HOMECORE v<strong>${v}</strong></span>
122181
<span><strong>${this.states.length}</strong> entities</span>
123182
</div>
124183
${this.states.length === 0
125184
? html`<div class="empty">
126-
No entities registered yet. Run
127-
<code>bash scripts/homecore-seed.sh</code> to populate
128-
~10 demo entities, or connect a plugin / integration.
185+
No entities registered yet. Click <strong>+ Add entity</strong>
186+
above, run <code>bash scripts/homecore-seed.sh</code>,
187+
or boot <code>homecore-server</code> without
188+
<code>--no-seed-entities</code>.
129189
</div>`
130190
: html`<div class="grid">
131191
${this.states.map(
132192
(s) => html`<hc-state-card .state=${s}></hc-state-card>`
133193
)}
134194
</div>`}
195+
196+
<hc-modal .open=${this.modalOpen} heading="Add entity"
197+
@hc-modal-close=${() => (this.modalOpen = false)}>
198+
<hc-entity-form
199+
@hc-entity-submit=${(e: Event) => this._onSubmit(e as CustomEvent)}
200+
@hc-entity-cancel=${() => (this.modalOpen = false)}></hc-entity-form>
201+
<button slot="footer" class="btn" @click=${() => this._form?.requestCancel()}>Cancel</button>
202+
<button slot="footer" class="btn primary" @click=${() => this._form?.requestSubmit()}>Create</button>
203+
</hc-modal>
135204
`;
136205
}
137206
}

0 commit comments

Comments
 (0)