Cascading Dropdowns from json fetches

Hey everyone,
Im trying to build a cascading dropdown fetch for my custom created nodes. However after spending a day of failures Im lost.

Fetching something independent from something else works just fine.
So here are partials of relevant code.

Im trying to make this modular so I can just copy paste the relevant stuff over all my nodes.
My result is that I only see the depending dropdown change whenever I deselect and select the supplier for example first. After I save my changes and deploy my values of the selected supplier appears in the dropdown list but not for all the cascading dropdowns.

Perhaps I made this too complicated for it to work properly and would really appreciate it if somebody could guide me to an example that also does this properly. I don't mind crafting my helper functions again.

 oneditprepare: function () {
      const node = this;

      console.log("--------------Starting on edit prepare-----------------");
      console.log("Saved Supplier:", node.supplier);
      console.log("Saved SubType:", node.subType);
      console.log("Saved Model:", node.model);

      const configUrls = {
        cloud: {
          units: "https://example.com/api/units",
          assets: "https://example.com/api/assets",
          config: "https://example.com/api/config",
        },
        local: {
          units: "http://localhost:1880/generalFunctions/datasets/unitData.json",
          assets: "http://localhost:1880/generalFunctions/datasets/assetData.json",
          config: "http://localhost:1880/measurement/dependencies/measurement/measurementConfig.json",
        },
      };

      const elements = {
        scalingCheckbox: document.getElementById("node-input-scaling"),
        logCheckbox: document.getElementById("node-input-enableLog"),
        rowInputMin: document.getElementById("row-input-i_min"),
        rowInputMax: document.getElementById("row-input-i_max"),
        rowLogLevel: document.getElementById("row-logLevel"),
        logLevelSelect: document.getElementById("node-input-logLevel"),
        supplier: document.getElementById("node-input-supplier"),
        subType: document.getElementById("node-input-subType"),
        model: document.getElementById("node-input-model"),
        unit: document.getElementById("node-input-unit"),
        smoothMethod: document.getElementById("node-input-smooth_method"),
      };

      function initializeToggles() {
        const toggleVisibility = menuUtils.toggleElementVisibility;

        elements.scalingCheckbox.addEventListener("change", () =>
          toggleVisibility(elements.scalingCheckbox.checked, [elements.rowInputMin, elements.rowInputMax])
        );

        elements.logCheckbox.addEventListener("change", () =>
          toggleVisibility(elements.logCheckbox.checked, [elements.rowLogLevel])
        );

        toggleVisibility(elements.scalingCheckbox.checked, [elements.rowInputMin, elements.rowInputMax]);
        toggleVisibility(elements.logCheckbox.checked, [elements.rowLogLevel]);
      }

      function populateSmoothingMethods() {
        menuUtils.fetchData(configUrls.cloud.config, configUrls.local.config)
          .then((configData) => {
            const smoothingMethods = configData.smoothing.smoothMethod.rules.values.map(o => o.value) || [];
            menuUtils.populateDropdown(elements.smoothMethod, smoothingMethods, node, "smooth_method");
          })
          .catch((err) => {
            console.error("Error loading smoothing methods", err);
          });
      }

        populateSmoothingMethods();
        initializeToggles();
        fetchAndPopulateDropdowns();

        async function fetchAndPopulateDropdowns() {
          try {
              const assetDatabaseURL = "http://localhost:1880/generalFunctions/datasets/assetData";
              console.log("Asset Database URL: ", assetDatabaseURL);

              const configData = await menuUtils.fetchData(configUrls.cloud.config, configUrls.local.config);
              const assetType = configData.asset?.type?.default;
              console.log("Asset Type: ", assetType);

              // Construct URL for suppliers
              const localSuppliersUrl = menuUtils.constructUrl(assetDatabaseURL, `${assetType}s`, "suppliers.json");
              console.log("Suppliers URL: ", localSuppliersUrl);

              const supplierData = await menuUtils.fetchData(localSuppliersUrl, localSuppliersUrl);
              const suppliers = supplierData.map((supplier) => supplier.name);

              console.log("Suppliers: ", suppliers);

              menuUtils.populateDropdown(elements.supplier, suppliers, node, "supplier", async (selectedSupplier) => {
                  // Construct URL for the selected supplier folder
                  const supplierFolder = menuUtils.constructUrl(assetDatabaseURL, `${assetType}s`, selectedSupplier);
                  console.log("Supplier Folder: ", supplierFolder);

                  try {
                      // Construct URL for subtypes
                      const subTypesUrl = menuUtils.constructUrl(supplierFolder, "subtypes.json");
                      console.log("SubTypes URL: ", subTypesUrl);

                      const subTypeData = await menuUtils.fetchData(subTypesUrl, subTypesUrl);
                      const subTypes = subTypeData.map((subType) => subType.name);

                      console.log("SubTypes: ", subTypes);

                      menuUtils.populateDropdown(elements.subType, subTypes, node, "subType", async (selectedSubType) => {
                          // Construct URL for the selected subType folder
                          const subTypeFolder = menuUtils.constructUrl(supplierFolder, selectedSubType);
                          console.log("SubType Folder: ", subTypeFolder);

                          try {
                              // Construct URL for models
                              const modelsUrl = menuUtils.constructUrl(subTypeFolder, "models.json");
                              console.log("Models URL: ", modelsUrl);

                              const modelData = await menuUtils.fetchData(modelsUrl, modelsUrl);
                              const models = modelData.map((model) => model.name);

                              console.log("============= > Models: ", models);

                              menuUtils.populateDropdown(elements.model, models, node, "model");
                          } catch (modelError) {
                              console.error("Error fetching models: ", modelError);
                          }
                      });
                  } catch (subTypeError) {
                      console.error("Error fetching subTypes: ", subTypeError);
                  }
              });
          } catch (error) {
              console.error("Error fetching config or suppliers: ", error);
          }
      }

    },

    oneditsave: function () {
      const node = this;

      // Save basic properties
      ["name", "unit", "smooth_method", "supplier", "subType", "model"].forEach(
        (field) => (node[field] = document.getElementById(`node-input-${field}`).value || "")
      );

      // Save numeric and boolean properties
      ["scaling", "enableLog", "simulator"].forEach(
        (field) => (node[field] = document.getElementById(`node-input-${field}`).checked)
      );

      ["i_min", "i_max", "i_offset", "o_min", "o_max", "count"].forEach(
        (field) => (node[field] = parseFloat(document.getElementById(`node-input-${field}`).value) || 0)
      );

      node.logLevel = document.getElementById("node-input-logLevel").value || "info";

      // Validation checks
      if (node.scaling && (isNaN(node.i_min) || isNaN(node.i_max))) {
        RED.notify("Scaling enabled, but input range is incomplete!", "error");
      }

      if (!node.unit) {
        RED.notify("Unit selection is required.", "error");
      }

      if (node.subType && !node.unit) {
        RED.notify("Unit must be set when specifying a subtype.", "error");
      }
    },
  });

