mirror of
https://github.com/CorentinTh/it-tools.git
synced 2025-08-07 22:45:19 +02:00
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
6 changed files with 273 additions and 113 deletions
|
@ -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 };
|
||||
|
||||
function getKeySignature(obj: Record<string, any>): string {
|
||||
// Create a normalized signature string of sorted keys
|
||||
return Object.keys(obj).sort().join(',');
|
||||
/**
|
||||
* Recursively generates a deep signature string for a given JSON value.
|
||||
* 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 {
|
||||
// Handle primitive values
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
if (data === null || typeof data !== 'object') {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Handle arrays
|
||||
if (Array.isArray(data)) {
|
||||
const seenSignatures = new Set<string>();
|
||||
const result: JsonValue[] = [];
|
||||
|
||||
for (const item of data) {
|
||||
if (typeof item === 'object' && item !== null && !Array.isArray(item)) {
|
||||
const signature = getKeySignature(item as Record<string, JsonValue>);
|
||||
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
||||
const signature = getDeepKeySignature(item);
|
||||
if (!seenSignatures.has(signature)) {
|
||||
seenSignatures.add(signature);
|
||||
result.push(condenseJsonStructures(item));
|
||||
|
@ -32,7 +65,6 @@ export function condenseJsonStructures(data: JsonValue): JsonValue {
|
|||
return result;
|
||||
}
|
||||
|
||||
// Handle plain objects
|
||||
const result: Record<string, JsonValue> = {};
|
||||
for (const key in data) {
|
||||
result[key] = condenseJsonStructures(data[key]);
|
||||
|
|
|
@ -5,6 +5,7 @@ import { condenseJsonStructures } from './json-data-condenser.service';
|
|||
const rawJson = ref('');
|
||||
const condensedJson = ref('');
|
||||
const error = ref<string | null>(null);
|
||||
const copySuccess = ref(false);
|
||||
|
||||
function condense() {
|
||||
error.value = null;
|
||||
|
@ -19,6 +20,17 @@ function condense() {
|
|||
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>
|
||||
|
||||
<template>
|
||||
|
@ -51,6 +63,12 @@ function condense() {
|
|||
rows="12"
|
||||
readonly multiline monospace raw-text
|
||||
/>
|
||||
|
||||
<div class="mt-4 flex">
|
||||
<c-button @click="copyToClipboard">
|
||||
{{ copySuccess ? 'Copied!' : 'Copy' }}
|
||||
</c-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Display -->
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue