|
|
@@ -1,4 +1,5 @@
|
|
|
-import { reactive, computed, watch, toRaw, isReactive } from "vue";
|
|
|
+import { reactive, computed, toRaw, isReactive, watch } from "vue";
|
|
|
+import { isEqual as isEqualDates, parse, isValid } from "date-fns";
|
|
|
|
|
|
export const useFormUpdateTracker = (initialFormValue) => {
|
|
|
const form = reactive(deepClone(initialFormValue));
|
|
|
@@ -13,53 +14,45 @@ export const useFormUpdateTracker = (initialFormValue) => {
|
|
|
return Object.keys(updatedFields).length > 0;
|
|
|
});
|
|
|
|
|
|
- const handleNestedObjects = (obj, orig, pathArr = []) => {
|
|
|
- Object.keys(obj).forEach((key) => {
|
|
|
- const value = obj[key];
|
|
|
- const origValue = orig ? orig[key] : undefined;
|
|
|
- const currentPath = pathArr.concat(key);
|
|
|
- const pathStr = currentPath.join(".");
|
|
|
-
|
|
|
- if (Array.isArray(value)) {
|
|
|
- if (!isEqual(value, origValue)) {
|
|
|
- updatedFields[pathStr] = deepClone(value);
|
|
|
- } else {
|
|
|
- delete updatedFields[pathStr];
|
|
|
- }
|
|
|
- } else if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
|
- handleNestedObjects(value, origValue, currentPath);
|
|
|
- } else {
|
|
|
- if (!isEqual(value, origValue)) {
|
|
|
- updatedFields[pathStr] = value;
|
|
|
- } else {
|
|
|
- delete updatedFields[pathStr];
|
|
|
- }
|
|
|
- }
|
|
|
- });
|
|
|
- };
|
|
|
-
|
|
|
watch(
|
|
|
form,
|
|
|
(newValue) => {
|
|
|
- handleNestedObjects(newValue, originalForm);
|
|
|
+ const changes = diff(newValue, originalForm);
|
|
|
+ Object.keys(changes).forEach((key) => {
|
|
|
+ if (changes[key] === null) {
|
|
|
+ delete updatedFields[key];
|
|
|
+ } else {
|
|
|
+ updatedFields[key] = deepClone(changes[key]);
|
|
|
+ }
|
|
|
+ });
|
|
|
},
|
|
|
{ deep: true },
|
|
|
);
|
|
|
|
|
|
const resetUpdateForm = () => {
|
|
|
- Object.keys(form).forEach((key) => {
|
|
|
- form[key] = deepClone(originalForm[key]);
|
|
|
- });
|
|
|
- Object.keys(updatedFields).forEach((key) => {
|
|
|
- delete updatedFields[key];
|
|
|
- });
|
|
|
+ const newFormState = deepClone(originalForm);
|
|
|
+ Object.keys(form).forEach((key) => delete form[key]);
|
|
|
+ Object.assign(form, newFormState);
|
|
|
};
|
|
|
|
|
|
const setUpdateFormAsOriginal = () => {
|
|
|
- originalForm = deepClone(form);
|
|
|
- Object.keys(updatedFields).forEach((key) => {
|
|
|
- delete updatedFields[key];
|
|
|
- });
|
|
|
+ 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 {
|
|
|
@@ -68,9 +61,101 @@ export const useFormUpdateTracker = (initialFormValue) => {
|
|
|
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 === null || data === undefined) {
|
|
|
+ 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);
|
|
|
@@ -85,18 +170,82 @@ function deepClone(obj) {
|
|
|
return JSON.parse(JSON.stringify(obj));
|
|
|
}
|
|
|
|
|
|
-function isEqual(a, b) {
|
|
|
+/**
|
|
|
+ * Attempts to parse a string into a Date object using a list of expected formats.
|
|
|
+ * @param {string} str The string to parse.
|
|
|
+ * @returns {Date|null} A valid Date object or null if parsing fails.
|
|
|
+ */
|
|
|
+function tryParseDate(str) {
|
|
|
+ if (typeof str !== "string") {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ const dateFormats = ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy-MM-dd"];
|
|
|
+
|
|
|
+ for (const format of dateFormats) {
|
|
|
+ const parsedDate = parse(str, format, new Date());
|
|
|
+ if (isValid(parsedDate)) {
|
|
|
+ return parsedDate;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return null;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Compares two values for deep equality.
|
|
|
+ * Handles arrays, objects, primitives, Date objects, and common date string formats.
|
|
|
+ * @param {*} a The first value to compare.
|
|
|
+ * @param {*} b The second value to compare.
|
|
|
+ * @returns {boolean} True if the values are equal, false otherwise.
|
|
|
+ */
|
|
|
+export function isEqual(a, b) {
|
|
|
if (a === b) return true;
|
|
|
- if (typeof a !== typeof b) return false;
|
|
|
+
|
|
|
+ if (
|
|
|
+ typeof a !== "object" ||
|
|
|
+ typeof b !== "object" ||
|
|
|
+ a === null ||
|
|
|
+ b === null
|
|
|
+ ) {
|
|
|
+ if (typeof a === "string" && typeof b === "string") {
|
|
|
+ const dateA = tryParseDate(a);
|
|
|
+ const dateB = tryParseDate(b);
|
|
|
+
|
|
|
+ if (dateA && dateB) {
|
|
|
+ return isEqualDates(dateA, dateB);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (a instanceof Date && b instanceof Date) {
|
|
|
+ return isEqualDates(a, b);
|
|
|
+ }
|
|
|
+
|
|
|
if (Array.isArray(a) && Array.isArray(b)) {
|
|
|
if (a.length !== b.length) return false;
|
|
|
- return a.every((item, i) => isEqual(item, b[i]));
|
|
|
+ for (let i = 0; i < a.length; i++) {
|
|
|
+ if (!isEqual(a[i], b[i])) return false;
|
|
|
+ }
|
|
|
+ return true;
|
|
|
}
|
|
|
- if (typeof a === "object" && a && b) {
|
|
|
- const aKeys = Object.keys(a);
|
|
|
- const bKeys = Object.keys(b);
|
|
|
- if (aKeys.length !== bKeys.length) return false;
|
|
|
- return aKeys.every((key) => isEqual(a[key], b[key]));
|
|
|
+
|
|
|
+ if (Array.isArray(a) || Array.isArray(b)) return false;
|
|
|
+
|
|
|
+ const aKeys = Object.keys(a);
|
|
|
+ const bKeys = Object.keys(b);
|
|
|
+
|
|
|
+ if (aKeys.length !== bKeys.length) return false;
|
|
|
+
|
|
|
+ for (const key of aKeys) {
|
|
|
+ if (
|
|
|
+ !Object.prototype.hasOwnProperty.call(b, key) ||
|
|
|
+ !isEqual(a[key], b[key])
|
|
|
+ ) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
}
|
|
|
- return false;
|
|
|
+
|
|
|
+ return true;
|
|
|
}
|