From f9c258f1a04b9ef56bfe6c30656054738c454bec Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Mon, 1 Sep 2025 13:02:35 +0530 Subject: [PATCH] fix: Prevent `[object Object]` when copying custom attributes (#12323) # Pull Request Template ## Description This PR fixes custom conversation attributes copying as `[object Object]` by enhancing the clipboard helper to properly serialize objects and handle different data types. Fixes [CW-5428](https://linear.app/chatwoot/issue/CW-5428/copying-custom-conversation-attribute-returns-object-object-instead-of), https://github.com/chatwoot/chatwoot/issues/12202 ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? ### Loom video https://www.loom.com/share/f52db17d4d524b3cbb5badb2b6f381eb?sid=2b34f38f-e95d-4981-be5f-6cb42a0212b9 ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- app/javascript/shared/helpers/clipboard.js | 9 +- .../shared/helpers/specs/clipboard.spec.js | 174 ++++++++++++++++++ 2 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 app/javascript/shared/helpers/specs/clipboard.spec.js diff --git a/app/javascript/shared/helpers/clipboard.js b/app/javascript/shared/helpers/clipboard.js index dbadd4037..dcd25afae 100644 --- a/app/javascript/shared/helpers/clipboard.js +++ b/app/javascript/shared/helpers/clipboard.js @@ -2,11 +2,16 @@ * Writes a text string to the system clipboard. * * @async - * @param {string} text text to be written to the clipboard + * @param {string} data text to be written to the clipboard * @throws {Error} unable to copy text to clipboard */ -export const copyTextToClipboard = async text => { +export const copyTextToClipboard = async data => { try { + const text = + typeof data === 'object' && data !== null + ? JSON.stringify(data, null, 2) + : String(data ?? ''); + await navigator.clipboard.writeText(text); } catch (error) { throw new Error(`Unable to copy text to clipboard: ${error.message}`); diff --git a/app/javascript/shared/helpers/specs/clipboard.spec.js b/app/javascript/shared/helpers/specs/clipboard.spec.js new file mode 100644 index 000000000..c675edd35 --- /dev/null +++ b/app/javascript/shared/helpers/specs/clipboard.spec.js @@ -0,0 +1,174 @@ +import { copyTextToClipboard } from '../clipboard'; + +const mockWriteText = vi.fn(); +Object.assign(navigator, { + clipboard: { + writeText: mockWriteText, + }, +}); + +describe('copyTextToClipboard', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('with string input', () => { + it('copies plain text string to clipboard', async () => { + const text = 'Hello World'; + await copyTextToClipboard(text); + + expect(mockWriteText).toHaveBeenCalledWith('Hello World'); + expect(mockWriteText).toHaveBeenCalledTimes(1); + }); + + it('copies empty string to clipboard', async () => { + const text = ''; + await copyTextToClipboard(text); + + expect(mockWriteText).toHaveBeenCalledWith(''); + expect(mockWriteText).toHaveBeenCalledTimes(1); + }); + }); + + describe('with number input', () => { + it('converts number to string', async () => { + await copyTextToClipboard(42); + + expect(mockWriteText).toHaveBeenCalledWith('42'); + expect(mockWriteText).toHaveBeenCalledTimes(1); + }); + + it('converts zero to string', async () => { + await copyTextToClipboard(0); + + expect(mockWriteText).toHaveBeenCalledWith('0'); + expect(mockWriteText).toHaveBeenCalledTimes(1); + }); + }); + + describe('with boolean input', () => { + it('converts true to string', async () => { + await copyTextToClipboard(true); + + expect(mockWriteText).toHaveBeenCalledWith('true'); + expect(mockWriteText).toHaveBeenCalledTimes(1); + }); + + it('converts false to string', async () => { + await copyTextToClipboard(false); + + expect(mockWriteText).toHaveBeenCalledWith('false'); + expect(mockWriteText).toHaveBeenCalledTimes(1); + }); + }); + + describe('with null/undefined input', () => { + it('converts null to empty string', async () => { + await copyTextToClipboard(null); + + expect(mockWriteText).toHaveBeenCalledWith(''); + expect(mockWriteText).toHaveBeenCalledTimes(1); + }); + + it('converts undefined to empty string', async () => { + await copyTextToClipboard(undefined); + + expect(mockWriteText).toHaveBeenCalledWith(''); + expect(mockWriteText).toHaveBeenCalledTimes(1); + }); + }); + + describe('with object input', () => { + it('stringifies simple object with proper formatting', async () => { + const obj = { name: 'John', age: 30 }; + await copyTextToClipboard(obj); + + const expectedJson = JSON.stringify(obj, null, 2); + expect(mockWriteText).toHaveBeenCalledWith(expectedJson); + expect(mockWriteText).toHaveBeenCalledTimes(1); + }); + + it('stringifies nested object with proper formatting', async () => { + const nestedObj = { + severity: { + user_id: 1181505, + user_name: 'test', + server_name: '[1253]test1253', + }, + }; + await copyTextToClipboard(nestedObj); + + const expectedJson = JSON.stringify(nestedObj, null, 2); + expect(mockWriteText).toHaveBeenCalledWith(expectedJson); + expect(mockWriteText).toHaveBeenCalledTimes(1); + }); + + it('stringifies array with proper formatting', async () => { + const arr = [1, 2, { name: 'test' }]; + await copyTextToClipboard(arr); + + const expectedJson = JSON.stringify(arr, null, 2); + expect(mockWriteText).toHaveBeenCalledWith(expectedJson); + expect(mockWriteText).toHaveBeenCalledTimes(1); + }); + + it('stringifies empty object', async () => { + const obj = {}; + await copyTextToClipboard(obj); + + expect(mockWriteText).toHaveBeenCalledWith('{}'); + expect(mockWriteText).toHaveBeenCalledTimes(1); + }); + + it('stringifies empty array', async () => { + const arr = []; + await copyTextToClipboard(arr); + + expect(mockWriteText).toHaveBeenCalledWith('[]'); + expect(mockWriteText).toHaveBeenCalledTimes(1); + }); + }); + + describe('error handling', () => { + it('throws error when clipboard API fails', async () => { + const error = new Error('Clipboard access denied'); + mockWriteText.mockRejectedValueOnce(error); + + await expect(copyTextToClipboard('test')).rejects.toThrow( + 'Unable to copy text to clipboard: Clipboard access denied' + ); + }); + + it('handles clipboard API not available', async () => { + // Temporarily remove clipboard API + const originalClipboard = navigator.clipboard; + delete navigator.clipboard; + + await expect(copyTextToClipboard('test')).rejects.toThrow( + 'Unable to copy text to clipboard:' + ); + + // Restore clipboard API + navigator.clipboard = originalClipboard; + }); + }); + + describe('edge cases', () => { + it('handles Date objects', async () => { + const date = new Date('2023-01-01T00:00:00.000Z'); + await copyTextToClipboard(date); + + const expectedJson = JSON.stringify(date, null, 2); + expect(mockWriteText).toHaveBeenCalledWith(expectedJson); + expect(mockWriteText).toHaveBeenCalledTimes(1); + }); + + it('handles functions by converting to string', async () => { + const func = () => 'test'; + await copyTextToClipboard(func); + + expect(mockWriteText).toHaveBeenCalledWith(func.toString()); + expect(mockWriteText).toHaveBeenCalledTimes(1); + }); + }); +});