From 47391b5124bf1b11658721a43c0945dd122dd850 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 21 Sep 2026 14:10:31 +0200 Subject: [PATCH 1/4] Cover much more of the UI's JavaScript with tests Test the chart data cache and fetch, the replay, date range, data and chart data helpers, the page-free helpers in ui-utils.js, and the classic scripts flexmeasures.js and map-init.js. Move the asset tree's layout out of _macros.html into asset-tree.js, and the flex-context editor's logic out of assets/asset_context.html into flex-context-utils.js, so that they can be tested too. Both templates are touched by no open PR. Fix what the new tests found: - embed the asset tree, the sensors list and the flex-context on the context page with tojson, so a quote in a value no longer stops the flex-context editor from loading, and names are shown as text - import getSensor on the context page, so new commitment prices follow a price sensor's currency - highlight the current asset, and recognise the "Add asset" node, by id rather than by name - keep the plus sign of a UTC offset in encodeUrlQuery - translate Python representations properly in processResourceRawJSON, and accept objects Closes #2452 Co-Authored-By: Claude Opus 5 Signed-off-by: F.N. Claessen --- documentation/changelog.rst | 4 + flexmeasures/ui/static/js/asset-tree.js | 447 ++++++++++++++++++ flexmeasures/ui/static/js/daterange-utils.js | 5 +- .../ui/static/js/flex-context-utils.js | 216 +++++++++ flexmeasures/ui/static/js/ui-utils.js | 64 ++- flexmeasures/ui/templates/_macros.html | 383 +-------------- .../ui/templates/assets/asset_context.html | 140 ++---- flexmeasures/ui/tests/js/README.md | 9 + flexmeasures/ui/tests/js/test_asset_tree.py | 153 ++++++ .../ui/tests/js/test_chart_data_cache.py | 217 +++++++++ .../ui/tests/js/test_chart_data_utils.py | 74 +++ flexmeasures/ui/tests/js/test_data_utils.py | 37 ++ .../ui/tests/js/test_daterange_utils.py | 127 +++++ .../ui/tests/js/test_flex_context_utils.py | 115 +++++ .../ui/tests/js/test_flexmeasures_js.py | 139 ++++++ flexmeasures/ui/tests/js/test_map_init.py | 54 +++ flexmeasures/ui/tests/js/test_replay_utils.py | 53 +++ .../ui/tests/js/test_ui_utils_helpers.py | 187 ++++++++ 18 files changed, 1941 insertions(+), 483 deletions(-) create mode 100644 flexmeasures/ui/static/js/asset-tree.js create mode 100644 flexmeasures/ui/static/js/flex-context-utils.js create mode 100644 flexmeasures/ui/tests/js/test_asset_tree.py create mode 100644 flexmeasures/ui/tests/js/test_chart_data_cache.py create mode 100644 flexmeasures/ui/tests/js/test_flex_context_utils.py create mode 100644 flexmeasures/ui/tests/js/test_flexmeasures_js.py create mode 100644 flexmeasures/ui/tests/js/test_map_init.py create mode 100644 flexmeasures/ui/tests/js/test_replay_utils.py create mode 100644 flexmeasures/ui/tests/js/test_ui_utils_helpers.py diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 096056f7fd..7d07753c23 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -52,6 +52,7 @@ Infrastructure / Support * Speed up ``GET /api/v3_0/assets`` on large catalogs by eager-loading the ``owner``, ``generic_asset_type`` and ``child_assets`` relations alongside the already eager-loaded ``sensors``, instead of lazy-loading each of them once per asset, which made the SQL statement count grow linearly with the number of assets returned [see `PR #2515 `_] * Speed up app boot by ~1s by dropping the ``inflect`` dependency in favor of the already-used, much lighter ``inflection`` package; API error messages that list several values, such as the permissions a request requires or the units a quantity accepts, no longer place a comma before the final "and" or "or" [see `PR #2514 `_] * The UI's JavaScript modules can now be tested, by running them in a headless browser from pytest, without adding a Node.js toolchain [see `PR #2435 `_] +* Test much more of the UI's JavaScript, and move the asset tree's layout and the flex-context editor's logic out of their templates into modules, so that they can be tested too [see `PR #XXXX `_] * Add ``FLEXMEASURES_DEPRECATION_AND_SUNSET`` so hosts can configure deprecation and sunset dates and information links per deprecated API version [see `PR #2362 `_]. * A CLI command that is called with an invalid option value now logs one error line, so that a cron job which captures only the log file still records why the command failed, where previously Click reported it on stderr alone and nothing was written [see `PR #2544 `_] * Settings that a plugin declares in its ``__settings__`` can now be set as environment variables, next to being set in the config file (which still wins), can declare a ``default`` to fall back to, and are reported as missing with a message that says whether such a default applies or the setting stays unset [see `PR #2501 `_] @@ -68,6 +69,9 @@ Bugfixes * An asset's status page showed only some of the sensors it reported on, in an order that changed between reloads, and it reported on fixed quantities from the flex-context, which have no data to be up to date with [see `PR #2489 `_] * Saving an asset chart as PNG or SVG drew the legend over the graph whenever it listed more sensors than fit beside a subplot; the exported image now makes room for every entry beside its own plot, without shrinking the plot, and spells out the sensor names that the on-screen legend abbreviates [see `PR #2517 `_] * Asset type groups were named by a pluralization that mangled acronyms and nouns ending in -y, so the asset pages listed ``PVS``, ``EVS``, ``CHPS`` and ``Factorys``; they now read ``PVs``, ``EVs``, ``CHPs`` and ``Factories`` [see `PR #2514 `_] +* On the asset context page, the flex-context editor no longer fails to load when a value in the asset's flex-context holds a double quote, such as a commitment's name, and the asset tree no longer fails to draw on unusual asset names [see `PR #XXXX `_] +* In the asset tree, only the asset whose page it is is highlighted, where a sub-asset of the same name was highlighted too, and an asset named "Add asset" is no longer drawn as the button to add one [see `PR #XXXX `_] +* A new commitment's prices now default to the currency of the price sensors in the flex-context, where they fell back to EUR unless a fixed price was set [see `PR #XXXX `_] Automations, in detail ----------------------- diff --git a/flexmeasures/ui/static/js/asset-tree.js b/flexmeasures/ui/static/js/asset-tree.js new file mode 100644 index 0000000000..6efd6db008 --- /dev/null +++ b/flexmeasures/ui/static/js/asset-tree.js @@ -0,0 +1,447 @@ +/** + * Layout of the asset tree shown on the asset context page ("Structure" tab). + * + * The page (see `show_tree` in _macros.html) owns the ECharts instance and the zoom interactions; + * this module turns the flat list of assets into the graph series to draw, + * so that the layout can be reasoned about, and tested, without a browser page or ECharts. + * + * Layout policy, per parent whose children are leaves ("a family"): + * keep the leaf children in ONE row until the canvas width is exhausted (at the 50px reference box), + * then use TWO balanced rows (top row at most 1 wider than the bottom), never more; + * when even two rows don't fit the width, the box size shrinks below the reference, + * i.e. the whole tree zooms out (wheel-zoom recovers detail). + * Non-leaf children and the "Add asset" action node each get their own row, recursively. + * + * Family connectors are routed to invisible "port" nodes on the top/bottom edge of each leaf box, + * so top-row connectors arrive from above and bottom-row connectors from below, + * keeping the lane between the two rows clean. + * Parents connect from their side. + */ + +export const BOX_REF = 50; // reference (minimum comfortable) box size +const GAP_RATIO = 0.28; // vertical gap as a fraction of box size + +export const ZOOM_MIN = 0.3; +export const ZOOM_MAX = 2.5; +export const ZOOM_STEP = 0.2; + +// The id the server gives the "Add asset" action node (see add_child_asset in views/assets/utils.py). +const ADD_ASSET_ID = "new"; + +const INVISIBLE = { + color: "rgba(0,0,0,0)", + borderWidth: 0, + shadowBlur: 0, + shadowColor: "transparent", + shadowOffsetX: 0, + shadowOffsetY: 0, +}; + +/** + * Keep a zoom factor within the supported range, rounded to hundredths. + * + * @param {number} zoom - The zoom factor asked for. + * @returns {number} - The zoom factor to use. + */ +export function clampZoom(zoom) { + return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(zoom * 100) / 100)); +} + +/** + * Build a hierarchy from the flat list of assets. + * + * An asset whose parent is not in the list is shown as a root. + * + * @param {Object[]} assets - Assets, each with an `id`, `name` and `parent` (the parent's id, or null). + * @returns {{assetMap: Object, rootNodes: Object[]}} - The nodes by id, and the roots of the hierarchy. + */ +export function buildAssetHierarchy(assets) { + const assetMap = {}; + const rootNodes = []; + + assets.forEach((asset) => { + assetMap[asset.id] = { + name: asset.name, + ...asset, + children: [], + }; + }); + + assets.forEach((asset) => { + if (asset.parent !== null && asset.parent !== undefined && assetMap[asset.parent]) { + assetMap[asset.parent].children.push(assetMap[asset.id]); + } else { + rootNodes.push(assetMap[asset.id]); + } + }); + + return { assetMap, rootNodes }; +} + +/** + * Wrap a name into at most `maxLines` lines, breaking on spaces where possible and ellipsising the overflow. + * + * We do this ourselves rather than rely on ECharts' rich-text width wrapping, + * which proved unreliable for long names (they would overflow the box horizontally). + * + * @param {string} name - The name to wrap. + * @param {number} charsPerLine - How many characters fit on one line. + * @param {number} maxLines - How many lines fit in the box. + * @returns {string} - The lines, joined by newlines. + */ +export function wrapName(name, charsPerLine, maxLines) { + const words = String(name).split(/\s+/); + let lines = []; + let cur = ""; + function pushHardWrapped(word) { + while (word.length > charsPerLine) { + lines.push(word.slice(0, charsPerLine)); + word = word.slice(charsPerLine); + } + return word; + } + for (let i = 0; i < words.length; i++) { + let w = words[i]; + const candidate = cur ? cur + " " + w : w; + if (candidate.length <= charsPerLine) { + cur = candidate; + } else { + if (cur) { + lines.push(cur); + cur = ""; + } + if (w.length > charsPerLine) { + w = pushHardWrapped(w); + } + cur = w; + } + } + if (cur) lines.push(cur); + if (lines.length > maxLines) { + lines = lines.slice(0, maxLines); + const last = lines[maxLines - 1]; + lines[maxLines - 1] = last.slice(0, Math.max(1, charsPerLine - 1)).trimEnd() + "…"; + } + return lines.join("\n"); +} + +function isAddAsset(node) { + return node.id === ADD_ASSET_ID; +} + +// Leaf children go into the family grid; internal children and the "Add asset" button each get their own row. +function splitKids(node) { + const kids = node.children || []; + return { + gridKids: kids.filter((k) => !(k.children || []).length && !isAddAsset(k)), + rowKids: kids.filter((k) => (k.children || []).length || isAddAsset(k)), + }; +} + +function depthOf(node) { + let d = 0; + (node.children || []).forEach((k) => { + d = Math.max(d, 1 + depthOf(k)); + }); + return d; +} + +/** + * Lay out the tree and return the ECharts option that draws it. + * + * Zoom is applied by re-rendering the tree at a new scale (node sizes + layout margins) + * while the canvas stays a fixed size, + * so the tree shrinks or grows within the gray area like a map, and the area itself never changes size. + * + * @param {Object[]} rootNodes - Roots of the hierarchy, as returned by buildAssetHierarchy. + * @param {Object} assetMap - Nodes by id, as returned by buildAssetHierarchy. + * @param {Object} options + * @param {number} options.width - Width of the canvas, in pixels. + * @param {number} options.viewportHeight - Height of the canvas, in pixels. + * @param {number} [options.zoomFactor] - Current zoom level. + * @param {*} [options.currentAssetId] - Id of the asset whose page this is, which is highlighted. + * @param {boolean} [options.animate] - Pass false to skip the update animation, e.g. during continuous wheel-zoom. + * @returns {Object} - The ECharts option. + */ +export function buildAssetTreeOption(rootNodes, assetMap, options) { + const { width: W, viewportHeight: viewportH, zoomFactor = 1, currentAssetId, animate } = options; + + // One x position per tree level (ancestors -> current -> children -> grandchildren); + // the deepest level's grid columns extend to the right from there. + let levels = 1; + rootNodes.forEach((rt) => { + levels = Math.max(levels, 1 + depthOf(rt)); + }); + const colPitch = levels > 1 ? Math.min((0.6 * W) / (levels - 1), 0.3 * W) : 0; + function colX(d) { + return 0.08 * W + d * colPitch; + } + + // Horizontal room for a grid block whose leaf column sits at level d. + function gridBudget(d) { + return W - colX(d) - 8; + } + function colsFitAt(d) { + return Math.max(1, Math.floor((gridBudget(d) - BOX_REF * 0.5) / (BOX_REF * 1.35)) + 1); + } + // One row while it fits; else two balanced rows, top at most 1 wider. + function colsFor(n, d) { + if (!n) return 1; + const fit = colsFitAt(d); + return n <= fit ? n : Math.ceil(n / 2); + } + + // Pre-pass: total rows (drives the vertical fit) and the widest grid block (drives the horizontal cap on the box size). + let hCap = Infinity; + function rowsOf(node, d) { + const s = splitKids(node); + if (!s.gridKids.length && !s.rowKids.length) return 1; + let r = 0; + if (s.gridKids.length) { + const cols = colsFor(s.gridKids.length, d + 1); + r += Math.ceil(s.gridKids.length / cols); + hCap = Math.min(hCap, gridBudget(d + 1) / ((cols - 1) * 1.35 + 0.5)); + } + s.rowKids.forEach((k) => { + r += rowsOf(k, d + 1); + }); + return Math.max(r, 1); + } + let totalRows = 0; + rootNodes.forEach((rt) => { + totalRows += rowsOf(rt, 0); + }); + + // Box size: fit the viewport vertically, clamped to a legible range, + // then capped so the widest grid block still fits the width. + // Two rows is the maximum, + // so very wide families lower the box size (the tree zooms out) instead of wrapping further. + const fitBox = (viewportH * 0.92) / Math.max(totalRows + 1, 5) / (1 + GAP_RATIO); + const BOX_SIZE = Math.round( + Math.min(Math.max(BOX_REF, Math.min(104, fitBox)), Math.max(20, hCap)) + ); + const slotY = BOX_SIZE * (1 + GAP_RATIO); // vertical row pitch + const colGapX = BOX_SIZE * 1.35; // horizontal grid column pitch + const blockGap = slotY * 0.55; // gap between family blocks + + // Labels (text + icon) are drawn by ECharts at a fixed on-screen size, unaffected by the view's zoom transform, + // so derive all label metrics from the EFFECTIVE on-screen box size instead. + // Since every zoom step re-renders, zooming in re-wraps names at a larger, more legible size, + // and zooming out shrinks them with the box instead of letting them pour out of it. + const BOX_EFF = BOX_SIZE * zoomFactor; + const SHOW_LABELS = BOX_EFF >= 24; // below this, text is just noise + const ICON_SIZE = Math.max(4, Math.floor(BOX_EFF * 0.26)); + const FONT_SIZE = Math.max(6, Math.round(BOX_EFF * 0.13)); + const LINE_H = FONT_SIZE + 2; + + // How many text lines fit under the icon, inside the box? + const textBudget = BOX_EFF - ICON_SIZE - 10; + const MAX_LINES = Math.min(3, Math.max(1, Math.floor(textBudget / LINE_H))); + // Approx chars per line at this font (~0.56em average glyph width). + const charsPerLine = Math.max(4, Math.floor((BOX_EFF - 12) / (FONT_SIZE * 0.56))); + + // Per-node style: white round rects, gold current asset, green "Add asset" button. + function nodeStyles(node) { + const isCurrent = node.id === currentAssetId; + const isAdd = isAddAsset(node); + + const displayName = wrapName(node.name, charsPerLine, MAX_LINES); + const rich = { + text: { + width: BOX_EFF - 6, + overflow: "truncate", + align: "center", + fontSize: FONT_SIZE, + lineHeight: LINE_H, + padding: [2, 0, 0, 0], + }, + }; + if (node.icon) { + rich["icon_" + node.id] = { + backgroundColor: { image: node.icon }, + height: ICON_SIZE, + width: ICON_SIZE, + align: "center", + }; + } + + return { + symbol: "roundRect", + symbolSize: isCurrent ? [BOX_SIZE * 1.08, BOX_SIZE * 1.08] : [BOX_SIZE, BOX_SIZE], + label: { + show: SHOW_LABELS, + position: "inside", + color: isCurrent ? "#000000" : isAdd ? "#ffffff" : "#1e293b", + fontSize: FONT_SIZE, + fontWeight: isCurrent ? "bold" : "600", + fontFamily: "system-ui, -apple-system, sans-serif", + // A function rather than a template string, + // so that ECharts does not substitute its template variables ({a}, {b}, {c}) in asset names. + formatter: () => + node.icon + ? "{icon_" + node.id + "|}\n{text|" + displayName + "}" + : "{text|" + displayName + "}", + rich: rich, + }, + itemStyle: { + color: isCurrent ? "#fcd34d" : isAdd ? "#10b981" : "#ffffff", + borderColor: isCurrent ? "#f59e0b" : isAdd ? "#047857" : "#cbd5e1", + borderWidth: isCurrent ? 3 : 1, + borderRadius: 8, + shadowColor: "rgba(0, 0, 0, 0.2)", + shadowBlur: 10, + shadowOffsetX: 2, + shadowOffsetY: 6, + }, + }; + } + + const nodes = []; + const links = []; + + // Place a node; if `port` is given, route the connector from the parent to an invisible port node + // on the box's top/bottom edge instead of to the box itself. + function pushNode(node, x, y, port) { + nodes.push( + Object.assign( + { + id: String(node.id), + name: node.name, + x: x, + y: y, + link: node.link, + tooltip: node.tooltip, + }, + nodeStyles(node) + ) + ); + if (node.parent === undefined || node.parent === null || !assetMap[node.parent]) { + return; + } + if (port) { + const pid = "port-" + node.id; + nodes.push({ + id: pid, + name: "", + x: port.x, + y: port.y, + symbol: "circle", + symbolSize: 0.1, + silent: true, + label: { show: false }, + itemStyle: INVISIBLE, + }); + links.push({ + source: String(node.parent), + target: pid, + lineStyle: { curveness: port.curveness }, + }); + } else { + links.push({ source: String(node.parent), target: String(node.id) }); + } + } + + // Recursively lay out `node` (at tree level `d`) and its subtree, starting at vertical offset topY. + // Returns the block height and the node's own center y. + function layoutSubtree(node, d, topY) { + const s = splitKids(node); + if (!s.gridKids.length && !s.rowKids.length) { + const cy = topY + slotY / 2; + pushNode(node, colX(d), cy); + return { height: slotY, centerY: cy }; + } + let y = topY; + const centers = []; + if (s.gridKids.length) { + const cols = colsFor(s.gridKids.length, d + 1); + const rows = Math.ceil(s.gridKids.length / cols); + s.gridKids.forEach((k, i) => { + const col = i % cols; + const row = Math.floor(i / cols); + const x = colX(d + 1) + col * colGapX; + const ky = y + slotY / 2 + row * slotY; + const topRow = row === 0; + pushNode(k, x, ky, { + x: x, + y: topRow ? ky - BOX_SIZE / 2 : ky + BOX_SIZE / 2, + curveness: topRow ? 0.18 : -0.18, + }); + }); + const blockH = rows * slotY; + centers.push(y + blockH / 2); + y += blockH + blockGap; + } + s.rowKids.forEach((k) => { + const sub = layoutSubtree(k, d + 1, y); + centers.push(sub.centerY); + y += sub.height + blockGap; + }); + y -= blockGap; // drop the trailing gap + const centerY = centers.reduce((a, b) => a + b, 0) / centers.length; + pushNode(node, colX(d), centerY); + return { height: y - topY, centerY: centerY }; + } + + let cursorY = slotY * 0.5; + rootNodes.forEach((rt) => { + cursorY += layoutSubtree(rt, 0, cursorY).height + blockGap; + }); + const contentH = cursorY - blockGap + slotY * 0.5; + + // Invisible pin nodes make the layout's bounding box deterministic: + // exactly the canvas width by at least the viewport height. + // The graph view fits that box into the canvas, + // so the transform is identity while the content fits, and a uniform zoom-out once the content is taller than the viewport. + [ + [0, 0], + [W, Math.max(contentH, viewportH)], + ].forEach((p, i) => { + nodes.push({ + id: "__pin" + i, + name: "", + x: p[0], + y: p[1], + symbol: "circle", + symbolSize: 0.1, + silent: true, + label: { show: false }, + itemStyle: INVISIBLE, + }); + }); + + return { + tooltip: { + trigger: "item", + triggerOn: "mousemove", + backgroundColor: "rgba(255, 255, 255, 0.95)", + borderColor: "#e2e8f0", + borderWidth: 1, + textStyle: { color: "#334155" }, + formatter: function (params) { + return params.data.tooltip || params.name; + }, + }, + series: [ + { + type: "graph", + layout: "none", + data: nodes, + links: links, + // Drag to pan; wheel and +/- buttons drive series.zoom. + roam: "move", + zoom: zoomFactor, + // Scale node symbols 1:1 with the view zoom. + nodeScaleRatio: 1, + edgeSymbol: ["none", "none"], + animationDuration: 550, + // No update animation during continuous wheel-zoom, so the tree follows the wheel without lagging behind. + animationDurationUpdate: animate === false ? 0 : 400, + lineStyle: { + color: "#94a3b8", + width: 2, + curveness: 0.15, + }, + }, + ], + }; +} diff --git a/flexmeasures/ui/static/js/daterange-utils.js b/flexmeasures/ui/static/js/daterange-utils.js index 84616101e3..59ab2e9345 100644 --- a/flexmeasures/ui/static/js/daterange-utils.js +++ b/flexmeasures/ui/static/js/daterange-utils.js @@ -227,6 +227,9 @@ export function encodeUrlQuery(rawUrl) { const [path, query] = rawUrl.split("?"); if (!query) return rawUrl; // No query to encode - const params = new URLSearchParams(query); + // URLSearchParams reads a literal "+" as an encoded space, as in form submissions, + // which would turn the offset in "+02:00" into " 02:00" before re-encoding it. + // Escaping it first keeps it a plus sign. + const params = new URLSearchParams(query.replaceAll("+", "%2B")); return `${path}?${params.toString()}`; } diff --git a/flexmeasures/ui/static/js/flex-context-utils.js b/flexmeasures/ui/static/js/flex-context-utils.js new file mode 100644 index 0000000000..30a396323e --- /dev/null +++ b/flexmeasures/ui/static/js/flex-context-utils.js @@ -0,0 +1,216 @@ +/** + * Helpers for the flex-context editor on the asset context page (assets/asset_context.html). + * + * These hold the editor's decisions that do not need the page: + * which fields take which kinds of values, what is sent on saving, + * and how commitment values are read from and written to text. + * The page keeps the DOM and the state; the schema is passed in rather than read from the page, + * so each decision can be tested on its own. + */ + +// Pulls the currency code out of a price unit, e.g. "EUR" from "EUR/MWh" or "12 KRW/kW". +const PRICE_UNIT_PATTERN = /([A-Z]{3})\/[kMG]?W/; + +/** + * Whether a field only takes sensors, and so offers no "Fixed value" tab in the editor. + * + * @param {Object} schemaSpecs - The UI flex-context schema, by field name. + * @param {string} fieldName - The field to ask about. + * @returns {boolean} + */ +export function isSensorOnlyField(schemaSpecs, fieldName) { + // typeTwo = a single sensor reference, typeFour = a list of sensors. + const backendType = schemaSpecs[fieldName]?.["types"]?.["backend"]; + return backendType === "typeTwo" || backendType === "typeFour"; +} + +/** + * Whether a field holds a list of sensors. + * + * Such fields (e.g. inflexible-consumption and inflexible-production) keep their card active, + * so several sensors can be added in a row. + * + * @param {Object} schemaSpecs - The UI flex-context schema, by field name. + * @param {string} fieldName - The field to ask about. + * @returns {boolean} + */ +export function isSensorListField(schemaSpecs, fieldName) { + return schemaSpecs[fieldName]?.["types"]?.["backend"] === "typeFour"; +} + +/** + * The sensor id of one entry of a sensor list field. + * + * Entries are sensor references ({"sensor": }, possibly with source filters); + * the deprecated inflexible-device-sensors field holds bare ids. + * + * @param {Object|number} entry - A sensor reference, or a bare sensor id. + * @returns {number} - The sensor id. + */ +export function entrySensorId(entry) { + return typeof entry === "object" && entry !== null ? entry["sensor"] : entry; +} + +// Fields managed by the commodity tab bar rather than as cards. +const STRUCTURAL_FIELDS = ["commodities", "commodity"]; + +/** + * Whether a field is managed by the commodity tab bar, rather than shown as a card. + * + * @param {string} fieldName - The field to ask about. + * @returns {boolean} + */ +export function isStructuralField(fieldName) { + return STRUCTURAL_FIELDS.includes(fieldName); +} + +/** + * Whether a field can be set in the commodity scope the editor is working on. + * + * The editor works either on the top-level flex-context (the electricity commodity), + * or on one entry of its "commodities" list, which takes only the fields marked per-commodity. + * + * @param {Object} schemaSpecs - The UI flex-context schema, by field name. + * @param {string} fieldName - The field to ask about. + * @param {?number} activeCommodityIndex - Index into "commodities", or null for the top level. + * @returns {boolean} + */ +export function isFieldAvailableInScope(schemaSpecs, fieldName, activeCommodityIndex) { + if (isStructuralField(fieldName)) { + return false; + } + if (activeCommodityIndex === null) { + return true; + } + return schemaSpecs[fieldName]?.["per-commodity"] === true; +} + +/** + * The part of the flex-context that the editor is working on. + * + * @param {Object} flexContext - The whole flex-context of the asset. + * @param {?number} activeCommodityIndex - Index into "commodities", or null for the top level. + * @returns {Object} - The top-level flex-context, or one of its commodity contexts. + */ +export function getActiveContext(flexContext, activeCommodityIndex) { + if (activeCommodityIndex === null) { + return flexContext; + } + return flexContext["commodities"][activeCommodityIndex]; +} + +/** + * Prepare a flex-context for saving, in place. + * + * Fields the schema does not know are taken out, sparing the structural ones + * (the commodities list itself is not part of the UI schema), + * and so are fields left empty. + * + * @param {Object} flexContext - The whole flex-context of the asset, which is modified. + * @param {Object} schema - Default values by field name; its keys are the fields that may be saved. + * @returns {Object} - The same flex-context, for convenience. + */ +export function cleanFlexContext(flexContext, schema) { + function cleanContext(context, isCommodityContext) { + for (const key in context) { + if (isCommodityContext && key === "commodity") { + continue; + } + if (!isCommodityContext && key === "commodities") { + continue; + } + if (!Object.prototype.hasOwnProperty.call(schema, key)) { + delete context[key]; + } + } + + for (const [key, value] of Object.entries(context)) { + if (value === null || value === "" || (Array.isArray(value) && value.length === 0)) { + delete context[key]; + } + } + } + + cleanContext(flexContext, false); + if (flexContext["commodities"]) { + for (const commodityContext of flexContext["commodities"]) { + cleanContext(commodityContext, true); + } + } + return flexContext; +} + +/** + * The currency code in a price unit, if any. + * + * @param {string} text - A unit or a quantity, e.g. "EUR/MWh" or "12 EUR/MWh". + * @returns {?string} - The currency code, e.g. "EUR", or null. + */ +export function currencyInUnit(text) { + const currencyMatch = (text || "").match(PRICE_UNIT_PATTERN); + return currencyMatch ? currencyMatch[1] : null; +} + +/** + * Look for the currency the flex-context already prices in. + * + * The scheduler requires all prices to share one currency, + * so a new price should follow any price already set. + * Fixed values say so directly; + * for sensor references, the caller has to look up the sensor's unit. + * + * @param {Object} flexContext - The whole flex-context of the asset. + * @returns {{currency: ?string, priceSensorIds: number[]}} - + * The currency of the first fixed price found (null if none), + * and the ids of sensors referenced by price fields, to fall back on. + */ +export function findFlexContextCurrency(flexContext) { + const contexts = [flexContext, ...(flexContext["commodities"] || [])]; + const priceSensorIds = []; + for (const context of contexts) { + for (const [key, value] of Object.entries(context || {})) { + if (typeof value === "string") { + const currency = currencyInUnit(value); + if (currency) { + return { currency: currency, priceSensorIds: priceSensorIds }; + } + } + if (key.includes("price") && value !== null && typeof value === "object" && value["sensor"]) { + priceSensorIds.push(value["sensor"]); + } + } + } + return { currency: null, priceSensorIds: priceSensorIds }; +} + +/** + * Turn free text into a commitment value. + * + * "sensor 536" (any casing) becomes a sensor reference; anything else is kept as a fixed quantity string. + * + * @param {string} raw - The text as typed. + * @returns {Object|string} - A sensor reference, or the trimmed text. + */ +export function parseCommitmentValue(raw) { + const sensorMatch = raw.trim().toLowerCase().match(/^sensor\s+(\d+)$/); + if (sensorMatch) { + return { sensor: parseInt(sensorMatch[1], 10) }; + } + return raw.trim(); +} + +/** + * Describe a commitment value in plain text, the inverse of parseCommitmentValue. + * + * @param {*} value - A sensor reference or a fixed quantity string. + * @returns {?string} - The description, or null when the value is not set. + */ +export function describeCommitmentValue(value) { + if (value === undefined || value === null || value === "") { + return null; + } + if (typeof value === "object" && value["sensor"]) { + return `sensor ${value["sensor"]}`; + } + return String(value); +} diff --git a/flexmeasures/ui/static/js/ui-utils.js b/flexmeasures/ui/static/js/ui-utils.js index 314050d6c3..b6c986479a 100644 --- a/flexmeasures/ui/static/js/ui-utils.js +++ b/flexmeasures/ui/static/js/ui-utils.js @@ -54,18 +54,66 @@ export async function getSensor(id) { return sensor; } +/** + * Convert the Python representation of a dict (as Jinja renders one) to JSON. + * + * Only what `repr` writes for JSON-like data is handled: + * strings in either quote style, with Python's escapes, and the constants None, True and False. + * Those constants are only replaced outside strings, so a value such as "Nonetheless" survives. + * + * @param {string} text - The Python representation. + * @returns {string} - The same data as JSON. + */ +export function pythonReprToJSON(text) { + const constants = { None: "null", True: "true", False: "false" }; + const escapes = { n: "\n", t: "\t", r: "\r", b: "\b", f: "\f", 0: "\0" }; + let out = ""; + let i = 0; + while (i < text.length) { + const ch = text[i]; + if (ch === "'" || ch === '"') { + let value = ""; + let j = i + 1; + while (j < text.length && text[j] !== ch) { + if (text[j] === "\\") { + const next = text[j + 1]; + const hexLength = { x: 2, u: 4, U: 8 }[next]; + if (hexLength) { + value += String.fromCodePoint(parseInt(text.slice(j + 2, j + 2 + hexLength), 16)); + j += 2 + hexLength; + } else { + value += next in escapes ? escapes[next] : next; + j += 2; + } + continue; + } + value += text[j]; + j++; + } + out += JSON.stringify(value); + i = j + 1; + continue; + } + const word = /^[A-Za-z_]\w*/.exec(text.slice(i)); + if (word) { + out += constants[word[0]] ?? word[0]; + i += word[0].length; + continue; + } + out += ch; + i++; + } + return out; +} + export function processResourceRawJSON(schema, rawJSON, allowExtra = false) { /* - allowExtra - whether to allow extra fields in the rawJSON that are not in the schema. + rawJSON - the resource's fields, either as an object or as the Python representation of a dict. + allowExtra - whether to allow extra fields in the rawJSON that are not in the schema. If false, those fields will be ignored. */ - let processedJSON = rawJSON.replace(/'/g, '"'); - // change None to null, True to true and False to false - processedJSON = processedJSON.replaceAll("None", "null"); - processedJSON = processedJSON.replaceAll("True", "true"); - processedJSON = processedJSON.replaceAll("False", "false"); - // update the assetFlexModel fields - processedJSON = JSON.parse(processedJSON); + const processedJSON = + typeof rawJSON === "string" ? JSON.parse(pythonReprToJSON(rawJSON)) : rawJSON; const extraFields = {}; for (const [key, value] of Object.entries(processedJSON)) { diff --git a/flexmeasures/ui/templates/_macros.html b/flexmeasures/ui/templates/_macros.html index 0e93494556..c2782b4b50 100644 --- a/flexmeasures/ui/templates/_macros.html +++ b/flexmeasures/ui/templates/_macros.html @@ -1,4 +1,4 @@ -{% macro show_tree(assets, current_asset_name) %} +{% macro show_tree(assets, current_asset_id) %}
{# touch-action: none keeps the browser from scrolling/zooming the page on touch gestures inside the tree, so one-finger drags pan the chart @@ -18,35 +18,15 @@
-