and my menuUtils.js

async function fetchData(url, fallbackUrl) {
  try {
    const response = await fetch(url);
    if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
    return await response.json();
  } catch (err) {
    console.warn(`Primary URL failed: ${url}. Trying fallback URL: ${fallbackUrl}`, err);
    try {
      const response = await fetch(fallbackUrl);
      if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
      return await response.json();
    } catch (fallbackErr) {
      console.error("Both primary and fallback URLs failed:", fallbackErr);
      return [];
    }
  }
}

function constructUrl(base, ...paths) {
  // Remove trailing slash from base and leading slashes from paths
  const sanitizedBase = base.replace(/\/+$/, "");
  const sanitizedPaths = paths.map((path) => path.replace(/^\/+|\/+$/g, ""));

  // Join sanitized base and paths
  const url = `${sanitizedBase}/${sanitizedPaths.join("/")}`;
  console.log("Base:", sanitizedBase);
  console.log("Paths:", sanitizedPaths);
  console.log("Constructed URL:", url);
  return url;
}

function toggleElementVisibility(condition, elements) {
  elements.forEach(elem => {
    elem.style.display = condition ? "block" : "none";
  });
}

function generateHtml(htmlElement, options, savedValue) {
  htmlElement.innerHTML = options.length
    ? `<option value="">Select...</option>${options.map(opt => `<option value="${opt}">${opt}</option>`).join("")}`
    : "<option value=''>No options available</option>";

  if (savedValue && options.includes(savedValue)) {
    htmlElement.value = savedValue;
  }
}

