Add field vaidations, general improvements

This commit is contained in:
2025-11-24 17:22:11 +00:00
parent 0a610bd39a
commit c4ddf1f32b
9 changed files with 249 additions and 54 deletions

View File

@@ -28,6 +28,7 @@ const DeviationList = ({
<TimeInput
value={dev.time}
onChange={newTime => onDeviationChange(index, 'time', newTime)}
errorMessage={t.timeRequired}
/>
<div className="w-32">
<NumericInput
@@ -36,6 +37,7 @@ const DeviationList = ({
increment={doseIncrement}
min={0}
unit={t.mg}
errorMessage={t.fieldRequired}
/>
</div>
<button

View File

@@ -11,6 +11,7 @@ const DoseSchedule = ({ doses, doseIncrement, onUpdateDoses, t }) => {
<TimeInput
value={dose.time}
onChange={newTime => onUpdateDoses(doses.map((d, i) => i === index ? {...d, time: newTime} : d))}
errorMessage={t.timeRequired}
/>
<div className="w-40">
<NumericInput
@@ -19,6 +20,7 @@ const DoseSchedule = ({ doses, doseIncrement, onUpdateDoses, t }) => {
increment={doseIncrement}
min={0}
unit={t.mg}
errorMessage={t.fieldRequired}
/>
</div>
<span className="text-gray-600 text-sm flex-1">{t[dose.label] || dose.label}</span>

View File

@@ -8,8 +8,13 @@ const NumericInput = ({
max = Infinity,
placeholder,
unit,
align = 'right' // 'left', 'center', 'right'
align = 'right', // 'left', 'center', 'right'
allowEmpty = false, // Allow empty value (e.g., for "Auto" mode)
errorMessage = 'This field is required' // Error message for mandatory empty fields
}) => {
const [hasError, setHasError] = React.useState(false);
const [showErrorTooltip, setShowErrorTooltip] = React.useState(false);
const containerRef = React.useRef(null);
// Determine decimal places based on increment
const getDecimalPlaces = () => {
const inc = String(increment || '1');
@@ -30,13 +35,17 @@ const NumericInput = ({
const updateValue = (direction) => {
const numIncrement = parseFloat(increment) || 1;
let numValue = Number(value);
// If value is empty/Auto, treat as 0
if (isNaN(numValue)) {
numValue = min !== -Infinity ? min : 0;
numValue = 0;
}
numValue += direction * numIncrement;
numValue = Math.max(min, numValue);
numValue = Math.min(max, numValue);
const finalValue = formatValue(numValue);
setHasError(false); // Clear error when using buttons
onChange(finalValue);
};
@@ -53,14 +62,23 @@ const NumericInput = ({
if (val === '' || /^-?\d*\.?\d*$/.test(val)) {
// Prevent object values
if (typeof val === 'object') return;
setHasError(false); // Clear error on input
onChange(val);
}
};
const handleBlur = (e) => {
const val = e.target.value;
// Check if field is empty and not allowed to be empty
if (val === '' && !allowEmpty) {
setHasError(true);
return;
}
if (val !== '' && !isNaN(Number(val))) {
// Format the value when user finishes editing
setHasError(false);
onChange(formatValue(val));
}
};
@@ -75,33 +93,136 @@ const NumericInput = ({
}
};
const handleClear = () => {
setHasError(false);
setShowErrorTooltip(false);
onChange('');
};
const handleFocus = () => {
if (hasError) setShowErrorTooltip(true);
};
const handleInputBlurWrapper = (e) => {
handleBlur(e);
// Small delay to allow error state to be set before hiding tooltip
setTimeout(() => {
setShowErrorTooltip(false);
}, 100);
};
const handleMouseEnter = () => {
if (hasError) setShowErrorTooltip(true);
};
const handleMouseLeave = () => {
setShowErrorTooltip(false);
};
React.useEffect(() => {
// Close tooltip when clicking outside
const handleClickOutside = (event) => {
if (containerRef.current && !containerRef.current.contains(event.target)) {
setShowErrorTooltip(false);
}
};
if (showErrorTooltip) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showErrorTooltip]);
// Calculate tooltip position to prevent overflow
const [tooltipPosition, setTooltipPosition] = React.useState({ top: true, left: true });
React.useEffect(() => {
if (showErrorTooltip && containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
const tooltipWidth = 300; // Approximate width
const tooltipHeight = 60; // Approximate height
const spaceRight = window.innerWidth - rect.right;
const spaceBottom = window.innerHeight - rect.bottom;
setTooltipPosition({
top: spaceBottom < tooltipHeight + 10,
left: spaceRight < tooltipWidth
});
}
}, [showErrorTooltip]);
return (
<div className="flex items-center w-full">
<button
onClick={() => updateValue(-1)}
className="px-2 py-1 border rounded-l-md bg-gray-100 hover:bg-gray-200 text-lg font-bold"
tabIndex={-1}
>
-
</button>
<input
type="text"
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
placeholder={placeholder}
style={{ width: '4em', minWidth: '4em', maxWidth: '8em' }}
className={`p-2 border-t border-b text-sm ${getAlignmentClass()}`}
/>
<button
onClick={() => updateValue(1)}
className="px-2 py-1 border rounded-r-md bg-gray-100 hover:bg-gray-200 text-lg font-bold"
tabIndex={-1}
>
+
</button>
<div
ref={containerRef}
className="relative"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="relative inline-flex items-center">
<button
onClick={() => updateValue(-1)}
className="px-2 py-1 border rounded-l-md bg-gray-100 hover:bg-gray-200 text-lg font-bold"
tabIndex={-1}
>
-
</button>
<input
type="text"
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleInputBlurWrapper}
onFocus={handleFocus}
placeholder={placeholder}
style={{ width: '4em', minWidth: '4em', maxWidth: '8em' }}
className={`p-2 border-t border-b text-sm ${getAlignmentClass()} ${hasError ? 'bg-red-50' : ''}`}
/>
<button
onClick={() => updateValue(1)}
className={`px-2 py-1 border bg-gray-100 hover:bg-gray-200 text-lg font-bold ${!allowEmpty ? 'rounded-r-md' : ''}`}
tabIndex={-1}
>
+
</button>
{allowEmpty && (
<button
onClick={handleClear}
className="px-2 py-1 border rounded-r-md bg-gray-100 hover:bg-gray-200 text-gray-600 hover:text-gray-800"
tabIndex={-1}
title="Clear (set to Auto)"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
)}
{hasError && (
<div className="absolute inset-0 border-2 border-red-500 rounded-md pointer-events-none" style={{ zIndex: 10 }} />
)}
</div>
{unit && <span className="ml-2 text-gray-500 text-sm whitespace-nowrap">{unit}</span>}
{hasError && showErrorTooltip && (
<div
className={`absolute z-50 bg-white border-2 border-red-500 rounded-md shadow-lg p-2 flex items-start gap-2 ${
tooltipPosition.top ? 'bottom-full mb-1' : 'top-full mt-1'
} ${
tooltipPosition.left ? 'right-0' : 'left-0'
}`}
style={{ minWidth: '200px', maxWidth: '400px' }}
>
<div className="flex-shrink-0 mt-0.5">
<svg className="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="#fa5252"/>
<path d="M17 15.59l-1.41 1.41-3.59-3.59-3.59 3.59-1.41-1.41 3.59-3.59-3.59-3.59 1.41-1.41 3.59 3.59 3.59-3.59 1.41 1.41-3.59 3.59z" fill="white"/>
</svg>
</div>
<span className="text-xs text-red-700 whitespace-nowrap">{errorMessage}</span>
</div>
)}
</div>
);
};

View File

@@ -39,6 +39,7 @@ const Settings = ({
min={2}
max={7}
unit={t.days}
errorMessage={t.fieldRequired}
/>
</div>
@@ -51,19 +52,20 @@ const Settings = ({
min={1}
max={parseInt(simulationDays, 10) || 1}
unit={t.days}
errorMessage={t.fieldRequired}
/>
</div>
<label className="block font-medium text-gray-600 pt-2">{t.yAxisRange}</label>
<div className="flex items-center space-x-3 mt-1">
<div className="w-32">
<div className="flex items-center mt-1">
<div className="w-30">
<NumericInput
value={yAxisMin}
onChange={val => onUpdateUiSetting('yAxisMin', val)}
increment={'5'}
min={0}
placeholder={t.auto}
unit="ng/ml"
allowEmpty={true}
/>
</div>
<span className="text-gray-500 px-2">-</span>
@@ -75,20 +77,21 @@ const Settings = ({
min={0}
placeholder={t.auto}
unit="ng/ml"
allowEmpty={true}
/>
</div>
</div>
<label className="block font-medium text-gray-600">{t.therapeuticRange}</label>
<div className="flex items-center space-x-3 mt-1">
<div className="w-32">
<div className="flex items-center mt-1">
<div className="w-30">
<NumericInput
value={therapeuticRange.min}
onChange={val => onUpdateTherapeuticRange('min', val)}
increment={'0.5'}
min={0}
placeholder={t.min}
unit="ng/ml"
errorMessage={t.fieldRequired}
/>
</div>
<span className="text-gray-500 px-2">-</span>
@@ -100,6 +103,7 @@ const Settings = ({
min={0}
placeholder={t.max}
unit="ng/ml"
errorMessage={t.fieldRequired}
/>
</div>
</div>
@@ -113,6 +117,7 @@ const Settings = ({
increment={'0.5'}
min={0.1}
unit="h"
errorMessage={t.fieldRequired}
/>
</div>
@@ -125,6 +130,7 @@ const Settings = ({
increment={'0.1'}
min={0.1}
unit="h"
errorMessage={t.fieldRequired}
/>
</div>
<div className="w-40">
@@ -135,6 +141,7 @@ const Settings = ({
increment={'0.1'}
min={0.1}
unit={t.faster}
errorMessage={t.fieldRequired}
/>
</div>

View File

@@ -1,10 +1,13 @@
import React from 'react';
const TimeInput = ({ value, onChange }) => {
const TimeInput = ({ value, onChange, errorMessage = 'Time is required' }) => {
const [displayValue, setDisplayValue] = React.useState(value);
const [isPickerOpen, setIsPickerOpen] = React.useState(false);
const [hasError, setHasError] = React.useState(false);
const [showErrorTooltip, setShowErrorTooltip] = React.useState(false);
const [pickerHours, pickerMinutes] = (value || "00:00").split(':').map(Number);
const pickerRef = React.useRef(null);
const containerRef = React.useRef(null);
React.useEffect(() => {
setDisplayValue(value);
@@ -16,19 +19,32 @@ const TimeInput = ({ value, onChange }) => {
if (pickerRef.current && !pickerRef.current.contains(event.target)) {
setIsPickerOpen(false);
}
if (containerRef.current && !containerRef.current.contains(event.target)) {
setShowErrorTooltip(false);
}
};
if (isPickerOpen) {
if (isPickerOpen || showErrorTooltip) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isPickerOpen]);
}, [isPickerOpen, showErrorTooltip]);
const handleBlur = (e) => {
let input = e.target.value.replace(/[^0-9]/g, '');
const inputValue = e.target.value.trim();
// Check if field is empty
if (inputValue === '') {
setHasError(true);
// Small delay before hiding tooltip
setTimeout(() => setShowErrorTooltip(false), 100);
return;
}
let input = inputValue.replace(/[^0-9]/g, '');
let hours = '00', minutes = '00';
if (input.length <= 2) {
hours = input.padStart(2, '0');
@@ -44,11 +60,15 @@ const TimeInput = ({ value, onChange }) => {
hours = Math.min(23, parseInt(hours, 10) || 0).toString().padStart(2, '0');
minutes = Math.min(59, parseInt(minutes, 10) || 0).toString().padStart(2, '0');
const formattedTime = `${hours}:${minutes}`;
setHasError(false);
setShowErrorTooltip(false);
setDisplayValue(formattedTime);
onChange(formattedTime);
};
const handleChange = (e) => {
setHasError(false); // Clear error on input
setShowErrorTooltip(false);
setDisplayValue(e.target.value);
};
@@ -60,19 +80,42 @@ const TimeInput = ({ value, onChange }) => {
newMinutes = val;
}
const formattedTime = `${String(newHours).padStart(2, '0')}:${String(newMinutes).padStart(2, '0')}`;
setHasError(false); // Clear error when using picker
setShowErrorTooltip(false);
onChange(formattedTime);
};
const handleFocus = () => {
if (hasError) setShowErrorTooltip(true);
};
const handleMouseEnter = () => {
if (hasError) setShowErrorTooltip(true);
};
const handleMouseLeave = () => {
setShowErrorTooltip(false);
};
return (
<div className="relative flex items-center">
<input
type="text"
value={displayValue}
onChange={handleChange}
onBlur={handleBlur}
placeholder="HH:MM"
className="p-2 border rounded-md w-24 text-sm text-center"
/>
<div
ref={containerRef}
className="relative"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="flex items-center">
<div className={`${hasError ? 'ring-2 ring-red-500 rounded-md' : ''}`}>
<input
type="text"
value={displayValue}
onChange={handleChange}
onBlur={handleBlur}
onFocus={handleFocus}
placeholder="HH:MM"
className={`p-2 border rounded-md w-24 text-sm text-center ${hasError ? 'bg-red-50 border-transparent' : ''}`}
/>
</div>
<button
onClick={() => setIsPickerOpen(!isPickerOpen)}
className="ml-2 p-2 text-gray-500 hover:text-gray-700"
@@ -82,7 +125,7 @@ const TimeInput = ({ value, onChange }) => {
</svg>
</button>
{isPickerOpen && (
<div ref={pickerRef} className="absolute top-full mt-2 z-10 bg-white p-4 rounded-lg shadow-xl border w-64">
<div ref={pickerRef} className="absolute top-full mt-2 z-[60] bg-white p-4 rounded-lg shadow-xl border w-64">
<div className="text-center text-lg font-bold mb-3">{value}</div>
<div>
<div className="mb-2"><span className="font-semibold">Hour:</span></div>
@@ -120,6 +163,18 @@ const TimeInput = ({ value, onChange }) => {
</button>
</div>
)}
{hasError && showErrorTooltip && (
<div className="absolute left-0 top-full mt-1 z-50 bg-white border-2 border-red-500 rounded-md shadow-lg p-2 flex items-start gap-2" style={{ maxWidth: '28rem' }}>
<div className="flex-shrink-0 mt-0.5">
<svg className="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="#fa5252"/>
<path d="M17 15.59l-1.41 1.41-3.59-3.59-3.59 3.59-1.41-1.41 3.59-3.59-3.59-3.59 1.41-1.41 3.59 3.59 3.59-3.59 1.41 1.41-3.59 3.59z" fill="white"/>
</svg>
</div>
<span className="text-xs text-red-700 break-words">{errorMessage}</span>
</div>
)}
</div>
</div>
);
};