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(); }); })