async function populateDropdown(htmlElement, options, node, property, callback) {
  generateHtml(htmlElement, options, node[property]);

  htmlElement.addEventListener("change", async (e) => {
    const newValue = e.target.value;
    console.log(`Dropdown changed: ${property} = ${newValue}`);
    node[property] = newValue;
    if (callback) await callback(newValue); // Ensure async callback completion
  });
}

function handleSpecificDropdownChange(htmlElement, node) {
  switch (htmlElement.id) {
    case "node-input-supplier":
      handleSupplierChange(node);
      break;
    case "node-input-subType":
      handleSubTypeChange(node);
      break;
    case "node-input-model":
      handleModelChange(node);
      break;
    case "node-input-unit":
      handleUnitChange(node);
      break;
    default:
      console.log("No specific logic for this dropdown");
  }
}

function validateData(data, expectedKey) {
  if (!Array.isArray(data) || !data.every(item => expectedKey in item)) {
    console.error(`Invalid data format. Expected array with key "${expectedKey}".`, data);
    return [];
  }
  return data.map(item => item[expectedKey]);
}


function handleAssetTypeChange(node, suppliersDropdown, subTypeDropdown, modelDropdown, unitDropdown) {
  node.supplier = "";
  node.subType = "";
  node.model = "";
  node.unit = "";

  generateHtml(suppliersDropdown, [], "");
  generateHtml(subTypeDropdown, [], "");
  generateHtml(modelDropdown, [], "");
  generateHtml(unitDropdown, [], "");

  // Fetch and populate suppliers based on the selected asset type
  const suppliers = assetData[node.assetType]?.suppliers || [];
  populateDropdown(suppliersDropdown, suppliers, node, "supplier");
}

function handleSupplierChange(node, subTypeDropdown, modelDropdown, unitDropdown) {
  node.subType = "";
  node.model = "";
  node.unit = "";

  generateHtml(subTypeDropdown, [], "");
  generateHtml(modelDropdown, [], "");
  generateHtml(unitDropdown, [], "");

  // Fetch and populate subTypes based on the selected supplier
  const subTypes = node.supplier?.subTypes || [];
  populateDropdown(subTypeDropdown, subTypes, node, "subType");
}

function handleSubTypeChange(node, modelDropdown, unitDropdown) {
  node.model = "";
  node.unit = "";

  generateHtml(modelDropdown, [], "");
  generateHtml(unitDropdown, [], "");

  // Fetch and populate models based on the selected subType
  const models = node.subType?.models || [];
  populateDropdown(modelDropdown, models, node, "model");
}

function handleModelChange(node, unitDropdown) {
  node.unit = "";

  // Units are only updated when the subType changes, so no action here
  console.log(`Model selected: ${node.model}`);
}

function handleUnitChange(node) {
  console.log(`Unit selected: ${node.unit}`);
}


/* Generic dropdowns */
/* Asset dropdown functions */
function populateLogLevelOptions(logLevelSelect, configData, node) {

  // debug log level 
  //console.log("Displaying configData => ", configData) ;

  const logLevels = configData?.general?.logging?.logLevel?.rules?.values?.map(l => l.value) || [];

  //console.log("Displaying logLevels => ", logLevels);

  // Reuse your existing generic populateDropdown helper
  populateDropdown(logLevelSelect, logLevels, node.logLevel);
}

