From 76f811b862ea42e98e50b557f6ce89a1407e8282 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 7 Sep 2026 19:15:44 +0200 Subject: [PATCH 1/2] Keep unchanged Settings defaults out of saved config --- .changeset/preserve-unchanged-settings.md | 7 +++ web/settings-shell.test.mjs | 69 ++++++++++++++++++++++- web/settings.js | 15 +++++ web/settings/tabs/planner.js | 9 ++- web/settings/tabs/planner.test.mjs | 11 ++++ 5 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 .changeset/preserve-unchanged-settings.md diff --git a/.changeset/preserve-unchanged-settings.md b/.changeset/preserve-unchanged-settings.md new file mode 100644 index 000000000..4ea69aea5 --- /dev/null +++ b/.changeset/preserve-unchanged-settings.md @@ -0,0 +1,7 @@ +--- +"ftw": patch +--- + +Keep unchanged form defaults out of saved settings. Opening Planner and saving +now preserves an unset SoC limit, including when saving from another tab. +Show the planner's 95% default maximum instead of 90%. diff --git a/web/settings-shell.test.mjs b/web/settings-shell.test.mjs index 1a4b8c6e7..e22cc793e 100644 --- a/web/settings-shell.test.mjs +++ b/web/settings-shell.test.mjs @@ -47,7 +47,7 @@ function loadShell(saveResponse, ok = true) { }, fetch(path, opts) { requests.push({ path, opts }); - return Promise.resolve({ ok, status: ok ? 200 : 400, json: () => Promise.resolve(saveResponse) }); + return Promise.resolve({ ok, status: ok ? 200 : 400, headers: { get: () => '"config-7"' }, json: () => Promise.resolve(saveResponse) }); }, // The shell only uses timers to clear the "Saved" status and to poll after // a restart; neither is what these tests are about. @@ -63,6 +63,73 @@ function loadShell(saveResponse, ok = true) { // One turn of the event loop, which is all the save chain needs to settle. const settled = () => new Promise((resolve) => setImmediate(resolve)); +async function formShell() { + const original = { site: { name: "Home" }, planner: { enabled: true }, hidden: { keep: 17 } }; + const rig = loadShell(structuredClone(original)); + const field = (path, type, value) => Object.assign(stubElement(), { dataset: { path }, type, value }); + const fields = { + min: field("planner.soc_min", "number", "0.1"), + max: field("planner.soc_max", "number", "0.95"), + engine: field("planner.engine", "select-one", ""), + checkbox: Object.assign(stubElement(), { dataset: { checkboxPath: "optional.enabled" }, checked: false }), + name: field("site.name", "text", "Home"), + }; + let visible = [], context; + rig.elements["settings-body"].querySelectorAll = selector => visible.filter(input => + selector === "[data-path]" ? input.dataset.path : selector === "[data-checkbox-path]" && input.dataset.checkboxPath); + rig.tabs.control = { render: ctx => { context = ctx; visible = [fields.name]; return ""; } }; + rig.tabs.planner = { render: () => { visible = [fields.min, fields.max, fields.engine, fields.checkbox]; return ""; } }; + rig.elements["settings-btn"].handlers.click(); + await settled(); + return { ...rig, original, fields, context, addField: input => visible.push(input), saved: () => JSON.parse(rig.requests.filter(r => r.opts?.method === "POST").at(-1).opts.body) }; +} + +describe("unchanged settings fields", () => { + for (const visitPlanner of [false, true]) { + it("preserves absent values on save, visit Planner=" + visitPlanner, async () => { + const rig = await formShell(); + if (visitPlanner) rig.context.navigateTab("planner"); + await rig.context.saveConfig(); + assert.deepEqual(rig.saved(), rig.original); + assert.equal(rig.requests.at(-1).opts.headers["If-Match"], '"config-7"'); + }); + } + + it("keeps Planner defaults absent after visiting it and saving another tab", async () => { + const rig = await formShell(); + rig.context.navigateTab("planner"); + rig.context.navigateTab("control"); + rig.fields.name.value = "New name"; + await rig.context.saveConfig(); + assert.deepEqual(rig.saved(), { ...rig.original, site: { name: "New name" } }); + }); + + it("saves explicit number, select and checkbox edits and a later return to the rendered value", async () => { + const rig = await formShell(); + rig.context.navigateTab("planner"); + rig.fields.max.value = "0.8"; + rig.fields.engine.value = "core"; + rig.fields.checkbox.checked = true; + await rig.context.saveConfig(); + assert.deepEqual(rig.saved().planner, { enabled: true, soc_max: 0.8, engine: "core" }); + assert.deepEqual(rig.saved().optional, { enabled: true }); + rig.fields.max.value = "0.95"; + await rig.context.saveConfig(); + assert.equal(rig.saved().planner.soc_max, 0.95); + }); + + it("preserves a late secret input until its value changes", async () => { + const rig = await formShell(); + const secret = Object.assign(stubElement(), { dataset: { path: "device.secret" }, type: "password", defaultValue: "", value: "" }); + rig.addField(secret); + await rig.context.saveConfig(); + assert.deepEqual(rig.saved(), rig.original); + secret.value = "local-test-value"; + await rig.context.saveConfig(); + assert.equal(rig.saved().device.secret, "local-test-value"); + }); +}); + describe("the settings shell after a save", () => { it("calls the tab back so it can ask the box again", async () => { const { elements, tabs } = loadShell({ restart_required: false, restart_reasons: [] }); diff --git a/web/settings.js b/web/settings.js index 5692db81d..86c935bd2 100644 --- a/web/settings.js +++ b/web/settings.js @@ -44,6 +44,7 @@ var currentConfig = null; var configETag = null; var currentTab = "control"; + var fieldValues = new WeakMap(); openBtn.addEventListener("click", function () { apiFetch("/api/config") @@ -220,9 +221,16 @@ statusEl.className = "settings-status" + (kind ? " " + kind : ""); } + function fieldValue(input, defaultValue) { + // Inputs added after render keep their DOM default until the user edits them. + return fieldValues.has(input) ? fieldValues.get(input) : defaultValue; + } + function captureCurrentTab() { var inputs = bodyEl.querySelectorAll("[data-path]"); inputs.forEach(function (input) { + // A displayed default must not become a saved setting on an unchanged form. + if (fieldValue(input, input.defaultValue) === input.value) return; var path = input.dataset.path; var val = input.type === "number" ? parseFloat(input.value) : input.value; if (input.type === "number" && isNaN(val)) val = 0; @@ -232,9 +240,12 @@ // Preserve a stored password when the user hasn't typed over it. if (input.type === "password" && val === "" && getByPath(currentConfig, path, "")) return; setByPath(currentConfig, path, val); + fieldValues.set(input, input.value); }); bodyEl.querySelectorAll("[data-checkbox-path]").forEach(function (input) { + if (fieldValue(input, input.defaultChecked) === input.checked) return; setByPath(currentConfig, input.dataset.checkboxPath, input.checked); + fieldValues.set(input, input.checked); }); } @@ -314,9 +325,13 @@ console.error("tab render:", tab, e); } bodyEl.innerHTML = html; + bodyEl.querySelectorAll("[data-path]").forEach(function (input) { + fieldValues.set(input, input.value); + }); // Generic handler for data-checkbox-path — shared across every tab. bodyEl.querySelectorAll("[data-checkbox-path]").forEach(function (cb) { + fieldValues.set(cb, cb.checked); cb.addEventListener("change", function () { setByPath(currentConfig, cb.dataset.checkboxPath, cb.checked); }); diff --git a/web/settings/tabs/planner.js b/web/settings/tabs/planner.js index b63efed29..2dc009b5f 100644 --- a/web/settings/tabs/planner.js +++ b/web/settings/tabs/planner.js @@ -66,8 +66,7 @@ S.tabs.planner = { render: function (ctx) { var field = ctx.field, selectField = ctx.selectField, help = ctx.help, config = ctx.config; - if (!config.planner) config.planner = {}; - var planner = config.planner; + var planner = config.planner || {}; if (planner.soc_min == null && planner.soc_min_pct != null) { planner.soc_min = planner.soc_min_pct / 100; } @@ -86,14 +85,14 @@ ''; } return '
MPC Planner' + - '
' + '
' + diff --git a/web/settings/tabs/planner.test.mjs b/web/settings/tabs/planner.test.mjs index 677c57f32..f645a81f9 100644 --- a/web/settings/tabs/planner.test.mjs +++ b/web/settings/tabs/planner.test.mjs @@ -136,6 +136,17 @@ describe("render", () => { assert.ok(!html.includes("planner.soc_max_pct")); }); + it("shows Core's default bounds without adding a planner config", () => { + const ctx = stubCtx(); + ctx.config = {}; + const defaults = {}; + ctx.field = (label, path, type, value) => { defaults[path] = value; return ""; }; + tab.render(ctx); + assert.equal(defaults["planner.soc_min"], 0.1); + assert.equal(defaults["planner.soc_max"], 0.95); + assert.deepEqual(ctx.config, {}); + }); + it("promotes legacy soc_min_pct / soc_max_pct into 0–1 fields", () => { const ctx = stubCtx(); ctx.config.planner = { soc_min_pct: 10, soc_max_pct: 90 }; From edff8719964c47ccdc1133645eb87c2a144d4ee8 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 7 Sep 2026 19:34:49 +0200 Subject: [PATCH 2/2] Preserve untouched asynchronous driver profiles --- .changeset/preserve-unchanged-settings.md | 1 + web/settings-shell.test.mjs | 67 +++++++++++++++++++++-- web/settings.js | 16 ++++-- web/settings/tabs/devices.js | 1 + 4 files changed, 75 insertions(+), 10 deletions(-) diff --git a/.changeset/preserve-unchanged-settings.md b/.changeset/preserve-unchanged-settings.md index 4ea69aea5..3166fb79e 100644 --- a/.changeset/preserve-unchanged-settings.md +++ b/.changeset/preserve-unchanged-settings.md @@ -5,3 +5,4 @@ Keep unchanged form defaults out of saved settings. Opening Planner and saving now preserves an unset SoC limit, including when saving from another tab. Show the planner's 95% default maximum instead of 90%. +Keep an untouched driver profile absent when its choices arrive after the form. diff --git a/web/settings-shell.test.mjs b/web/settings-shell.test.mjs index e22cc793e..d231c39df 100644 --- a/web/settings-shell.test.mjs +++ b/web/settings-shell.test.mjs @@ -39,6 +39,7 @@ function loadShell(saveResponse, ok = true) { for (const id of ELEMENT_IDS) elements[id] = stubElement(); const requests = []; + const responses = {}; const sandbox = { window: { FTWSettings: { tabs: {} } }, document: { @@ -47,7 +48,7 @@ function loadShell(saveResponse, ok = true) { }, fetch(path, opts) { requests.push({ path, opts }); - return Promise.resolve({ ok, status: ok ? 200 : 400, headers: { get: () => '"config-7"' }, json: () => Promise.resolve(saveResponse) }); + return Promise.resolve({ ok, status: ok ? 200 : 400, headers: { get: () => '"config-7"' }, json: () => Promise.resolve(responses[path] ?? saveResponse) }); }, // The shell only uses timers to clear the "Saved" status and to poll after // a restart; neither is what these tests are about. @@ -57,14 +58,14 @@ function loadShell(saveResponse, ok = true) { sandbox.globalThis = sandbox; vm.createContext(sandbox); vm.runInContext(source, sandbox); - return { elements, requests, tabs: sandbox.window.FTWSettings.tabs }; + return { elements, requests, responses, tabs: sandbox.window.FTWSettings.tabs, + loadTab: file => vm.runInContext(readFileSync(new URL(file, import.meta.url), "utf8"), sandbox) }; } // One turn of the event loop, which is all the save chain needs to settle. const settled = () => new Promise((resolve) => setImmediate(resolve)); -async function formShell() { - const original = { site: { name: "Home" }, planner: { enabled: true }, hidden: { keep: 17 } }; +async function formShell(original = { site: { name: "Home" }, planner: { enabled: true }, hidden: { keep: 17 } }) { const rig = loadShell(structuredClone(original)); const field = (path, type, value) => Object.assign(stubElement(), { dataset: { path }, type, value }); const fields = { @@ -130,6 +131,64 @@ describe("unchanged settings fields", () => { }); }); +// Run the real asynchronous Devices catalog callback. The shim parses the +// select it inserts; like HTMLSelectElement, it has no defaultValue property. +async function profileShell() { + const original = { site: { name: "Home" }, drivers: [{ name: "GoodWe", lua: "drivers/goodwe.lua", + capabilities: { modbus: { unit_id: 1 } }, config: {} }] }; + const rig = await formShell(original); + rig.loadTab("./settings/tabs/devices.js"); + rig.responses["/api/drivers/catalog"] = { entries: [{ id: "goodwe", path: "drivers/goodwe.lua", version: "1.0.2" }] }; + const unit = Object.assign(stubElement(), { dataset: { path: "drivers.0.capabilities.modbus.unit_id" }, + type: "number", defaultValue: "1", value: "1" }); + rig.addField(unit); + let select; + const slot = { + getAttribute: () => "0", + set innerHTML(html) { + const options = [...html.matchAll(/