wrolpi/app/src/hooks/useForm.js
Roland e00f0439da
ui: the last of Semantic UI, in name and in dead CSS
The library has been uninstalled for some time, but the codebase still read as
though it were there: identifiers named after it, rules written against its
class names, and about 180 comments explaining a rule in terms of a thing that
is no longer installed.

Renamed, keeping the vocabulary the app actually uses: semanticUIColorMap ->
navColorHexMap, semanticColorNames -> paletteColorNames, semanticNames ->
iconNameAliases, semanticSizes -> sizeAliases, and SemanticUIColors ->
NavColors in wrolpi/schema.py, which is what validates nav_color on the way in.
ColorToSemanticHexColor had no callers at all and is gone.

The baseline ratchet has done its job -- the list reached zero, so the JSON is
deleted and the guard keeps only the checks with teeth: no package, no import,
no `className="ui "`, no toast library.  Its Theme.tsx check went with the
wrappers it was watching for.  It also asserts its own walk found files, so a
clean result cannot mean it scanned nothing.

Deleted from App.css: every rule requiring `.ui`, `.inverted`, `.segment` or
`.modal`, none of which any component has set since the stylesheet stopped
loading, plus the form skeletons, ui-alerts and progress-text rules that
nothing renders.  Two of them were doing harm rather than nothing: the drag
tints had a dark-theme variant under `.inverted` that stopped applying, leaving
15% of a fixed blue over a dark row, and now mix a token instead.

Two selectors naming markup that no longer exists were live bugs.  The file
browser's filter had `.ui.input.file-browser-filter`, so it rendered at the
browser default width on a bar laid out to give it 34em -- now qualified by its
container, because `.wrolpi-action-input` sets `width: 100%` at the same
specificity and is loaded later.  And SearchBox takes named props rather than a
rest spread, so the `id` DestinationForm hands it was dropped in silence and
the label it renders pointed at nothing; the download forms' Destination field
has had no associated label since the migration.

"Semantic roles" survives in four places, where it means what it says.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:54 -06:00

505 lines
17 KiB
JavaScript

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 <input type="number" step="0.25"> 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 <input/>, 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 <input/>
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 kind={kind} title={message.title ?? message.header}>
{message.children ?? message.content}
</Message>;
}
/*
* 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 <div>
<label htmlFor={`${name}_input`} style={{display: 'block', marginBottom: 4}}>
<b>{label} {required && <RequiredAsterisk/>}</b>
{helpContent &&
<InfoPopup
content={helpContent}
position={helpPosition}
/>
}
</label>
<div className='wrolpi-form-field' data-error={inputProps.error ? true : undefined}>
<input
id={`${name}_input`}
placeholder={placeholder}
{...extraInputProps}
{...inputProps}
/>
</div>
{messageElm}
</div>
}
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 <InputForm
form={form}
required={required}
name={name}
path={path}
type='number'
validator={validator}
placeholder={placeholder}
label={label}
helpContent={helpContent}
helpPosition={helpPosition}
extraInputProps={{min, max}}
/>
}
export function UrlInput({form, required = true, name = 'url', path = 'url', disabled = false}) {
const validator = (i) => {
return validURL(i) ? null : 'Invalid URL';
};
return <InputForm
form={form}
type='url'
label='URL'
required={required}
name={name}
path={path}
validator={validator}
disabled={disabled}
/>
}
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 (
<Textarea
id="urls_textarea"
label="URLs"
required
error={inputProps.error}
placeholder="Enter one URL per line"
name="urls"
onDrop={handleDrop}
onKeyDown={handleKeyDown}
autosize
minRows={3}
{...inputProps}
/>
);
}
export function ToggleForm({form, name, label, path, icon = null, iconSize = 'big', disabled = false}) {
const [inputProps, inputAttrs] = form.getCustomProps({name, path})
const iconElm = icon ? <Icon size={iconSize === 'big' ? 'large' : iconSize} name={icon}/> : null;
return <div className='wrolpi-form-field'>
<label><b>{label}</b></label>
<div style={{display: 'flex', alignItems: 'center', gap: 8}}>
{iconElm}
<Toggle
disabled={disabled || form.disabled}
name={name}
checked={inputProps.value}
onChange={inputProps.onChange}
/>
</div>
</div>
}