/* measurement specific */
function populateSmoothingOptions(smoothMethodSelect, configData, node) {
  const methods = configData?.smoothing?.smoothMethod?.rules?.values.map(m => m.value) || [];
  generateHtml(smoothMethodSelect, methods, node.smooth_method);
}


export {
  fetchData,
  toggleElementVisibility,
  generateHtml,
  populateDropdown,
  handleSpecificDropdownChange,
  handleAssetTypeChange,
  handleSupplierChange,
  handleSubTypeChange,
  handleModelChange,
  handleUnitChange,
  populateLogLevelOptions,
  populateSmoothingOptions,
  constructUrl
};

If you are coding for the Editor, you are better off using jQuery which is built into the editor. Then you may wish to look for a jQuery library that does what you want - might save a lot of forced hair removal. :slight_smile:

Knowning nothing about jQuery or there libs. You have anything you can send me (link/example)? In parallel Ill start reading into that :stuck_out_tongue:

No problem. The jQuery API documentation is generally very good. w3schools have a decent intro:

But possibly the fastest way to get going is to look at the code for some well used nodes.

jQuery has been around a long time so is very mature and continues to be developed. It has a very rich plugins scene.

1 Like

I solved it without jQuery in the end:

For people wanting to learn from it or searching for something similar:

menuUtils.js helper file

async function fetchData(url, fallbackUrl) {
  try {
    const response = await fetch(url);
    if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
    return await response.json();
  } catch (err) {
    console.warn(`Primary URL failed: ${url}. Trying fallback URL: ${fallbackUrl}`, err);
    try {
      const response = await fetch(fallbackUrl);
      if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
      return await response.json();
    } catch (fallbackErr) {
      console.error("Both primary and fallback URLs failed:", fallbackErr);
      return [];
    }
  }
}

function constructUrl(base, ...paths) {
  // Remove trailing slash from base and leading slashes from paths
  const sanitizedBase = base.replace(/\/+$/, "");
  const sanitizedPaths = paths.map((path) => path.replace(/^\/+|\/+$/g, ""));

  // Join sanitized base and paths
  const url = `${sanitizedBase}/${sanitizedPaths.join("/")}`;
  console.log("Base:", sanitizedBase);
  console.log("Paths:", sanitizedPaths);
  console.log("Constructed URL:", url);
  return url;
}

//Adjust for API of PIM
function constructCloudURL(base, ...paths) {
  // Remove trailing slash from base and leading slashes from paths
  const sanitizedBase = base.replace(/\/+$/, "");
  const sanitizedPaths = paths.map((path) => path.replace(/^\/+|\/+$/g, ""));
  // Join sanitized base and paths
  const url = `${sanitizedBase}/${sanitizedPaths.join("/")}`;
  return url;
}

function generateHtml(htmlElement, options, savedValue) {
  htmlElement.innerHTML = options.length
    ? `<option value="">Select...</option>${options.map(opt => `<option value="${opt}">${opt}</option>`).join("")}`
    : "<option value=''>No options available</option>";

  if (savedValue && options.includes(savedValue)) {
    htmlElement.value = savedValue;
  }
}

async function populateDropdown(htmlElement, options, node, property, callback) {
  generateHtml(htmlElement, options, node[property]);

  htmlElement.addEventListener("change", async (e) => {
    const newValue = e.target.value;
    console.log(`Dropdown changed: ${property} = ${newValue}`);
    node[property] = newValue;
    RED.nodes.dirty(true);
    if (callback) await callback(newValue); // Ensure async callback completion
  });
}

    // Define the initialize toggles function within scope
