import { reactive, computed, toRaw, isReactive, watch } from "vue"; import isEqual from "fast-deep-equal"; export const useFormUpdateTracker = (initialFormValue) => { const form = reactive(deepClone(initialFormValue)); let originalForm = deepClone(initialFormValue); const updatedFields = reactive({}); const getUpdatedFields = computed(() => { return updatedFields; }); const hasUpdatedFields = computed(() => { return Object.keys(updatedFields).length > 0; }); watch( form, (newValue) => { const changes = diff(toRaw(newValue), originalForm); Object.keys(updatedFields).forEach((key) => delete updatedFields[key]); Object.assign(updatedFields, changes); }, { deep: true }, ); const resetUpdateForm = () => { const newFormState = deepClone(originalForm); Object.keys(form).forEach((key) => delete form[key]); Object.assign(form, newFormState); }; const setUpdateFormAsOriginal = () => { originalForm = deepClone(toRaw(form)); }; const getFormAsFormData = () => { const formData = new FormData(); buildFormData(formData, form); return formData; }; const getUpdatedFieldsAsFormData = (spoofMethod = null) => { const formData = new FormData(); buildFormData(formData, updatedFields); if (spoofMethod) { formData.append("_method", spoofMethod.toUpperCase()); } return formData; }; return { form, getUpdatedFields, hasUpdatedFields, resetUpdateForm, setUpdateFormAsOriginal, getFormAsFormData, getUpdatedFieldsAsFormData, }; }; /** * A recursive function to find the differences between two objects. * It returns a new object containing only the keys that have been added, * changed, or removed (set to null). * @param {object} currentObj The current state of the object. * @param {object} baseObj The original object to compare against. * @returns {object} An object with only the changed, new, or deleted keys. */ function diff(currentObj, baseObj) { const changes = {}; const currentKeys = Object.keys(currentObj); const baseKeys = Object.keys(baseObj); for (const key of currentKeys) { const currentValue = currentObj[key]; const baseValue = baseObj[key]; if (!isEqual(currentValue, baseValue)) { if ( currentValue && typeof currentValue === "object" && !Array.isArray(currentValue) && baseValue && typeof baseValue === "object" && !Array.isArray(baseValue) ) { const nestedChanges = diff(currentValue, baseValue); if (Object.keys(nestedChanges).length > 0) { changes[key] = nestedChanges; } } else { changes[key] = deepClone(currentValue); } } } for (const key of baseKeys) { if (!Object.prototype.hasOwnProperty.call(currentObj, key)) { changes[key] = null; } } return changes; } /** * Recursively builds a FormData object from a nested plain object using bracket notation * for keys, which is compatible with PHP/Laravel backends. * @param {FormData} formData The FormData instance. * @param {object} data The plain object to serialize. * @param {string} parentKey The base key for nested properties. */ function buildFormData(formData, data, parentKey = "") { if (data === undefined) { return; } if (data == null) { formData.append(parentKey, null); return; } if (Array.isArray(data)) { data.forEach((value, index) => { buildFormData(formData, value, `${parentKey}[${index}]`); }); return; } if ( typeof data === "object" && !(data instanceof File) && !(data instanceof Date) ) { Object.keys(data).forEach((key) => { const propName = parentKey ? `${parentKey}[${key}]` : key; buildFormData(formData, data[key], propName); }); return; } let valueToAppend = data; if (data instanceof Date) { valueToAppend = data.toISOString().slice(0, 19).replace("T", " "); } formData.append(parentKey, valueToAppend); } /** * Deep clones an object using structuredClone if available, * otherwise falls back to JSON methods. * If the object is reactive, it converts it to a raw object first. * @param {object} obj The object to clone. * @returns {object} A deep clone of the input object. */ function deepClone(obj) { if (obj && isReactive(obj)) { obj = toRaw(obj); } if (typeof structuredClone === "function") { try { return structuredClone(obj); } catch (e) { console.warn( "structuredClone not supported, using JSON methods instead: " + e, ); } } return JSON.parse(JSON.stringify(obj)); }