From 2ac504ca291d29b06eafe28c41127bec4084625a Mon Sep 17 00:00:00 2001 From: Moirtz Wagner Date: Mon, 31 Aug 2026 16:10:59 +0200 Subject: [PATCH] Geraeteauswahl: Suchfeld statt Auswahlliste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Gruppierung nach Geraeteart hat die Liste geordnet, aber nicht kuerzer gemacht: elf Jalousien bleiben elf Zeilen. Statt der Auswahlliste steht jetzt ein Knopf in der Zeile, der eine durchsuchbare Liste ueber die volle Breite aufklappt. Gesucht wird ueber Beschriftung UND Geraeteart zugleich, also quer zur Gruppierung: "bad" findet die drei Thermostate im Bad und die zwei Jalousien "Bad Links"/"Bad Rechts", "jalousie" alle elf auf einmal. Ohne Suchtext bleiben die Zwischenueberschriften stehen; sobald gefiltert wird, faellt die Gruppierung weg, weil die Treffer dann quer ueber alle Arten stehen und die Art ohnehin an jeder Zeile mitlaeuft. Die aufgeklappte Liste haengt unter der ganzen Zeile und nicht in ihr - in einer input-group stuende sie sonst als weitere Spalte daneben. Escape schliesst, Enter nimmt den ersten Treffer, ein Klick daneben schliesst ebenfalls. Es ist immer hoechstens eine offen. Dabei zwei Sachen mitgenommen: Eine neue Zeile beginnt jetzt ohne Geraet ("Gerät wählen …"). Bisher 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 musste. Der Satz darunter sagt in dem Fall "Noch kein Ausloeser", und der Server lehnt eine unvollstaendige Bedingung ohnehin ab. Auf dem Handy quetschten sich Geraet, Messwert, Vergleich, Wert, Einheit und Papierkorb in eine Zeile - uebrig blieben "Therm OG Bad" und "te". Unter 576 px bekommen Geraet und Messwert jetzt je eine eigene Zeile. Die Regel braucht die input-group-Klasse im Selektor: solar.css wird vor AdminLTE geladen, und Bootstraps ".input-group > .form-select" haette sonst dasselbe Gewicht und wuerde als spaeteres Blatt gewinnen. Geprueft bei 375 px und am Rechner. Co-Authored-By: Claude Opus 5 --- css/solar.css | 17 +++ js/solar/autoActionFuncs.js | 228 ++++++++++++++++++++++++------------ 2 files changed, 170 insertions(+), 75 deletions(-) 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(); }); })