diff --git a/src/assets/icons/triangle-fill.svg b/src/assets/icons/triangle-fill.svg new file mode 100644 index 00000000..9c9eaa70 --- /dev/null +++ b/src/assets/icons/triangle-fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/Parameter.jsx b/src/components/Parameter.jsx index 5e3d40ad..3e3fdd33 100644 --- a/src/components/Parameter.jsx +++ b/src/components/Parameter.jsx @@ -16,10 +16,11 @@ import { Form, } from 'antd'; import { checkExist } from 'utils/file'; -import { forwardRef } from 'react'; +import { forwardRef, useCallback } from 'react'; import { isElectron, openDialog } from 'utils/electron'; import { SelectWithFileDialog } from 'features/scenario/components/CreateScenarioForms/FormInput'; +import { apiClient } from 'lib/api/axios'; // Helper component to standardize Form.Item props export const FormField = ({ name, help, children, ...props }) => { @@ -37,10 +38,54 @@ export const FormField = ({ name, help, children, ...props }) => { ); }; -const Parameter = ({ parameter, form }) => { - const { name, type, value, choices, nullable, help } = parameter; +const useParameterValidation = ({ needs_validation, toolName, name, form }) => { + // Create async validator for Ant Design Form.Item rules + const validator = useCallback( + async (_, fieldValue) => { + // Skip validation if not needed + if (!needs_validation || !toolName || !name) return Promise.resolve(); + + try { + const formValues = form.getFieldsValue(); + const response = await apiClient.post( + `/api/tools/${toolName}/validate-field`, + { + parameter_name: name, + value: fieldValue, + form_values: formValues, + }, + ); + + if (response.data.valid) { + return Promise.resolve(); + } else { + return Promise.reject(new Error(response.data.error)); + } + } catch (error) { + console.error('Validation error:', error); + const errorMessage = + error?.response?.data?.error || error?.message || 'Validation failed'; + return Promise.reject(new Error(errorMessage)); + } + }, + [needs_validation, toolName, name, form], + ); + + return validator; +}; + +const Parameter = ({ parameter, form, toolName }) => { + const { name, type, value, choices, nullable, help, needs_validation } = + parameter; const { setFieldsValue } = form; + const validator = useParameterValidation({ + needs_validation, + toolName, + name, + form, + }); + switch (type) { case 'IntegerParameter': case 'RealParameter': { @@ -167,6 +212,7 @@ const Parameter = ({ parameter, form }) => { ); } + case 'NetworkLayoutChoiceParameter': case 'ChoiceParameter': case 'PlantNodeParameter': case 'ScenarioNameParameter': @@ -179,35 +225,39 @@ const Parameter = ({ parameter, form }) => { value: choice, })); + const optionsValidator = (_, value) => { + if (choices.length < 1) { + if (type === 'GenerationParameter') + return Promise.reject( + 'No generations found. Run optimization first.', + ); + else + return Promise.reject('There are no valid choices for this input'); + } else if (!nullable) { + if (!value) return Promise.reject('Select a choice'); + if (!choices.includes(value)) + return Promise.reject(`${value} is not a valid choice`); + } + + return Promise.resolve(); + }; + return ( { - if (choices.length < 1) { - if (type === 'GenerationParameter') - return Promise.reject( - 'No generations found. Run optimization first.', - ); - else - return Promise.reject( - 'There are no valid choices for this input', - ); - } else if (value == null) { - return Promise.reject('Select a choice'); - } else if (!choices.includes(value)) { - return Promise.reject(`${value} is not a valid choice`); - } else { - return Promise.resolve(); - } - }, + validator: optionsValidator, }, ]} initialValue={value} > - ); } @@ -380,6 +430,22 @@ const Parameter = ({ parameter, form }) => { ); } + case 'NetworkLayoutNameParameter': { + return ( + + + + ); + } + default: return ( diff --git a/src/features/jobs/components/Jobs/JobInfoModal.jsx b/src/features/jobs/components/Jobs/JobInfoModal.jsx index 9299f326..2394b0de 100644 --- a/src/features/jobs/components/Jobs/JobInfoModal.jsx +++ b/src/features/jobs/components/Jobs/JobInfoModal.jsx @@ -92,7 +92,7 @@ const JobOutputModal = ({ job, visible, setVisible }) => { width={800} footer={false} onCancel={() => setVisible(false)} - destroyOnClose + destroyOnHidden >
{job.state == 1 && } diff --git a/src/features/map/components/Map/Layers/Selectors/Choice.jsx b/src/features/map/components/Map/Layers/Selectors/Choice.jsx index 37e038bb..bae43f1a 100644 --- a/src/features/map/components/Map/Layers/Selectors/Choice.jsx +++ b/src/features/map/components/Map/Layers/Selectors/Choice.jsx @@ -69,7 +69,12 @@ const ChoiceSelector = ({ scenarioName, mapLayerParameters ?? {}, ); - setChoices(data); + // Backend can return either array (legacy) or {choices: [...], default: "..."} (new) + if (Array.isArray(data)) { + setChoices({ choices: data, default: data[0] }); + } else { + setChoices(data); + } } catch (error) { console.error(error.response?.data); setChoices(null); @@ -79,21 +84,26 @@ const ChoiceSelector = ({ fetchChoices(); }, [dependsOnValues]); - // Set the first choice as the default value + // Set the default value from backend (or first choice as fallback) useEffect(() => { if (choices) { - handleChange(choices[0]); + const defaultValue = choices.default || choices.choices?.[0]; + console.log( + `[Choice] ${parameterName}: Using default value:`, + defaultValue, + ); + handleChange(defaultValue); } else { handleChange(null); } }, [choices]); - const options = choices?.map((choice) => ({ + const options = choices?.choices?.map((choice) => ({ value: choice, label: choice, })); - if (!choices) return null; + if (!choices || !choices.choices) return null; return (
diff --git a/src/features/map/components/Map/Map.jsx b/src/features/map/components/Map/Map.jsx index 4aa9f1e5..1911241b 100644 --- a/src/features/map/components/Map/Map.jsx +++ b/src/features/map/components/Map/Map.jsx @@ -3,14 +3,17 @@ import { useRef, useEffect, useState, useMemo, useCallback } from 'react'; import { DeckGL } from '@deck.gl/react'; import { GeoJsonLayer, + IconLayer, PointCloudLayer, PolygonLayer, TextLayer, } from '@deck.gl/layers'; -import { DataFilterExtension } from '@deck.gl/extensions'; +import { DataFilterExtension, PathStyleExtension } from '@deck.gl/extensions'; import positron from 'constants/mapStyles/positron.json'; import no_label from 'constants/mapStyles/positron_nolabel.json'; +// eslint-disable-next-line import/no-unresolved +import triangleFillIcon from 'assets/icons/triangle-fill.svg?url'; import * as turf from '@turf/turf'; import './Map.css'; @@ -220,13 +223,18 @@ const useMapLayers = (onHover = () => {}) => { data: mapLayers[name]?.edges, getLineWidth: (f) => normalizeLineWidth( - f.properties['peak_mass_flow'], + f.properties?.['peak_mass_flow'] ?? 0, min, max, 1, 7 * scale, ), getLineColor: edgeColour, + getDashArray: (f) => + f.properties?.['peak_mass_flow'] != null ? [0, 0] : [8, 4], + dashJustified: true, + dashGapPickable: true, + extensions: [new PathStyleExtension({ dash: true })], updateTriggers: { getLineWidth: [scale, min, max], }, @@ -239,23 +247,114 @@ const useMapLayers = (onHover = () => {}) => { }), ); - _layers.push( - new GeoJsonLayer({ - id: `${name}-nodes`, - data: mapLayers[name]?.nodes, - getFillColor: (f) => nodeFillColor(f.properties['type']), - getPointRadius: (f) => nodeRadius(f.properties['type']), - getLineColor: (f) => nodeLineColor(f.properties['type']), - getLineWidth: 1, - updateTriggers: { - getPointRadius: [scale], - }, - onHover: onHover, - pickable: true, - - parameters: { depthTest: false }, - }), + // Partition nodes by type + const nodesData = mapLayers[name]?.nodes; + const { plantNodes, consumerNodes, noneNodes } = ( + nodesData?.features ?? [] + ).reduce( + (acc, feature) => { + const type = feature.properties['type']; + if (type === 'PLANT') { + acc.plantNodes.push(feature); + } else if (type === 'CONSUMER') { + acc.consumerNodes.push(feature); + } else { + acc.noneNodes.push(feature); + } + return acc; + }, + { plantNodes: [], consumerNodes: [], noneNodes: [] }, ); + + // Add GeoJsonLayer for NONE nodes - rendered first (bottom layer) + if (noneNodes.length > 0) { + _layers.push( + new GeoJsonLayer({ + id: `${name}-none-nodes`, + data: { + type: 'FeatureCollection', + features: noneNodes, + }, + getFillColor: (f) => nodeFillColor(f.properties['type']), + getPointRadius: (f) => nodeRadius(f.properties['type']), + getLineColor: (f) => nodeLineColor(f.properties['type']), + getLineWidth: 1, + updateTriggers: { + getPointRadius: [scale], + }, + onHover: onHover, + pickable: true, + parameters: { depthTest: false }, + }), + ); + } + + // Add GeoJsonLayer for CONSUMER nodes - rendered second (above NONE nodes) + if (consumerNodes.length > 0) { + _layers.push( + new GeoJsonLayer({ + id: `${name}-consumer-nodes`, + data: { + type: 'FeatureCollection', + features: consumerNodes, + }, + getFillColor: (f) => nodeFillColor(f.properties['type']), + getPointRadius: (f) => nodeRadius(f.properties['type']), + getLineColor: (f) => nodeLineColor(f.properties['type']), + getLineWidth: 1, + updateTriggers: { + getPointRadius: [scale], + }, + onHover: onHover, + pickable: true, + parameters: { depthTest: false }, + }), + ); + } + + // Add IconLayer for plant nodes with triangle icon + // Rendered after other nodes to appear on top + if (plantNodes.length > 0) { + // Use bright yellow for high visibility and to complement blue/red edges + const plantColor = [255, 209, 29, 255]; // Bright yellow + + _layers.push( + new IconLayer({ + id: `${name}-plant-nodes`, + data: plantNodes, + getIcon: () => ({ + url: triangleFillIcon, + width: 64, + height: 64, + anchorY: 32, + mask: true, + }), + getPosition: (f) => { + const coords = f.geometry.coordinates; + // Add z-elevation of 3 meters to lift icon above the map + return [coords[0], coords[1], 3]; + }, + getSize: 10 * scale, + getColor: plantColor, + sizeUnits: 'meters', + sizeMinPixels: 20, + billboard: true, + loadOptions: { + imagebitmap: { + resizeWidth: 64, + resizeHeight: 64, + resizeQuality: 'high', + }, + }, + onHover: onHover, + pickable: true, + updateTriggers: { + getSize: [scale], + }, + parameters: { depthTest: false }, + }), + ); + } } if (name == DEMAND && mapLayers?.[name]) { diff --git a/src/features/map/components/Map/MapTooltip.jsx b/src/features/map/components/Map/MapTooltip.jsx index 36f72254..14e108d1 100644 --- a/src/features/map/components/Map/MapTooltip.jsx +++ b/src/features/map/components/Map/MapTooltip.jsx @@ -176,9 +176,10 @@ const MapTooltip = ({ info }) => { ? Math.round(Number(properties.pipe_DN) * 100) / 100 : null; - const peakMassFlow = properties?.peak_mass_flow - ? Math.round(Number(properties.peak_mass_flow) * 1000) / 1000 - : null; + const peakMassFlow = + properties?.peak_mass_flow != null + ? Math.round(Number(properties.peak_mass_flow) * 1000) / 1000 + : null; return (
@@ -212,12 +213,24 @@ const MapTooltip = ({ info }) => {
); - } else if (layer.id === `${THERMAL_NETWORK}-nodes`) { + } else if ( + layer.id === `${THERMAL_NETWORK}-none-nodes` || + layer.id === `${THERMAL_NETWORK}-consumer-nodes` || + layer.id === `${THERMAL_NETWORK}-plant-nodes` + ) { if (properties?.type === 'NONE') return null; + // Determine title based on node type + const nodeTitle = + properties?.type === 'PLANT' + ? 'Plant Node' + : properties?.type === 'CONSUMER' + ? 'Building Node' + : 'Network Node'; + return (
- Network Node + {nodeTitle}
ID
{object?.id} diff --git a/src/features/project/stores/projectStore.jsx b/src/features/project/stores/projectStore.jsx index 97df97ce..f3c3a27e 100644 --- a/src/features/project/stores/projectStore.jsx +++ b/src/features/project/stores/projectStore.jsx @@ -51,6 +51,17 @@ export const fetchProjectChoices = async () => { return data; } catch (error) { + // Handle 400 error when project root is not configured + if ( + error?.response?.status === 400 && + error?.response?.data?.detail === 'Project root not defined' + ) { + console.warn( + 'Project root not configured. Please set the project root path in settings.', + ); + // Return empty projects list instead of throwing + return { projects: [] }; + } console.error(error?.response?.data); throw error; } diff --git a/src/features/tools/components/Tools/Tool.jsx b/src/features/tools/components/Tools/Tool.jsx index 3ebb72e8..e5da1b1f 100644 --- a/src/features/tools/components/Tools/Tool.jsx +++ b/src/features/tools/components/Tools/Tool.jsx @@ -25,17 +25,20 @@ const useCheckMissingInputs = (tool) => { const [fetching, setFetching] = useState(false); const [error, setError] = useState(); - const fetch = async (parameters) => { - setFetching(true); - try { - await apiClient.post(`/api/tools/${tool}/check`, parameters); - setError(null); - } catch (err) { - setError(err.response.data?.detail?.script_suggestions); - } finally { - setFetching(false); - } - }; + const fetch = useCallback( + async (parameters) => { + setFetching(true); + try { + await apiClient.post(`/api/tools/${tool}/check`, parameters); + setError(null); + } catch (err) { + setError(err.response.data?.detail?.script_suggestions); + } finally { + setFetching(false); + } + }, + [tool], + ); // reset error when tool changes useEffect(() => { @@ -113,7 +116,10 @@ const useToolForm = ( externalForm = null, ) => { const [form] = Form.useForm(externalForm); - const { saveToolParams, setDefaultToolParams } = useToolsStore(); + const saveToolParams = useToolsStore((state) => state.saveToolParams); + const setDefaultToolParams = useToolsStore( + (state) => state.setDefaultToolParams, + ); const { createJob } = useJobsStore(); const setShowLoginModal = useSetShowLoginModal(); @@ -122,7 +128,7 @@ const useToolForm = ( }; // TODO: Add error callback - const getForm = async () => { + const getForm = useCallback(async () => { let out = null; if (!parameters) return out; @@ -165,15 +171,24 @@ const useToolForm = ( // setActiveKey((oldValue) => oldValue.concat(categoriesWithErrors)); } } - }; + }, [form, parameters, categoricalParameters]); const runScript = async () => { const params = await getForm(); - return createJob(script, params).catch((err) => { - if (err?.response?.status === 401) handleLogin(); - else console.error(`Error creating job: ${err}`); - }); + return createJob(script, params) + .then((result) => { + // Clear network-name field after successful job creation to prevent duplicate runs + if (script === 'network-layout' && params?.['network-name']) { + form.setFieldsValue({ 'network-name': '' }); + // Don't call validateFields - the component will detect the change and clear validation state + } + return result; + }) + .catch((err) => { + if (err?.response?.status === 401) handleLogin(); + else console.error(`Error creating job: ${err}`); + }); }; const saveParams = async () => { @@ -212,6 +227,10 @@ const Tool = ({ script, onToolSelected, header, form: externalForm }) => { const { isSaving } = useToolsStore((state) => state.toolSaving); const fetchToolParams = useToolsStore((state) => state.fetchToolParams); const resetToolParams = useToolsStore((state) => state.resetToolParams); + const updateParameterMetadata = useToolsStore( + (state) => state.updateParameterMetadata, + ); + const [isRefetching, setIsRefetching] = useState(false); const changes = useChangesExist(); @@ -223,7 +242,11 @@ const Tool = ({ script, onToolSelected, header, form: externalForm }) => { categorical_parameters: categoricalParameters, } = params; - const { fetch, fetching, error: _error } = useCheckMissingInputs(script); + const { + fetch: checkMissingInputs, + fetching, + error: _error, + } = useCheckMissingInputs(script); const disableButtons = fetching || _error !== null; const [headerVisible, setHeaderVisible] = useState(true); @@ -250,6 +273,13 @@ const Tool = ({ script, onToolSelected, header, form: externalForm }) => { } }, [description, showSkeleton]); + useEffect(() => { + // Reset header visibility when description changes + setHeaderVisible(true); + lastScrollPositionRef.current = 0; + descriptionHeightRef.current = 'auto'; + }, [description]); + const handleScroll = useCallback((e) => { // Ensure the scroll threshold greater than the description height to prevent layout shifts const scrollThreshold = descriptionHeightRef.current; @@ -267,10 +297,6 @@ const Tool = ({ script, onToolSelected, header, form: externalForm }) => { lastScrollPositionRef.current = currentScrollPosition; }, []); - const checkMissingInputs = (params) => { - fetch?.(params); - }; - const { form, getForm, runScript, saveParams, setDefault } = useToolForm( script, parameters, @@ -282,28 +308,78 @@ const Tool = ({ script, onToolSelected, header, form: externalForm }) => { externalForm, ); - const onMount = async () => { + const fetchParams = async () => { + if (script) await fetchToolParams(script); + else resetToolParams(); + + // Reset form fields to ensure they are in sync with the fetched parameters + form.resetFields(); + + // Check for missing inputs after fetching parameters const params = await getForm(); if (params) checkMissingInputs(params); }; + // FIXME: Run check missing inputs when form validation passes useEffect(() => { - const fetchParams = async () => { - if (script) await fetchToolParams(script); - else resetToolParams(); - - // Reset form fields to ensure they are in sync with the fetched parameters - form.resetFields(); - }; - fetchParams(); - - // Reset header visibility when the component mounts - setHeaderVisible(true); - lastScrollPositionRef.current = 0; - descriptionHeightRef.current = 'auto'; }, [script, fetchToolParams, resetToolParams, form]); + const handleRefetch = useCallback( + async (formValues, changedParam, affectedParams) => { + try { + setIsRefetching(true); + console.log( + `[handleRefetch] Refetching metadata - changed: ${changedParam}, affected: ${affectedParams?.join(', ')}`, + ); + + // Call API to get updated parameter metadata + const response = await apiClient.post( + `/api/tools/${script}/parameter-metadata`, + { + form_values: formValues, + affected_parameters: affectedParams, + }, + ); + + const { parameters } = response.data; + console.log( + `[handleRefetch] Received metadata for ${Object.keys(parameters).length} parameters`, + ); + + // Update parameter definitions in store + updateParameterMetadata(parameters); + + // Update form values for affected parameters if value changed + Object.keys(parameters).forEach((paramName) => { + const metadata = parameters[paramName]; + if (metadata.value !== undefined) { + const currentValue = form.getFieldValue(paramName); + if (currentValue !== metadata.value) { + console.log( + `[handleRefetch] Updating ${paramName} value: ${currentValue} -> ${metadata.value}`, + ); + form.setFieldValue(paramName, metadata.value); + } + } + }); + + // Re-check for missing inputs after metadata update + // Parameters may now depend on different input files + const currentParams = await getForm(); + if (currentParams) { + console.log('[handleRefetch] Re-checking for missing inputs'); + checkMissingInputs(currentParams); + } + } catch (err) { + console.error('Error refetching parameter metadata:', err); + } finally { + setIsRefetching(false); + } + }, + [script, form, updateParameterMetadata, getForm, checkMissingInputs], + ); + if (status == 'fetching' || showSkeleton) return (
@@ -321,7 +397,10 @@ const Tool = ({ script, onToolSelected, header, form: externalForm }) => { if (!label) return null; return ( - +
{ categoricalParameters={categoricalParameters} script={script} disableButtons={disableButtons} - onMount={onMount} + onRefetchNeeded={handleRefetch} />
diff --git a/src/features/tools/components/Tools/ToolForm.jsx b/src/features/tools/components/Tools/ToolForm.jsx index 6339f115..519903eb 100644 --- a/src/features/tools/components/Tools/ToolForm.jsx +++ b/src/features/tools/components/Tools/ToolForm.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useState, useCallback } from 'react'; import Parameter from 'components/Parameter'; import { Button, Collapse, Form } from 'antd'; import { animated } from '@react-spring/web'; @@ -7,14 +7,93 @@ import { useHoverGrow } from 'features/project/hooks/hover-grow'; import { RunIcon } from 'assets/icons'; -const ToolForm = ({ form, parameters, categoricalParameters, onMount }) => { +const ToolForm = ({ + form, + parameters, + categoricalParameters, + script, + onRefetchNeeded, +}) => { const [activeKey, setActiveKey] = useState([]); + const [watchedValues, setWatchedValues] = useState({}); + + // Watch for changes in parameters that have dependents + const handleFieldChange = useCallback( + (changedFields) => { + // Build dependency map: parameter_name -> [dependent_parameter_names] + const allParams = [ + ...(parameters || []), + ...Object.values(categoricalParameters || {}).flat(), + ]; + + const dependencyMap = {}; + allParams.forEach((param) => { + if (param.depends_on && Array.isArray(param.depends_on)) { + param.depends_on.forEach((depName) => { + if (!dependencyMap[depName]) { + dependencyMap[depName] = []; + } + dependencyMap[depName].push(param.name); + }); + } + // Backward compatibility: also check triggers_refetch + if (param.triggers_refetch) { + if (!dependencyMap[param.name]) { + dependencyMap[param.name] = []; + } + } + }); + + // Check if any changed field has dependents + for (const changedField of changedFields) { + const fieldName = changedField.name[0]; + const newValue = changedField.value; + const oldValue = watchedValues[fieldName]; + + // Skip refetch on initial load (when oldValue is undefined) + const isInitialLoad = oldValue === undefined; + + // Always update watched values when value changes + if (newValue !== oldValue) { + setWatchedValues((prev) => ({ ...prev, [fieldName]: newValue })); + } + + // Check if this field has dependents (via depends_on or triggers_refetch) + const hasDependents = dependencyMap[fieldName]?.length > 0; + + // Only trigger refetch if not initial load AND value changed AND has dependents + if (!isInitialLoad && newValue !== oldValue && hasDependents) { + console.log( + `Parameter ${fieldName} changed, refetching form (dependents: ${dependencyMap[fieldName].join(', ')})`, + ); + + // Trigger refetch with current form values, changed param, and affected params + const formValues = form.getFieldsValue(); + onRefetchNeeded?.( + { ...formValues, [fieldName]: newValue }, + fieldName, + dependencyMap[fieldName], + ); + break; // Only need one refetch + } + } + }, + [parameters, categoricalParameters, watchedValues, form, onRefetchNeeded], + ); let toolParams = null; if (parameters) { toolParams = parameters.map((param) => { if (param.type === 'ScenarioParameter') return null; - return ; + return ( + + ); }); } @@ -24,7 +103,13 @@ const ToolForm = ({ form, parameters, categoricalParameters, onMount }) => { key: category, label: category, children: categoricalParameters[category].map((param) => ( - + )), })); categoricalParams = ( @@ -36,12 +121,13 @@ const ToolForm = ({ form, parameters, categoricalParameters, onMount }) => { ); } - useEffect(() => { - onMount?.(); - }, []); - return ( -
+ {toolParams} {categoricalParams}
diff --git a/src/features/tools/stores/toolsStore.js b/src/features/tools/stores/toolsStore.js index 8130d046..8e884762 100644 --- a/src/features/tools/stores/toolsStore.js +++ b/src/features/tools/stores/toolsStore.js @@ -93,8 +93,6 @@ const useToolsStore = create((set, get) => ({ params, ); return response.data; - } catch (error) { - throw error; } finally { set((state) => ({ toolSaving: { ...state.toolSaving, isSaving: false }, @@ -102,6 +100,54 @@ const useToolsStore = create((set, get) => ({ } }, + updateParameterMetadata: (updatedMetadata) => { + set((state) => { + const currentParams = state.toolParams.params; + const newParameters = [...(currentParams.parameters || [])]; + const newCategoricalParameters = { + ...(currentParams.categoricalParameters || {}), + }; + + // Update parameters + Object.keys(updatedMetadata).forEach((paramName) => { + const metadata = updatedMetadata[paramName]; + + // Find in regular parameters + const paramIndex = newParameters.findIndex((p) => p.name === paramName); + if (paramIndex >= 0) { + newParameters[paramIndex] = { + ...newParameters[paramIndex], + ...metadata, + }; + } + + // Find in categorical parameters + Object.keys(newCategoricalParameters).forEach((category) => { + const catParamIndex = newCategoricalParameters[category].findIndex( + (p) => p.name === paramName, + ); + if (catParamIndex >= 0) { + newCategoricalParameters[category][catParamIndex] = { + ...newCategoricalParameters[category][catParamIndex], + ...metadata, + }; + } + }); + }); + + return { + toolParams: { + ...state.toolParams, + params: { + ...currentParams, + parameters: newParameters, + categoricalParameters: newCategoricalParameters, + }, + }, + }; + }); + }, + setDefaultToolParams: async (tool) => { set((state) => ({ toolSaving: { ...state.toolSaving, isSaving: true }, diff --git a/src/utils/validation.js b/src/utils/validation.js new file mode 100644 index 00000000..6aea086a --- /dev/null +++ b/src/utils/validation.js @@ -0,0 +1,75 @@ +/** + * Validation utilities for parameters + */ + +/** + * Debounce function - delays execution until after wait time has elapsed + * since the last invocation + */ +export const debounce = (func, wait) => { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +}; + +/** + * Validates network name for invalid filesystem characters + * @param {string} value - The network name to validate + * @returns {Promise} Resolves if valid, rejects with error message if invalid + */ +export const validateNetworkNameChars = (value) => { + const invalidChars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|']; + const hasInvalidChars = invalidChars.some((char) => value.includes(char)); + + if (hasInvalidChars) { + return Promise.reject( + `Network name contains invalid characters. Avoid: ${invalidChars.join(' ')}`, + ); + } + + return Promise.resolve(); +}; + +/** + * Validates network name against backend (collision detection) + * @param {Object} apiClient - Axios instance for API calls + * @param {string} tool - Tool name (e.g., 'network-layout') + * @param {string} value - The network name to validate + * @param {Object} config - Current config with scenario and network_type + * @returns {Promise} Resolves if valid, rejects with error message if invalid + */ +export const validateNetworkNameCollision = async ( + apiClient, + tool, + value, + config, +) => { + try { + // Call backend to save config with the new network name + // The backend's decode() method will validate for collisions + const params = { + 'network-name': value, + // Include dependencies for validation context + scenario: config.scenario, + 'network-type': config.network_type, + }; + + await apiClient.post(`/api/tools/${tool}/save-config`, params); + return Promise.resolve(); + } catch (error) { + // Backend validation failed - extract error message + const errorMessage = + error?.response?.data?.message || + error?.response?.data?.error || + error?.message || + 'Validation failed'; + + return Promise.reject(errorMessage); + } +};