export function initializeToggles(elements) {
      // Toggle visibility for scaling inputs
      elements.scalingCheckbox.addEventListener("change", function() {
          elements.rowInputMin.style.display = this.checked ? "block" : "none";
          elements.rowInputMax.style.display = this.checked ? "block" : "none";
      });

      // Toggle visibility for log level
      elements.logCheckbox.addEventListener("change", function() {
          elements.rowLogLevel.style.display = this.checked ? "block" : "none";
      });

      // Set initial states
      elements.rowInputMin.style.display = elements.scalingCheckbox.checked ? "block" : "none";
      elements.rowInputMax.style.display = elements.scalingCheckbox.checked ? "block" : "none";
      elements.rowLogLevel.style.display = elements.logCheckbox.checked ? "block" : "none";
  }

        // Define the smoothing methods population function within scope
export function populateSmoothingMethods(configUrls, elements, node) {
          fetchData(configUrls.cloud.config, configUrls.local.config)
              .then((configData) => {
                  const smoothingMethods = configData.smoothing?.smoothMethod?.rules?.values?.map(o => o.value) || [];
                  populateDropdown(elements.smoothMethod, smoothingMethods, node, "smooth_method");
              })
              .catch((err) => {
                  console.error("Error loading smoothing methods", err);
              });
      }

/* Generic dropdowns */
/* Asset dropdown functions */
export function populateLogLevelOptions(logLevelSelect, configData, node) {

  // debug log level 
  //console.log("Displaying configData => ", configData) ;

  const logLevels = configData?.general?.logging?.logLevel?.rules?.values?.map(l => l.value) || [];

  //console.log("Displaying logLevels => ", logLevels);

  // Reuse your existing generic populateDropdown helper
  populateDropdown(logLevelSelect, logLevels, node.logLevel);
}

//cascade dropdowns
export function fetchAndPopulateDropdowns(configUrls, elements, node) {
  const assetDatabaseURL = "http://localhost:1880/generalFunctions/datasets/assetData";
  
  fetchData(configUrls.cloud.config, configUrls.local.config)
    .then((configData) => {
      const assetType = configData.asset?.type?.default;
      const localSuppliersUrl = constructUrl(assetDatabaseURL, `${assetType}s`, "suppliers.json");
      
      return fetchData(localSuppliersUrl, localSuppliersUrl)
        .then((supplierData) => {
          const suppliers = supplierData.map((supplier) => supplier.name);
          
          // Populate suppliers dropdown and set up its change handler
          return populateDropdown(elements.supplier, suppliers, node, "supplier", function(selectedSupplier) {
            if (selectedSupplier) {
              populateSubTypes(configUrls, elements, node, selectedSupplier);
            }
          });
        })
        .then(() => {
          // If we have a saved supplier, trigger subTypes population
          if (node.supplier) {
            populateSubTypes(configUrls, elements, node, node.supplier);
          }
        });
    })
    .catch((error) => {
      console.error("Error in initial dropdown population:", error);
    });
}

function populateSubTypes(configUrls,elements, node, selectedSupplier) {
  const assetDatabaseURL = "http://localhost:1880/generalFunctions/datasets/assetData";
  
  fetchData(configUrls.cloud.config, configUrls.local.config)
    .then((configData) => {
      const assetType = configData.asset?.type?.default;
      const supplierFolder = constructUrl(assetDatabaseURL, `${assetType}s`, selectedSupplier);
      const subTypesUrl = constructUrl(supplierFolder, "subtypes.json");
      
      return fetchData(subTypesUrl, subTypesUrl)
        .then((subTypeData) => {
          const subTypes = subTypeData.map((subType) => subType.name);
          
          return populateDropdown(elements.subType, subTypes, node, "subType", function(selectedSubType) {
            if (selectedSubType) {
              // When subType changes, update both models and units
              populateModels(configUrls,elements, node, selectedSupplier, selectedSubType);
              populateUnitsForSubType(configUrls,elements, node, selectedSubType);
            }
          });
        })
        .then(() => {
          // If we have a saved subType, trigger both models and units population
          if (node.subType) {
            populateModels(configUrls,elements, node, selectedSupplier, node.subType);
            populateUnitsForSubType(configUrls,elements, node, node.subType);
          }
        });
    })
    .catch((error) => {
      console.error("Error populating subtypes:", error);
    });
} 

