mirror of
https://github.com/CorentinTh/it-tools.git
synced 2025-08-08 06:55: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 };
|
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…
Add table
Add a link
Reference in a new issue