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
This commit is contained in:
@@ -2,11 +2,16 @@
|
|||||||
* Writes a text string to the system clipboard.
|
* Writes a text string to the system clipboard.
|
||||||
*
|
*
|
||||||
* @async
|
* @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
|
* @throws {Error} unable to copy text to clipboard
|
||||||
*/
|
*/
|
||||||
export const copyTextToClipboard = async text => {
|
export const copyTextToClipboard = async data => {
|
||||||
try {
|
try {
|
||||||
|
const text =
|
||||||
|
typeof data === 'object' && data !== null
|
||||||
|
? JSON.stringify(data, null, 2)
|
||||||
|
: String(data ?? '');
|
||||||
|
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Unable to copy text to clipboard: ${error.message}`);
|
throw new Error(`Unable to copy text to clipboard: ${error.message}`);
|
||||||
|
|||||||
174
app/javascript/shared/helpers/specs/clipboard.spec.js
Normal file
174
app/javascript/shared/helpers/specs/clipboard.spec.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user