function populateUnitsForSubType(configUrls,elements, node, selectedSubType) {
// Fetch the units data
fetchData(configUrls.cloud.units, configUrls.local.units)
.then((unitsData) => {
  // Find the category that matches the subType name
  const categoryData = unitsData.units.find(
    category => category.category.toLowerCase() === selectedSubType.toLowerCase()
  );

  if (categoryData) {
    // Extract just the unit values and descriptions
    const units = categoryData.values.map(unit => ({
      value: unit.value,
      description: unit.description
    }));

    // Create the options array with descriptions as labels
    const options = units.map(unit => ({
      value: unit.value,
      label: `${unit.value} - ${unit.description}`
    }));

    // Populate the units dropdown
    populateDropdown(
      elements.unit,
      options.map(opt => opt.value),
      node,
      "unit"
    );

    // If there's no currently selected unit but we have options, select the first one
    if (!node.unit && options.length > 0) {
      node.unit = options[0].value;
      elements.unit.value = options[0].value;
    }
  } else {
    // If no matching category is found, provide a default % option
    const defaultUnits = [{ value: "%", description: "Percentage" }];
    populateDropdown(
      elements.unit,
      defaultUnits.map(unit => unit.value),
      node,
      "unit"
    );
    console.warn(`No matching unit category found for subType: ${selectedSubType}`);
  }
})
.catch((error) => {
  console.error("Error fetching units:", error);
});
}

function populateModels(configUrls,elements, node, selectedSupplier, selectedSubType) {
  const assetDatabaseURL = "http://localhost:1880/generalFunctions/datasets/assetData";
  
  fetchData(configUrls.cloud.config, configUrls.local.config)
    .then((configData) => {
      const assetType = configData.asset?.type?.default;
      const supplierFolder = constructUrl(assetDatabaseURL, `${assetType}s`, selectedSupplier);
      const subTypeFolder = constructUrl(supplierFolder, selectedSubType);
      const modelsUrl = constructUrl(subTypeFolder, "models.json");
      
      return fetchData(modelsUrl, modelsUrl)
        .then((modelData) => {
          const models = modelData.map((model) => model.name);
          populateDropdown(elements.model, models, node, "model");
        });
    })
    .catch((error) => {
      console.error("Error populating models:", error);
    });
}

Complete HTML for my custom node:

