From ba40487c59e2723eaa1c815baf79f1711053db72 Mon Sep 17 00:00:00 2001 From: khemthit Date: Tue, 26 May 2026 21:31:52 +0700 Subject: [PATCH] feat: add JSON Table preview with recursive nested rendering --- .gitignore | 3 +- .../QueryResultPreview/JsonPreview.tsx | 84 ++++--- .../JsonTablePreview/FilterInput.tsx | 43 ++++ .../JsonTablePreview/ModeToggle.tsx | 78 +++++++ .../JsonTablePreview/StyledWrapper.ts | 216 ++++++++++++++++++ .../JsonTablePreview/TableView.tsx | 189 +++++++++++++++ .../JsonTablePreview/ValueRenderer.tsx | 130 +++++++++++ .../JsonTablePreview/flatten.spec.ts | 114 +++++++++ .../JsonTablePreview/flatten.ts | 95 ++++++++ .../JsonTablePreview/index.tsx | 41 ++++ .../JsonTablePreview/tableState.spec.ts | 167 ++++++++++++++ .../JsonTablePreview/tableState.ts | 94 ++++++++ .../providers/ReduxStore/slices/tabs.ts | 11 + 13 files changed, 1232 insertions(+), 33 deletions(-) create mode 100644 src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/FilterInput.tsx create mode 100644 src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/ModeToggle.tsx create mode 100644 src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/StyledWrapper.ts create mode 100644 src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/TableView.tsx create mode 100644 src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/ValueRenderer.tsx create mode 100644 src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/flatten.spec.ts create mode 100644 src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/flatten.ts create mode 100644 src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/index.tsx create mode 100644 src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/tableState.spec.ts create mode 100644 src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/tableState.ts diff --git a/.gitignore b/.gitignore index 065dce0..48ba44e 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ Thumbs.db .env.* # Logs +logs *.log npm-debug.log* @@ -31,4 +32,4 @@ npm-debug.log* *.tsbuildinfo # Test -test-results/ +test-results/ \ No newline at end of file diff --git a/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonPreview.tsx b/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonPreview.tsx index be42d8b..42f6462 100644 --- a/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonPreview.tsx +++ b/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonPreview.tsx @@ -1,33 +1,39 @@ import React from 'react'; import ReactJson from 'react-json-view'; +import { useDispatch, useSelector } from 'react-redux'; +import find from 'lodash/find'; import ErrorBanner from 'ui/ErrorBanner'; +import { updateResponseJsonPreviewMode } from 'providers/ReduxStore/slices/tabs'; +import JsonTablePreview from './JsonTablePreview'; +import ModeToggle, { type JsonPreviewMode } from './JsonTablePreview/ModeToggle'; interface JsonPreviewProps { - data?: unknown[]; - displayedTheme?: boolean; + data?: unknown; + displayedTheme?: string; } -const JsonPreview = ({ - data, - displayedTheme -}: any) => { - const validateJsonData = (data: unknown): { data: unknown; error: string | null } => { - // If data is already an object or array, use it directly - if (typeof data === 'object' && data !== null) { - return { data, error: null }; - } +const JsonPreview = ({ data, displayedTheme }: any) => { + const dispatch = useDispatch(); + const tabs = useSelector((state: any) => state.tabs.tabs); + const activeTabUid = useSelector((state: any) => state.tabs.activeTabUid); + const focusedTab = find(tabs, (t: any) => t.uid === activeTabUid); + + const mode: JsonPreviewMode = focusedTab?.responseJsonPreviewMode === 'table' ? 'table' : 'tree'; + + const handleModeChange = (next: JsonPreviewMode) => { + if (!focusedTab) return; + dispatch(updateResponseJsonPreviewMode({ uid: focusedTab.uid, mode: next })); + }; - // If data is a string, try to parse it - if (typeof data === 'string') { + const validateJsonData = (input: unknown): { data: unknown; error: string | null } => { + if (typeof input === 'object' && input !== null) return { data: input, error: null }; + if (typeof input === 'string') { try { - const parsed = JSON.parse(data); - return { data: parsed, error: null }; + return { data: JSON.parse(input), error: null }; } catch (e) { return { data: null, error: `Invalid JSON format: ${(e as Error).message}` }; } } - - // For other types, return error return { data: null, error: 'Invalid input. Expected a JSON object, array, or valid JSON string.' }; }; @@ -45,22 +51,36 @@ const JsonPreview = ({ return ; } + if (mode === 'table') { + return ( + } + /> + ); + } + return ( - +
+
+ +
+ +
); }; diff --git a/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/FilterInput.tsx b/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/FilterInput.tsx new file mode 100644 index 0000000..c6cae3c --- /dev/null +++ b/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/FilterInput.tsx @@ -0,0 +1,43 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import debounce from 'lodash/debounce'; + +interface FilterInputProps { + onChange: (value: string) => void; + placeholder?: string; + resetToken?: number; +} + +const FilterInput: React.FC = ({ onChange, placeholder = 'Filter rows…', resetToken }) => { + const [value, setValue] = useState(''); + + const debounced = useMemo( + () => debounce((v: string) => onChange(v), 200), + [onChange] + ); + + useEffect(() => () => debounced.cancel(), [debounced]); + + useEffect(() => { + if (resetToken === undefined) return; + setValue(''); + debounced.cancel(); + onChange(''); + }, [resetToken]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( +
+ { + setValue(e.target.value); + debounced(e.target.value); + }} + aria-label="Filter table rows" + /> +
+ ); +}; + +export default FilterInput; diff --git a/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/ModeToggle.tsx b/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/ModeToggle.tsx new file mode 100644 index 0000000..4592ce3 --- /dev/null +++ b/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/ModeToggle.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import styled from 'styled-components'; + +export type JsonPreviewMode = 'tree' | 'table'; + +interface ModeToggleProps { + mode: JsonPreviewMode; + onChange: (mode: JsonPreviewMode) => void; +} + +const ToggleGroup = styled.div` + display: inline-flex; + align-items: stretch; + border: 1px solid ${(props) => props.theme.border.border2}; + border-radius: ${(props) => props.theme.border.radius.sm}; + overflow: hidden; + background: ${(props) => props.theme.background.surface1}; + font-size: 11px; + line-height: 1; + font-family: inherit; + + button { + appearance: none; + background: transparent; + border: 0; + padding: 4px 12px; + cursor: pointer; + color: ${(props) => props.theme.colors.text.muted}; + transition: background-color 120ms ease, color 120ms ease; + } + + button + button { + border-left: 1px solid ${(props) => props.theme.border.border2}; + } + + button:hover { + color: ${(props) => props.theme.colors.text.white}; + } + + button.active { + background: ${(props) => props.theme.background.surface0}; + color: ${(props) => props.theme.colors.text.yellow}; + font-weight: 600; + } + + button:focus { + outline: none; + } + + button:focus-visible { + box-shadow: inset 0 0 0 2px ${(props) => props.theme.colors.text.yellow}; + } +`; + +const ModeToggle: React.FC = ({ mode, onChange }) => ( + + + + +); + +export default ModeToggle; diff --git a/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/StyledWrapper.ts b/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/StyledWrapper.ts new file mode 100644 index 0000000..be65af7 --- /dev/null +++ b/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/StyledWrapper.ts @@ -0,0 +1,216 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + position: relative; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + font-size: 12px; + background: ${(props) => props.theme.background.base}; + + .json-table-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 8px; + background: ${(props) => props.theme.background.base}; + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + } + + .json-table-filter input { + width: 240px; + padding: 3px 8px; + border: 1px solid ${(props) => props.theme.border.border2}; + border-radius: ${(props) => props.theme.border.radius.sm}; + background: ${(props) => props.theme.background.surface1}; + color: ${(props) => props.theme.colors.text.white}; + font-size: 11px; + + &:focus { + outline: none; + border-color: ${(props) => props.theme.colors.text.yellow}; + } + } + + /* ──── Main table ─────────────────────────────── */ + table.json-table { + width: 100%; + border-collapse: collapse; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + table-layout: auto; + } + + table.json-table > thead > tr > th { + position: sticky; + top: 0; + z-index: 1; + background: ${(props) => props.theme.background.crust}; + color: ${(props) => props.theme.colors.text.white}; + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + border-right: 1px solid ${(props) => props.theme.border.border1}; + padding: 5px 10px; + text-align: left; + cursor: pointer; + user-select: none; + white-space: nowrap; + font-weight: 600; + } + + table.json-table > thead > tr > th:last-child { + border-right: 0; + } + + table.json-table > thead > tr > th:hover { + background: ${(props) => props.theme.background.surface0}; + } + + table.json-table > tbody > tr > td { + padding: 4px 10px; + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + border-right: 1px solid ${(props) => props.theme.border.border1}; + vertical-align: top; + color: ${(props) => props.theme.colors.text.white}; + white-space: nowrap; + } + + table.json-table > tbody > tr > td:last-child { + border-right: 0; + } + + /* Cells that contain a nested sub-table fill flush and allow multi-line */ + table.json-table > tbody > tr > td.json-table-has-sub { + padding: 0; + white-space: normal; + overflow: visible; + max-width: none; + } + + table.json-table > tbody > tr:hover > td:not(.json-table-has-sub) { + background: ${(props) => props.theme.background.surface0}; + } + + /* ──── Sub-table (recursive) ─────────────────── */ + table.json-sub-table { + width: 100%; + border-collapse: collapse; + font-family: inherit; + font-size: inherit; + background: transparent; + } + + /* Key column (for object sub-tables) and index column (for array sub-tables) */ + table.json-sub-table th.json-sub-key, + table.json-sub-table th.json-sub-idx { + background: ${(props) => props.theme.background.mantle}; + color: ${(props) => props.theme.colors.text.white}; + font-weight: 600; + text-align: left; + padding: 4px 10px; + border-right: 1px solid ${(props) => props.theme.border.border1}; + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + vertical-align: top; + white-space: nowrap; + width: 1%; /* shrink-to-content */ + } + + table.json-sub-table th.json-sub-idx { + color: ${(props) => props.theme.colors.text.muted}; + text-align: right; + font-weight: 500; + } + + /* Top header row (array-of-objects sub-table) */ + table.json-sub-table > thead > tr > th { + background: ${(props) => props.theme.background.mantle}; + color: ${(props) => props.theme.colors.text.muted}; + font-weight: 600; + text-align: left; + padding: 4px 10px; + border-right: 1px solid ${(props) => props.theme.border.border1}; + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + white-space: nowrap; + cursor: default; + position: static; + } + + table.json-sub-table > thead > tr > th:last-child { + border-right: 0; + } + + table.json-sub-table > tbody > tr > td { + padding: 4px 10px; + border-right: 1px solid ${(props) => props.theme.border.border1}; + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + vertical-align: top; + color: ${(props) => props.theme.colors.text.white}; + white-space: normal; + } + + table.json-sub-table > tbody > tr > td:last-child { + border-right: 0; + } + + table.json-sub-table > tbody > tr:last-child > td, + table.json-sub-table > tbody > tr:last-child > th { + border-bottom: 0; + } + + /* Sub-table cell that itself contains a sub-table: flush */ + table.json-sub-table td.json-table-has-sub { + padding: 0; + } + + .json-table-nested { + color: ${(props) => props.theme.colors.text.muted}; + font-style: italic; + } + + .json-table-null { + color: ${(props) => props.theme.colors.text.muted}; + } + + .json-table-scroll { + flex: 1; + overflow: auto; + } + + .json-table-pager { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 6px; + background: ${(props) => props.theme.background.base}; + border-top: 1px solid ${(props) => props.theme.border.border1}; + font-size: 11px; + color: ${(props) => props.theme.colors.text.muted}; + } + + .json-table-pager button { + background: ${(props) => props.theme.background.surface1}; + border: 1px solid ${(props) => props.theme.border.border2}; + border-radius: ${(props) => props.theme.border.radius.sm}; + padding: 2px 10px; + cursor: pointer; + color: ${(props) => props.theme.colors.text.white}; + } + + .json-table-pager button:hover:not(:disabled) { + background: ${(props) => props.theme.background.surface0}; + } + + .json-table-pager button:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + .json-table-empty { + padding: 24px; + text-align: center; + color: ${(props) => props.theme.colors.text.muted}; + } +`; + +export default StyledWrapper; diff --git a/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/TableView.tsx b/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/TableView.tsx new file mode 100644 index 0000000..02af9a7 --- /dev/null +++ b/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/TableView.tsx @@ -0,0 +1,189 @@ +import React, { useState } from 'react'; +import toast from 'react-hot-toast'; +import type { Cell, TableShape } from './flatten'; +import { + applyFilter, + applySort, + nextSortState, + paginate, + type SortState +} from './tableState'; +import ValueRenderer from './ValueRenderer'; + +const PAGE_SIZE = 100; + +const cellToCopyString = (cell: Cell): string => { + if (cell === null) return 'null'; + if (typeof cell === 'object' && cell !== null && '__nested' in cell) { + try { + return JSON.stringify(cell.raw, null, 2); + } catch { + return cell.preview; + } + } + return String(cell); +}; + +const isNested = (cell: Cell): cell is { __nested: true; preview: string; raw: unknown } => + cell !== null && typeof cell === 'object' && '__nested' in cell; + +const renderCell = (cell: Cell): React.ReactNode => { + if (cell === null) return —; + if (isNested(cell)) { + return ; + } + if (typeof cell === 'boolean') return cell ? 'true' : 'false'; + return String(cell); +}; + +interface ArrayTableProps { + columns: string[]; + rows: Cell[][]; + filter: string; + resetToken?: number; +} + +const ArrayTable: React.FC = ({ columns, rows, filter, resetToken }) => { + const [sort, setSort] = useState(null); + const [page, setPage] = useState(1); + + const filtered = React.useMemo(() => applyFilter(rows, filter), [rows, filter]); + const sorted = React.useMemo(() => applySort(filtered, sort), [filtered, sort]); + const paged = React.useMemo(() => paginate(sorted, { page, pageSize: PAGE_SIZE }), [sorted, page]); + + // reset page when filter or sort changes + React.useEffect(() => { + setPage(1); + }, [filter, sort]); + + // reset sort/page when the underlying data changes (per spec section 6/12) + React.useEffect(() => { + if (resetToken === undefined) return; + setSort(null); + setPage(1); + }, [resetToken]); + + const handleHeaderClick = (columnIndex: number) => { + setSort((curr) => nextSortState(curr, columnIndex)); + }; + + const handleCellContextMenu = (e: React.MouseEvent, cell: Cell) => { + e.preventDefault(); + e.stopPropagation(); + navigator.clipboard + .writeText(cellToCopyString(cell)) + .then(() => toast.success('Copied to clipboard')) + .catch(() => toast.error('Failed to copy')); + }; + + const sortIndicator = (i: number) => { + if (!sort || sort.columnIndex !== i) return ''; + return sort.direction === 'asc' ? ' ▲' : ' ▼'; + }; + + if (filtered.length === 0) { + return ( +
+ {filter ? `No rows match "${filter}"` : 'No rows'} +
+ ); + } + + return ( + <> +
+ + + + {columns.map((col, i) => ( + + ))} + + + + {paged.rows.map((row, rowIdx) => ( + + {row.map((cell, ci) => { + const hasSub = isNested(cell); + return ( + + ); + })} + + ))} + +
handleHeaderClick(i)} + title={`Sort by ${col}`} + > + {col}{sortIndicator(i)} +
handleCellContextMenu(e, cell)} + title={typeof cell === 'string' ? cell : undefined} + > + {renderCell(cell)} +
+
+ {paged.totalPages > 1 && ( +
+ + Showing {paged.start}–{paged.end} of {paged.totalRows} + +
+ )} + + ); +}; + +interface ObjectTableProps { + rows: [string, Cell][]; + filter: string; + resetToken?: number; +} + +const ObjectTable: React.FC = ({ rows, filter, resetToken }) => { + // Reuse the array-table pipeline by treating rows as Cell[][] + const cellRows: Cell[][] = rows.map(([k, v]) => [k, v]); + return ; +}; + +interface TableViewProps { + shape: TableShape; + filter: string; + resetToken?: number; +} + +const TableView: React.FC = ({ shape, filter, resetToken }) => { + if (shape.kind === 'array') { + if (shape.rows.length === 0) { + return
No rows
; + } + return ; + } + if (shape.kind === 'object') { + if (shape.rows.length === 0) { + return
No fields
; + } + return ; + } + return null; +}; + +export default TableView; diff --git a/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/ValueRenderer.tsx b/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/ValueRenderer.tsx new file mode 100644 index 0000000..ce69354 --- /dev/null +++ b/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/ValueRenderer.tsx @@ -0,0 +1,130 @@ +import React from 'react'; + +interface ValueRendererProps { + value: unknown; + depth: number; +} + +const MAX_DEPTH = 12; + +const isPlainObject = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v); + +const isComplex = (v: unknown): boolean => + isPlainObject(v) || (Array.isArray(v) && v.length > 0); + +const collectColumns = (objects: Record[]): string[] => { + const cols: string[] = []; + const seen = new Set(); + for (const obj of objects) { + for (const key of Object.keys(obj)) { + if (!seen.has(key)) { + seen.add(key); + cols.push(key); + } + } + } + return cols; +}; + +const ValueRenderer: React.FC = ({ value, depth }) => { + if (depth > MAX_DEPTH) { + return …; + } + + if (value === null || value === undefined) { + return —; + } + + if (typeof value === 'boolean') { + return <>{value ? 'true' : 'false'}; + } + + if (typeof value === 'number' || typeof value === 'string') { + return <>{String(value)}; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return [ ]; + } + + if (value.every(isPlainObject)) { + const cols = collectColumns(value); + return ( + + + + + ))} + + + + {value.map((row, i) => ( + + + {cols.map((c) => { + const v = row[c]; + const complex = isComplex(v); + return ( + + ); + })} + + ))} + +
{c}
{i} + +
+ ); + } + + return ( + + + {value.map((v, i) => { + const complex = isComplex(v); + return ( + + + + + ); + })} + +
{i} + +
+ ); + } + + if (isPlainObject(value)) { + const entries = Object.entries(value); + if (entries.length === 0) { + return {'{ }'}; + } + return ( + + + {entries.map(([k, v]) => { + const complex = isComplex(v); + return ( + + + + + ); + })} + +
{k} + +
+ ); + } + + return <>{String(value)}; +}; + +export default ValueRenderer; diff --git a/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/flatten.spec.ts b/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/flatten.spec.ts new file mode 100644 index 0000000..ff648d4 --- /dev/null +++ b/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/flatten.spec.ts @@ -0,0 +1,114 @@ +// flatten.spec.ts +import { describe, test, expect } from 'vitest'; +import { flatten } from './flatten'; + +describe('flatten', () => { + test('array of homogeneous objects → array shape with union columns', () => { + const input = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' } + ]; + const result = flatten(input); + expect(result.kind).toBe('array'); + if (result.kind !== 'array') return; + expect(result.columns).toEqual(['id', 'name']); + expect(result.rows).toEqual([ + [1, 'Alice'], + [2, 'Bob'] + ]); + }); + + test('array with missing keys → null fill', () => { + const r = flatten([{ a: 1 }, { b: 2 }]); + expect(r.kind).toBe('array'); + if (r.kind !== 'array') return; + expect(r.columns).toEqual(['a', 'b']); + expect(r.rows).toEqual([ + [1, null], + [null, 2] + ]); + }); + + test('array of primitives → [index, value] columns', () => { + const r = flatten(['x', 'y', 'z']); + expect(r.kind).toBe('array'); + if (r.kind !== 'array') return; + expect(r.columns).toEqual(['index', 'value']); + expect(r.rows).toEqual([[0, 'x'], [1, 'y'], [2, 'z']]); + }); + + test('array of mixed types → [index, value] with nested cells for objects/arrays', () => { + const r = flatten([1, 'x', { a: 1 }]); + expect(r.kind).toBe('array'); + if (r.kind !== 'array') return; + expect(r.columns).toEqual(['index', 'value']); + expect(r.rows[0]).toEqual([0, 1]); + expect(r.rows[1]).toEqual([1, 'x']); + const nested = r.rows[2][1] as { __nested: true; preview: string; raw: unknown }; + expect(nested.__nested).toBe(true); + expect(nested.preview).toBe('{ a }'); + expect(nested.raw).toEqual({ a: 1 }); + }); + + test('single object → object shape with key/value rows in source order', () => { + const r = flatten({ z: 1, a: 2 }); + expect(r.kind).toBe('object'); + if (r.kind !== 'object') return; + expect(r.rows).toEqual([['z', 1], ['a', 2]]); + }); + + test('empty array → array shape with no columns/rows', () => { + const r = flatten([]); + expect(r).toEqual({ kind: 'array', columns: [], rows: [] }); + }); + + test('empty object → object shape with no rows', () => { + const r = flatten({}); + expect(r).toEqual({ kind: 'object', rows: [] }); + }); + + test('primitive at root → unsupported', () => { + expect(flatten(42)).toEqual({ + kind: 'unsupported', + reason: 'Top-level value is not an object or array' + }); + expect(flatten('hello')).toEqual({ + kind: 'unsupported', + reason: 'Top-level value is not an object or array' + }); + }); + + test('null / undefined at root → unsupported "No data"', () => { + expect(flatten(null)).toEqual({ kind: 'unsupported', reason: 'No data' }); + expect(flatten(undefined)).toEqual({ kind: 'unsupported', reason: 'No data' }); + }); + + test('nested object cell preview shows up to 3 keys then ellipsis', () => { + const r = flatten([{ a: { x: 1, y: 2, z: 3, w: 4 } }]); + if (r.kind !== 'array') throw new Error('expected array'); + const cell = r.rows[0][0] as { preview: string }; + expect(cell.preview).toBe('{ x, y, z, … }'); + }); + + test('nested array cell preview shows item count', () => { + const r = flatten([{ items: [1, 2, 3, 4, 5] }]); + if (r.kind !== 'array') throw new Error('expected array'); + const cell = r.rows[0][0] as { preview: string }; + expect(cell.preview).toBe('[ 5 items ]'); + }); + + test('circular reference → [Circular] preview without throwing', () => { + const obj: Record = { id: 1 }; + obj.self = obj; + const r = flatten([obj]); + if (r.kind !== 'array') throw new Error('expected array'); + const cell = r.rows[0][1] as { preview: string }; + expect(cell.preview).toBe('[Circular]'); + }); + + test('undefined inside object becomes null cell', () => { + const r = flatten([{ a: undefined, b: 1 }]); + if (r.kind !== 'array') throw new Error('expected array'); + expect(r.rows[0]).toEqual([null, 1]); + }); +}); diff --git a/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/flatten.ts b/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/flatten.ts new file mode 100644 index 0000000..e53c319 --- /dev/null +++ b/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/flatten.ts @@ -0,0 +1,95 @@ +// flatten.ts +export type Cell = + | string + | number + | boolean + | null + | { __nested: true; preview: string; raw: unknown }; + +export type TableShape = + | { kind: 'array'; columns: string[]; rows: Cell[][] } + | { kind: 'object'; rows: [string, Cell][] } + | { kind: 'unsupported'; reason: string }; + +const PRIMITIVE = new Set(['string', 'number', 'boolean']); + +const isPlainObject = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v); + +// Single-depth: toCell does not recurse into nested values. It produces a +// preview string for any object/array and returns the raw value untouched. +// The `seen` WeakSet only guards against the row object itself being reached +// via a back-reference (caller pre-seeds it with the ancestor row object). +const toCell = (value: unknown, seen: WeakSet): Cell => { + if (value === undefined || value === null) return null; + if (PRIMITIVE.has(typeof value)) return value as Cell; + + const obj = value as object; + if (seen.has(obj)) { + return { __nested: true, preview: '[Circular]', raw: null }; + } + if (Array.isArray(obj)) { + return { __nested: true, preview: `[ ${obj.length} items ]`, raw: obj }; + } + const keys = Object.keys(obj as Record); + const shown = keys.slice(0, 3).join(', '); + const preview = + keys.length === 0 + ? '{ }' + : keys.length > 3 + ? `{ ${shown}, … }` + : `{ ${shown} }`; + return { __nested: true, preview, raw: obj }; +}; + +const collectColumns = (objects: Record[]): string[] => { + const cols: string[] = []; + const seen = new Set(); + for (const obj of objects) { + for (const key of Object.keys(obj)) { + if (!seen.has(key)) { + seen.add(key); + cols.push(key); + } + } + } + return cols; +}; + +export const flatten = (input: unknown): TableShape => { + if (input === null || input === undefined) { + return { kind: 'unsupported', reason: 'No data' }; + } + + if (Array.isArray(input)) { + if (input.length === 0) return { kind: 'array', columns: [], rows: [] }; + + if (input.every(isPlainObject)) { + const columns = collectColumns(input); + const rows = input.map((obj) => { + const seen = new WeakSet(); + seen.add(obj); + return columns.map((col) => toCell(obj[col], seen)); + }); + return { kind: 'array', columns, rows }; + } + + // primitives or mixed + const columns = ['index', 'value']; + const rows = input.map((value, i) => { + const seen = new WeakSet(); + return [i, toCell(value, seen)] as Cell[]; + }); + return { kind: 'array', columns, rows }; + } + + if (isPlainObject(input)) { + const rows: [string, Cell][] = Object.keys(input).map((key) => { + const seen = new WeakSet(); + return [key, toCell(input[key], seen)]; + }); + return { kind: 'object', rows }; + } + + return { kind: 'unsupported', reason: 'Top-level value is not an object or array' }; +}; diff --git a/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/index.tsx b/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/index.tsx new file mode 100644 index 0000000..9751f73 --- /dev/null +++ b/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/index.tsx @@ -0,0 +1,41 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import ErrorBanner from 'ui/ErrorBanner'; +import StyledWrapper from './StyledWrapper'; +import FilterInput from './FilterInput'; +import TableView from './TableView'; +import { flatten } from './flatten'; + +interface JsonTablePreviewProps { + data: unknown; + modeToggle: React.ReactNode; +} + +const JsonTablePreview: React.FC = ({ data, modeToggle }) => { + const shape = useMemo(() => flatten(data), [data]); + const [filter, setFilter] = useState(''); + const resetTokenRef = useRef(0); + + // Reset filter/sort/page when the underlying data changes (per spec section 6/12). + useEffect(() => { + setFilter(''); + resetTokenRef.current += 1; + }, [data]); + + return ( + +
+ + {modeToggle} +
+ {shape.kind === 'unsupported' ? ( +
+ +
+ ) : ( + + )} +
+ ); +}; + +export default JsonTablePreview; diff --git a/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/tableState.spec.ts b/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/tableState.spec.ts new file mode 100644 index 0000000..143fcad --- /dev/null +++ b/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/tableState.spec.ts @@ -0,0 +1,167 @@ +// tableState.spec.ts +import { describe, test, expect } from 'vitest'; +import { + applyFilter, + applySort, + paginate, + nextSortState, + type SortState +} from './tableState'; +import type { Cell } from './flatten'; + +const arrayShape = { + kind: 'array' as const, + columns: ['id', 'name'], + rows: [ + [1, 'Alice'], + [2, 'Bob'], + [3, 'Charlie'] + ] as Cell[][] +}; + +describe('applyFilter', () => { + test('returns all rows when query is empty', () => { + expect(applyFilter(arrayShape.rows, '')).toEqual(arrayShape.rows); + }); + + test('matches substring case-insensitively across any column', () => { + expect(applyFilter(arrayShape.rows, 'ali')).toEqual([[1, 'Alice']]); + expect(applyFilter(arrayShape.rows, 'BOB')).toEqual([[2, 'Bob']]); + expect(applyFilter(arrayShape.rows, '3')).toEqual([[3, 'Charlie']]); + }); + + test('matches nested cells via their preview', () => { + const rows: Cell[][] = [ + [1, { __nested: true, preview: '{ a, b }', raw: { a: 1, b: 2 } }], + [2, 'plain'] + ]; + expect(applyFilter(rows, 'a, b')).toEqual([rows[0]]); + }); +}); + +describe('applySort', () => { + test('numeric column sorts numerically ascending', () => { + const rows: Cell[][] = [[10], [2], [30]]; + expect(applySort(rows, { columnIndex: 0, direction: 'asc' })).toEqual([[2], [10], [30]]); + }); + + test('numeric column sorts numerically descending', () => { + const rows: Cell[][] = [[10], [2], [30]]; + expect(applySort(rows, { columnIndex: 0, direction: 'desc' })).toEqual([[30], [10], [2]]); + }); + + test('string column sorts via localeCompare', () => { + const rows: Cell[][] = [['c'], ['a'], ['b']]; + expect(applySort(rows, { columnIndex: 0, direction: 'asc' })).toEqual([['a'], ['b'], ['c']]); + }); + + test('null values always sort last', () => { + const rows: Cell[][] = [[1], [null], [2]]; + expect(applySort(rows, { columnIndex: 0, direction: 'asc' })).toEqual([[1], [2], [null]]); + expect(applySort(rows, { columnIndex: 0, direction: 'desc' })).toEqual([[2], [1], [null]]); + }); + + test('nested cells sort by preview string', () => { + const rows: Cell[][] = [ + [{ __nested: true, preview: '{ z }', raw: null }], + [{ __nested: true, preview: '{ a }', raw: null }] + ]; + expect(applySort(rows, { columnIndex: 0, direction: 'asc' })).toEqual([rows[1], rows[0]]); + }); + + test('null sort direction returns input unchanged (stable)', () => { + const rows: Cell[][] = [[2], [1]]; + expect(applySort(rows, null)).toEqual([[2], [1]]); + }); + + test('mixed types fall back to string comparison', () => { + const rows: Cell[][] = [['10'], [2], ['1']]; + const result = applySort(rows, { columnIndex: 0, direction: 'asc' }); + expect(result).toEqual([['1'], ['10'], [2]]); + }); + + test('boolean column sorts false before true ascending', () => { + const rows: Cell[][] = [[true], [false], [true]]; + expect(applySort(rows, { columnIndex: 0, direction: 'asc' })).toEqual([[false], [true], [true]]); + expect(applySort(rows, { columnIndex: 0, direction: 'desc' })).toEqual([[true], [true], [false]]); + }); +}); + +describe('nextSortState', () => { + test('cycles none → asc → desc → none for the same column', () => { + const c = 0; + expect(nextSortState(null, c)).toEqual({ columnIndex: c, direction: 'asc' }); + expect(nextSortState({ columnIndex: c, direction: 'asc' }, c)).toEqual({ columnIndex: c, direction: 'desc' }); + expect(nextSortState({ columnIndex: c, direction: 'desc' }, c)).toBeNull(); + }); + + test('clicking a different column starts at asc', () => { + expect(nextSortState({ columnIndex: 0, direction: 'desc' }, 1)).toEqual({ + columnIndex: 1, + direction: 'asc' + }); + }); +}); + +describe('paginate', () => { + const rows: Cell[][] = Array.from({ length: 250 }, (_, i) => [i]); + + test('returns the correct slice and totals for page 1', () => { + const r = paginate(rows, { page: 1, pageSize: 100 }); + expect(r.rows.length).toBe(100); + expect(r.rows[0]).toEqual([0]); + expect(r.rows[99]).toEqual([99]); + expect(r.totalRows).toBe(250); + expect(r.totalPages).toBe(3); + expect(r.hasPrev).toBe(false); + expect(r.hasNext).toBe(true); + expect(r.start).toBe(1); + expect(r.end).toBe(100); + }); + + test('last page may be partial; hasNext is false', () => { + const r = paginate(rows, { page: 3, pageSize: 100 }); + expect(r.rows.length).toBe(50); + expect(r.hasPrev).toBe(true); + expect(r.hasNext).toBe(false); + expect(r.start).toBe(201); + expect(r.end).toBe(250); + }); + + test('empty rows → totals zero, no prev/next', () => { + const r = paginate([], { page: 1, pageSize: 100 }); + expect(r).toEqual({ + rows: [], + totalRows: 0, + totalPages: 0, + hasPrev: false, + hasNext: false, + start: 0, + end: 0 + }); + }); + + test('clamps page above totalPages to totalPages', () => { + const r = paginate(rows, { page: 999, pageSize: 100 }); + expect(r.rows.length).toBe(50); + expect(r.hasNext).toBe(false); + }); +}); + +describe('composition (filter → sort → paginate)', () => { + test('filter narrows then sort orders then paginate slices', () => { + const rows: Cell[][] = [ + [3, 'apple'], + [1, 'banana'], + [2, 'apricot'], + [4, 'cherry'] + ]; + const filtered = applyFilter(rows, 'ap'); + const sorted = applySort(filtered, { columnIndex: 0, direction: 'asc' }); + const page = paginate(sorted, { page: 1, pageSize: 100 }); + expect(page.rows).toEqual([ + [2, 'apricot'], + [3, 'apple'] + ]); + }); +}); diff --git a/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/tableState.ts b/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/tableState.ts new file mode 100644 index 0000000..cc26df2 --- /dev/null +++ b/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/tableState.ts @@ -0,0 +1,94 @@ +// tableState.ts +import type { Cell } from './flatten'; + +export type SortDirection = 'asc' | 'desc'; +export type SortState = { columnIndex: number; direction: SortDirection } | null; +export type PaginateInput = { page: number; pageSize: number }; + +export type PaginateResult = { + rows: Cell[][]; + totalRows: number; + totalPages: number; + hasPrev: boolean; + hasNext: boolean; + start: number; // 1-indexed inclusive + end: number; // 1-indexed inclusive +}; + +const cellToString = (cell: Cell): string => { + if (cell === null) return ''; + if (typeof cell === 'object' && '__nested' in cell) return cell.preview; + return String(cell); +}; + +export const applyFilter = (rows: Cell[][], query: string): Cell[][] => { + const q = query.trim().toLowerCase(); + if (!q) return rows; + return rows.filter((row) => + row.some((cell) => cellToString(cell).toLowerCase().includes(q)) + ); +}; + +const allNonNullAreType = (rows: Cell[][], columnIndex: number, type: 'number' | 'boolean'): boolean => { + let sawAny = false; + for (const row of rows) { + const v = row[columnIndex]; + if (v === null) continue; + if (typeof v === 'object' && v !== null && '__nested' in v) return false; + if (typeof v !== type) return false; + sawAny = true; + } + return sawAny; +}; + +export const applySort = (rows: Cell[][], state: SortState): Cell[][] => { + if (state === null) return rows; + const { columnIndex, direction } = state; + const dir = direction === 'asc' ? 1 : -1; + + const isNumeric = allNonNullAreType(rows, columnIndex, 'number'); + const isBoolean = !isNumeric && allNonNullAreType(rows, columnIndex, 'boolean'); + + const copy = rows.slice(); + copy.sort((a, b) => { + const av = a[columnIndex]; + const bv = b[columnIndex]; + if (av === null && bv === null) return 0; + if (av === null) return 1; // nulls always last + if (bv === null) return -1; + + if (isNumeric) return ((av as number) - (bv as number)) * dir; + if (isBoolean) return ((av === true ? 1 : 0) - (bv === true ? 1 : 0)) * dir; + + return cellToString(av).localeCompare(cellToString(bv)) * dir; + }); + return copy; +}; + +export const nextSortState = (current: SortState, columnIndex: number): SortState => { + if (!current || current.columnIndex !== columnIndex) { + return { columnIndex, direction: 'asc' }; + } + if (current.direction === 'asc') return { columnIndex, direction: 'desc' }; + return null; +}; + +export const paginate = (rows: Cell[][], { page, pageSize }: PaginateInput): PaginateResult => { + const totalRows = rows.length; + if (totalRows === 0) { + return { rows: [], totalRows: 0, totalPages: 0, hasPrev: false, hasNext: false, start: 0, end: 0 }; + } + const totalPages = Math.ceil(totalRows / pageSize); + const clampedPage = Math.min(Math.max(1, page), totalPages); + const startIdx = (clampedPage - 1) * pageSize; + const endIdx = Math.min(startIdx + pageSize, totalRows); + return { + rows: rows.slice(startIdx, endIdx), + totalRows, + totalPages, + hasPrev: clampedPage > 1, + hasNext: clampedPage < totalPages, + start: startIdx + 1, + end: endIdx + }; +}; diff --git a/src/webview/providers/ReduxStore/slices/tabs.ts b/src/webview/providers/ReduxStore/slices/tabs.ts index b7a728a..398313b 100644 --- a/src/webview/providers/ReduxStore/slices/tabs.ts +++ b/src/webview/providers/ReduxStore/slices/tabs.ts @@ -14,6 +14,7 @@ interface Tab { responsePaneScrollPosition: number | null; responseFormat: string | null; responseViewTab: string | null; + responseJsonPreviewMode: 'tree' | 'table' | null; type: string; preview: boolean; folderUid?: string; @@ -84,6 +85,7 @@ export const tabsSlice = createSlice({ responsePaneScrollPosition: null, responseFormat: null, responseViewTab: null, + responseJsonPreviewMode: null, type: type || 'request', preview: preview !== undefined ? preview @@ -106,6 +108,7 @@ export const tabsSlice = createSlice({ responsePaneScrollPosition: null, responseFormat: null, responseViewTab: null, + responseJsonPreviewMode: null, type: type || 'request', ...(uid ? { folderUid: uid } : {}), preview: preview !== undefined @@ -188,6 +191,13 @@ export const tabsSlice = createSlice({ tab.responseViewTab = action.payload.responseViewTab; } }, + updateResponseJsonPreviewMode: (state, action: PayloadAction) => { + const tab = find(state.tabs, (t) => t.uid === action.payload.uid); + + if (tab) { + tab.responseJsonPreviewMode = action.payload.mode; + } + }, closeTabs: (state, action: PayloadAction) => { const activeTab = find(state.tabs, (t) => t.uid === state.activeTabUid); const tabUids = action.payload.tabUids || []; @@ -272,6 +282,7 @@ export const { updateResponsePaneScrollPosition, updateResponseFormat, updateResponseViewTab, + updateResponseJsonPreviewMode, closeTabs, closeAllCollectionTabs, makeTabPermanent,