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
};