Jelajahi Sumber

refactor: simplified form update tracking, enhanced date comparison logic and added formData functions

Denis 10 bulan lalu
induk
melakukan
eceb08fa28
1 mengubah file dengan 195 tambahan dan 46 penghapusan
  1. 195 46
      src/composables/useFormUpdateTracker.js

+ 195 - 46
src/composables/useFormUpdateTracker.js

@@ -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;
 }