Skip to content

Commit

Permalink
feat: [PROD-13747] dynamic value fetching for number inputs
Browse files Browse the repository at this point in the history
  • Loading branch information
esasova committed Sep 16, 2024
1 parent 0e4da04 commit 81bff8f
Show file tree
Hide file tree
Showing 8 changed files with 187 additions and 27 deletions.
Empty file.
3 changes: 3 additions & 0 deletions public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,9 @@
"noValues": "No scenario selected",
"noOptions": "No scenarios available"
},
"numberInput": {
"fetchingValue": "Fetching parameter value..."
},
"loading": {
"line": {
"workspace": {
Expand Down
3 changes: 3 additions & 0 deletions public/locales/fr/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,9 @@
"noValues": "Aucun scénario sélectionné",
"noOptions": "Aucun scénario disponible"
},
"numberInput": {
"fetchingValue": "Récupération de la valeur en cours..."
},
"loading": {
"line": {
"workspace": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,43 @@
// Copyright (c) Cosmo Tech.
// Licensed under the MIT license.
import React, { useCallback } from 'react';
import React, { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import PropTypes from 'prop-types';
import { Grid } from '@mui/material';
import { BasicNumberInput } from '@cosmotech/ui';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import { Grid, Stack } from '@mui/material';
import { BasicNumberInput, FadingTooltip } from '@cosmotech/ui';
import { useLoadInitialValueFromDataset } from '../../../../hooks/DynamicValuesHooks';
import { useParameterConstraintValidation } from '../../../../hooks/ParameterConstraintsHooks';
import { TranslationUtils } from '../../../../utils';

export const GenericNumberInput = ({ parameterData, context, parameterValue, setParameterValue, isDirty, error }) => {
export const GenericNumberInput = ({
parameterData,
context,
parameterValue,
setParameterValue,
resetParameterValue,
isDirty,
error,
}) => {
const { t } = useTranslation();

const textFieldProps = {
disabled: !context.editMode,
id: `number-input-${parameterData.id}`,
};
const { dynamicValue, dynamicValueError, loadingDynamicValuePlaceholder } = useLoadInitialValueFromDataset(
parameterValue,
parameterData,
context.targetDatasetId
);

let value = parameterValue;
if (value == null) {
value = NaN;
}
useEffect(() => {
if (parameterValue == null && dynamicValue != null && !isDirty) {
if (parameterData.options?.dynamicValues) {
resetParameterValue(dynamicValue);
}
}
}, [parameterValue, dynamicValue, isDirty, resetParameterValue, parameterData.options?.dynamicValues]);

const changeValue = useCallback(
(newValue) => {
Expand All @@ -28,20 +46,29 @@ export const GenericNumberInput = ({ parameterData, context, parameterValue, set
[setParameterValue]
);

if (loadingDynamicValuePlaceholder) return loadingDynamicValuePlaceholder;

return (
<Grid item xs={3}>
<BasicNumberInput
key={parameterData.id}
id={parameterData.id}
label={t(TranslationUtils.getParameterTranslationKey(parameterData.id), parameterData.id)}
tooltipText={t(TranslationUtils.getParameterTooltipTranslationKey(parameterData.id), '')}
value={value}
changeNumberField={changeValue}
textFieldProps={textFieldProps}
isDirty={isDirty}
error={error}
/>
</Grid>
<Stack direction="row" gap={1} alignItems="center">
<Grid item xs={3}>
<BasicNumberInput
key={parameterData.id}
id={parameterData.id}
label={t(TranslationUtils.getParameterTranslationKey(parameterData.id), parameterData.id)}
tooltipText={t(TranslationUtils.getParameterTooltipTranslationKey(parameterData.id), '')}
value={parameterValue ?? NaN}
changeNumberField={changeValue}
textFieldProps={textFieldProps}
isDirty={isDirty}
error={error}
/>
</Grid>
{dynamicValueError && (
<FadingTooltip title={dynamicValueError} placement={'right'}>
<ErrorOutlineIcon data-cy="dynamic-value-error-icon" size="small" />
</FadingTooltip>
)}
</Stack>
);
};

Expand All @@ -50,6 +77,7 @@ GenericNumberInput.propTypes = {
context: PropTypes.object.isRequired,
parameterValue: PropTypes.any,
setParameterValue: PropTypes.func.isRequired,
resetParameterValue: PropTypes.func,
defaultParameterValue: PropTypes.number,
isDirty: PropTypes.bool,
error: PropTypes.object,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import React, { useCallback, useRef } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { useStore } from 'react-redux';
import PropTypes from 'prop-types';
import { ConfigUtils } from '../../../../utils/ConfigUtils';
import { ConfigUtils } from '../../../../utils';
import { VAR_TYPES_COMPONENTS_MAPPING } from '../../../../utils/scenarioParameters/VarTypesComponentsMapping';
import { useScenarioResetValues } from '../../ScenarioParametersContext';

Expand Down
125 changes: 123 additions & 2 deletions src/hooks/DynamicValuesHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { INGESTION_STATUS } from '../services/config/ApiConstants';
import { dispatchSetApplicationErrorMessage } from '../state/dispatchers/app/ApplicationDispatcher';
import { useFindDatasetById } from '../state/hooks/DatasetHooks';
import { useOrganizationId } from '../state/hooks/OrganizationHooks';
import { useCurrentScenarioParametersValues } from '../state/hooks/ScenarioHooks';
import { GENERIC_VAR_TYPES_DEFAULT_VALUES } from '../utils/scenarioParameters/generic/DefaultValues';

const useStyles = makeStyles((theme) => ({
error: {
Expand All @@ -30,8 +32,8 @@ export const useDynamicValues = (parameter, targetDatasetId) => {
// Possible value types for dynamicValues:
// - 'undefined' when dynamic values are not enabled in the parameter configuration
// - 'null' when fetching data, a placeholder with a spinner will be displayed instead of the dropdown list
// - a list when the dynamic values have been retrieved sucessfully, these values will be shown in the dropdown list
// - a string when an error occured, this string should be the error message to display
// - a list when the dynamic values have been retrieved successfully, these values will be shown in the dropdown list
// - a string when an error occurred, this string should be the error message to display
const [dynamicValues, setDynamicValues] = useState(null);

useEffect(() => {
Expand Down Expand Up @@ -115,3 +117,122 @@ export const useDynamicValues = (parameter, targetDatasetId) => {
loadingDynamicValuesPlaceholder,
};
};

export const useLoadInitialValueFromDataset = (parameterValue, parameter, targetDatasetId) => {
const { t } = useTranslation();
const findDatasetById = useFindDatasetById();
const organizationId = useOrganizationId();
const parametersValues = useCurrentScenarioParametersValues();

const isUnmounted = useRef(false);
useEffect(() => () => (isUnmounted.current = true), []);

// Possible value types for dynamicValue:
// - 'undefined' when dynamic values are not enabled in the parameter configuration
// - 'null' when fetching data, a placeholder with a spinner will be displayed instead of the input
// - a value when the query is successful, it will be displayed in the input
const [dynamicValue, setDynamicValue] = useState(undefined);
const [dynamicValueError, setDynamicValueError] = useState(null);
const defaultValue = parameter?.defaultValue ?? GENERIC_VAR_TYPES_DEFAULT_VALUES[parameter?.varType];

useEffect(() => {
if (isUnmounted.current) return;
const dynamicSourceConfig = parameter.options?.dynamicValues;
const scenarioParameterValue =
parametersValues.find((scenarioParameter) => scenarioParameter.parameterId === parameter.id) ?? null;
if (scenarioParameterValue !== null) {
setDynamicValueError(null);
}

const fetchDynamicValue = async () => {
if (parameterValue !== null) {
setDynamicValue(undefined);
return;
}

if (targetDatasetId == null) {
setDynamicValue(defaultValue);
setDynamicValueError(
"No dataset id forwarded to the parameter, can't fetch its value dynamically." +
' Parameter default value is displayed'
);
return;
}

const targetDataset = findDatasetById(targetDatasetId);
if (!targetDataset) {
setDynamicValue(defaultValue);
setDynamicValueError(
"Can't retrieve dynamic values: dataset doesn't exist. Parameter default value is displayed"
);
return;
}
if (targetDataset.ingestionStatus === null) {
setDynamicValue(defaultValue);
setDynamicValueError("Can't retrieve dynamic values: dataset is not twingraph type");
return;
}
if (targetDataset.ingestionStatus !== INGESTION_STATUS.SUCCESS) {
setDynamicValue(defaultValue);
setDynamicValueError(
`Can't retrieve dynamic values: dataset ingestionStatus is "${targetDataset.ingestionStatus}"`
);
return;
}

const query = { query: dynamicSourceConfig.query };
let data;
try {
data = await Api.Datasets.twingraphQuery(organizationId, targetDatasetId, query);
const resultKey = dynamicSourceConfig.resultKey;
const newDynamicValue = data.data[0]?.[resultKey];
if (newDynamicValue === undefined) {
setDynamicValue(defaultValue);
setDynamicValueError(
`No property found with result key "${resultKey}" in response to dynamic value query. ` +
'Please check your dataset and your solution configuration.'
);
return;
}
if (!isUnmounted.current) setDynamicValue(newDynamicValue);
} catch (error) {
console.warn(`An error occurred when loading dynamic value of parameter "${parameter.id}"`);
console.error(error);
setDynamicValue(defaultValue);
setDynamicValueError(
'genericcomponent.enumInput.fetchingDynamicValuesError',
'Impossible to retrieve dynamic values from data source'
);
}
};
if (dynamicSourceConfig) {
setDynamicValue(null);
fetchDynamicValue();
}
// Re-run this effect when
// 1. the target dataset (e.g. parent dataset for sub-dataset creation) changes
// 2. parameter value changes, otherwise, input doesn't receive the fetched value and displays an error
// 3. when parametersValues change after save to hide error icon
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [targetDatasetId, parameterValue, parametersValues]);

const loadingDynamicValuePlaceholder = useMemo(
() =>
dynamicValue === null ? (
<Grid container direction="row" alignItems="stretch">
<CircularProgress data-cy="fetching-dynamic-parameter-spinner" size="1rem" color="inherit" />
<Typography sx={{ px: 2 }}>
{t('genericcomponent.numberInput.fetchingValue', 'Fetching parameter value...')}
</Typography>
</Grid>
) : null,
[t, dynamicValue]
);

return {
dynamicValue,
setDynamicValue, // Not strictly necessary, but could be useful to reset dynamic values
dynamicValueError,
loadingDynamicValuePlaceholder,
};
};
4 changes: 4 additions & 0 deletions src/state/hooks/ScenarioHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ export const useCurrentScenarioData = () => {
return useSelector((state) => state.scenario.current?.data);
};

export const useCurrentScenarioParametersValues = () => {
return useSelector((state) => state.scenario.current?.data?.parametersValues);
};

export const useCurrentScenarioId = () => {
return useSelector((state) => state.scenario.current?.data?.id);
};
Expand Down
7 changes: 4 additions & 3 deletions src/utils/scenarioParameters/ScenarioParametersUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const shouldForceScenarioParametersUpdate = (runTemplateParametersIds, parameter
const isDynamicValueUploaded = Object.keys(parametersValues).some(
(parameterId) =>
dynamicParametersIds?.includes(parameterId) &&
parametersValues[parameterId].status === UPLOAD_FILE_STATUS_KEY.READY_TO_UPLOAD
parametersValues[parameterId]?.status === UPLOAD_FILE_STATUS_KEY.READY_TO_UPLOAD
);
return (
isDynamicValueUploaded || runTemplateParametersIds.some((parameterId) => hiddenParametersIds.includes(parameterId))
Expand Down Expand Up @@ -135,7 +135,7 @@ const _getDefaultParameterValue = (parameterId, solutionParameters) => {
console.warn(`Unknown scenario parameter "${parameterId}"`);
return undefined;
}

if (solutionParameter.options?.dynamicValues) return null;
let defaultValue = solutionParameter.defaultValue;
// defaultValue might not be in parameter data for parameters overridden by local config; when sent by the back-end,
// parameters should always have a defaultValue property, that will be set to 'null' by default
Expand All @@ -148,7 +148,8 @@ const _getDefaultParameterValue = (parameterId, solutionParameters) => {
}
console.warn(
`Couldn't find default value to use for scenario parameter "${parameterId}". Its varType may not be ` +
'defined, or its default value may be set to undefined (in this case, please use "null" instead of "undefined").'
'defined, or its default value may be set to undefined ' +
'(in this case, please use "null" instead of "undefined").'
);
};

Expand Down

0 comments on commit 81bff8f

Please sign in to comment.