326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
/** Card editor for the Maintenance Supporter Lovelace card. */
|
|
|
|
import { LitElement, html, css, nothing } from "lit";
|
|
import { property, state } from "lit/decorators.js";
|
|
import { t } from "./styles";
|
|
import type { HomeAssistant, CardConfig, MaintenanceObjectResponse, SavedView } from "./types";
|
|
|
|
const STATUS_KEYS = ["overdue", "triggered", "due_soon", "ok"] as const;
|
|
|
|
export class MaintenanceSupporterCardEditor extends LitElement {
|
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
|
@state() private _config: CardConfig = { type: "custom:maintenance-supporter-card" };
|
|
@state() private _objects: MaintenanceObjectResponse[] = [];
|
|
@state() private _loadingObjects = true;
|
|
@state() private _loadError = false;
|
|
@state() private _views: SavedView[] = [];
|
|
|
|
private _objectsLoaded = false;
|
|
|
|
private get _lang(): string {
|
|
return this.hass?.language || "en";
|
|
}
|
|
|
|
setConfig(config: CardConfig): void {
|
|
this._config = { ...config };
|
|
}
|
|
|
|
updated(changedProps: Map<string, unknown>): void {
|
|
super.updated(changedProps);
|
|
if (changedProps.has("hass") && this.hass && !this._objectsLoaded) {
|
|
this._objectsLoaded = true;
|
|
this._loadObjects();
|
|
}
|
|
}
|
|
|
|
private async _loadObjects(): Promise<void> {
|
|
try {
|
|
const result = await this.hass.connection.sendMessagePromise({
|
|
type: "maintenance_supporter/objects",
|
|
}) as { objects: MaintenanceObjectResponse[] };
|
|
this._objects = result.objects || [];
|
|
this._loadError = false;
|
|
} catch {
|
|
this._objects = [];
|
|
this._loadError = true;
|
|
}
|
|
this._loadingObjects = false;
|
|
// Saved views feed the scope dropdown; best-effort — the editor works
|
|
// without them (the dropdown just stays at "none").
|
|
try {
|
|
const res = await this.hass.connection.sendMessagePromise({
|
|
type: "maintenance_supporter/views/list",
|
|
}) as { views: SavedView[] };
|
|
this._views = res.views || [];
|
|
} catch {
|
|
this._views = [];
|
|
}
|
|
}
|
|
|
|
private _valueChanged(key: string, value: unknown): void {
|
|
const newConfig = { ...this._config, [key]: value };
|
|
// Drop empty arrays / empty strings so the saved YAML stays clean
|
|
if ((Array.isArray(value) && value.length === 0) || value === "") {
|
|
delete (newConfig as Record<string, unknown>)[key];
|
|
}
|
|
this._config = newConfig;
|
|
this.dispatchEvent(
|
|
new CustomEvent("config-changed", { detail: { config: newConfig } })
|
|
);
|
|
}
|
|
|
|
private _toggleStatus(status: string, on: boolean): void {
|
|
const current = new Set(this._config.filter_status || []);
|
|
if (on) current.add(status); else current.delete(status);
|
|
this._valueChanged("filter_status", [...current]);
|
|
}
|
|
|
|
private _toggleObject(name: string, on: boolean): void {
|
|
const current = new Set(this._config.filter_objects || []);
|
|
if (on) current.add(name); else current.delete(name);
|
|
this._valueChanged("filter_objects", [...current]);
|
|
}
|
|
|
|
private _onEntitiesChanged = (e: CustomEvent<{ value: string[] }>): void => {
|
|
this._valueChanged("entity_ids", e.detail.value || []);
|
|
};
|
|
|
|
render() {
|
|
const L = this._lang;
|
|
const selectedStatuses = new Set(this._config.filter_status || []);
|
|
const selectedObjects = new Set(this._config.filter_objects || []);
|
|
const objectNames = [...this._objects]
|
|
.map((o) => o.object.name)
|
|
.sort((a, b) => a.localeCompare(b));
|
|
// Build the list of OUR sensor + binary_sensor entity_ids so the
|
|
// ha-entities-picker only shows maintenance_supporter entities, not
|
|
// every sensor in HA.
|
|
const ourEntities: string[] = [];
|
|
for (const o of this._objects) {
|
|
for (const t of o.tasks) {
|
|
if (t.sensor_entity_id) ourEntities.push(t.sensor_entity_id);
|
|
if (t.binary_sensor_entity_id) ourEntities.push(t.binary_sensor_entity_id);
|
|
}
|
|
}
|
|
|
|
return html`
|
|
<div class="editor">
|
|
<ha-textfield
|
|
label="${t("card_title", L)}"
|
|
.value=${this._config.title || ""}
|
|
@input=${(e: Event) =>
|
|
this._valueChanged("title", (e.target as HTMLInputElement).value)}
|
|
></ha-textfield>
|
|
|
|
<!-- Status filter (chip row) -->
|
|
<div class="field">
|
|
<div class="field-label">${t("card_filter_status", L)}</div>
|
|
<div class="chip-row">
|
|
${STATUS_KEYS.map((s) => html`
|
|
<label class="chip ${selectedStatuses.has(s) ? "active" : ""}">
|
|
<input type="checkbox"
|
|
.checked=${selectedStatuses.has(s)}
|
|
@change=${(e: Event) => this._toggleStatus(s, (e.target as HTMLInputElement).checked)} />
|
|
${t(s, L)}
|
|
</label>
|
|
`)}
|
|
</div>
|
|
<div class="field-help">${t("card_filter_status_help", L)}</div>
|
|
</div>
|
|
|
|
<!-- Object filter (multi-checkbox) -->
|
|
<div class="field">
|
|
<div class="field-label">${t("card_filter_objects", L)}</div>
|
|
${this._loadingObjects
|
|
? html`<div class="field-help">${t("card_loading_objects", L)}</div>`
|
|
: this._loadError
|
|
? html`<div class="field-help error-text">${t("card_load_error", L)}</div>`
|
|
: objectNames.length === 0
|
|
? html`<div class="field-help">${t("no_objects", L)}</div>`
|
|
: html`
|
|
<div class="object-list">
|
|
${objectNames.map((name) => html`
|
|
<label class="object-row">
|
|
<input type="checkbox"
|
|
.checked=${selectedObjects.has(name)}
|
|
@change=${(e: Event) => this._toggleObject(name, (e.target as HTMLInputElement).checked)} />
|
|
<span>${name}</span>
|
|
</label>
|
|
`)}
|
|
</div>
|
|
<div class="field-help">${t("card_filter_objects_help", L)}</div>
|
|
`
|
|
}
|
|
</div>
|
|
|
|
<!-- Entity-id filter (HA-native pattern). Limited to our integration's
|
|
sensor + binary_sensor entities via includeEntities so the picker
|
|
stays usable on installs with thousands of entities. -->
|
|
<div class="field">
|
|
<div class="field-label">${t("card_filter_entities", L)}</div>
|
|
<ha-entities-picker
|
|
.hass=${this.hass}
|
|
.value=${this._config.entity_ids || []}
|
|
.includeDomains=${["sensor", "binary_sensor"]}
|
|
.includeEntities=${ourEntities}
|
|
@value-changed=${this._onEntitiesChanged}
|
|
></ha-entities-picker>
|
|
<div class="field-help">${t("card_filter_entities_help", L)}</div>
|
|
</div>
|
|
|
|
<!-- Saved-view scope (v2.26): applies the view's status/user/label
|
|
filters on top of everything above. Hidden while no views exist —
|
|
views are created in the panel toolbar, not here. -->
|
|
${this._views.length > 0
|
|
? html`
|
|
<div class="field">
|
|
<div class="field-label">${t("card_saved_view", L)}</div>
|
|
<select
|
|
class="view-select"
|
|
.value=${this._config.view_id || ""}
|
|
@change=${(e: Event) =>
|
|
this._valueChanged("view_id", (e.target as HTMLSelectElement).value)}
|
|
>
|
|
<option value="" ?selected=${!this._config.view_id}>
|
|
${t("card_saved_view_none", L)}
|
|
</option>
|
|
${this._views.map(
|
|
(v) => html`<option value=${v.id} ?selected=${this._config.view_id === v.id}>
|
|
${v.name}
|
|
</option>`
|
|
)}
|
|
</select>
|
|
<div class="field-help">${t("card_saved_view_help", L)}</div>
|
|
</div>
|
|
`
|
|
: nothing}
|
|
|
|
<ha-formfield label="${t("card_show_header", L)}">
|
|
<ha-switch
|
|
.checked=${this._config.show_header !== false}
|
|
@change=${(e: Event) =>
|
|
this._valueChanged("show_header", (e.target as HTMLInputElement).checked)}
|
|
></ha-switch>
|
|
</ha-formfield>
|
|
|
|
<ha-formfield label="${t("card_show_actions", L)}">
|
|
<ha-switch
|
|
.checked=${this._config.show_actions !== false}
|
|
@change=${(e: Event) =>
|
|
this._valueChanged("show_actions", (e.target as HTMLInputElement).checked)}
|
|
></ha-switch>
|
|
</ha-formfield>
|
|
|
|
<ha-formfield label="${t("card_compact", L)}">
|
|
<ha-switch
|
|
.checked=${this._config.compact || false}
|
|
@change=${(e: Event) =>
|
|
this._valueChanged("compact", (e.target as HTMLInputElement).checked)}
|
|
></ha-switch>
|
|
</ha-formfield>
|
|
|
|
<ha-textfield
|
|
label="${t("card_max_items", L)}"
|
|
type="number"
|
|
.value=${String(this._config.max_items || 0)}
|
|
@input=${(e: Event) =>
|
|
this._valueChanged("max_items", parseInt((e.target as HTMLInputElement).value, 10) || 0)}
|
|
></ha-textfield>
|
|
${nothing}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
static styles = css`
|
|
.editor {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
padding: 16px;
|
|
}
|
|
ha-textfield { display: block; }
|
|
.field {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
.field-label {
|
|
font-size: 13px;
|
|
color: var(--secondary-text-color);
|
|
font-weight: 500;
|
|
}
|
|
.field-help {
|
|
font-size: 12px;
|
|
color: var(--secondary-text-color);
|
|
font-style: italic;
|
|
}
|
|
.field-help.error-text {
|
|
color: var(--error-color, #f44336);
|
|
font-style: normal;
|
|
}
|
|
.chip-row {
|
|
display: flex;
|
|
gap: 6px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 4px 10px;
|
|
border: 1px solid var(--divider-color);
|
|
border-radius: 14px;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
user-select: none;
|
|
transition: background 0.15s, border-color 0.15s;
|
|
}
|
|
.chip:hover {
|
|
background: var(--secondary-background-color);
|
|
}
|
|
.chip.active {
|
|
background: var(--primary-color);
|
|
color: var(--text-primary-color);
|
|
border-color: var(--primary-color);
|
|
}
|
|
.chip input {
|
|
display: none;
|
|
}
|
|
.object-list {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
|
gap: 4px 12px;
|
|
padding: 6px 0;
|
|
}
|
|
.object-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 3px 0;
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
}
|
|
.object-row input { cursor: pointer; }
|
|
.view-select {
|
|
padding: 8px;
|
|
border: 1px solid var(--divider-color);
|
|
border-radius: 4px;
|
|
background: var(--card-background-color, #fff);
|
|
color: var(--primary-text-color);
|
|
font-size: 14px;
|
|
max-width: 320px;
|
|
}
|
|
`;
|
|
}
|
|
|
|
// Module-bottom registration so esbuild's tree-shaker doesn't drop the
|
|
// element class when the only reference looks "type-only" (the
|
|
// @customElement decorator pattern triggered exactly that bug for the
|
|
// dialog components — same fix applied here for the card editor).
|
|
if (!customElements.get("maintenance-supporter-card-editor")) {
|
|
customElements.define(
|
|
"maintenance-supporter-card-editor",
|
|
MaintenanceSupporterCardEditor,
|
|
);
|
|
}
|