fix: address CodeRabbit findings on the style-editor/font-selector PR

- Fix _load_font_sized double-wrapping the (font, size) tuple on the
  missing-font path, which handed callers a tuple instead of a font.
- Fix _set_nested_value skipping an explicit None when the key already
  existed, which silently kept stale overrides when a user cleared a
  nullable per-mode field or blanked all channels of an indexed color.
- Preserve BDF scalable/native_size metadata through fetchFontCatalog's
  catalog-format mapping so maxFixedSize filtering actually applies.
- Stop caching an empty array on a failed font-catalog fetch so a later
  call can retry instead of being stuck with the failed result.
- Keep a saved font selected in the style editor even when it no longer
  fits a newly declared maxFixedSize, instead of silently deselecting it.
- Don't drop in-progress user edits to fallback fields when a plugin
  widget finishes loading asynchronously and takes over the form.
- Tighten the removed font-override endpoint test to assert 405, not
  just != 200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-09-13 05:34:27 +00:00
parent a5342939af
commit f9e9eeba00
8 changed files with 76 additions and 15 deletions
+5 -3
View File
@@ -1026,9 +1026,11 @@ def _set_nested_value(config, key_path, value):
current[seg] = {}
current = current[seg]
# Set the final value (don't overwrite with empty dict if value is None and we want to preserve structure)
if value is not None or segments[-1] not in current:
current[segments[-1]] = value
# Set the final value. _SKIP_FIELD (checked above) is the only sentinel
# for "leave the existing value alone" -- an explicit None here is a real
# value (e.g. the per-mode "inherit the base" override) and must overwrite
# whatever was already stored.
current[segments[-1]] = value
def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', config_node=None):
"""Walk schema and set missing boolean form fields to False.
@@ -121,7 +121,9 @@
family: family,
display_name: info.display_name || generateDisplayName(info.filename || family),
path: info.path,
type: info.type || 'unknown'
type: info.type || 'unknown',
scalable: info.scalable,
native_size: info.native_size
}));
} else if (Array.isArray(data)) {
// Direct array format
@@ -44,7 +44,10 @@
if (FONT_CACHE) { return Promise.resolve(FONT_CACHE); }
if (FONT_INFLIGHT) { return FONT_INFLIGHT; }
FONT_INFLIGHT = fetch('/api/v3/fonts/catalog')
.then(function (r) { return r.json(); })
.then(function (r) {
if (!r.ok) { throw new Error('font catalog fetch failed: ' + r.status); }
return r.json();
})
.then(function (payload) {
var catalog = (payload && payload.data && payload.data.catalog) || {};
FONT_CACHE = Object.keys(catalog).map(function (key) {
@@ -60,8 +63,10 @@
})
.catch(function (e) {
console.warn('[StyleEditor] could not load the font catalog', e);
FONT_CACHE = [];
return FONT_CACHE;
// Leave FONT_CACHE unset and clear FONT_INFLIGHT so the next
// call retries instead of being stuck on an empty result.
FONT_INFLIGHT = null;
return [];
});
return FONT_INFLIGHT;
}
@@ -138,9 +143,13 @@
* rather than a list curated by hand, which is what these fields used
* to carry and why an uploaded font could never appear in one.
*/
function usableFonts(fonts, maxFixedSize) {
function usableFonts(fonts, maxFixedSize, current) {
if (!maxFixedSize) { return fonts; }
return fonts.filter(function (f) {
// Keep the font already saved on this element even if it no
// longer fits the cap -- dropping it would leave the select
// with nothing chosen and silently misrepresent the config.
if (current && f.filename === current) { return true; }
if (f.scalable !== false) { return true; }
// An unknown native size is not evidence it is too big.
if (!f.nativeSize) { return true; }
@@ -358,7 +367,7 @@
var xOptions = prop['x-options'] || prop['x_options'] || {};
var cap = Number(xOptions.maxFixedSize) || opts.maxFixedSize || null;
return fontControl(opts.name, opts.current,
usableFonts(opts.fonts, cap),
usableFonts(opts.fonts, cap, opts.current),
opts.optional, opts.onFontChange);
}
if (types.indexOf('boolean') !== -1) {
@@ -88,6 +88,23 @@
</div>
<script>
(function() {
function fallbackIsDirty(root) {
if (!root) { return false; }
var controls = root.querySelectorAll('input, select, textarea');
for (var i = 0; i < controls.length; i++) {
var c = controls[i];
if (c.type === 'checkbox' || c.type === 'radio') {
if (c.checked !== c.defaultChecked) { return true; }
} else if (c.tagName === 'SELECT') {
for (var j = 0; j < c.options.length; j++) {
if (c.options[j].selected !== c.options[j].defaultSelected) { return true; }
}
} else if (c.value !== c.defaultValue) {
return true;
}
}
return false;
}
function initWidget() {
if (!window.LEDMatrixWidgets) { setTimeout(initWidget, 50); return; }
var widget = window.LEDMatrixWidgets.get('style-editor');
@@ -95,6 +112,11 @@
var container = document.getElementById('{{ field_id }}_container');
var fallback = document.getElementById('{{ field_id }}_fallback');
if (!container) { return; }
// A user may already be editing the plain fallback fields
// while this widget's script is still loading; swapping
// in the widget now would replace them with the stale
// server-rendered value and silently drop that edit.
if (fallbackIsDirty(fallback)) { container.remove(); return; }
var value = {{ obj_value|tojson|safe }};
var config = { schema: {{ prop|tojson|safe }} };
try {
@@ -944,7 +966,10 @@
if (!available) { container.remove(); return; }
var widget = window.LEDMatrixWidgets.get(WIDGET);
if (!widget || typeof widget.render !== 'function') { container.remove(); return; }
var value = {{ str_value|tojson|safe }};
// Read the fallback's live value rather than the
// server-rendered one, so an edit typed while the
// widget script was still loading isn't lost.
var value = fallback ? fallback.value : {{ str_value|tojson|safe }};
var config = {
'type': '{{ field_type }}',
'enum': {{ (prop.enum or [])|tojson|safe }},