mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-03 02:11:07 +05:30
feat(renderer): TableGeneratorDialog (markdown table generator)
This commit is contained in:
@@ -0,0 +1,132 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
|
|
||||||
|
function pad(s: string, w: number) {
|
||||||
|
return s.padEnd(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTable(rows: number, cols: number, hasHeader: boolean, headers: string[], data: string[][]): string {
|
||||||
|
const width = cols;
|
||||||
|
const colWidths: number[] = Array.from({ length: width }, (_, c) => {
|
||||||
|
const all = [headers[c] ?? '', ...data.map((r) => r[c] ?? '')];
|
||||||
|
return Math.max(...all.map((s) => s.length), 3);
|
||||||
|
});
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (hasHeader) {
|
||||||
|
lines.push('| ' + Array.from({ length: width }, (_, c) => pad(headers[c] ?? `Col ${c + 1}`, colWidths[c])).join(' | ') + ' |');
|
||||||
|
lines.push('| ' + colWidths.map((w) => '-'.repeat(w)).join(' | ') + ' |');
|
||||||
|
}
|
||||||
|
for (const row of data) {
|
||||||
|
lines.push('| ' + Array.from({ length: width }, (_, c) => pad(row[c] ?? '', colWidths[c])).join(' | ') + ' |');
|
||||||
|
}
|
||||||
|
// Add empty rows if data has fewer
|
||||||
|
for (let i = data.length; i < rows; i++) {
|
||||||
|
lines.push('| ' + colWidths.map((w) => pad('', w)).join(' | ') + ' |');
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableGeneratorDialog() {
|
||||||
|
const closeModal = useAppStore((s) => s.closeModal);
|
||||||
|
const [rows, setRows] = useState(3);
|
||||||
|
const [cols, setCols] = useState(3);
|
||||||
|
const [hasHeader, setHasHeader] = useState(true);
|
||||||
|
const [headers, setHeaders] = useState<string[]>(['Col 1', 'Col 2', 'Col 3']);
|
||||||
|
|
||||||
|
const output = generateTable(rows, cols, hasHeader, headers, []);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(output);
|
||||||
|
toast.success('Copied to clipboard');
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to copy');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateHeadersCount = (newCols: number) => {
|
||||||
|
setCols(newCols);
|
||||||
|
setHeaders((prev) => {
|
||||||
|
if (prev.length < newCols) return [...prev, ...Array(newCols - prev.length).fill('').map((_, i) => `Col ${prev.length + i + 1}`)];
|
||||||
|
return prev.slice(0, newCols);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
||||||
|
<DialogContent aria-describedby="table-desc" className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Table generator</DialogTitle>
|
||||||
|
<DialogDescription id="table-desc">Specify rows × columns to generate a markdown table</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="table-rows">Rows</Label>
|
||||||
|
<Input
|
||||||
|
id="table-rows"
|
||||||
|
aria-label="Rows"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
value={rows}
|
||||||
|
onChange={(e) => setRows(Math.max(1, Math.min(50, Number(e.target.value) || 1)))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="table-cols">Columns</Label>
|
||||||
|
<Input
|
||||||
|
id="table-cols"
|
||||||
|
aria-label="Cols"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
value={cols}
|
||||||
|
onChange={(e) => updateHeadersCount(Math.max(1, Math.min(20, Number(e.target.value) || 1)))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<Checkbox checked={hasHeader} onCheckedChange={(c) => setHasHeader(!!c)} aria-label="Header" />
|
||||||
|
Include header row
|
||||||
|
</label>
|
||||||
|
{hasHeader && (
|
||||||
|
<div>
|
||||||
|
<Label>Header names</Label>
|
||||||
|
<div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}>
|
||||||
|
{Array.from({ length: cols }, (_, i) => (
|
||||||
|
<Input
|
||||||
|
key={i}
|
||||||
|
aria-label={`Header ${i + 1}`}
|
||||||
|
value={headers[i] ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...headers];
|
||||||
|
next[i] = e.target.value;
|
||||||
|
setHeaders(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<Label>Output</Label>
|
||||||
|
<pre className="overflow-auto rounded border border-border bg-card/30 p-3 text-xs" data-testid="table-output">
|
||||||
|
{output}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={closeModal}>Close</Button>
|
||||||
|
<Button onClick={handleCopy}>Copy</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { TableGeneratorDialog } from '@/components/modals/TableGeneratorDialog';
|
||||||
|
|
||||||
|
describe('TableGeneratorDialog', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with rows, cols, and header inputs', () => {
|
||||||
|
render(<TableGeneratorDialog />);
|
||||||
|
expect(screen.getByText(/table generator/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('spinbutton', { name: /rows/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('spinbutton', { name: /cols/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('checkbox', { name: /header/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates a markdown table on rows/cols change', async () => {
|
||||||
|
render(<TableGeneratorDialog />);
|
||||||
|
const rowsInput = screen.getByRole('spinbutton', { name: /rows/i });
|
||||||
|
const colsInput = screen.getByRole('spinbutton', { name: /cols/i });
|
||||||
|
await userEvent.clear(rowsInput);
|
||||||
|
await userEvent.type(rowsInput, '2');
|
||||||
|
await userEvent.clear(colsInput);
|
||||||
|
await userEvent.type(colsInput, '3');
|
||||||
|
// Output should have header + separator + 2 rows = 4 lines for a 3-col table with header
|
||||||
|
const output = screen.getByTestId('table-output');
|
||||||
|
expect(output.textContent).toContain('|');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a copy button next to the output', () => {
|
||||||
|
render(<TableGeneratorDialog />);
|
||||||
|
expect(screen.getByRole('button', { name: /copy/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user