import React from "react"; import _ from "lodash"; import {InfoPopup, RequiredAsterisk, Toggle, validURL, validURLs} from "../components/Common"; import {Icon, Message, Textarea} from "../components/ui"; async function asyncNoOp() { } export function useForm({ fetcher, submitter, defaultFormData, emptyFormData, onSuccess = asyncNoOp, onFailure = asyncNoOp, onDirty = _.noop, clearOnSuccess = false, fetchOnSuccess = false, }) { const [formData, setFormData] = React.useState(defaultFormData || {}); const [disabled, setDisabled] = React.useState(false); const [loading, setLoading] = React.useState(false); const [ready, setReady] = React.useState(false); const [dirty, setDirty] = React.useState(false); // Form-level error (e.g. initial fetch failed). Distinct from field-level `errors`. const [error, setError] = React.useState(null); const [errors, setErrors] = React.useState({}); const [validators, setValidators] = React.useState({}); const [validValues, setValidValues] = React.useState({}); const [requires, setRequires] = React.useState({}); const memoizedFormData = React.useMemo(() => formData, [formData]); const memoizedErrors = React.useMemo(() => errors, [errors]); const memoizedValidValues = React.useMemo(() => validValues, [validValues]); const memoizedRequires = React.useMemo(() => requires, [requires]); if (clearOnSuccess && fetchOnSuccess) { console.error('Cannot use both clearOnSuccess and fetchOnSuccess!'); } if (fetchOnSuccess && !fetcher) { console.error('Cannot fetchOnSuccess without fetcher!'); } React.useEffect(() => { // Form is dirty if it has deviated from the default. setDirty(!_.isEqual(defaultFormData, memoizedFormData)); // Fetch/load failures keep the form not ready so pages can show error UI. if (error) { console.debug('Form invalid because of form-level error'); setReady(false); return } const errorValues = Object.values(errors).filter(i => !!i); if (errorValues.length > 0) { console.debug('Form invalid because it has errors'); setReady(false); return } const invalidValues = Object.values(memoizedValidValues).filter(i => i !== true); if (invalidValues.length > 0) { console.debug('Form invalid because it failed validators'); setReady(false); return } const missingValues = Object.keys(memoizedRequires).filter(i => !_.get(formData, i)); if (missingValues.length > 0) { console.debug(`Form invalid because it is missing required value: ${missingValues[0]}`); setReady(false); return } setReady(true); }, [loading, memoizedFormData, memoizedErrors, memoizedValidValues, memoizedRequires, error]); const localFetch = async () => { if (fetcher) { try { const result = await fetcher(); setFormData(result); setError(null); return result; } catch (e) { // Catch so callers (useEffect, page refresh) do not get an unhandled rejection // (which would show the webpack-dev-server error overlay and break Cypress). console.error('Failed to fetch form data:', e); setError(e?.message || String(e) || 'Failed to load'); } } } React.useEffect(() => { localFetch(); }, []); React.useEffect(() => { if (dirty) { onDirty(); } }, [dirty, onDirty]); const patchFormData = (newFormData) => { newFormData = {...formData, ...newFormData}; // console.debug('useForm.patchFormData', newFormData); setFormData(newFormData); }; const reset = () => { setFormData(emptyFormData || defaultFormData); } const onSubmit = async () => { if (!ready) { console.error('Refusing to submit form because it is not ready'); return; } setDisabled(true); setLoading(true); setReady(false); try { await submitter(formData); if (fetchOnSuccess && fetcher) { const result = await fetcher(); setFormData(result); } else if (clearOnSuccess) { reset(); } await onSuccess(); } catch (error) { await onFailure(error); throw error; } finally { setLoading(false); setDisabled(false); } } // Using useCallback with dependencies to ensure the debounce function doesn't change on every render const debouncedValidate = React.useCallback(_.debounce((path, value) => { const validator = validators[path]; let error = null; if (validator) { try { error = validator(value); } catch (e) { console.error(`Failed to validate ${path}`); } } console.debug('useForm.debouncedValidate', path, value, 'error=', error); setValidValues(prev => ({...prev, [path]: !error})); setErrors(prev => { if (error) { return {...prev, [path]: error}; } else { const {[path]: _, ...newErrors} = prev; return newErrors; } }); }, 300), [validators]); const handleInputEvent = (e) => { if (e) e.preventDefault(); let {type, value} = e.target; let {path} = e.target.dataset; path = path || e.target.name; if (type === 'number' && value !== '' && !isNaN(value)) { // Use parseFloat (not parseInt) so decimal values (sleep_requests=0.75, durations, etc.) // coming from are preserved as floats. // The explicit empty-string check prevents isNaN('') === false from producing NaN in state. value = parseFloat(value); } console.debug('handleInputEvent', 'path=', path, 'type=', type, 'value=', value); // Change value in state first, this part happens immediately const newFormData = _.set(formData, path.split('.'), value); patchFormData(newFormData); // Trigger validation after a delay debouncedValidate(path, value); return value; } const addValidator = (path, validator) => { if (path && !!validator && !(path in validators)) { setValidators({...validators, [path]: validator}); } } const addRequires = (name) => { if (name && !(name in requires)) { setRequires({...requires, [name]: false}); } } const setValue = (path, newValue) => { patchFormData(_.set(formData, path, newValue)); } const getCustomProps = ({name, validator, type = 'text', path, required = false, afterChange}) => { // Props for any other elements. path = path || name; addValidator(path, validator); if (required) { addRequires(path); } const value = _.get(formData, path); // Get a path, separated by .'s if (value === undefined) { patchFormData(_.set(formData, path, type === 'array' ? [] : null)); } // Allow bypass of `handleInputEvent` which only handles an input event. const localSetValue = newValue => { setValue(path, newValue); debouncedValidate(path, newValue); } // Props for , etc. const inputProps = { type, disabled, value, onChange: (newValue) => { setValue(path, newValue); if (afterChange) { afterChange(newValue); } }, 'data-path': path, } // Attributes about the input, but should not be passed as properties. const inputAttrs = { valid: validValues[path], path, localSetValue, }; return [inputProps, inputAttrs] } const getInputProps = ({ name, validator, path, required = false, type = 'text', onChange = null, afterChange = null }) => { // Props for path = path || name; // Use generic validators if no validator is provided. if (!validator && type === 'url') { validator = validURL; } const [customProps, inputAttrs] = getCustomProps({name, validator, path, type, required, afterChange}); const localHandleInputEvent = async (e) => { if (e) e.preventDefault(); const value = handleInputEvent(e); if (onChange) { await onChange(value); } } // Attributes that should be passed as properties to the input. const inputProps = { ...customProps, name: name, onChange: localHandleInputEvent, error: errors[path] || null, required: required ? null : undefined, } return [inputProps, inputAttrs] } const getSelectionProps = ({name, validator, path, type, required = false, afterChange = null}) => { // Props for Dropdowns. path = path || name; const [customProps, inputAttrs] = getCustomProps({name, validator, type, path, required, afterChange}); customProps.onChange = (e, {value}) => { patchFormData(_.set(formData, path, value)); if (afterChange) { afterChange(value); } }; return [customProps, inputAttrs] } return { dirty, disabled, error, fetcher: localFetch, formData, getCustomProps, getInputProps, getSelectionProps, handleInputEvent, setValue, loading, onSubmit, ready, reset, } } export const commaSeparatedValidator = (value) => { if (typeof value !== 'string') { return 'Expected a string'; } if (value.endsWith(',')) { return 'Cannot end with comma'; } if (value.startsWith(',')) { return 'Cannot start with comma'; } } export function InputForm({ form, required = false, name, path, type = 'text', validator, placeholder = '', label, helpContent = null, helpPosition = 'top', extraInputProps = {}, disabled = false, onChange = null, message = null, }) { const [inputProps, inputAttrs] = form.getInputProps({name, path, validator, type, required, onChange}); inputProps.disabled = disabled || inputProps.disabled; /* * Callers build these as {content, header, positive|negative} -- * and the pages that build them are migrating separately, so both spellings are * accepted until they have all moved to {kind, title}. */ let messageElm = null; if (message) { const kind = message.kind || (message.positive && 'success') || (message.negative && 'error') || 'info'; messageElm = {message.children ?? message.content} ; } /* * One element, not a fragment: every caller that puts an InputForm in a grid or flex * container was getting the label and the input laid out as two separate items -- the * input beside its own label rather than under it. The label is block with the same * 4px gap the migrated fields (Download.js FieldLabel) use, so an InputForm and a * DestinationForm side by side line their inputs up at the same height. */ return
{messageElm}
} export function NumberInputForm({ form, required, name, path, validator, placeholder = '', label, helpContent, helpPosition, min = 1, max = 9999999999, }) { // Default to positive number. validator = validator || ((value) => { value = _.isNumber(value) ? value : parseFloat(value); if (value < 0) { return 'Number must be positive' } }); return } export function UrlInput({form, required = true, name = 'url', path = 'url', disabled = false}) { const validator = (i) => { return validURL(i) ? null : 'Invalid URL'; }; return } export function UrlsTextarea({name = 'urls', required, form}) { required = required !== undefined; // Memoize input props to prevent recomputation during render const [inputProps, inputAttrs] = React.useMemo(() => { const validator = (value) => { if (!validURLs(value)) { return 'Invalid URLs'; } }; return form.getInputProps({name, validator, required}); }, [form, name, required]); const handleDrop = (e) => { if (e) e.preventDefault(); const droppedUrl = e.dataTransfer.getData('text'); let urls = (inputProps.value || '').split('\n'); urls = [...urls, droppedUrl]; urls = urls.filter(i => !!i).join('\n'); inputAttrs.localSetValue(`${urls}\n`); }; const handleKeyDown = (event) => { if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { event.preventDefault(); form.onSubmit(); } }; return (