<script type="module">
  import * as menuUtils from "/generalFunctions/helper/menuUtils.js";

  RED.nodes.registerType("measurement", {
    category: "digital twin",
    color: "#e4a363",

    defaults: {
      name: { value: "", required: true },
      scaling: { value: false },
      i_min: { value: 0, required: true },
      i_max: { value: 0, required: true },
      i_offset: { value: 0 },
      o_min: { value: 0, required: true },
      o_max: { value: 1, required: true },
      simulator: { value: false },
      unit: { value: "unit", required: true },
      smooth_method: { value: "" },
      count: { value: "10", required: true },
      enableLog: { value: false },
      logLevel: { value: "info" },
      supplier: { value: "" },
      subType: { value: "" },
      model: { value: "" },
    },

    inputs: 1,
    outputs: 4,
    inputLabels: ["Measurement Input"],
    outputLabels: ["process", "dbase", "upStreamParent", "downStreamParent"],
    icon: "font-awesome/fa-tachometer",

    label: function () {
      return this.name || "Measurement";
    },


    oneditprepare: function() {
    const node = this;

    const configUrls = {
      cloud: {
        units: "https://example.com/api/units",
        config: "https://example.com/api/config",
      },
      local: {
        units: "http://localhost:1880/generalFunctions/datasets/unitData.json",
        config: "http://localhost:1880/measurement/dependencies/measurement/measurementConfig.json",
      },
    };

    const elements = {
      scalingCheckbox: document.getElementById("node-input-scaling"),
      logCheckbox: document.getElementById("node-input-enableLog"),
      rowInputMin: document.getElementById("row-input-i_min"),
      rowInputMax: document.getElementById("row-input-i_max"),
      rowLogLevel: document.getElementById("row-logLevel"),
      logLevelSelect: document.getElementById("node-input-logLevel"),
      supplier: document.getElementById("node-input-supplier"),
      subType: document.getElementById("node-input-subType"),
      model: document.getElementById("node-input-model"),
      unit: document.getElementById("node-input-unit"),
      smoothMethod: document.getElementById("node-input-smooth_method"),
    };

    // UI elements 
    menuUtils.initializeToggles(elements);
    menuUtils.populateSmoothingMethods(configUrls, elements, node);
    menuUtils.fetchAndPopulateDropdowns(configUrls, elements, node); // function for all assets

  },

    oneditsave: function () {
      const node = this;

      // Save basic properties
      ["name", "unit", "smooth_method", "supplier", "subType", "model"].forEach(
        (field) => (node[field] = document.getElementById(`node-input-${field}`).value || "")
      );

      // Save numeric and boolean properties
      ["scaling", "enableLog", "simulator"].forEach(
        (field) => (node[field] = document.getElementById(`node-input-${field}`).checked)
      );

      ["i_min", "i_max", "i_offset", "o_min", "o_max", "count"].forEach(
        (field) => (node[field] = parseFloat(document.getElementById(`node-input-${field}`).value) || 0)
      );

      node.logLevel = document.getElementById("node-input-logLevel").value || "info";

      // Validation checks
      if (node.scaling && (isNaN(node.i_min) || isNaN(node.i_max))) {
        RED.notify("Scaling enabled, but input range is incomplete!", "error");
      }

      if (!node.unit) {
        RED.notify("Unit selection is required.", "error");
      }

      if (node.subType && !node.unit) {
        RED.notify("Unit must be set when specifying a subtype.", "error");
      }
    },
  });
</script>

