Geraeteauswahl: Suchfeld statt Auswahlliste
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 <noreply@anthropic.com>
This commit is contained in:
@@ -85,3 +85,20 @@ input[type='range']::-webkit-slider-runnable-track {
|
|||||||
font-size: .8rem;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+143
-65
@@ -48,15 +48,21 @@ function operatorLabel(state, op) {
|
|||||||
return treffer ? treffer.label : op;
|
return treffer ? treffer.label : op;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Erster Messwert des ersten Geraets - Vorbelegung fuer eine neue Zeile. */
|
/**
|
||||||
function ersterMesswert() {
|
* Eine neue Zeile beginnt ohne Geraet.
|
||||||
const dev = autoDevices.find(d => d.states.length > 0);
|
*
|
||||||
return dev ? dev.states[0] : null;
|
* 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() {
|
function leereAktion() {
|
||||||
const dev = autoDevices.find(d => d.commands.length > 0);
|
return { command_id: 0, params: {} };
|
||||||
return dev ? dev.commands[0] : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Bausteine fuer die Oberflaeche --------------------------------------
|
// --- Bausteine fuer die Oberflaeche --------------------------------------
|
||||||
@@ -103,21 +109,116 @@ function geraeteGruppen(geraete) {
|
|||||||
return gruppen;
|
return gruppen;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Auswahlliste mit Zwischenueberschriften je Geraeteart fuellen. */
|
/**
|
||||||
function geraeteListe(select, geraete, gewaehlt) {
|
* Geraeteauswahl mit Suchfeld.
|
||||||
select.innerHTML = "";
|
*
|
||||||
const gruppen = geraeteGruppen(geraete);
|
* Bei ueber fuenfzig Geraeten hilft Tippen mehr als Blaettern - und es hilft
|
||||||
// Nur eine Gruppe? Dann waere die Ueberschrift blosse Zierde.
|
* quer zur Gruppierung: "bad" findet das Thermostat im Bad und die Jalousie
|
||||||
if (gruppen.length < 2) {
|
* "Bad Links", "jalousie" findet alle elf auf einmal. Gesucht wird deshalb
|
||||||
geraete.forEach(d => select.appendChild(option({ value: d.id, text: d.label }, gewaehlt)));
|
* 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 = "<span>" + textSicher(d.label) + "</span>"
|
||||||
|
+ "<small class='ms-2 opacity-75'>" + textSicher(d.type) + "</small>";
|
||||||
|
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 = "<div class='p-2 small text-body-secondary'>Nichts gefunden.</div>";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
gruppen.forEach(g => {
|
// Beim Suchen die Zwischenueberschriften weglassen: die Treffer stehen
|
||||||
const kopf = document.createElement("optgroup");
|
// dann quer ueber alle Arten, und die Art steht ohnehin an jeder Zeile.
|
||||||
kopf.label = g.titel;
|
if (suchtext) {
|
||||||
g.geraete.forEach(d => kopf.appendChild(option({ value: d.id, text: d.label }, gewaehlt)));
|
passend.forEach(d => liste.appendChild(eintrag(d)));
|
||||||
select.appendChild(kopf);
|
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, "<").replace(/>/g, ">");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -199,12 +300,7 @@ function renderConditions() {
|
|||||||
add.type = "button";
|
add.type = "button";
|
||||||
add.className = "btn btn-outline-success btn-sm mt-2";
|
add.className = "btn btn-outline-success btn-sm mt-2";
|
||||||
add.innerHTML = "<i class='bi bi-plus-lg'></i> Bedingung";
|
add.innerHTML = "<i class='bi bi-plus-lg'></i> Bedingung";
|
||||||
add.onclick = () => {
|
add.onclick = () => { gruppe.push(leereBedingung()); renderConditions(); };
|
||||||
const state = ersterMesswert();
|
|
||||||
if (!state) return;
|
|
||||||
gruppe.push({ state_id: state.id, operator: state.operators[0].value, value: standardWert(state) });
|
|
||||||
renderConditions();
|
|
||||||
};
|
|
||||||
block.appendChild(add);
|
block.appendChild(add);
|
||||||
|
|
||||||
ziel.appendChild(block);
|
ziel.appendChild(block);
|
||||||
@@ -215,30 +311,26 @@ function renderConditions() {
|
|||||||
|
|
||||||
function conditionRow(gi, bi, bed) {
|
function conditionRow(gi, bi, bed) {
|
||||||
const zeile = document.createElement("div");
|
const zeile = document.createElement("div");
|
||||||
zeile.className = "input-group";
|
zeile.className = "input-group auto-zeile";
|
||||||
|
|
||||||
const treffer = stateById(bed.state_id);
|
const treffer = stateById(bed.state_id);
|
||||||
const dev = treffer ? treffer.device : null;
|
const dev = treffer ? treffer.device : null;
|
||||||
const state = treffer ? treffer.state : null;
|
const state = treffer ? treffer.state : null;
|
||||||
|
|
||||||
// Geraet
|
// Geraet
|
||||||
const geraet = document.createElement("select");
|
zeile.appendChild(geraeteWaehler(
|
||||||
geraet.className = "form-select";
|
autoDevices.filter(d => d.states.length), dev ? dev.id : 0,
|
||||||
geraeteListe(geraet, autoDevices.filter(d => d.states.length), dev ? dev.id : "");
|
neu => {
|
||||||
geraet.onchange = () => {
|
|
||||||
const neu = deviceById(Number(geraet.value));
|
|
||||||
if (!neu || !neu.states.length) return;
|
|
||||||
bed.state_id = neu.states[0].id;
|
bed.state_id = neu.states[0].id;
|
||||||
bed.operator = neu.states[0].operators[0].value;
|
bed.operator = neu.states[0].operators[0].value;
|
||||||
bed.value = standardWert(neu.states[0]);
|
bed.value = standardWert(neu.states[0]);
|
||||||
renderConditions();
|
renderConditions();
|
||||||
};
|
}));
|
||||||
zeile.appendChild(geraet);
|
|
||||||
|
|
||||||
// Messwert - nur zeigen, wenn das Geraet mehr als einen hat
|
// Messwert - nur zeigen, wenn das Geraet mehr als einen hat
|
||||||
if (dev && dev.states.length > 1) {
|
if (dev && dev.states.length > 1) {
|
||||||
const messwert = document.createElement("select");
|
const messwert = document.createElement("select");
|
||||||
messwert.className = "form-select";
|
messwert.className = "form-select auto-messwert";
|
||||||
optionListe(messwert, dev.states.map(s => ({
|
optionListe(messwert, dev.states.map(s => ({
|
||||||
value: s.id,
|
value: s.id,
|
||||||
text: s.name + (s.value ? " (= " + s.value + ")" : "")
|
text: s.name + (s.value ? " (= " + s.value + ")" : "")
|
||||||
@@ -299,10 +391,12 @@ function conditionRow(gi, bi, bed) {
|
|||||||
* muss, sondern schwarz auf weiss liest.
|
* muss, sondern schwarz auf weiss liest.
|
||||||
*/
|
*/
|
||||||
function conditionText() {
|
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 text = gruppe.map(bed => {
|
||||||
const treffer = stateById(bed.state_id);
|
const treffer = stateById(bed.state_id);
|
||||||
if (!treffer) return "?";
|
|
||||||
let s = treffer.device.label + ": " + treffer.state.name + " "
|
let s = treffer.device.label + ": " + treffer.state.name + " "
|
||||||
+ operatorLabel(treffer.state, bed.operator) + " " + bed.value;
|
+ operatorLabel(treffer.state, bed.operator) + " " + bed.value;
|
||||||
if (treffer.state.unit) s += " " + treffer.state.unit;
|
if (treffer.state.unit) s += " " + treffer.state.unit;
|
||||||
@@ -330,27 +424,23 @@ function renderActions() {
|
|||||||
|
|
||||||
function actionRow(ai, aktion) {
|
function actionRow(ai, aktion) {
|
||||||
const zeile = document.createElement("div");
|
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 treffer = commandById(aktion.command_id);
|
||||||
const dev = treffer ? treffer.device : null;
|
const dev = treffer ? treffer.device : null;
|
||||||
const cmd = treffer ? treffer.command : null;
|
const cmd = treffer ? treffer.command : null;
|
||||||
|
|
||||||
const geraet = document.createElement("select");
|
zeile.appendChild(geraeteWaehler(
|
||||||
geraet.className = "form-select";
|
autoDevices.filter(d => d.commands.length), dev ? dev.id : 0,
|
||||||
geraeteListe(geraet, autoDevices.filter(d => d.commands.length), dev ? dev.id : "");
|
neu => {
|
||||||
geraet.onchange = () => {
|
|
||||||
const neu = deviceById(Number(geraet.value));
|
|
||||||
if (!neu || !neu.commands.length) return;
|
|
||||||
aktion.command_id = neu.commands[0].id;
|
aktion.command_id = neu.commands[0].id;
|
||||||
aktion.params = standardParams(neu.commands[0]);
|
aktion.params = standardParams(neu.commands[0]);
|
||||||
renderActions();
|
renderActions();
|
||||||
};
|
}));
|
||||||
zeile.appendChild(geraet);
|
|
||||||
|
|
||||||
if (dev && dev.commands.length > 1) {
|
if (dev && dev.commands.length > 1) {
|
||||||
const befehl = document.createElement("select");
|
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);
|
optionListe(befehl, dev.commands.map(c => ({ value: c.id, text: c.name })), aktion.command_id);
|
||||||
befehl.onchange = () => {
|
befehl.onchange = () => {
|
||||||
const neu = dev.commands.find(c => c.id === Number(befehl.value));
|
const neu = dev.commands.find(c => c.id === Number(befehl.value));
|
||||||
@@ -482,18 +572,10 @@ function loadAutomatic() {
|
|||||||
});
|
});
|
||||||
autoModel.groups = Array.from(nachGruppe.values());
|
autoModel.groups = Array.from(nachGruppe.values());
|
||||||
|
|
||||||
// Eine neue Automatik startet mit einer leeren Bedingung und einer Aktion,
|
// Eine neue Automatik startet mit je einer leeren Zeile, sonst steht man
|
||||||
// sonst steht man vor einem leeren Formular ohne erkennbaren Anfang.
|
// vor einem leeren Formular ohne erkennbaren Anfang.
|
||||||
if (!autoModel.groups.length) {
|
if (!autoModel.groups.length) autoModel.groups = [[leereBedingung()]];
|
||||||
const state = ersterMesswert();
|
if (!autoModel.actions.length) autoModel.actions = [leereAktion()];
|
||||||
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) }];
|
|
||||||
}
|
|
||||||
|
|
||||||
fuelleFormular();
|
fuelleFormular();
|
||||||
}
|
}
|
||||||
@@ -531,15 +613,11 @@ function openAutoActionModal(params) {
|
|||||||
modalBodyElement.innerHTML = html;
|
modalBodyElement.innerHTML = html;
|
||||||
loadAutomatic();
|
loadAutomatic();
|
||||||
document.getElementById("btnAddGroup").addEventListener("click", () => {
|
document.getElementById("btnAddGroup").addEventListener("click", () => {
|
||||||
const state = ersterMesswert();
|
autoModel.groups.push([leereBedingung()]);
|
||||||
if (!state) return;
|
|
||||||
autoModel.groups.push([{ state_id: state.id, operator: state.operators[0].value, value: standardWert(state) }]);
|
|
||||||
renderConditions();
|
renderConditions();
|
||||||
});
|
});
|
||||||
document.getElementById("btnAddActor").addEventListener("click", () => {
|
document.getElementById("btnAddActor").addEventListener("click", () => {
|
||||||
const cmd = ersterBefehl();
|
autoModel.actions.push(leereAktion());
|
||||||
if (!cmd) return;
|
|
||||||
autoModel.actions.push({ command_id: cmd.id, params: standardParams(cmd) });
|
|
||||||
renderActions();
|
renderActions();
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user