@ordojs/forms
Version:
Comprehensive form handling system for OrdoJS
472 lines • 16.1 kB
JavaScript
/**
* Core form state management with reactive field-level tracking
*/
export class ReactiveForm {
constructor(config = {}) {
this.fieldConfigs = new Map();
this.fieldStates = new Map();
this.subscribers = new Set();
this.fieldSubscribers = new Map();
this.validationTimeouts = new Map();
this.config = {
initialValues: config.initialValues || {},
validateOnChange: config.validateOnChange ?? true,
validateOnBlur: config.validateOnBlur ?? true,
validateOnSubmit: config.validateOnSubmit ?? true,
resetOnSubmit: config.resetOnSubmit ?? false,
enableReinitialize: config.enableReinitialize ?? false,
onSubmit: config.onSubmit || (() => { }),
onValidationError: config.onValidationError || (() => { }),
onReset: config.onReset || (() => { })
};
this.state = this.createInitialState();
}
createInitialState() {
return {
values: { ...this.config.initialValues },
errors: {},
touched: {},
dirty: {},
valid: true,
validating: false,
submitting: false,
submitted: false,
submitCount: 0
};
}
createInitialFieldState(config) {
const value = this.state.values[config.name] ?? config.defaultValue ?? '';
return {
value,
error: null,
touched: false,
dirty: false,
valid: true,
validating: false,
focused: false
};
}
notifySubscribers() {
this.subscribers.forEach(callback => callback(this.state));
}
notifyFieldSubscribers(fieldName) {
const fieldState = this.fieldStates.get(fieldName);
if (fieldState) {
const subscribers = this.fieldSubscribers.get(fieldName);
if (subscribers) {
subscribers.forEach(callback => callback(fieldState));
}
}
}
async validateFieldInternal(name) {
const config = this.fieldConfigs.get(name);
const fieldState = this.fieldStates.get(name);
if (!config || !fieldState) {
return true;
}
// Set validating state
fieldState.validating = true;
this.state.validating = true;
this.notifyFieldSubscribers(name);
this.notifySubscribers();
let isValid = true;
let error = null;
try {
// Run synchronous rules first
if (config.rules) {
for (const rule of config.rules) {
const result = rule.validate(fieldState.value, this.state.values);
if (typeof result === 'boolean' && !result) {
error = { type: rule.type, message: rule.message, field: name };
isValid = false;
break;
}
else if (result instanceof Promise) {
const asyncResult = await result;
if (!asyncResult) {
error = { type: rule.type, message: rule.message, field: name };
isValid = false;
break;
}
}
}
}
// Run async rules if sync rules passed
if (isValid && config.asyncRules) {
for (const rule of config.asyncRules) {
const result = await rule.validate(fieldState.value, this.state.values);
if (!result) {
error = { type: rule.type, message: rule.message, field: name };
isValid = false;
break;
}
}
}
}
catch (err) {
error = {
type: 'validation_error',
message: err instanceof Error ? err.message : 'Validation failed',
field: name
};
isValid = false;
}
// Update field state
fieldState.valid = isValid;
fieldState.error = error;
fieldState.validating = false;
// Update form state
if (error) {
this.state.errors[name] = error;
}
else {
delete this.state.errors[name];
}
// Update form validity
this.updateFormValidity();
this.notifyFieldSubscribers(name);
this.notifySubscribers();
return isValid;
}
updateFormValidity() {
const hasErrors = Object.keys(this.state.errors).length > 0;
const hasValidatingFields = Array.from(this.fieldStates.values()).some(field => field.validating);
this.state.valid = !hasErrors;
this.state.validating = hasValidatingFields;
}
debounceValidation(name, ms) {
const existingTimeout = this.validationTimeouts.get(name);
if (existingTimeout) {
clearTimeout(existingTimeout);
}
const timeout = setTimeout(() => {
this.validateField(name);
this.validationTimeouts.delete(name);
}, ms);
this.validationTimeouts.set(name, timeout);
}
// FormInstance implementation
getState() {
return { ...this.state };
}
getFieldState(name) {
const fieldState = this.fieldStates.get(name);
return fieldState ? { ...fieldState } : undefined;
}
getValues() {
return { ...this.state.values };
}
getValue(name) {
return this.state.values[name];
}
getErrors() {
return { ...this.state.errors };
}
getError(name) {
return this.state.errors[name] || null;
}
setValue(name, value, shouldValidate = true) {
const config = this.fieldConfigs.get(name);
const fieldState = this.fieldStates.get(name);
if (!fieldState) {
return;
}
// Transform value if transformer exists
const transformedValue = config?.transform ? config.transform(value) : value;
// Update state
const oldValue = fieldState.value;
fieldState.value = transformedValue;
fieldState.dirty = transformedValue !== (config?.defaultValue ?? '');
this.state.values[name] = transformedValue;
this.state.dirty[name] = fieldState.dirty;
// Clear existing error if value changed
if (oldValue !== transformedValue && fieldState.error) {
fieldState.error = null;
delete this.state.errors[name];
this.updateFormValidity();
}
this.notifyFieldSubscribers(name);
this.notifySubscribers();
// Validate if required
if (shouldValidate && this.config.validateOnChange && config?.validateOnChange !== false) {
const debounceMs = config?.debounceMs ?? 300;
if (debounceMs > 0) {
this.debounceValidation(name, debounceMs);
}
else {
this.validateField(name);
}
}
}
setValues(values, shouldValidate = true) {
Object.entries(values).forEach(([name, value]) => {
this.setValue(name, value, false);
});
if (shouldValidate) {
this.validateForm();
}
}
setError(name, error) {
const fieldState = this.fieldStates.get(name);
if (fieldState) {
fieldState.error = error;
fieldState.valid = false;
}
this.state.errors[name] = error;
this.updateFormValidity();
this.notifyFieldSubscribers(name);
this.notifySubscribers();
}
setErrors(errors) {
Object.entries(errors).forEach(([name, error]) => {
this.setError(name, error);
});
}
clearError(name) {
const fieldState = this.fieldStates.get(name);
if (fieldState) {
fieldState.error = null;
fieldState.valid = true;
}
delete this.state.errors[name];
this.updateFormValidity();
this.notifyFieldSubscribers(name);
this.notifySubscribers();
}
clearErrors() {
this.fieldStates.forEach((fieldState, name) => {
fieldState.error = null;
fieldState.valid = true;
});
this.state.errors = {};
this.updateFormValidity();
this.fieldStates.forEach((_, name) => {
this.notifyFieldSubscribers(name);
});
this.notifySubscribers();
}
registerField(config) {
this.fieldConfigs.set(config.name, config);
if (!this.fieldStates.has(config.name)) {
this.fieldStates.set(config.name, this.createInitialFieldState(config));
}
// Initialize value if not already set
if (!(config.name in this.state.values)) {
this.state.values[config.name] = config.defaultValue ?? '';
}
this.notifySubscribers();
return () => {
this.unregisterField(config.name);
};
}
unregisterField(name) {
this.fieldConfigs.delete(name);
this.fieldStates.delete(name);
this.fieldSubscribers.delete(name);
delete this.state.values[name];
delete this.state.errors[name];
delete this.state.touched[name];
delete this.state.dirty[name];
// Clear any pending validation timeout
const timeout = this.validationTimeouts.get(name);
if (timeout) {
clearTimeout(timeout);
this.validationTimeouts.delete(name);
}
this.updateFormValidity();
this.notifySubscribers();
}
touchField(name) {
const fieldState = this.fieldStates.get(name);
if (fieldState) {
fieldState.touched = true;
this.state.touched[name] = true;
this.notifyFieldSubscribers(name);
this.notifySubscribers();
}
}
untouchField(name) {
const fieldState = this.fieldStates.get(name);
if (fieldState) {
fieldState.touched = false;
this.state.touched[name] = false;
this.notifyFieldSubscribers(name);
this.notifySubscribers();
}
}
focusField(name) {
const fieldState = this.fieldStates.get(name);
if (fieldState) {
fieldState.focused = true;
this.notifyFieldSubscribers(name);
}
}
blurField(name) {
const fieldState = this.fieldStates.get(name);
const config = this.fieldConfigs.get(name);
if (fieldState) {
fieldState.focused = false;
this.touchField(name);
this.notifyFieldSubscribers(name);
// Validate on blur if enabled
if (this.config.validateOnBlur && config?.validateOnBlur !== false) {
this.validateField(name);
}
}
}
async validateField(name) {
return this.validateFieldInternal(name);
}
async validateForm() {
const validationPromises = Array.from(this.fieldConfigs.keys()).map(name => this.validateFieldInternal(name));
const results = await Promise.all(validationPromises);
return results.every(result => result);
}
isValid() {
return this.state.valid;
}
isValidating() {
return this.state.validating;
}
async submit() {
this.state.submitting = true;
this.state.submitCount++;
this.notifySubscribers();
try {
// Validate form if required
if (this.config.validateOnSubmit) {
const isValid = await this.validateForm();
if (!isValid) {
this.config.onValidationError(this.state.errors, this);
return;
}
}
// Call onSubmit handler
await this.config.onSubmit(this.state.values, this);
this.state.submitted = true;
// Reset form if configured
if (this.config.resetOnSubmit) {
this.reset();
}
}
finally {
this.state.submitting = false;
this.notifySubscribers();
}
}
reset(values) {
const resetValues = values || this.config.initialValues;
// Reset form state
this.state = this.createInitialState();
this.state.values = { ...resetValues };
// Reset field states
this.fieldStates.forEach((fieldState, name) => {
const config = this.fieldConfigs.get(name);
const resetValue = resetValues[name] ?? config?.defaultValue ?? '';
fieldState.value = resetValue;
fieldState.error = null;
fieldState.touched = false;
fieldState.dirty = false;
fieldState.valid = true;
fieldState.validating = false;
fieldState.focused = false;
});
// Clear validation timeouts
this.validationTimeouts.forEach(timeout => clearTimeout(timeout));
this.validationTimeouts.clear();
this.config.onReset(this);
// Notify all subscribers
this.fieldStates.forEach((_, name) => {
this.notifyFieldSubscribers(name);
});
this.notifySubscribers();
}
resetField(name, value) {
const config = this.fieldConfigs.get(name);
const fieldState = this.fieldStates.get(name);
if (config && fieldState) {
const resetValue = value ?? config.defaultValue ?? '';
fieldState.value = resetValue;
fieldState.error = null;
fieldState.touched = false;
fieldState.dirty = false;
fieldState.valid = true;
fieldState.validating = false;
this.state.values[name] = resetValue;
delete this.state.errors[name];
this.state.touched[name] = false;
this.state.dirty[name] = false;
// Clear validation timeout
const timeout = this.validationTimeouts.get(name);
if (timeout) {
clearTimeout(timeout);
this.validationTimeouts.delete(name);
}
this.updateFormValidity();
this.notifyFieldSubscribers(name);
this.notifySubscribers();
}
}
isDirty() {
return Object.values(this.state.dirty).some(Boolean);
}
isTouched() {
return Object.values(this.state.touched).some(Boolean);
}
isSubmitting() {
return this.state.submitting;
}
hasBeenSubmitted() {
return this.state.submitted;
}
getSubmitCount() {
return this.state.submitCount;
}
handleSubmit(event) {
if (event) {
event.preventDefault();
}
return this.submit();
}
handleReset(event) {
if (event) {
event.preventDefault();
}
this.reset();
}
handleFieldChange(name, value) {
this.setValue(name, value);
}
handleFieldBlur(name) {
this.blurField(name);
}
handleFieldFocus(name) {
this.focusField(name);
}
subscribe(callback) {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
subscribeToField(name, callback) {
if (!this.fieldSubscribers.has(name)) {
this.fieldSubscribers.set(name, new Set());
}
const subscribers = this.fieldSubscribers.get(name);
subscribers.add(callback);
return () => {
subscribers.delete(callback);
if (subscribers.size === 0) {
this.fieldSubscribers.delete(name);
}
};
}
}
/**
* Create a new reactive form instance
*/
export function createForm(config) {
return new ReactiveForm(config);
}
//# sourceMappingURL=form.js.map