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 (
-
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);
+ }
+};