Updated json condenser to handle nested array, added copy to clipboard button, and updated related tests
This commit is contained in:
		
							parent
							
								
									3f00795361
								
							
						
					
					
						commit
						d4e9f0e39d
					
				| @ -0,0 +1,106 @@ | |||||||
|  | import { expect, test } from '@playwright/test'; | ||||||
|  | 
 | ||||||
|  | test.describe('Tool - Json data condenser', () => { | ||||||
|  |   test.beforeEach(async ({ page }) => { | ||||||
|  |     await page.goto('/json-data-condenser'); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   test('Has correct title', async ({ page }) => { | ||||||
|  |     await expect(page).toHaveTitle('Json data condenser - IT Tools'); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   test('Condenses valid JSON input correctly', async ({ page }) => { | ||||||
|  |     const validJson = JSON.stringify({ | ||||||
|  |       users: [ | ||||||
|  |         { id: 1, name: 'Alice' }, | ||||||
|  |         { id: 2, name: 'Bob' }, | ||||||
|  |         { id: 3, name: 'Charlie' }, | ||||||
|  |         { id: 4, name: 'David', email: 'david@example.com' }, | ||||||
|  |         { id: 5, name: 'Eve' }, | ||||||
|  |       ], | ||||||
|  |       status: 'active', | ||||||
|  |     }, null, 2); | ||||||
|  | 
 | ||||||
|  |     await page.getByPlaceholder('Paste a JSON payload here...').fill(validJson); | ||||||
|  |     await page.getByRole('button', { name: 'Condense JSON' }).click(); | ||||||
|  | 
 | ||||||
|  |     const outputTextarea = page.getByPlaceholder('Condensed JSON will appear here...'); | ||||||
|  |     const outputText = await outputTextarea.inputValue(); | ||||||
|  | 
 | ||||||
|  |     expect(outputText).toContain('"status": "active"'); | ||||||
|  |     expect(outputText).toContain('Alice'); | ||||||
|  |     expect(outputText).toContain('David'); | ||||||
|  |     expect(outputText).not.toContain('Bob'); | ||||||
|  |     expect(outputText).not.toContain('Charlie'); | ||||||
|  |     expect(outputText).not.toContain('Eve'); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   test('Displays error on invalid JSON input', async ({ page }) => { | ||||||
|  |     await page.getByPlaceholder('Paste a JSON payload here...').fill('{ invalid json '); | ||||||
|  |     await page.getByRole('button', { name: 'Condense JSON' }).click(); | ||||||
|  | 
 | ||||||
|  |     await expect(page.getByText('Invalid JSON input. Please fix and try again.')).toBeVisible(); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   test('Handles nested arrays of objects and preserves distinct structures', async ({ page }) => { | ||||||
|  |     const nestedJson = JSON.stringify({ | ||||||
|  |       data: { | ||||||
|  |         results: [ | ||||||
|  |           { | ||||||
|  |             id: '1', | ||||||
|  |             components: [ | ||||||
|  |               { content_type: 'text', content: { format: 'markdown', text: 'Foo' } }, | ||||||
|  |               { content_type: 'video', content: { video_id: 'v1', duration: '1:00', platform: 'yt' } }, | ||||||
|  |               { content_type: 'image', content: { url: 'i.jpg', alt: 'Bar' } }, | ||||||
|  |               { content_type: 'text', content: { format: 'markdown', text: 'Baz' } }, | ||||||
|  |             ], | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             id: '2', | ||||||
|  |             components: [ | ||||||
|  |               { content_type: 'code', content: { lang: 'js', code: 'x' } }, | ||||||
|  |               { content_type: 'code', content: { lang: 'py', code: 'y' } }, | ||||||
|  |             ], | ||||||
|  |           }, | ||||||
|  |         ], | ||||||
|  |       }, | ||||||
|  |     }, null, 2); | ||||||
|  | 
 | ||||||
|  |     await page.getByPlaceholder('Paste a JSON payload here...').fill(nestedJson); | ||||||
|  |     await page.getByRole('button', { name: 'Condense JSON' }).click(); | ||||||
|  | 
 | ||||||
|  |     const outputTextarea = page.getByPlaceholder('Condensed JSON will appear here...'); | ||||||
|  |     const outputText = await outputTextarea.inputValue(); | ||||||
|  | 
 | ||||||
|  |     expect(outputText).toContain('"format": "markdown"'); | ||||||
|  |     expect(outputText).toContain('"video_id": "v1"'); | ||||||
|  |     expect(outputText).toContain('"url": "i.jpg"'); | ||||||
|  |     expect(outputText).toContain('"lang": "js"'); | ||||||
|  | 
 | ||||||
|  |     expect(outputText).not.toContain('"text": "Baz"'); | ||||||
|  |     expect(outputText).not.toContain('"lang": "py"'); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   test('Copies condensed JSON to clipboard', async ({ page }) => { | ||||||
|  |     const validJson = JSON.stringify({ | ||||||
|  |       items: [ | ||||||
|  |         { id: 1, name: 'Foo' }, | ||||||
|  |         { id: 2, name: 'Bar' }, | ||||||
|  |         { id: 3, name: 'Baz', extra: true }, | ||||||
|  |       ], | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     await page.getByPlaceholder('Paste a JSON payload here...').fill(validJson); | ||||||
|  |     await page.getByRole('button', { name: 'Condense JSON' }).click(); | ||||||
|  | 
 | ||||||
|  |     // Click the copy button
 | ||||||
|  |     await page.getByRole('button', { name: 'Copy' }).click(); | ||||||
|  | 
 | ||||||
|  |     // Read from clipboard
 | ||||||
|  |     const clipboardText = await page.evaluate(() => navigator.clipboard.readText()); | ||||||
|  | 
 | ||||||
|  |     // Confirm clipboard contains condensed data
 | ||||||
|  |     expect(clipboardText).toContain('"id": 1'); | ||||||
|  |     expect(clipboardText).toContain('"name": "Foo"'); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
| @ -0,0 +1,108 @@ | |||||||
|  | import { describe, expect, it } from 'vitest'; | ||||||
|  | import { type JsonValue, condenseJsonStructures } from '../json-data-condenser.service'; | ||||||
|  | 
 | ||||||
|  | const asJsonValue = <T extends JsonValue>(val: T): T => val; | ||||||
|  | 
 | ||||||
|  | describe('condenseJsonStructures', () => { | ||||||
|  |   it('removes duplicate object structures in an array but keeps unique ones', () => { | ||||||
|  |     const input = asJsonValue({ | ||||||
|  |       users: [ | ||||||
|  |         { id: 1, name: 'Alice' }, | ||||||
|  |         { id: 2, name: 'Bob' }, | ||||||
|  |         { id: 3, name: 'Charlie' }, | ||||||
|  |         { id: 4, name: 'David', email: 'david@example.com' }, | ||||||
|  |         { id: 5, name: 'Eve' }, | ||||||
|  |       ], | ||||||
|  |       status: 'active', | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     const output = condenseJsonStructures(input); | ||||||
|  | 
 | ||||||
|  |     expect(output).toEqual({ | ||||||
|  |       users: [ | ||||||
|  |         { id: 1, name: 'Alice' }, | ||||||
|  |         { id: 4, name: 'David', email: 'david@example.com' }, | ||||||
|  |       ], | ||||||
|  |       status: 'active', | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   it('keeps all non-array values untouched', () => { | ||||||
|  |     const input = asJsonValue({ | ||||||
|  |       status: 'ok', | ||||||
|  |       count: 5, | ||||||
|  |       success: true, | ||||||
|  |       metadata: { | ||||||
|  |         source: 'api', | ||||||
|  |         timestamp: '2025-06-27T12:00:00Z', | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     const output = condenseJsonStructures(input); | ||||||
|  | 
 | ||||||
|  |     expect(output).toEqual(input); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   it('keeps non-object array values untouched', () => { | ||||||
|  |     const input = asJsonValue([1, 2, 3, 'a', true, null]); | ||||||
|  | 
 | ||||||
|  |     const output = condenseJsonStructures(input); | ||||||
|  | 
 | ||||||
|  |     expect(output).toEqual([1, 2, 3, 'a', true, null]); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   it('returns primitive values unchanged', () => { | ||||||
|  |     expect(condenseJsonStructures(42)).toBe(42); | ||||||
|  |     expect(condenseJsonStructures('hello')).toBe('hello'); | ||||||
|  |     expect(condenseJsonStructures(null)).toBe(null); | ||||||
|  |     expect(condenseJsonStructures(true)).toBe(true); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   it('handles nested object arrays and preserves unique structures', () => { | ||||||
|  |     const input = asJsonValue({ | ||||||
|  |       data: { | ||||||
|  |         results: [ | ||||||
|  |           { | ||||||
|  |             id: '1', | ||||||
|  |             components: [ | ||||||
|  |               { content_type: 'text', content: { format: 'markdown', text: 'Foo' } }, | ||||||
|  |               { content_type: 'video', content: { video_id: 'v1', duration: '1:00', platform: 'yt' } }, | ||||||
|  |               { content_type: 'image', content: { url: 'i.jpg', alt: 'Bar' } }, | ||||||
|  |               { content_type: 'text', content: { format: 'markdown', text: 'Baz' } }, | ||||||
|  |             ], | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             id: '2', | ||||||
|  |             components: [ | ||||||
|  |               { content_type: 'code', content: { lang: 'js', code: 'x' } }, | ||||||
|  |               { content_type: 'code', content: { lang: 'py', code: 'y' } }, | ||||||
|  |             ], | ||||||
|  |           }, | ||||||
|  |         ], | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     const output = condenseJsonStructures(input); | ||||||
|  | 
 | ||||||
|  |     expect(output).toEqual({ | ||||||
|  |       data: { | ||||||
|  |         results: [ | ||||||
|  |           { | ||||||
|  |             id: '1', | ||||||
|  |             components: [ | ||||||
|  |               { content_type: 'text', content: { format: 'markdown', text: 'Foo' } }, | ||||||
|  |               { content_type: 'video', content: { video_id: 'v1', duration: '1:00', platform: 'yt' } }, | ||||||
|  |               { content_type: 'image', content: { url: 'i.jpg', alt: 'Bar' } }, | ||||||
|  |             ], | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             id: '2', | ||||||
|  |             components: [ | ||||||
|  |               { content_type: 'code', content: { lang: 'js', code: 'x' } }, | ||||||
|  |             ], | ||||||
|  |           }, | ||||||
|  |         ], | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
| @ -1,44 +0,0 @@ | |||||||
| import { expect, test } from '@playwright/test'; |  | ||||||
| 
 |  | ||||||
| test.describe('Tool - Json data condenser', () => { |  | ||||||
|   test.beforeEach(async ({ page }) => { |  | ||||||
|     await page.goto('/json-data-condenser'); |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   test('Has correct title', async ({ page }) => { |  | ||||||
|     await expect(page).toHaveTitle('Json data condenser - IT Tools'); |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   test('Condenses valid JSON input correctly', async ({ page }) => { |  | ||||||
|     const validJson = JSON.stringify({ |  | ||||||
|       users: [ |  | ||||||
|         { id: 1, name: 'Alice' }, |  | ||||||
|         { id: 2, name: 'Bob' }, |  | ||||||
|         { id: 3, name: 'Charlie' }, |  | ||||||
|         { id: 4, name: 'David', email: 'david@example.com' }, |  | ||||||
|         { id: 5, name: 'Eve' }, |  | ||||||
|       ], |  | ||||||
|       status: 'active', |  | ||||||
|     }, null, 2); |  | ||||||
| 
 |  | ||||||
|     await page.getByPlaceholder('Paste a JSON payload here...').fill(validJson); |  | ||||||
|     await page.getByRole('button', { name: 'Condense JSON' }).click(); |  | ||||||
| 
 |  | ||||||
|     const outputTextarea = page.getByPlaceholder('Condensed JSON will appear here...'); |  | ||||||
|     const outputText = await outputTextarea.inputValue(); |  | ||||||
| 
 |  | ||||||
|     expect(outputText).toContain('"status": "active"'); |  | ||||||
|     expect(outputText).toContain('Alice'); |  | ||||||
|     expect(outputText).toContain('David'); |  | ||||||
|     expect(outputText).not.toContain('Bob'); |  | ||||||
|     expect(outputText).not.toContain('Charlie'); |  | ||||||
|     expect(outputText).not.toContain('Eve'); |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   test('Displays error on invalid JSON input', async ({ page }) => { |  | ||||||
|     await page.getByPlaceholder('Paste a JSON payload here...').fill('{ invalid json '); |  | ||||||
|     await page.getByRole('button', { name: 'Condense JSON' }).click(); |  | ||||||
| 
 |  | ||||||
|     await expect(page.getByText('Invalid JSON input. Please fix and try again.')).toBeVisible(); |  | ||||||
|   }); |  | ||||||
| }); |  | ||||||
| @ -1,60 +0,0 @@ | |||||||
| import { describe, expect, it } from 'vitest'; |  | ||||||
| import { type JsonValue, condenseJsonStructures } from './json-data-condenser.service'; |  | ||||||
| 
 |  | ||||||
| const asJsonValue = <T extends JsonValue>(val: T): T => val; |  | ||||||
| 
 |  | ||||||
| describe('condenseJsonStructures', () => { |  | ||||||
|   it('removes duplicate object structures in an array but keeps unique ones', () => { |  | ||||||
|     const input = asJsonValue({ |  | ||||||
|       users: [ |  | ||||||
|         { id: 1, name: 'Alice' }, |  | ||||||
|         { id: 2, name: 'Bob' }, |  | ||||||
|         { id: 3, name: 'Charlie' }, |  | ||||||
|         { id: 4, name: 'David', email: 'david@example.com' }, // unique structure
 |  | ||||||
|         { id: 5, name: 'Eve' }, |  | ||||||
|       ], |  | ||||||
|       status: 'active', |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     const output = condenseJsonStructures(input); |  | ||||||
| 
 |  | ||||||
|     expect(output).toEqual({ |  | ||||||
|       users: [ |  | ||||||
|         { id: 1, name: 'Alice' }, |  | ||||||
|         { id: 4, name: 'David', email: 'david@example.com' }, // kept due to extra key
 |  | ||||||
|       ], |  | ||||||
|       status: 'active', |  | ||||||
|     }); |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   it('keeps all non-array values untouched', () => { |  | ||||||
|     const input = asJsonValue({ |  | ||||||
|       status: 'ok', |  | ||||||
|       count: 5, |  | ||||||
|       success: true, |  | ||||||
|       metadata: { |  | ||||||
|         source: 'api', |  | ||||||
|         timestamp: '2025-06-27T12:00:00Z', |  | ||||||
|       }, |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     const output = condenseJsonStructures(input); |  | ||||||
| 
 |  | ||||||
|     expect(output).toEqual(input); |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   it('keeps non-object array values untouched', () => { |  | ||||||
|     const input = asJsonValue([1, 2, 3, 'a', true, null]); |  | ||||||
| 
 |  | ||||||
|     const output = condenseJsonStructures(input); |  | ||||||
| 
 |  | ||||||
|     expect(output).toEqual([1, 2, 3, 'a', true, null]); |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   it('returns primitive values unchanged', () => { |  | ||||||
|     expect(condenseJsonStructures(42)).toBe(42); |  | ||||||
|     expect(condenseJsonStructures('hello')).toBe('hello'); |  | ||||||
|     expect(condenseJsonStructures(null)).toBe(null); |  | ||||||
|     expect(condenseJsonStructures(true)).toBe(true); |  | ||||||
|   }); |  | ||||||
| }); |  | ||||||
| @ -1,24 +1,57 @@ | |||||||
|  | /** | ||||||
|  |  * Represents any valid JSON value, including nested objects and arrays. | ||||||
|  |  */ | ||||||
| export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; | export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; | ||||||
| 
 | 
 | ||||||
| function getKeySignature(obj: Record<string, any>): string { | /** | ||||||
|   // Create a normalized signature string of sorted keys
 |  * Recursively generates a deep signature string for a given JSON value. | ||||||
|   return Object.keys(obj).sort().join(','); |  * This signature reflects the structure and key types of the value, | ||||||
|  |  * and is used to detect and eliminate redundant object structures in arrays. | ||||||
|  |  * | ||||||
|  |  * @param value - The JSON value to generate a structural signature for. | ||||||
|  |  * @returns A normalized string representing the structure of the value. | ||||||
|  |  */ | ||||||
|  | function getDeepKeySignature(value: JsonValue): string { | ||||||
|  |   if (value === null || typeof value !== 'object') { | ||||||
|  |     return typeof value; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   if (Array.isArray(value)) { | ||||||
|  |     return `array<${value.map(getDeepKeySignature).join('|')}>`; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   const keys = Object.keys(value).sort(); | ||||||
|  |   const nested = keys | ||||||
|  |     .map(key => `${key}:${getDeepKeySignature((value as Record<string, JsonValue>)[key])}`) | ||||||
|  |     .join(','); | ||||||
|  | 
 | ||||||
|  |   return `{${nested}}`; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | /** | ||||||
|  |  * Recursively condenses a JSON object by removing redundant objects | ||||||
|  |  * with identical structures (key shape and nested types) in arrays. | ||||||
|  |  * | ||||||
|  |  * For arrays of objects, only one representative per unique structure | ||||||
|  |  * is kept. Objects with additional or differing key paths are preserved. | ||||||
|  |  * | ||||||
|  |  * Non-array values and primitives are returned unchanged. | ||||||
|  |  * | ||||||
|  |  * @param data - The JSON value to condense. | ||||||
|  |  * @returns A condensed version of the original JSON value. | ||||||
|  |  */ | ||||||
| export function condenseJsonStructures(data: JsonValue): JsonValue { | export function condenseJsonStructures(data: JsonValue): JsonValue { | ||||||
|   // Handle primitive values
 |   if (data === null || typeof data !== 'object') { | ||||||
|   if (typeof data !== 'object' || data === null) { |  | ||||||
|     return data; |     return data; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   // Handle arrays
 |  | ||||||
|   if (Array.isArray(data)) { |   if (Array.isArray(data)) { | ||||||
|     const seenSignatures = new Set<string>(); |     const seenSignatures = new Set<string>(); | ||||||
|     const result: JsonValue[] = []; |     const result: JsonValue[] = []; | ||||||
| 
 | 
 | ||||||
|     for (const item of data) { |     for (const item of data) { | ||||||
|       if (typeof item === 'object' && item !== null && !Array.isArray(item)) { |       if (item && typeof item === 'object' && !Array.isArray(item)) { | ||||||
|         const signature = getKeySignature(item as Record<string, JsonValue>); |         const signature = getDeepKeySignature(item); | ||||||
|         if (!seenSignatures.has(signature)) { |         if (!seenSignatures.has(signature)) { | ||||||
|           seenSignatures.add(signature); |           seenSignatures.add(signature); | ||||||
|           result.push(condenseJsonStructures(item)); |           result.push(condenseJsonStructures(item)); | ||||||
| @ -32,7 +65,6 @@ export function condenseJsonStructures(data: JsonValue): JsonValue { | |||||||
|     return result; |     return result; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   // Handle plain objects
 |  | ||||||
|   const result: Record<string, JsonValue> = {}; |   const result: Record<string, JsonValue> = {}; | ||||||
|   for (const key in data) { |   for (const key in data) { | ||||||
|     result[key] = condenseJsonStructures(data[key]); |     result[key] = condenseJsonStructures(data[key]); | ||||||
|  | |||||||
| @ -5,6 +5,7 @@ import { condenseJsonStructures } from './json-data-condenser.service'; | |||||||
| const rawJson = ref(''); | const rawJson = ref(''); | ||||||
| const condensedJson = ref(''); | const condensedJson = ref(''); | ||||||
| const error = ref<string | null>(null); | const error = ref<string | null>(null); | ||||||
|  | const copySuccess = ref(false); | ||||||
| 
 | 
 | ||||||
| function condense() { | function condense() { | ||||||
|   error.value = null; |   error.value = null; | ||||||
| @ -19,6 +20,17 @@ function condense() { | |||||||
|     error.value = 'Invalid JSON input. Please fix and try again.'; |     error.value = 'Invalid JSON input. Please fix and try again.'; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | async function copyToClipboard() { | ||||||
|  |   try { | ||||||
|  |     await navigator.clipboard.writeText(condensedJson.value); | ||||||
|  |     copySuccess.value = true; | ||||||
|  |     setTimeout(() => (copySuccess.value = false), 2000); | ||||||
|  |   } | ||||||
|  |   catch { | ||||||
|  |     error.value = 'Failed to copy to clipboard.'; | ||||||
|  |   } | ||||||
|  | } | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <template> | <template> | ||||||
| @ -51,6 +63,12 @@ function condense() { | |||||||
|         rows="12" |         rows="12" | ||||||
|         readonly multiline monospace raw-text |         readonly multiline monospace raw-text | ||||||
|       /> |       /> | ||||||
|  | 
 | ||||||
|  |       <div class="mt-4 flex"> | ||||||
|  |         <c-button @click="copyToClipboard"> | ||||||
|  |           {{ copySuccess ? 'Copied!' : 'Copy' }} | ||||||
|  |         </c-button> | ||||||
|  |       </div> | ||||||
|     </div> |     </div> | ||||||
| 
 | 
 | ||||||
|     <!-- Error Display --> |     <!-- Error Display --> | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user