Merge pull request #330 from dhaanisi/feature/json-to=csv

feat: add JSON to CSV converter
This commit is contained in:
Chesterkxng 2026-03-15 03:54:10 +01:00 committed by GitHub
commit 0e99d0120e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 528 additions and 0 deletions

View file

@ -46,6 +46,37 @@
"shortDescription": "Convert objects to JSON string",
"title": "Stringify JSON"
},
"tsvToJson": {
"description": "Convert TSV (Tab-Separated Values) data to JSON format. Transform tabular data into structured JSON objects.",
"shortDescription": "Convert TSV to JSON format",
"title": "TSV to JSON"
},
"jsonToCsv": {
"description": "Convert JSON data to CSV format. Supports nested objects and arrays, automatic header detection, and configurable delimiters and quoting — ready for Excel, Google Sheets, or any spreadsheet application.",
"shortDescription": "Convert JSON to CSV format",
"title": "JSON to CSV",
"quotingOption": "String Quoting",
"delimiterOption": "Delimiter",
"headerOption": "Headers",
"inputTitle": "Input JSON",
"outputTitle": "Output CSV",
"options": {
"delimiter": "Character used to separate values in each row (e.g. comma, semicolon, tab)",
"alwaysQuote": {
"label": "Always quote",
"description": "Wrap every cell in double-quotes, regardless of its content."
},
"autoQuote": {
"label": "Auto (recommended)",
"description": "Only quote cells that contain the delimiter, a double-quote, or a newline. Keeps output clean and minimal."
},
"header": {
"label": "Include header row",
"description": "Use JSON keys as column names in the first row. Recommended for readability and import compatibility."
}
}
},
"validateJson": {
"description": "Check if JSON is valid and well-formed.",
"inputTitle": "Input JSON",

View file

@ -5,6 +5,7 @@ import { tool as validateJson } from './validateJson/meta';
import { tool as jsonToXml } from './json-to-xml/meta';
import { tool as escapeJson } from './escape-json/meta';
import { tool as jsonComparison } from './json-comparison/meta';
import { tool as jsonToCsv } from './json-to-csv/meta';
export const jsonTools = [
validateJson,
@ -12,6 +13,7 @@ export const jsonTools = [
jsonMinify,
jsonStringify,
jsonToXml,
jsonToCsv,
escapeJson,
jsonComparison
];

View file

@ -0,0 +1,165 @@
import { useState } from 'react';
import ToolContent from '@components/ToolContent';
import ToolCodeInput from '@components/input/ToolCodeInput';
import ToolTextResult from '@components/result/ToolTextResult';
import { convertJsonToCsv } from './service';
import { CardExampleType } from '@components/examples/ToolExamples';
import { ToolComponentProps } from '@tools/defineTool';
import { Box } from '@mui/material';
import CheckboxWithDesc from '@components/options/CheckboxWithDesc';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import SimpleRadio from '@components/options/SimpleRadio';
import { InitialValuesType } from './types';
import { useTranslation } from 'react-i18next';
const initialValues: InitialValuesType = {
delimiter: ',',
includeHeaders: true,
quoteStrings: 'auto'
};
const exampleCards: CardExampleType<InitialValuesType>[] = [
{
title: 'Array of objects',
description:
'Convert multiple JSON objects into CSV rows, one row per object.',
sampleText: `[
{ "name": "John Doe", "age": 25, "city": "New York" },
{ "name": "Jane Doe", "age": 30, "city": "Los Angeles" },
{ "name": "Bob Smith", "age": 22, "city": "Chicago" }
]`,
sampleResult: `name,age,city\nJohn Doe,25,New York\nJane Doe,30,Los Angeles\nBob Smith,22,Chicago`,
sampleOptions: {
...initialValues
}
},
{
title: 'Nested object (dot notation)',
description:
'Nested keys are flattened using dot notation (e.g. address.city).',
sampleText: `[
{
"name": "John Doe",
"age": 25,
"address": {
"street": "123 Main St",
"city": "New York",
"state": "NY",
"postalCode": "10001"
},
"hobbies": ["reading", "running"]
}
]`,
sampleResult: `name,age,address.street,address.city,address.state,address.postalCode,hobbies[0],hobbies[1]\nJohn Doe,25,123 Main St,New York,NY,10001,reading,running`,
sampleOptions: {
...initialValues
}
},
{
title: 'Sparse rows',
description:
'Missing keys are filled with empty values to keep columns aligned.',
sampleText: `[
{ "name": "Alice", "age": 30 },
{ "name": "Bob", "city": "Paris" },
{ "name": "Carol", "age": 25, "city": "Rome" }
]`,
sampleResult: `name,age,city\nAlice,30,\nBob,,Paris\nCarol,25,Rome`,
sampleOptions: {
...initialValues
}
}
];
export default function JsonToCsv({ title }: ToolComponentProps) {
const { t } = useTranslation('json');
const [input, setInput] = useState<string>('');
const [result, setResult] = useState<string>('');
const compute = (values: InitialValuesType, input: string) => {
if (input) {
try {
const csvResult = convertJsonToCsv(input, values);
setResult(csvResult);
} catch (error) {
setResult(
`Error: ${
error instanceof Error ? error.message : 'Invalid JSON format'
}`
);
}
}
};
return (
<ToolContent
title={title}
input={input}
setInput={setInput}
initialValues={initialValues}
compute={compute}
exampleCards={exampleCards}
inputComponent={
<ToolCodeInput
title={t('jsonToCsv.inputTitle')}
value={input}
onChange={setInput}
language="json"
/>
}
resultComponent={
<ToolTextResult
title={t('jsonToCsv.outputTitle')}
value={result}
extension={'csv'}
/>
}
getGroups={({ values, updateField }) => [
{
title: t('jsonToCsv.delimiterOption'),
component: (
<Box>
<TextFieldWithDesc
description={t('jsonToCsv.options.delimiter')}
value={values.delimiter}
onOwnChange={(val) => updateField('delimiter', val)}
/>
</Box>
)
},
{
title: t('jsonToCsv.quotingOption'),
component: (
<Box>
<SimpleRadio
checked={values.quoteStrings === 'auto'}
title={t('jsonToCsv.options.autoQuote.label')}
description={t('jsonToCsv.options.autoQuote.description')}
onClick={() => updateField('quoteStrings', 'auto')}
/>
<SimpleRadio
checked={values.quoteStrings === 'always'}
title={t('jsonToCsv.options.alwaysQuote.label')}
description={t('jsonToCsv.options.alwaysQuote.description')}
onClick={() => updateField('quoteStrings', 'always')}
/>
</Box>
)
},
{
title: t('jsonToCsv.headerOption'),
component: (
<Box>
<CheckboxWithDesc
checked={values.includeHeaders}
onChange={(value) => updateField('includeHeaders', value)}
title={t('jsonToCsv.options.header.label')}
description={t('jsonToCsv.options.header.description')}
/>
</Box>
)
}
]}
/>
);
}

View file

@ -0,0 +1,15 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('json', {
path: 'json-to-csv',
icon: 'material-symbols:code',
keywords: [],
component: lazy(() => import('./index')),
i18n: {
name: 'json:jsonToCsv.title',
description: 'json:jsonToCsv.description',
shortDescription: 'json:jsonToCsv.shortDescription',
userTypes: ['generalUsers', 'developers']
}
});

View file

@ -0,0 +1,172 @@
import { describe, it, expect } from 'vitest';
import { convertJsonToCsv } from './service';
const defaultOptions: Parameters<typeof convertJsonToCsv>[1] = {
delimiter: ',',
includeHeaders: true,
quoteStrings: 'auto'
};
describe('convertJsonToCsv', () => {
describe('basic conversion', () => {
it('converts a flat array of objects', () => {
const input = JSON.stringify([
{ name: 'Alice', age: 30 },
{ name: 'Bob', age: 25 }
]);
expect(convertJsonToCsv(input, defaultOptions)).toBe(
`name,age\r\nAlice,30\r\nBob,25`
);
});
it('converts a single object', () => {
const input = JSON.stringify({ name: 'Alice', age: 30 });
expect(convertJsonToCsv(input, defaultOptions)).toBe(
`name,age\r\nAlice,30`
);
});
it('excludes header row when includeHeaders is false', () => {
const input = JSON.stringify([{ name: 'Alice', age: 30 }]);
expect(
convertJsonToCsv(input, { ...defaultOptions, includeHeaders: false })
).toBe(`Alice,30`);
});
});
describe('flattening', () => {
it('flattens nested objects using dot notation', () => {
const input = JSON.stringify([
{ name: 'Alice', address: { city: 'Paris', zip: '75000' } }
]);
expect(convertJsonToCsv(input, defaultOptions)).toBe(
`name,address.city,address.zip\r\nAlice,Paris,75000`
);
});
it('flattens arrays using index notation', () => {
const input = JSON.stringify([
{ name: 'Alice', tags: ['admin', 'user'] }
]);
expect(convertJsonToCsv(input, defaultOptions)).toBe(
`name,tags[0],tags[1]\r\nAlice,admin,user`
);
});
it('flattens deeply nested structures', () => {
const input = JSON.stringify([
{ user: { address: { geo: { lat: 48.8566, lng: 2.3522 } } } }
]);
expect(convertJsonToCsv(input, defaultOptions)).toBe(
`user.address.geo.lat,user.address.geo.lng\r\n48.8566,2.3522`
);
});
});
describe('sparse rows', () => {
it('fills missing keys with empty values', () => {
const input = JSON.stringify([
{ name: 'Alice', age: 30 },
{ name: 'Bob', city: 'Paris' }
]);
expect(convertJsonToCsv(input, defaultOptions)).toBe(
`name,age,city\r\nAlice,30,\r\nBob,,Paris`
);
});
it('filters out empty objects', () => {
const input = JSON.stringify([{}, { name: 'Alice' }]);
expect(convertJsonToCsv(input, defaultOptions)).toBe(`name\r\nAlice`);
});
});
describe('quoting', () => {
it('quotes cells containing the delimiter', () => {
const input = JSON.stringify([{ name: 'Smith, John' }]);
expect(convertJsonToCsv(input, defaultOptions)).toBe(
`name\r\n"Smith, John"`
);
});
it('escapes double-quotes by doubling them', () => {
const input = JSON.stringify([{ name: 'He said "hello"' }]);
expect(convertJsonToCsv(input, defaultOptions)).toBe(
`name\r\n"He said ""hello"""`
);
});
it('quotes all cells when quoteStrings is always', () => {
const input = JSON.stringify([{ name: 'Alice', age: 30 }]);
expect(
convertJsonToCsv(input, { ...defaultOptions, quoteStrings: 'always' })
).toBe(`"name","age"\r\n"Alice","30"`);
});
it('quotes cells containing newlines', () => {
const input = JSON.stringify([{ notes: 'line1\nline2' }]);
expect(convertJsonToCsv(input, defaultOptions)).toBe(
`notes\r\n"line1\nline2"`
);
});
});
describe('delimiters', () => {
it('uses semicolon as delimiter', () => {
const input = JSON.stringify([{ name: 'Alice', age: 30 }]);
expect(
convertJsonToCsv(input, { ...defaultOptions, delimiter: ';' })
).toBe(`name;age\r\nAlice;30`);
});
it('uses tab as delimiter', () => {
const input = JSON.stringify([{ name: 'Alice', age: 30 }]);
expect(
convertJsonToCsv(input, { ...defaultOptions, delimiter: '\t' })
).toBe(`name\tage\r\nAlice\t30`);
});
});
describe('null and undefined values', () => {
it('converts null values to empty strings', () => {
const input = JSON.stringify([{ name: 'Alice', age: null }]);
expect(convertJsonToCsv(input, defaultOptions)).toBe(
`name,age\r\nAlice,`
);
});
});
describe('errors', () => {
it('throws on invalid JSON', () => {
expect(() => convertJsonToCsv('invalid json', defaultOptions)).toThrow(
'Invalid JSON input.'
);
});
it('throws on bare primitive', () => {
expect(() => convertJsonToCsv('42', defaultOptions)).toThrow(
'JSON input must be an object or array of objects, not a bare primitive.'
);
});
it('throws when no data rows are found', () => {
expect(() =>
convertJsonToCsv(JSON.stringify([{}, {}]), defaultOptions)
).toThrow('No data found in the provided JSON.');
});
});
});

View file

@ -0,0 +1,120 @@
import { InitialValuesType } from './types';
import { getJsonHeaders } from 'utils/json';
/**
* Recursively flattens any JSON value into a flat object.
* Objects dot notation
* Arrays index notation
*/
function flattenRecursive(
value: unknown,
prefix = '',
result: Record<string, string> = {}
): Record<string, string> {
if (value === null || value === undefined) {
if (prefix) result[prefix] = '';
return result;
}
if (typeof value !== 'object') {
if (prefix) result[prefix] = String(value);
return result;
}
if (Array.isArray(value)) {
value.forEach((item, index) => {
const newKey = prefix ? `${prefix}[${index}]` : `[${index}]`;
flattenRecursive(item, newKey, result);
});
return result;
}
for (const [key, val] of Object.entries(value)) {
const newKey = prefix ? `${prefix}.${key}` : key;
flattenRecursive(val, newKey, result);
}
return result;
}
/**
* Converts any JSON structure into row objects.
*/
function flattenToRows(json: unknown): Record<string, string>[] {
if (Array.isArray(json)) {
return json.map((item) => flattenRecursive(item));
}
if (typeof json === 'object' && json !== null) {
return [flattenRecursive(json)];
}
throw new Error(
'JSON input must be an object or array of objects, not a bare primitive.'
);
}
/**
* Escapes and quotes CSV cells according to options
*/
function quoteCell(value: string, options: InitialValuesType): string {
const { delimiter, quoteStrings } = options;
const escaped = value.replace(/"/g, '""');
const needsQuoting =
value.includes(delimiter) ||
value.includes('"') ||
value.includes('\n') ||
value.includes('\r');
if (quoteStrings === 'always') return `"${escaped}"`;
return needsQuoting ? `"${escaped}"` : value;
}
/**
* Converts JSON string to CSV
*/
export function convertJsonToCsv(
input: string,
options: InitialValuesType
): string {
const { delimiter, includeHeaders } = options;
if (!delimiter) throw new Error('No CSV delimiter.');
let parsed: unknown;
try {
parsed = JSON.parse(input);
} catch {
throw new Error('Invalid JSON input.');
}
const rows = flattenToRows(parsed).filter(
(row) => Object.keys(row).length > 0
);
if (rows.length === 0) {
throw new Error('No data found in the provided JSON.');
}
const headers = getJsonHeaders(rows);
const lines: string[] = [];
if (includeHeaders) {
lines.push(headers.map((h) => quoteCell(h, options)).join(delimiter));
}
for (const row of rows) {
const line = headers
.map((header) => quoteCell(row[header] ?? '', options))
.join(delimiter);
lines.push(line);
}
return lines.join('\r\n');
}

View file

@ -0,0 +1,5 @@
export type InitialValuesType = {
delimiter: string;
includeHeaders: boolean;
quoteStrings: 'always' | 'auto';
};

18
src/utils/json.ts Normal file
View file

@ -0,0 +1,18 @@
/**
* Collects all unique keys from an array of row objects, preserving first-encountered order.
* Handles sparse rows where different rows may have different keys.
*
* @param rows - Array of flattened row objects
* @returns Array of unique header strings in insertion order
*
* @example
* getJsonHeaders([{ a: '1' }, { a: '2', b: '3' }]) // → ['a', 'b']
*/
export function getJsonHeaders(rows: Record<string, string>[]): string[] {
return Array.from(
rows.reduce<Set<string>>((set, row) => {
Object.keys(row).forEach((key) => set.add(key));
return set;
}, new Set())
);
}