diff --git a/css/solar.css b/css/solar.css
index e0ec8f0..a935481 100644
--- a/css/solar.css
+++ b/css/solar.css
@@ -85,3 +85,20 @@ input[type='range']::-webkit-slider-runnable-track {
font-size: .8rem;
}
}
+
+/* Editor der Automatiken. Eine Bedingungszeile traegt Geraet, Messwert,
+ Vergleich, Wert, Einheit und den Papierkorb nebeneinander - auf einem
+ Handy bleiben davon "Therm OG Bad" und "te" uebrig, weil die Felder in
+ der input-group einfach mitschrumpfen. Unter 576 px bekommen deshalb
+ Geraet und Messwert je eine eigene Zeile; Vergleich, Wert und Einheit
+ sind kurz genug, um zusammen in die naechste zu passen. */
+@media (max-width: 575.98px) {
+ /* Die input-group-Klasse muss mit in den Selektor: solar.css wird vor
+ AdminLTE geladen, und Bootstraps ".input-group > .form-select" haette
+ sonst dasselbe Gewicht und wuerde als spaeteres Blatt gewinnen. */
+ .input-group.auto-zeile > .auto-geraet,
+ .input-group.auto-zeile > .auto-messwert {
+ flex: 1 1 100%;
+ width: auto;
+ }
+}
diff --git a/js/solar/autoActionFuncs.js b/js/solar/autoActionFuncs.js
index 5870ea4..3208c21 100644
--- a/js/solar/autoActionFuncs.js
+++ b/js/solar/autoActionFuncs.js
@@ -48,15 +48,21 @@ function operatorLabel(state, op) {
return treffer ? treffer.label : op;
}
-/** Erster Messwert des ersten Geraets - Vorbelegung fuer eine neue Zeile. */
-function ersterMesswert() {
- const dev = autoDevices.find(d => d.states.length > 0);
- return dev ? dev.states[0] : null;
+/**
+ * Eine neue Zeile beginnt ohne Geraet.
+ *
+ * Frueher stand dort das erste Geraet mit seinem ersten Messwert - nach dem
+ * Geraete-Discovery also "Bad Links: Gesperrt = ", eine Bedingung, die
+ * niemand gemeint hat und die man erst wegklicken muss. Ohne Vorbelegung
+ * sagt die Zeile, was zu tun ist, und der Server lehnt eine unvollstaendige
+ * Bedingung ohnehin ab.
+ */
+function leereBedingung() {
+ return { state_id: 0, operator: "=", value: "" };
}
-function ersterBefehl() {
- const dev = autoDevices.find(d => d.commands.length > 0);
- return dev ? dev.commands[0] : null;
+function leereAktion() {
+ return { command_id: 0, params: {} };
}
// --- Bausteine fuer die Oberflaeche --------------------------------------
@@ -103,21 +109,116 @@ function geraeteGruppen(geraete) {
return gruppen;
}
-/** Auswahlliste mit Zwischenueberschriften je Geraeteart fuellen. */
-function geraeteListe(select, geraete, gewaehlt) {
- select.innerHTML = "";
- const gruppen = geraeteGruppen(geraete);
- // Nur eine Gruppe? Dann waere die Ueberschrift blosse Zierde.
- if (gruppen.length < 2) {
- geraete.forEach(d => select.appendChild(option({ value: d.id, text: d.label }, gewaehlt)));
- return;
- }
- gruppen.forEach(g => {
- const kopf = document.createElement("optgroup");
- kopf.label = g.titel;
- g.geraete.forEach(d => kopf.appendChild(option({ value: d.id, text: d.label }, gewaehlt)));
- select.appendChild(kopf);
- });
+/**
+ * Geraeteauswahl mit Suchfeld.
+ *
+ * Bei ueber fuenfzig Geraeten hilft Tippen mehr als Blaettern - und es hilft
+ * quer zur Gruppierung: "bad" findet das Thermostat im Bad und die Jalousie
+ * "Bad Links", "jalousie" findet alle elf auf einmal. Gesucht wird deshalb
+ * ueber Beschriftung und Geraeteart zugleich.
+ *
+ * Statt einer Auswahlliste steht ein Knopf in der Zeile; die Liste klappt
+ * darunter ueber die volle Breite auf. Auf dem Handy ist das mehr Platz als
+ * ein natives Auswahlfeld hergibt, und das Suchfeld waere darin ohnehin
+ * nicht unterzubringen.
+ */
+function geraeteWaehler(geraete, aktuelleId, beiAuswahl) {
+ const knopf = document.createElement("button");
+ knopf.type = "button";
+ knopf.className = "form-select text-start auto-geraet";
+ const dev = geraete.find(d => d.id === aktuelleId);
+ knopf.textContent = dev ? dev.label : "Gerät wählen …";
+ if (!dev) knopf.classList.add("text-body-secondary");
+ knopf.onclick = () => oeffneWaehler(knopf, geraete, aktuelleId, beiAuswahl);
+ return knopf;
+}
+
+/** Die aufgeklappte Liste - es ist immer hoechstens eine offen. */
+function schliesseWaehler() {
+ const offen = document.querySelector(".geraete-waehler");
+ if (offen) offen.remove();
+}
+
+function oeffneWaehler(knopf, geraete, aktuelleId, beiAuswahl) {
+ const schonOffen = knopf.nextElementSibling
+ && knopf.nextElementSibling.classList.contains("geraete-waehler");
+ schliesseWaehler();
+ if (schonOffen) return; // zweiter Klick schliesst wieder
+
+ const panel = document.createElement("div");
+ panel.className = "geraete-waehler border rounded p-2 mt-1";
+ const suche = document.createElement("input");
+ suche.type = "search";
+ suche.className = "form-control form-control-sm mb-2";
+ suche.placeholder = "Suchen – Name oder Geräteart";
+ const liste = document.createElement("div");
+ liste.className = "list-group list-group-flush";
+ liste.style.maxHeight = "15rem";
+ liste.style.overflowY = "auto";
+ panel.appendChild(suche);
+ panel.appendChild(liste);
+ // Hinter die ganze Zeile haengen, nicht in sie hinein: in einer
+ // input-group stuende die Liste sonst als weitere Spalte daneben.
+ knopf.closest(".input-group").after(panel);
+
+ const eintrag = (d) => {
+ const zeile = document.createElement("button");
+ zeile.type = "button";
+ zeile.className = "list-group-item list-group-item-action py-1"
+ + (d.id === aktuelleId ? " active" : "");
+ zeile.innerHTML = "" + textSicher(d.label) + ""
+ + "" + textSicher(d.type) + "";
+ zeile.onclick = () => { schliesseWaehler(); beiAuswahl(d); };
+ return zeile;
+ };
+
+ const zeichne = () => {
+ const suchtext = suche.value.trim().toLowerCase();
+ liste.innerHTML = "";
+ const passend = geraete.filter(d => !suchtext
+ || d.label.toLowerCase().includes(suchtext)
+ || d.type.toLowerCase().includes(suchtext));
+ if (!passend.length) {
+ liste.innerHTML = "
Nichts gefunden.
";
+ return;
+ }
+ // Beim Suchen die Zwischenueberschriften weglassen: die Treffer stehen
+ // dann quer ueber alle Arten, und die Art steht ohnehin an jeder Zeile.
+ if (suchtext) {
+ passend.forEach(d => liste.appendChild(eintrag(d)));
+ return;
+ }
+ geraeteGruppen(passend).forEach(g => {
+ const kopf = document.createElement("div");
+ kopf.className = "px-2 pt-2 pb-1 small fw-bold text-body-secondary";
+ kopf.textContent = g.titel;
+ liste.appendChild(kopf);
+ g.geraete.forEach(d => liste.appendChild(eintrag(d)));
+ });
+ };
+
+ suche.oninput = zeichne;
+ suche.onkeydown = e => {
+ if (e.key === "Escape") { schliesseWaehler(); knopf.focus(); }
+ if (e.key === "Enter") {
+ e.preventDefault();
+ const erster = liste.querySelector("button.list-group-item");
+ if (erster) erster.click();
+ }
+ };
+ zeichne();
+ suche.focus();
+}
+
+// Klick daneben schliesst die Liste - sonst bliebe sie stehen, wenn man es
+// sich anders ueberlegt.
+document.addEventListener("click", e => {
+ if (e.target.closest(".geraete-waehler") || e.target.closest(".form-select.text-start")) return;
+ schliesseWaehler();
+});
+
+function textSicher(text) {
+ return String(text).replace(/&/g, "&").replace(//g, ">");
}
/**
@@ -199,12 +300,7 @@ function renderConditions() {
add.type = "button";
add.className = "btn btn-outline-success btn-sm mt-2";
add.innerHTML = " Bedingung";
- add.onclick = () => {
- const state = ersterMesswert();
- if (!state) return;
- gruppe.push({ state_id: state.id, operator: state.operators[0].value, value: standardWert(state) });
- renderConditions();
- };
+ add.onclick = () => { gruppe.push(leereBedingung()); renderConditions(); };
block.appendChild(add);
ziel.appendChild(block);
@@ -215,30 +311,26 @@ function renderConditions() {
function conditionRow(gi, bi, bed) {
const zeile = document.createElement("div");
- zeile.className = "input-group";
+ zeile.className = "input-group auto-zeile";
const treffer = stateById(bed.state_id);
const dev = treffer ? treffer.device : null;
const state = treffer ? treffer.state : null;
// Geraet
- const geraet = document.createElement("select");
- geraet.className = "form-select";
- geraeteListe(geraet, autoDevices.filter(d => d.states.length), dev ? dev.id : "");
- geraet.onchange = () => {
- const neu = deviceById(Number(geraet.value));
- if (!neu || !neu.states.length) return;
- bed.state_id = neu.states[0].id;
- bed.operator = neu.states[0].operators[0].value;
- bed.value = standardWert(neu.states[0]);
- renderConditions();
- };
- zeile.appendChild(geraet);
+ zeile.appendChild(geraeteWaehler(
+ autoDevices.filter(d => d.states.length), dev ? dev.id : 0,
+ neu => {
+ bed.state_id = neu.states[0].id;
+ bed.operator = neu.states[0].operators[0].value;
+ bed.value = standardWert(neu.states[0]);
+ renderConditions();
+ }));
// Messwert - nur zeigen, wenn das Geraet mehr als einen hat
if (dev && dev.states.length > 1) {
const messwert = document.createElement("select");
- messwert.className = "form-select";
+ messwert.className = "form-select auto-messwert";
optionListe(messwert, dev.states.map(s => ({
value: s.id,
text: s.name + (s.value ? " (= " + s.value + ")" : "")
@@ -299,10 +391,12 @@ function conditionRow(gi, bi, bed) {
* muss, sondern schwarz auf weiss liest.
*/
function conditionText() {
- const teile = autoModel.groups.filter(g => g.length).map(gruppe => {
+ const teile = autoModel.groups
+ .map(g => g.filter(bed => stateById(bed.state_id)))
+ .filter(g => g.length)
+ .map(gruppe => {
const text = gruppe.map(bed => {
const treffer = stateById(bed.state_id);
- if (!treffer) return "?";
let s = treffer.device.label + ": " + treffer.state.name + " "
+ operatorLabel(treffer.state, bed.operator) + " " + bed.value;
if (treffer.state.unit) s += " " + treffer.state.unit;
@@ -330,27 +424,23 @@ function renderActions() {
function actionRow(ai, aktion) {
const zeile = document.createElement("div");
- zeile.className = "input-group mb-2";
+ zeile.className = "input-group mb-2 auto-zeile";
const treffer = commandById(aktion.command_id);
const dev = treffer ? treffer.device : null;
const cmd = treffer ? treffer.command : null;
- const geraet = document.createElement("select");
- geraet.className = "form-select";
- geraeteListe(geraet, autoDevices.filter(d => d.commands.length), dev ? dev.id : "");
- geraet.onchange = () => {
- const neu = deviceById(Number(geraet.value));
- if (!neu || !neu.commands.length) return;
- aktion.command_id = neu.commands[0].id;
- aktion.params = standardParams(neu.commands[0]);
- renderActions();
- };
- zeile.appendChild(geraet);
+ zeile.appendChild(geraeteWaehler(
+ autoDevices.filter(d => d.commands.length), dev ? dev.id : 0,
+ neu => {
+ aktion.command_id = neu.commands[0].id;
+ aktion.params = standardParams(neu.commands[0]);
+ renderActions();
+ }));
if (dev && dev.commands.length > 1) {
const befehl = document.createElement("select");
- befehl.className = "form-select";
+ befehl.className = "form-select auto-messwert";
optionListe(befehl, dev.commands.map(c => ({ value: c.id, text: c.name })), aktion.command_id);
befehl.onchange = () => {
const neu = dev.commands.find(c => c.id === Number(befehl.value));
@@ -482,18 +572,10 @@ function loadAutomatic() {
});
autoModel.groups = Array.from(nachGruppe.values());
- // Eine neue Automatik startet mit einer leeren Bedingung und einer Aktion,
- // sonst steht man vor einem leeren Formular ohne erkennbaren Anfang.
- if (!autoModel.groups.length) {
- const state = ersterMesswert();
- if (state) {
- autoModel.groups = [[{ state_id: state.id, operator: state.operators[0].value, value: standardWert(state) }]];
- }
- }
- if (!autoModel.actions.length) {
- const cmd = ersterBefehl();
- if (cmd) autoModel.actions = [{ command_id: cmd.id, params: standardParams(cmd) }];
- }
+ // Eine neue Automatik startet mit je einer leeren Zeile, sonst steht man
+ // vor einem leeren Formular ohne erkennbaren Anfang.
+ if (!autoModel.groups.length) autoModel.groups = [[leereBedingung()]];
+ if (!autoModel.actions.length) autoModel.actions = [leereAktion()];
fuelleFormular();
}
@@ -531,15 +613,11 @@ function openAutoActionModal(params) {
modalBodyElement.innerHTML = html;
loadAutomatic();
document.getElementById("btnAddGroup").addEventListener("click", () => {
- const state = ersterMesswert();
- if (!state) return;
- autoModel.groups.push([{ state_id: state.id, operator: state.operators[0].value, value: standardWert(state) }]);
+ autoModel.groups.push([leereBedingung()]);
renderConditions();
});
document.getElementById("btnAddActor").addEventListener("click", () => {
- const cmd = ersterBefehl();
- if (!cmd) return;
- autoModel.actions.push({ command_id: cmd.id, params: standardParams(cmd) });
+ autoModel.actions.push(leereAktion());
renderActions();
});
})