Browse Source

webui: revamp group addition & deletion

Generate explicit events. Don't have a separate group observer that
tracks deletion, but handle it immediately from the 'button' event

Replace kv array with a direct key updates. While the backed part still
must optimize for size, from this side we should operate on keys directly
pull/2508/head
Maxim Prokhorov 2 years ago
parent
commit
b5dca42cbc
5 changed files with 217 additions and 164 deletions
  1. +0
    -2
      code/espurna/config/general.h
  2. +15
    -53
      code/espurna/ws.cpp
  3. +3
    -3
      code/html/custom.css
  4. +170
    -72
      code/html/custom.js
  5. +29
    -34
      code/html/index.html

+ 0
- 2
code/espurna/config/general.h View File

@ -1109,8 +1109,6 @@
#define SETTINGS_AUTOSAVE 1 // Autosave settings or force manual commit
#endif
#define SETTINGS_MAX_LIST_COUNT 16 // Maximum index for settings lists
// -----------------------------------------------------------------------------
// LIGHT
// -----------------------------------------------------------------------------


+ 15
- 53
code/espurna/ws.cpp View File

@ -424,74 +424,36 @@ bool wsDebugSend(const char* prefix, const char* message) {
namespace {
// Check the existing setting before saving it
// TODO: this should know of the default values, somehow?
// (we only care about the settings storage, don't mind the build values)
bool _wsStore(const String& key, const String& value) {
if (!hasSetting(key) || value != getSetting(key)) {
return setSetting(key, value);
auto current = settings::internal::get(key);
if (!current || (current.ref() != value)) {
return settings::internal::set(key, value);
}
return false;
}
bool _wsStore(const String& prefix, JsonArray& values) {
bool changed { false };
size_t index { 0 };
for (auto& element : values) {
const auto value = element.as<String>();
const auto key = SettingsKey {prefix, index};
auto kv = settings::internal::get(key.value());
if (!kv || (value != kv.ref())) {
setSetting(key, value);
changed = true;
}
++index;
// TODO: generate "accepted" keys in the initial phase of the connection?
// TODO: is value ever used... by anything?
bool _wsCheckKey(const char* key, JsonVariant& value) {
#if NTP_SUPPORT
if (strncmp_P(key, PSTR("ntpTZ"), strlen(key)) == 0) {
_wsResetUpdateTimer();
return true;
}
#endif
// Remove every key with index greater than the array size
// TODO: should this be delegated to the modules, since they know better how much entities they could store?
constexpr size_t SettingsMaxListCount { SETTINGS_MAX_LIST_COUNT };
for (auto next_index = index; next_index < SettingsMaxListCount; ++next_index) {
if (!delSetting({prefix, next_index})) {
break;
}
changed = true;
if (strncmp_P(key, PSTR("adminPass"), strlen(key)) == 0) {
const auto pass = getAdminPass();
return !pass.equalsConstantTime(value.as<String>());
}
return changed;
}
// TODO: generate "accepted" keys in the initial phase of the connection?
// TODO: is value ever used... by anything?
bool _wsCheckKey(const char* key, JsonVariant& value) {
for (auto& callback : _ws_callbacks.on_keycheck) {
if (callback(key, value)) {
return true;
}
}
return false;
}
bool _wsProcessAdminPass(JsonVariant& value) {
auto current = getAdminPass();
if (value.is<String>()) {
auto string = value.as<String>();
if (!current.equalsConstantTime(string)) {
setSetting("adminPass", string);
return true;
}
} else if (value.is<JsonArray&>()) {
JsonArray& values = value.as<JsonArray&>();
if (values.size() == 2) {
auto lhs = values[0].as<String>();
auto rhs = values[1].as<String>();
if ((lhs == rhs) && (!current.equalsConstantTime(lhs))) {
setSetting("adminPass", lhs);
return true;
}
}
}
return false;
}


+ 3
- 3
code/html/custom.css View File

@ -520,10 +520,10 @@ summary {
font-family: EmojiSymbols,Segoe UI Symbol;
background: rgba(0,0,0,0);
display: inline-block;
float: right;
float: auto;
z-index: 50;
margin-top: 6px;
margin-left: -30px;
margin-top: -0.6em;
margin-left: -1.7em;
vertical-align: middle;
font-size: 1.2em;
height: 100%;


+ 170
- 72
code/html/custom.js View File

@ -277,20 +277,82 @@ function validateForms(forms) {
// TODO: distinguish 'current' state to avoid sending keys when adding and immediatly removing the latest node?
// TODO: previous implementation relied on defaultValue and / or jquery $(...).val(), but this does not really work where 'line' only has <select>
function groupSettingsHandleUpdate(event) {
if (!event.target.children.length) {
return;
}
function groupElementInfo(target) {
const out = [];
let last = event.target.children[event.target.children.length - 1];
for (let target of settingsTargets(event.target)) {
let elem = last.querySelector(`[name='${target}']`);
if (elem) {
setChangedElement(elem);
const inputs = target.querySelectorAll("input,select");
inputs.forEach((elem) => {
const name = elem.dataset.settingsRealName || elem.name;
if (name === undefined) {
return;
}
}
out.push({
element: elem,
key: name,
value: elem.dataset["original"] || getDataForElement(elem)
});
});
return out;
}
const groupSettingsHandler = {
// to 'instantiate' a new element, we must explicitly set 'target' keys in kvs
// notice that the 'row' creation *should* be handled by the group-specific
// event listener, we already expect the dom element to exist at this point
add: function(event) {
const group = event.target;
const index = group.children.length - 1;
const last = group.children[index];
addGroupPending(group, index);
for (const target of settingsTargets(group)) {
const elem = last.querySelector(`[name='${target}']`);
if (elem) {
setChangedElement(elem);
}
}
},
// removing the element means we need to notify the kvs about the updated keys
// in case it's the last row, just remove those keys from the store
// in case we are in the middle, make sure to handle difference update
// in case change was 'ephemeral' (i.e. from the previous add that was not saved), do nothing
del: function(event) {
const group = event.currentTarget;
const elems = Array.from(group.children);
const shiftFrom = elems.indexOf(group);
const info = elems.map(groupElementInfo);
const out = [];
for (let index = -1; index < info.length; ++index) {
const prev = (index > 0)
? info[index - 1]
: null;
const current = info[index];
if ((index > shiftFrom) && prev && (prev.length === current.length)) {
for (let inner = 0; inner < prev.length; ++inner) {
const [lhs, rhs] = [prev[inner], current[inner]];
if (lhs.value !== rhs.value) {
setChangedElement(rhs.element);
}
}
}
}
updateCheckboxes(group);
if (elems.length) {
popGroupPending(group, elems.length - 1);
}
event.preventDefault();
event.stopImmediatePropagation();
event.target.remove();
}
};
// -----------------------------------------------------------------------------
// Settings groups & templates
// -----------------------------------------------------------------------------
@ -373,19 +435,20 @@ function addFromTemplate(container, template, cfg) {
mergeTemplate(container, line);
}
// Group settings are special elements on the page that represent kv that are indexed in settings
// Special 'add' element will trigger update on the specified '.settings-group' element id, which
// 'settings-group' contain elements that represent kv list that is suffixed with an index in raw kvs
// 'button-add-settings-group' will trigger update on the specified 'data-settings-group' element id, which
// needs to have 'settings-group-add' event handler attached to it.
function groupSettingsOnAdd(elementId, listener) {
document.getElementById(elementId).addEventListener("settings-group-add", listener);
}
// handle addition to the group via the button
// (notice that since we still use the dataset for the elements, hyphens are just capitalized)
function groupSettingsAdd(event) {
const prefix = "settingsGroupDetail";
const elem = event.target;
// TODO: note that still has the dataset format, thus every hyphen capitalizes the next word
let eventInit = {detail: null};
for (let key of Object.keys(elem.dataset)) {
if (!key.startsWith(prefix)) {
@ -404,18 +467,6 @@ function groupSettingsAdd(event) {
group.dispatchEvent(new CustomEvent("settings-group-add", eventInit));
}
var GroupSettingsObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (isChangedElement(mutation.target) || mutation.removedNodes.length) {
setChangedForNode(mutation.target);
}
if (mutation.removedNodes.length) {
updateCheckboxes(mutation.target);
}
});
});
// When receiving / returning data, <select multiple=true> <option> values are treated as bitset (u32) indexes (i.e. individual bits that are set)
// For example 0b101 is translated to ["0", "2"], or 0b1111 is translated to ["0", "1", "2", "3"]
// Right now only `hbReport` uses such format, but it is not yet clear how such select element should behave when value is not an integer
@ -488,13 +539,46 @@ function resetChangedElement(elem) {
elem.dataset["changed"] = "false";
}
function resetChangedGroups() {
function resetGroupPending(elem) {
elem.dataset["settingsGroupPending"] = "";
}
function resetSettingsGroup() {
const elems = document.getElementsByClassName("settings-group");
for (let elem of elems) {
resetChangedElement(elem);
resetGroupPending(elem);
}
}
function getGroupPending(elem) {
const raw = elem.dataset["settingsGroupPending"] || "";
if (!raw.length) {
return [];
}
return raw.split(",");
}
function addGroupPending(elem, index) {
const pending = getGroupPending(elem);
pending.push(`set:${index}`);
elem.dataset["settingsGroupPending"] = pending.join(",");
}
function popGroupPending(elem, index) {
const pending = getGroupPending(elem);
const added = pending.indexOf(`set:${index}`);
if (added >= 0) {
pending.splice(added, 1);
} else {
pending.push(`del:${index}`);
}
elem.dataset["settingsGroupPending"] = pending.join(",");
}
function isGroupElement(elem) {
return elem.dataset["settingsGroupElement"] !== undefined;
}
@ -614,18 +698,24 @@ function getDataForElement(element) {
return null;
}
function getData(forms, changed, cleanup) {
function getData(forms, options) {
// Populate two sets of data, ones that had been changed and ones that stayed the same
var data = {};
var changed_data = [];
if (cleanup === undefined) {
cleanup = true;
if (options === undefined) {
options = {};
}
const data = {};
const changed_data = [];
if (options.cleanup === undefined) {
options.cleanup = true;
}
if (changed === undefined) {
changed = true;
if (options.changed === undefined) {
options.changed = true;
}
const group_counter = {};
// TODO: <input type="radio"> can be found as both individual elements and as a `RadioNodeList` view.
// matching will extract the specific radio element, but will ignore the list b/c it has no tagName
// TODO: actually use type="radio" in the WebUI to check whether this works
@ -644,45 +734,52 @@ function getData(forms, changed, cleanup) {
continue;
}
const group_element = isGroupElement(elem);
const group_index = group_counter[name] || 0;
const group_name = `${name}${group_index}`;
if (group_element) {
group_counter[name] = group_index + 1;
}
const value = getDataForElement(elem);
if (null !== value) {
var indexed = changed_data.indexOf(name) >= 0;
if ((isChangedElement(elem) || !changed) && !indexed) {
changed_data.push(name);
const elem_indexed = changed_data.indexOf(name) >= 0;
if ((isChangedElement(elem) || !options.changed) && !elem_indexed) {
changed_data.push(group_element ? group_name : name);
}
// make sure to group keys from templates (or, manually flagged as such)
if (isGroupElement(elem)) {
if (name in data) {
data[name].push(value);
} else {
data[name] = [value];
}
} else {
data[name] = value;
}
data[group_element ? group_name : name] = value;
}
}
}
// Finally, filter out only fields that had changed.
// Note: We need to preserve dynamic lists like schedules, wifi etc.
// so we don't accidentally break when user deletes entry in the middle
const resulting_data = {};
for (let value in data) {
if (changed_data.indexOf(value) >= 0) {
resulting_data[value] = data[value];
// Finally, filter out only fields that *must* be assigned.
const resulting_data = {
set: {
},
del: [
]
};
for (const name in data) {
if (!options.changed || (changed_data.indexOf(name) >= 0)) {
resulting_data.set[name] = data[name];
}
}
// Hack: clean-up leftover arrays.
// When empty, the receiving side will prune all keys greater than the current one.
if (cleanup) {
for (let group of document.getElementsByClassName("settings-group")) {
if (isChangedElement(group) && !group.children.length) {
settingsTargets(group).forEach((target) => {
resulting_data[target] = [];
});
// Make sure to remove dynamic group entries from the kvs
// Only group keys can be removed atm, so only process .settings-group
if (options.cleanup) {
for (let elem of document.getElementsByClassName("settings-group")) {
for (let pair of getGroupPending(elem)) {
const [action, index] = pair.split(":");
if (action === "del") {
const keysRaw = elem.dataset["settingsSchema"] || elem.dataset["settingsTarget"];
const keys = !keysRaw ? [] : keysRaw.split(" ");
keys.forEach((key) => {
resulting_data.del.push(`${key}${index}`);
});
}
}
}
}
@ -755,7 +852,7 @@ function initSetupPassword(form) {
event.preventDefault();
const forms = [form];
if (validateFormsPasswords(forms, true)) {
sendConfig(getData(forms, true, false));
applySettings(getData(forms, true, false));
}
});
elementSelectorOnClick(".button-generate-password", (event) => {
@ -935,7 +1032,8 @@ function fillTemplateLineFromCfg(line, id, cfg) {
function delParent(event) {
event.target.parentElement.remove();
event.target.parentElement.dispatchEvent(
new CustomEvent("settings-group-del", {bubbles: true}));
}
function moreElem(container) {
@ -1030,13 +1128,13 @@ function askAndCallAction(event) {
// Settings kv as either {key: value} or {key: [value0, value1, ...etc...]}
function sendConfig(config) {
send(JSON.stringify({config}));
function applySettings(settings) {
send(JSON.stringify({settings}));
}
function resetOriginals() {
setOriginalsFromValues();
resetChangedGroups();
resetSettingsGroup();
Settings.resetCounters();
Settings.saved = false;
}
@ -1173,11 +1271,11 @@ function waitForSaved(){
}
}
function sendConfigFromAllForms() {
function applySettingsFromAllForms() {
// Since we have 2-page config, make sure we select the active one
let forms = document.getElementsByClassName("form-settings");
if (validateForms(forms)) {
sendConfig(getData(forms));
applySettings(getData(forms));
Settings.counters.changed = 0;
waitForSaved();
}
@ -2708,7 +2806,7 @@ function main() {
elementSelectorOnClick(".pure-menu-link", showPanel);
elementSelectorOnClick(".button-update", (event) => {
event.preventDefault();
sendConfigFromAllForms();
applySettingsFromAllForms();
});
elementSelectorOnClick(".button-reconnect", askAndCallReconnect);
elementSelectorOnClick(".button-reboot", askAndCallReboot);
@ -2855,9 +2953,9 @@ function main() {
// No group handler should be registered after this point, since we depend on the order
// of registration to trigger 'after-add' handler and update group attributes *after*
// module function finishes modifying the container
for (let group of document.querySelectorAll(".settings-group")) {
GroupSettingsObserver.observe(group, {childList: true});
group.addEventListener("settings-group-add", groupSettingsHandleUpdate, false);
for (const group of document.querySelectorAll(".settings-group")) {
group.addEventListener("settings-group-add", groupSettingsHandler.add, false);
group.addEventListener("settings-group-del", groupSettingsHandler.del, false);
}
// don't autoconnect when opening from filesystem


+ 29
- 34
code/html/index.html View File

@ -27,46 +27,40 @@
<div class="content">
<form id="form-setup-password" class="pure-form" autocomplete="off">
<div class="panel block" id="panel-password">
<div class="header">
<h1>SECURITY</h1>
<h2>Before using this device you have to change the default password for the user <strong>admin</strong>. This password will be used for the <strong>AP mode hotspot</strong>, the <strong>web interface</strong> (where you are now) and the <strong>over-the-air updates</strong>.</h2>
<h2>Before using this device you have to change the default password for the user <strong>admin</strong>. This password will be used for the <strong>AP mode hotspot</strong>, the <strong>web interface</strong> (where you are now) and the <strong>over-the-air updates</strong>.
Password must be <strong>8…63 characters</strong> (numbers and letters and any of these special characters: _,.;:~!?@#$%^&amp;*&lt;&gt;\|(){}[]) and have at least <strong>one lowercase</strong> and <strong>one uppercase</strong> or <strong>one number</strong>.</h2>
</div>
<div class="page">
<fieldset>
<div class="pure-g">
<label class="pure-u-1 pure-u-lg-1-4">New Password</label>
<input class="pure-u-1 pure-u-lg-3-4" name="adminPass0" data-settings-group-element="true" data-settings-real-name="adminPass" minlength="8" maxlength="63" type="password" autocomplete="false" spellcheck="false" required>
<span class="no-select password-reveal"></span>
</div>
<form id="form-setup-password" class="pure-form pure-form-aligned">
<div class="pure-g">
<label class="pure-u-1 pure-u-lg-1-4">Repeat password</label>
<input class="pure-u-1 pure-u-lg-3-4" name="adminPass1" data-settings-group-element="true" data-settings-real-name="adminPass" minlength="8" maxlength="63" type="password" autocomplete="false" spellcheck="false" required>
<span class="no-select password-reveal"></span>
</div>
</fieldset>
<fieldset>
<div class="pure-control-group">
<label>New Password</label>
<input class="pure-input-3-4" name="adminPass0" data-settings-real-name="adminPass" minlength="8" maxlength="63" type="password" autocomplete="new-password" spellcheck="false" required>
<span class="no-select password-reveal"></span>
</div>
<div class="pure-g">
<div class="pure-u-1 pure-u-lg-1 hint">
Password must be <strong>8..63 characters</strong> (numbers and letters and any of these special characters: _,.;:~!?@#$%^&amp;*&lt;&gt;\|(){}[]) and have at least <strong>one lowercase</strong> and <strong>one uppercase</strong> or <strong>one number</strong>.</div>
</div>
<div class="pure-control-group">
<label>Repeat password</label>
<input class="pure-input-3-4" name="adminPass1" data-settings-real-name="adminPass" minlength="8" maxlength="63" type="password" autocomplete="new-password" spellcheck="false" required>
<span class="no-select password-reveal"></span>
</div>
<button class="pure-input-1 pure-button button-generate-password" type="button" title="Generate password based on password policy">Generate</button>
<button class="pure-input-1 pure-button button-setup-password" type="button" title="Save new password">Save</button>
</fieldset>
<div class="pure-g">
<button class="pure-u-11-24 pure-u-lg-1-4 pure-button button-generate-password" type="button" title="Generate password based on password policy">Generate</button>
<div class="pure-u-2-24 pure-u-lg-1-2"></div>
<button class="pure-u-11-24 pure-u-lg-1-4 pure-button button-setup-password" type="button" title="Save new password">Save</button>
</div>
</form>
</div>
</div>
</form>
</div> <!-- content -->
@ -503,7 +497,7 @@
</details>
<fieldset>
<div id="leds" class="settings-group" data-settings-target="ledGpio"></div>
<div id="leds" class="settings-group" data-settings-target="ledGpio" data-settings-schema="ledGpio ledInv ledMode ledRelay" ></div>
<button type="button" class="pure-button button-add-settings-group" data-settings-group="leds">Add LED</button>
</fieldset>
</div>
@ -733,10 +727,10 @@
<div class="pure-g">
<label class="pure-u-1 pure-u-lg-1-4">Admin password</label>
<input name="adminPass0" class="pure-u-1 pure-u-lg-3-4" placeholder="New password" data-settings-group-element="true" data-settings-real-name="adminPass" minlength="8" maxlength="63" type="password" data-action="reboot" autocomplete="false" spellcheck="false">
<input name="adminPass0" class="pure-u-1 pure-u-lg-3-4" placeholder="New password" data-settings-real-name="adminPass" minlength="8" maxlength="63" type="password" data-action="reboot" autocomplete="new-password" spellcheck="false">
<span class="no-select password-reveal"></span>
<div class="pure-u-1 pure-u-lg-1-4"></div>
<input name="adminPass1" class="pure-u-1 pure-u-lg-3-4" placeholder="Repeat password" data-settings-group-element="true" data-settings-real-name="adminPass" minlength="8" maxlength="63" type="password" data-action="reboot" autocomplete="false" spellcheck="false">
<input name="adminPass1" class="pure-u-1 pure-u-lg-3-4" placeholder="Repeat password" data-settings-real-name="adminPass" minlength="8" maxlength="63" type="password" data-action="reboot" autocomplete="new-password" spellcheck="false">
<span class="no-select password-reveal"></span>
<div class="pure-u-0 pure-u-lg-1-4"></div>
@ -906,7 +900,7 @@
<fieldset>
<legend>Networks</legend>
<div id="networks" class="settings-group" data-settings-target="ssid pass">
<div id="networks" class="settings-group" data-settings-target="ssid pass" data-settings-schema="ssid pass ip gw mask dns bssid chan" >
</div>
<button type="button" class="pure-button button-add-settings-group" data-settings-group="networks">Add network</button>
</fieldset>
@ -925,7 +919,7 @@
<div class="page">
<fieldset>
<legend>Schedules</legend>
<div id="schedules" class="settings-group" data-settings-target="schTarget schType" ></div>
<div id="schedules" class="settings-group" data-settings-target="schTarget schType" data-settings-schema="schTarget schType schHour schMinute schUTC schWDs schAction schRestore schEnabled"></div>
</fieldset>
<fieldset>
@ -1427,10 +1421,10 @@
<div class="pure-u-1 hint">Set IDX to 0 to disable notifications from that component.</div>
</div>
<div id="dczRelays" class="settings-group"></div>
<div id="dczRelays" class="settings-group" data-settings-target="dczRelay" ></div>
<!-- removeIf(!sensor) -->
<div id="dczMagnitudes" class="settings-group"></div>
<div id="dczMagnitudes" class="settings-group" data-settings-target="dczMagnitude" ></div>
<!-- endRemoveIf(!sensor) -->
</fieldset>
</div>
@ -1543,10 +1537,10 @@
<div class="pure-u-1 hint">Enter the field number to send each data to, 0 disable notifications from that component.</div>
</div>
<div id="tspkRelays" class="settings-group"></div>
<div id="tspkRelays" class="settings-group" data-settings-target="tspkRelay" ></div>
<!-- removeIf(!sensor) -->
<div id="tspkMagnitudes" class="settings-group"></div>
<div id="tspkMagnitudes" class="settings-group" data-settings-target="tspkMagnitude" ></div>
<!-- endRemoveIf(!sensor) -->
</fieldset>
</div>
@ -1957,6 +1951,7 @@
<select class="pure-u-1 pure-u-lg-1-4 enumerable enumerable-relay" name="ledRelay" ></select>
</div>
</fieldset>
<button class="pure-button button-del-parent more" type="button">Delete LED</button>
</div>
</template>


Loading…
Cancel
Save