<!-- Main UI Template -->
<script type="text/html" data-template-name="measurement">
  <!-- Node Name -->
  <div class="form-row">
    <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
    <input
      type="text"
      id="node-input-name"
      placeholder="Measurement Name"
      style="width:70%;"
    />
  </div>

  <!-- Scaling Checkbox -->
  <div class="form-row">
    <label for="node-input-scaling"
      ><i class="fa fa-compress"></i> Scaling</label
    >
    <input
      type="checkbox"
      id="node-input-scaling"
      style="width:20px; vertical-align:baseline;"
    />
    <span>Enable input scaling?</span>
  </div>

  <!-- Source Min/Max (only if scaling is true) -->
  <div class="form-row" id="row-input-i_min">
    <label for="node-input-i_min"
      ><i class="fa fa-arrow-down"></i> Source Min</label
    >
    <input type="number" id="node-input-i_min" placeholder="0" />
  </div>

  <div class="form-row" id="row-input-i_max">
    <label for="node-input-i_max"
      ><i class="fa fa-arrow-up"></i> Source Max</label
    >
    <input type="number" id="node-input-i_max" placeholder="3000" />
  </div>

  <!-- Offset -->
  <div class="form-row">
    <label for="node-input-i_offset"
      ><i class="fa fa-adjust"></i> Input Offset</label
    >
    <input type="number" id="node-input-i_offset" placeholder="0" />
  </div>

  <!-- Output / Process Min/Max -->
  <div class="form-row">
    <label for="node-input-o_min"><i class="fa fa-tag"></i> Process Min</label>
    <input type="number" id="node-input-o_min" placeholder="0" />
  </div>
  <div class="form-row">
    <label for="node-input-o_max"><i class="fa fa-tag"></i> Process Max</label>
    <input type="number" id="node-input-o_max" placeholder="1" />
  </div>

  <!-- Simulator Checkbox -->
  <div class="form-row">
    <label for="node-input-simulator"
      ><i class="fa fa-cog"></i> Simulator</label
    >
    <input
      type="checkbox"
      id="node-input-simulator"
      style="width:20px; vertical-align:baseline;"
    />
    <span>Activate internal simulation?</span>
  </div>

  <!-- Smoothing Method -->
  <div class="form-row">
    <label for="node-input-smooth_method"
      ><i class="fa fa-line-chart"></i> Smoothing</label
    >
    <select id="node-input-smooth_method" style="width:60%;">
      <!-- Filled dynamically from measurementConfig.json or fallback -->
    </select>
  </div>

  <!-- Smoothing Window -->
  <div class="form-row">
    <label for="node-input-count">Window</label>
    <input
      type="number"
      id="node-input-count"
      placeholder="10"
      style="width:60px;"
    />
    <div class="form-tips">Number of samples for smoothing</div>
  </div>

  <!-- Optional Extended Fields: supplier, type, subType, model -->
  <hr />
  <div class="form-row">
    <label for="node-input-supplier"
      ><i class="fa fa-industry"></i> Supplier</label
    >
    <select id="node-input-supplier" style="width:60%;">
      <option value="">(optional)</option>
    </select>
  </div>
  <div class="form-row">
    <label for="node-input-subType"
      ><i class="fa fa-puzzle-piece"></i> SubType</label
    >
    <select id="node-input-subType" style="width:60%;">
      <option value="">(optional)</option>
    </select>
  </div>
  <div class="form-row">
    <label for="node-input-model"><i class="fa fa-wrench"></i> Model</label>
    <select id="node-input-model" style="width:60%;">
      <option value="">(optional)</option>
    </select>
  </div>
  <div class="form-row">
    <label for="node-input-unit"><i class="fa fa-balance-scale"></i> Unit</label>
    <select id="node-input-unit" style="width:60%;"></select>
  </div>

  <hr />

  <!-- loglevel checkbox -->
  <div class="form-row">
    <label for="node-input-enableLog"
      ><i class="fa fa-cog"></i> Enable Log</label
    >
    <input
      type="checkbox"
      id="node-input-enableLog"
      style="width:20px; vertical-align:baseline;"
    />
    <span>Enable logging</span>
  </div>

<div class="form-row" id="row-logLevel">
  <label for="node-input-logLevel"><i class="fa fa-cog"></i> Log Level</label>
  <select id="node-input-logLevel" style="width:60%;">
    <option value="info">Info</option>
    <option value="debug">Debug</option>
    <option value="warn">Warn</option>
    <option value="error">Error</option>
  </select>
</div>

</script>


<script type="text/html" data-help-name="measurement">
  <p>
    <b>Measurement Node</b>: Scales, smooths, and simulates measurement data.
  </p>
  <ul>
    <li><b>Scaling:</b> Enable to map Source Min/Max → Process Min/Max.</li>
    <li><b>Offset:</b> Adds a fixed offset to your input data.</li>
    <li>
      <b>Simulator:</b> Generate pseudo-random input without a real sensor.
    </li>
    <li>
      <b>Smoothing:</b> Choose a method and a window size for averaging or
      filtering data.
    </li>
    <li>
      <b>Advanced:</b> Optionally specify
      <i>supplier / type / subType / model</i> if relevant to your asset.
    </li>
  </ul>
</script>

Perhaps one day I will have some loading text displaying awaiting the fetched data but for now (until tomorrow) this is fine :stuck_out_tongue:

Why do you use RED.nodes.dirty(true)?

If you call the code below outside of oneditsave the node is not able to detect the change.
node[property] = newValue;