mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
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>
505 lines
17 KiB
JavaScript
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>
|
|
}
|