useFormUpdateTracker.js 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. import { reactive, computed, toRaw, isReactive, watch } from "vue";
  2. import isEqual from "fast-deep-equal";
  3. export const useFormUpdateTracker = (initialFormValue) => {
  4. const form = reactive(deepClone(initialFormValue));
  5. let originalForm = deepClone(initialFormValue);
  6. const updatedFields = reactive({});
  7. const getUpdatedFields = computed(() => {
  8. return updatedFields;
  9. });
  10. const hasUpdatedFields = computed(() => {
  11. return Object.keys(updatedFields).length > 0;
  12. });
  13. watch(
  14. form,
  15. (newValue) => {
  16. const changes = diff(toRaw(newValue), originalForm);
  17. Object.keys(updatedFields).forEach((key) => delete updatedFields[key]);
  18. Object.assign(updatedFields, changes);
  19. },
  20. { deep: true },
  21. );
  22. const resetUpdateForm = () => {
  23. const newFormState = deepClone(originalForm);
  24. Object.keys(form).forEach((key) => delete form[key]);
  25. Object.assign(form, newFormState);
  26. };
  27. const setUpdateFormAsOriginal = () => {
  28. originalForm = deepClone(toRaw(form));
  29. };
  30. const getFormAsFormData = () => {
  31. const formData = new FormData();
  32. buildFormData(formData, form);
  33. return formData;
  34. };
  35. const getUpdatedFieldsAsFormData = (spoofMethod = null) => {
  36. const formData = new FormData();
  37. buildFormData(formData, updatedFields);
  38. if (spoofMethod) {
  39. formData.append("_method", spoofMethod.toUpperCase());
  40. }
  41. return formData;
  42. };
  43. return {
  44. form,
  45. getUpdatedFields,
  46. hasUpdatedFields,
  47. resetUpdateForm,
  48. setUpdateFormAsOriginal,
  49. getFormAsFormData,
  50. getUpdatedFieldsAsFormData,
  51. };
  52. };
  53. /**
  54. * A recursive function to find the differences between two objects.
  55. * It returns a new object containing only the keys that have been added,
  56. * changed, or removed (set to null).
  57. * @param {object} currentObj The current state of the object.
  58. * @param {object} baseObj The original object to compare against.
  59. * @returns {object} An object with only the changed, new, or deleted keys.
  60. */
  61. function diff(currentObj, baseObj) {
  62. const changes = {};
  63. const currentKeys = Object.keys(currentObj);
  64. const baseKeys = Object.keys(baseObj);
  65. for (const key of currentKeys) {
  66. const currentValue = currentObj[key];
  67. const baseValue = baseObj[key];
  68. if (!isEqual(currentValue, baseValue)) {
  69. if (
  70. currentValue &&
  71. typeof currentValue === "object" &&
  72. !Array.isArray(currentValue) &&
  73. baseValue &&
  74. typeof baseValue === "object" &&
  75. !Array.isArray(baseValue)
  76. ) {
  77. const nestedChanges = diff(currentValue, baseValue);
  78. if (Object.keys(nestedChanges).length > 0) {
  79. changes[key] = nestedChanges;
  80. }
  81. } else {
  82. changes[key] = deepClone(currentValue);
  83. }
  84. }
  85. }
  86. for (const key of baseKeys) {
  87. if (!Object.prototype.hasOwnProperty.call(currentObj, key)) {
  88. changes[key] = null;
  89. }
  90. }
  91. return changes;
  92. }
  93. /**
  94. * Recursively builds a FormData object from a nested plain object using bracket notation
  95. * for keys, which is compatible with PHP/Laravel backends.
  96. * @param {FormData} formData The FormData instance.
  97. * @param {object} data The plain object to serialize.
  98. * @param {string} parentKey The base key for nested properties.
  99. */
  100. function buildFormData(formData, data, parentKey = "") {
  101. if (data === undefined) {
  102. return;
  103. }
  104. if (data == null) {
  105. formData.append(parentKey, null);
  106. return;
  107. }
  108. if (Array.isArray(data)) {
  109. data.forEach((value, index) => {
  110. buildFormData(formData, value, `${parentKey}[${index}]`);
  111. });
  112. return;
  113. }
  114. if (
  115. typeof data === "object" &&
  116. !(data instanceof File) &&
  117. !(data instanceof Date)
  118. ) {
  119. Object.keys(data).forEach((key) => {
  120. const propName = parentKey ? `${parentKey}[${key}]` : key;
  121. buildFormData(formData, data[key], propName);
  122. });
  123. return;
  124. }
  125. let valueToAppend = data;
  126. if (data instanceof Date) {
  127. valueToAppend = data.toISOString().slice(0, 19).replace("T", " ");
  128. }
  129. formData.append(parentKey, valueToAppend);
  130. }
  131. /** * Deep clones an object using structuredClone if available,
  132. * otherwise falls back to JSON methods.
  133. * If the object is reactive, it converts it to a raw object first.
  134. * @param {object} obj The object to clone.
  135. * @returns {object} A deep clone of the input object.
  136. */
  137. function deepClone(obj) {
  138. if (obj && isReactive(obj)) {
  139. obj = toRaw(obj);
  140. }
  141. if (typeof structuredClone === "function") {
  142. try {
  143. return structuredClone(obj);
  144. } catch (e) {
  145. console.warn(
  146. "structuredClone not supported, using JSON methods instead: " + e,
  147. );
  148. }
  149. }
  150. return JSON.parse(JSON.stringify(obj));
  151. }