diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationAdvancedFilter.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationAdvancedFilter.vue index c0752a478..5c076d5cd 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationAdvancedFilter.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationAdvancedFilter.vue @@ -89,6 +89,7 @@ import { filterAttributeGroups } from './advancedFilterItems'; import filterMixin from 'shared/mixins/filterMixin'; import * as OPERATORS from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js'; import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events'; +import cloneObject from 'dashboard/helpers/clone'; export default { components: { @@ -351,7 +352,7 @@ export default { if (this.$v.$invalid) return; this.$store.dispatch( 'setConversationFilters', - JSON.parse(JSON.stringify(this.appliedFilters)) + cloneObject(this.appliedFilters) ); this.$emit('applyFilter', this.appliedFilters); this.$track(CONVERSATION_EVENTS.APPLY_FILTER, { diff --git a/app/javascript/dashboard/helper/actionQueryGenerator.js b/app/javascript/dashboard/helper/actionQueryGenerator.js index 4ecae3df4..e11db0dd7 100644 --- a/app/javascript/dashboard/helper/actionQueryGenerator.js +++ b/app/javascript/dashboard/helper/actionQueryGenerator.js @@ -1,3 +1,5 @@ +import cloneObject from 'dashboard/helpers/clone'; + const allElementsString = arr => { return arr.every(elem => typeof elem === 'string'); }; @@ -27,7 +29,7 @@ const generatePayloadForObject = item => { }; const generatePayload = data => { - const actions = JSON.parse(JSON.stringify(data)); + const actions = cloneObject(data); let payload = actions.map(item => { if (Array.isArray(item.action_params)) { item.action_params = formatArray(item.action_params); diff --git a/app/javascript/dashboard/helper/automationHelper.js b/app/javascript/dashboard/helper/automationHelper.js index 33e920edc..a45b5a2a4 100644 --- a/app/javascript/dashboard/helper/automationHelper.js +++ b/app/javascript/dashboard/helper/automationHelper.js @@ -5,6 +5,8 @@ import { } from 'dashboard/routes/dashboard/settings/automation/operators'; import filterQueryGenerator from './filterQueryGenerator'; import actionQueryGenerator from './actionQueryGenerator'; +import cloneObject from 'dashboard/helpers/clone'; + const MESSAGE_CONDITION_VALUES = [ { id: 'incoming', @@ -255,7 +257,7 @@ export const getStandardAttributeInputType = (automationTypes, event, key) => { }; export const generateAutomationPayload = payload => { - const automation = JSON.parse(JSON.stringify(payload)); + const automation = cloneObject(payload); automation.conditions[automation.conditions.length - 1].query_operator = null; automation.conditions = filterQueryGenerator(automation.conditions).payload; automation.actions = actionQueryGenerator(automation.actions); diff --git a/app/javascript/dashboard/helper/clone.js b/app/javascript/dashboard/helper/clone.js new file mode 100644 index 000000000..00e094071 --- /dev/null +++ b/app/javascript/dashboard/helper/clone.js @@ -0,0 +1,33 @@ +/** + * Creates a deep clone of the provided object. + * + * This function attempts to use the `structuredClone` method if available. + * If `structuredClone` is not supported, it falls back to using + * `JSON.parse(JSON.stringify())`. + * + * @param {*} obj - The object to be cloned. Can be of any type. + * @returns {*} A deep clone of the input object. + * + * @throws {TypeError} If the object contains values that JSON cannot serialize + * (e.g., functions, undefined) when falling back to + * JSON methods. + * + * @example + * const original = { a: 1, b: { c: 2 } }; + * const clone = cloneObject(original); + * console.log(clone); // { a: 1, b: { c: 2 } } + * console.log(original === clone); // false + */ +export function cloneObject(obj) { + if (typeof structuredClone === 'function') { + return structuredClone(obj); + } + + // The JSON method doesn't handle all JavaScript types correctly and may cause unexpected behavior. + // At the moment structuredClone has good adoption across browsers https://caniuse.com/mdn-api_structuredclone + // and is the preferred method for cloning objects. + // + // We can consider implementing a more robust fallback method in the future if we find users are running into issues. + // Ref: https://github.com/lukeed/klona + return JSON.parse(JSON.stringify(obj)); +} diff --git a/app/javascript/dashboard/helper/filterQueryGenerator.js b/app/javascript/dashboard/helper/filterQueryGenerator.js index b2eff3d84..138f33f92 100644 --- a/app/javascript/dashboard/helper/filterQueryGenerator.js +++ b/app/javascript/dashboard/helper/filterQueryGenerator.js @@ -1,3 +1,5 @@ +import cloneObject from 'dashboard/helpers/clone'; + const setArrayValues = item => { return item.values[0]?.id ? item.values.map(val => val.id) : item.values; }; @@ -21,7 +23,7 @@ const generateValues = item => { const generatePayload = data => { // Make a copy of data to avoid vue data reactivity issues - const filters = JSON.parse(JSON.stringify(data)); + const filters = cloneObject(data); let payload = filters.map(item => { // If item key is content, we will split it using comma and return as array // FIX ME: Make this generic option instead of using the key directly here diff --git a/app/javascript/dashboard/helper/specs/clone.spec.js b/app/javascript/dashboard/helper/specs/clone.spec.js new file mode 100644 index 000000000..fcbedc6cb --- /dev/null +++ b/app/javascript/dashboard/helper/specs/clone.spec.js @@ -0,0 +1,61 @@ +import { describe, it, expect, vi } from 'vitest'; +import { cloneObject } from '../clone'; // Update this path + +describe('cloneObject', () => { + it('should clone a simple object', () => { + const original = { a: 1, b: 'string', c: true }; + const cloned = cloneObject(original); + expect(cloned).toEqual(original); + expect(cloned).not.toBe(original); + }); + + it('should clone a nested object', () => { + const original = { a: 1, b: { c: 2, d: { e: 3 } } }; + const cloned = cloneObject(original); + expect(cloned).toEqual(original); + expect(cloned.b).not.toBe(original.b); + expect(cloned.b.d).not.toBe(original.b.d); + }); + + it('should clone an array', () => { + const original = [1, 2, [3, 4]]; + const cloned = cloneObject(original); + expect(cloned).toEqual(original); + expect(cloned).not.toBe(original); + expect(cloned[2]).not.toBe(original[2]); + }); + + it('should clone a Date object', () => { + const original = new Date(); + const cloned = cloneObject(original); + expect(cloned).toEqual(original); + expect(cloned).not.toBe(original); + }); + + it('should use structuredClone when available', () => { + const structuredCloneSpy = vi.fn(x => x); + global.structuredClone = structuredCloneSpy; + const obj = { a: 1 }; + + cloneObject(obj); + + expect(structuredCloneSpy).toHaveBeenCalledWith(obj); + delete global.structuredClone; + }); + + it('should fall back to JSON methods when structuredClone is not available', () => { + const jsonParseSpy = vi.spyOn(JSON, 'parse'); + const jsonStringifySpy = vi.spyOn(JSON, 'stringify'); + + const original = { a: 1 }; + global.structuredClone = undefined; + + cloneObject(original); + + expect(jsonStringifySpy).toHaveBeenCalledWith(original); + expect(jsonParseSpy).toHaveBeenCalled(); + + jsonParseSpy.mockRestore(); + jsonStringifySpy.mockRestore(); + }); +}); diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue index 6f5e92017..a2ee5e8e8 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue @@ -98,6 +98,8 @@ import { filterAttributeGroups } from '../contactFilterItems'; import filterMixin from 'shared/mixins/filterMixin'; import * as OPERATORS from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js'; import { CONTACTS_EVENTS } from '../../../../helper/AnalyticsHelper/events'; +import cloneObject from 'dashboard/helpers/clone'; + export default { components: { FilterInputBox, @@ -318,7 +320,7 @@ export default { if (this.$v.$invalid) return; this.$store.dispatch( 'contacts/setContactFilters', - JSON.parse(JSON.stringify(this.appliedFilters)) + cloneObject(this.appliedFilters) ); this.$emit('applyFilter', this.appliedFilters); this.$track(CONTACTS_EVENTS.APPLY_FILTER, { diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/AddAutomationRule.vue b/app/javascript/dashboard/routes/dashboard/settings/automation/AddAutomationRule.vue index 152b55aae..dde0ab441 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/automation/AddAutomationRule.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/automation/AddAutomationRule.vue @@ -150,6 +150,7 @@ import automationMethodsMixin from 'dashboard/mixins/automations/methodsMixin'; import automationValidationsMixin from 'dashboard/mixins/automations/validationsMixin'; import filterInputBox from 'dashboard/components/widgets/FilterInput/Index.vue'; import automationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue'; +import cloneObject from 'dashboard/helpers/clone'; import { AUTOMATION_RULE_EVENTS, @@ -171,7 +172,7 @@ export default { data() { return { - automationTypes: JSON.parse(JSON.stringify(AUTOMATIONS)), + automationTypes: cloneObject(AUTOMATIONS), automationRuleEvent: AUTOMATION_RULE_EVENTS[0].key, automationRuleEvents: AUTOMATION_RULE_EVENTS, automationMutated: false, diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/EditAutomationRule.vue b/app/javascript/dashboard/routes/dashboard/settings/automation/EditAutomationRule.vue index 1b2b5a9fe..af40445b8 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/automation/EditAutomationRule.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/automation/EditAutomationRule.vue @@ -148,6 +148,7 @@ import automationMethodsMixin from 'dashboard/mixins/automations/methodsMixin'; import automationValidationsMixin from 'dashboard/mixins/automations/validationsMixin'; import filterInputBox from 'dashboard/components/widgets/FilterInput/Index.vue'; import automationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue'; +import cloneObject from 'dashboard/helpers/clone'; import { AUTOMATION_RULE_EVENTS, @@ -173,7 +174,7 @@ export default { }, data() { return { - automationTypes: JSON.parse(JSON.stringify(AUTOMATIONS)), + automationTypes: cloneObject(AUTOMATIONS), automationRuleEvent: AUTOMATION_RULE_EVENTS[0].key, automationRuleEvents: AUTOMATION_RULE_EVENTS, automationMutated: false, diff --git a/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue b/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue index 7c9f476e4..6fbc2a512 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue @@ -19,6 +19,7 @@ import { mapGetters } from 'vuex'; import actionQueryGenerator from 'dashboard/helper/actionQueryGenerator.js'; import alertMixin from 'shared/mixins/alertMixin'; import macrosMixin from 'dashboard/mixins/macrosMixin'; +import cloneObject from 'dashboard/helpers/clone'; export default { components: { @@ -128,7 +129,7 @@ export default { this.mode === 'EDIT' ? this.$t('MACROS.EDIT.API.SUCCESS_MESSAGE') : this.$t('MACROS.ADD.API.SUCCESS_MESSAGE'); - let serializedMacro = JSON.parse(JSON.stringify(macro)); + let serializedMacro = cloneObject(macro); serializedMacro.actions = actionQueryGenerator(serializedMacro.actions); await this.$store.dispatch(action, serializedMacro); this.showAlert(successMessage);