Current section
Files
Jump to
Current section
Files
priv/static/useLiveForm.js
import { ref, reactive, computed, toValue, watch, onScopeDispose, provide, inject, readonly, } from "vue";
import { useLiveVue } from "./use";
import { parsePath, getValueByPath, setValueByPath, deepClone, debounce, replaceReactiveObject, deepToRaw, deepEqual, sanitizeId, } from "./utils";
// Injection key for providing form instances to child components
export const LIVE_FORM_INJECTION_KEY = Symbol("LiveForm");
export function useLiveForm(form, options = {}) {
const { changeEvent = null, submitEvent = "submit", debounceInMiliseconds = 300, prepareData = (data) => data, } = options;
// Get initial form data
const initialForm = toValue(form);
const initialValues = reactive(deepClone(initialForm.values));
const currentValues = reactive(deepClone(initialForm.values));
const currentErrors = reactive(deepClone(initialForm.errors));
// Form-level state tracking
const touchedFields = reactive(new Set());
const submitCount = ref(0);
// Memoization for field instances to prevent recreation
const fieldCache = new Map();
const fieldArrayCache = new Map();
// Helper function to check if any errors exist in nested error structure
function hasAnyErrors(errors) {
if (Array.isArray(errors)) {
// Check if it's an array of strings (field errors) or array of objects (nested form errors)
if (errors.length === 0)
return false;
// If first item is string, it's a field error array
if (typeof errors[0] === "string") {
return errors.length > 0;
}
// If first item is object, it's an array of nested error objects
return errors.some(item => hasAnyErrors(item));
}
if (typeof errors === "object" && errors !== null) {
return Object.values(errors).some(value => hasAnyErrors(value));
}
return false;
}
// Form-level computed properties
const isValid = computed(() => !hasAnyErrors(currentErrors));
const isDirty = computed(() => {
return JSON.stringify(currentValues) !== JSON.stringify(initialValues);
});
const isTouched = computed(() => {
return submitCount.value > 0 || touchedFields.size > 0;
});
// LiveView integration
const live = useLiveVue();
// Immediate change handler
const sendChanges = async () => {
if (changeEvent) {
const values = deepToRaw(currentValues);
const data = prepareData(values);
return new Promise(resolve => live.pushEvent(changeEvent, { [initialForm.name]: data }, (result) => {
resolve(result);
}));
}
else {
return Promise.resolve(null);
}
};
// Create debounced change handler with validation status
const debounceWait = live && changeEvent ? debounceInMiliseconds : 0;
const { debouncedFn: debouncedSendChanges, isPending: isValidatingChanges } = debounce(sendChanges, debounceWait);
// Create a form field for a given path
function createFormField(path, options = {}) {
// Get or create array of fields for this path
if (!fieldCache.has(path)) {
fieldCache.set(path, []);
}
const fieldsForPath = fieldCache.get(path);
// Find existing field with matching options
const existingField = fieldsForPath.find(f => deepEqual(f._options, options));
if (existingField) {
return existingField;
}
// For checkboxes with values, determine multi-checkbox behavior based on current value type
const keys = parsePath(path);
const fieldId = sanitizeId(path) + (options.value !== undefined ? `_${sanitizeId(String(options.value))}` : "");
const fieldValue = computed({
get() {
return getValueByPath(currentValues, keys);
},
set(newValue) {
setValueByPath(currentValues, keys, newValue);
debouncedSendChanges();
},
});
const fieldErrors = computed(() => {
const errors = getValueByPath(currentErrors, keys);
return Array.isArray(errors) ? errors : [];
});
const fieldErrorMessage = computed(() => {
const errors = fieldErrors.value;
return errors.length > 0 ? errors[0] : undefined;
});
const fieldIsValid = computed(() => fieldErrors.value.length === 0);
const fieldIsTouched = computed(() => submitCount.value > 0 || touchedFields.has(path));
const fieldIsDirty = computed(() => {
const initialVal = getValueByPath(initialValues, keys);
return JSON.stringify(fieldValue.value) !== JSON.stringify(initialVal);
});
const setTouched = () => touchedFields.add(path);
const isMultiCheckboxValue = options.type === "checkbox" && Array.isArray(fieldValue.value);
const fieldInputAttrs = computed(() => {
const baseAttrs = {
name: path,
id: fieldId,
type: options.type,
onBlur: setTouched,
"aria-invalid": !fieldIsValid.value,
...(fieldErrors.value.length > 0 ? { "aria-describedby": `${fieldId}-error` } : {}),
};
// if it's a multi-checkbox, we need to set or unset the value in the array
if (isMultiCheckboxValue) {
return {
...baseAttrs,
value: options.value,
checked: (fieldValue.value || []).includes(options.value),
onInput: (event) => {
const target = event.target;
const currentArray = fieldValue.value;
const idx = currentArray.indexOf(options.value);
if (target.checked && idx === -1) {
currentArray.push(options.value);
}
else if (!target.checked && idx !== -1) {
currentArray.splice(idx, 1);
}
},
};
}
else if (options.type === "checkbox") {
const optionsValue = options.value !== undefined ? options.value : true;
return {
...baseAttrs,
value: options.value,
checked: fieldValue.value === optionsValue,
onInput: (event) => {
const target = event.target;
fieldValue.value = target.checked ? optionsValue : null;
},
};
}
else {
// Regular input
return {
...baseAttrs,
value: fieldValue.value,
onInput: (event) => {
const target = event.target;
fieldValue.value = target.value;
},
};
}
});
const field = {
value: fieldValue,
errors: fieldErrors,
errorMessage: fieldErrorMessage,
isValid: fieldIsValid,
isDirty: fieldIsDirty,
isTouched: fieldIsTouched,
inputAttrs: fieldInputAttrs,
_options: options,
field(key, options) {
const subPath = path ? `${path}.${String(key)}` : String(key);
return createFormField(subPath, options);
},
fieldArray(key) {
const subPath = path ? `${path}.${String(key)}` : String(key);
return createFormFieldArray(subPath);
},
blur() {
touchedFields.add(path);
},
};
// Add to cache
fieldsForPath.push(field);
return field;
}
function createFormFieldArray(path) {
// Check cache first
if (fieldArrayCache.has(path)) {
return fieldArrayCache.get(path);
}
const baseField = createFormField(path);
const keys = parsePath(path);
const updateArray = (newArray) => {
setValueByPath(currentValues, keys, newArray);
return debouncedSendChanges();
};
const fieldArray = {
...baseField,
add(item) {
// we don't want to add item immediately, rather we want to send it to the server if validation is enabled
const currentArray = baseField.value.value || [];
return updateArray([...currentArray, item]);
},
remove(index) {
const currentArray = baseField.value.value || [];
return updateArray(currentArray.filter((_, i) => i !== index));
},
move(from, to) {
const currentArray = [...(baseField.value.value || [])];
if (from >= 0 && from < currentArray.length && to >= 0 && to < currentArray.length) {
const item = currentArray.splice(from, 1)[0];
currentArray.splice(to, 0, item);
return updateArray(currentArray);
}
else {
return Promise.resolve();
}
},
fields: computed(() => {
const array = baseField.value.value || [];
return array.map((_, index) => createFormField(`${path}[${index}]`));
}),
field(pathOrIndex, options) {
// Handle number shortcut: convert 0 to "[0]"
if (typeof pathOrIndex === "number") {
return createFormField(`${path}[${pathOrIndex}]`, options);
}
// Handle string path: use as-is, could be "[0]", "[0].name", etc.
return createFormField(`${path}${pathOrIndex}`, options);
},
fieldArray(pathOrIndex) {
// Handle number shortcut: convert 0 to "[0]"
if (typeof pathOrIndex === "number") {
return createFormFieldArray(`${path}[${pathOrIndex}]`);
}
// Handle string path: use as-is, could be "[0]", "[0].tags", etc.
return createFormFieldArray(`${path}${pathOrIndex}`);
},
};
// Cache the field array instance
fieldArrayCache.set(path, fieldArray);
return fieldArray;
}
// Method to update form state from server
function updateFromServer(newForm) {
// Always update errors, we don't want to lose them
replaceReactiveObject(currentErrors, deepClone(newForm.errors));
// Only apply value updates if no validation is in progress
// Otherwise we could overwrite local client data with server data
if (!isValidatingChanges.value) {
Object.assign(currentValues, deepClone(newForm.values));
}
}
// Watch for server updates to the form
// setTimeout ensures updates are processed after current execution cycle
const stopWatchingForm = watch(() => toValue(form), () => setTimeout(() => updateFromServer(toValue(form)), 0), { deep: true });
const reset = () => {
Object.assign(currentValues, deepClone(initialValues));
touchedFields.clear();
submitCount.value = 0;
};
const submit = async () => {
// Increment submit count to mark all fields as touched
submitCount.value += 1;
if (live) {
const data = prepareData(deepToRaw(currentValues));
return await new Promise(resolve => {
// Send submit event to LiveView
live.pushEvent(submitEvent, { [initialForm.name]: data }, (result) => {
// if it was successful, we want to reset the form, but it's hard to determine if it was successfull or not in an automated way
// because eg initial form might have errors
// so, user should reset his form manually if it was successfull
// we provide a shortcut: if there's a reply with {reset: true},
// it means it should be resetted on the client side as well
if (result && result.reset) {
setTimeout(() => {
// let's wait for update from the server to be processed
Object.assign(initialValues, deepClone(currentValues));
reset();
}, 0);
}
resolve(result);
});
});
}
else {
// Fallback when not in LiveView context
console.warn("LiveView hook not available, form submission skipped");
return Promise.resolve(undefined);
}
};
// Clean up watchers when component unmounts
onScopeDispose(() => {
stopWatchingForm();
});
const formInstance = {
isValid,
isDirty,
isTouched,
isValidating: readonly(isValidatingChanges),
submitCount: readonly(submitCount),
initialValues: readonly(initialValues),
submit: submit,
reset: reset,
field(path, options) {
return createFormField(path, options);
},
fieldArray(path) {
return createFormFieldArray(path);
},
};
// Provide the form instance to child components
provide(LIVE_FORM_INJECTION_KEY, formInstance);
return formInstance;
}
/**
* Hook to access form fields from an injected form instance
* @param path - The field path (e.g., "name", "user.email", "items[0].title")
* @throws Error if no form was provided via inject
* @returns FormField instance for the specified path
*/
export function useField(path, options) {
const form = inject(LIVE_FORM_INJECTION_KEY);
if (!form) {
throw new Error("useField() can only be used inside components where a form has been provided. " +
"Make sure to use useLiveForm() in a parent component.");
}
return form.field(path, options);
}
/**
* Hook to access form array fields from an injected form instance
* @param path - The field path for an array field (e.g., "items", "user.tags", "posts[0].comments")
* @throws Error if no form was provided via inject
* @returns FormFieldArray instance for the specified path
*/
export function useArrayField(path) {
const form = inject(LIVE_FORM_INJECTION_KEY);
if (!form) {
throw new Error("useArrayField() can only be used inside components where a form has been provided. " +
"Make sure to use useLiveForm() in a parent component.");
}
return form.fieldArray(path);
}