diff --git a/src/ApiUsage.tsx b/src/ApiUsage.tsx index 08d835c..ec2aca0 100644 --- a/src/ApiUsage.tsx +++ b/src/ApiUsage.tsx @@ -2,10 +2,10 @@ import { useState, useEffect, useCallback } from 'react'; import EmptyState from './components/EmptyState'; import Skeleton, { SkeletonRow } from './components/Skeleton'; import { formatPrice } from './utils/format'; -import RequestBodyEditor from './components/RequestBodyEditor'; import type { JsonSchema } from './components/RequestBodyEditor'; import CallHistoryRow from './components/CallHistoryRow'; import Breadcrumb from './components/Breadcrumb'; +import ParamsBuilder from './components/ParamsBuilder'; type ApiEndpoint = { id: string; @@ -602,14 +602,11 @@ export default function ApiUsage() {
-
diff --git a/src/components/ParamsBuilder.test.tsx b/src/components/ParamsBuilder.test.tsx new file mode 100644 index 0000000..8b73bfa --- /dev/null +++ b/src/components/ParamsBuilder.test.tsx @@ -0,0 +1,391 @@ +// @vitest-environment jsdom + +/** + * ParamsBuilder tests + * + * Coverage: + * • Empty state renders with "No parameters yet" and Add CTA + * • Adding a row populates the row list + * • Removing a row updates the list + * • Editing key / type / value serialises correct JSON + * • Boolean value renders a select (not a text input) + * • Number value renders a number input + * • Tab order: key → type → value → remove (via DOM order) + * • Mode toggle: form → raw serialises rows to JSON + * • Mode toggle: raw → form parses JSON into rows + * • Invalid raw JSON stays in raw mode and surfaces an inline error + * • Non-object raw JSON surfaces an inline error on switch + * • onChange is called with the correct JSON string + * • disabled prop disables all interactive elements + * • Aria attributes: labelledby, aria-invalid, aria-live regions + */ + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import ParamsBuilder from './ParamsBuilder'; +import type { ParamsBuilderProps } from './ParamsBuilder'; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +function setup(props: Partial = {}) { + const onChange = vi.fn(); + const utils = render( + , + ); + return { ...utils, onChange }; +} + +/** Click the "Form" mode button. */ +function clickForm() { + fireEvent.click(screen.getByRole('button', { name: 'Form' })); +} + +/** Click the "Raw JSON" mode button. */ +function clickRaw() { + fireEvent.click(screen.getByRole('button', { name: 'Raw JSON' })); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('ParamsBuilder', () => { + afterEach(cleanup); + + // ── Empty state ───────────────────────────────────────────────────────── + + it('shows "No parameters yet" when value is "{}"', () => { + setup({ value: '{}' }); + expect(screen.getByText('No parameters yet.')).toBeTruthy(); + }); + + it('shows an Add parameter CTA in the empty state', () => { + setup({ value: '{}' }); + expect(screen.getByRole('button', { name: /add parameter/i })).toBeTruthy(); + }); + + // ── Adding a row ──────────────────────────────────────────────────────── + + it('adds a row when "Add parameter" is clicked', () => { + setup({ value: '{}' }); + fireEvent.click(screen.getByRole('button', { name: /add parameter/i })); + // After adding, a key input should be visible + expect(screen.getByLabelText(/parameter 1 key/i)).toBeTruthy(); + }); + + it('calls onChange with empty-key object when a blank row is added', () => { + const { onChange } = setup({ value: '{}' }); + fireEvent.click(screen.getByRole('button', { name: /add parameter/i })); + // Blank key → excluded from serialisation → still "{}" + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0]; + expect(JSON.parse(lastCall)).toEqual({}); + }); + + // ── Editing rows ──────────────────────────────────────────────────────── + + it('serialises a string row into JSON', () => { + const { onChange } = setup({ value: '{}' }); + fireEvent.click(screen.getByRole('button', { name: /add parameter/i })); + + fireEvent.change(screen.getByLabelText(/parameter 1 key/i), { + target: { value: 'name' }, + }); + fireEvent.change(screen.getByLabelText(/parameter 1 value/i), { + target: { value: 'Alice' }, + }); + + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0]; + expect(JSON.parse(lastCall)).toEqual({ name: 'Alice' }); + }); + + it('serialises a number row into JSON', () => { + const { onChange } = setup({ value: '{}' }); + fireEvent.click(screen.getByRole('button', { name: /add parameter/i })); + + fireEvent.change(screen.getByLabelText(/parameter 1 key/i), { + target: { value: 'limit' }, + }); + fireEvent.change(screen.getByLabelText(/parameter 1 type/i), { + target: { value: 'number' }, + }); + fireEvent.change(screen.getByLabelText(/parameter 1 value/i), { + target: { value: '10' }, + }); + + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0]; + expect(JSON.parse(lastCall)).toEqual({ limit: 10 }); + }); + + it('renders a select for the boolean value field', () => { + setup({ value: '{"active":true}' }); + // The existing row's type should be boolean → value select + const valueEl = screen.getByLabelText(/parameter 1 value/i); + expect(valueEl.tagName.toLowerCase()).toBe('select'); + expect((valueEl as HTMLSelectElement).value).toBe('true'); + }); + + it('renders a number input for the number value field', () => { + setup({ value: '{"count":5}' }); + const valueEl = screen.getByLabelText(/parameter 1 value/i); + expect((valueEl as HTMLInputElement).type).toBe('number'); + expect((valueEl as HTMLInputElement).value).toBe('5'); + }); + + it('serialises a boolean row correctly', () => { + const { onChange } = setup({ value: '{}' }); + fireEvent.click(screen.getByRole('button', { name: /add parameter/i })); + + fireEvent.change(screen.getByLabelText(/parameter 1 key/i), { + target: { value: 'active' }, + }); + fireEvent.change(screen.getByLabelText(/parameter 1 type/i), { + target: { value: 'boolean' }, + }); + // Value select defaults to "true" + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0]; + expect(JSON.parse(lastCall)).toEqual({ active: true }); + }); + + // ── Removing a row ────────────────────────────────────────────────────── + + it('removes a row when the remove button is clicked', () => { + setup({ value: '{"x":"1"}' }); + expect(screen.getByLabelText(/parameter 1 key/i)).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: /remove parameter 1/i })); + + expect(screen.queryByLabelText(/parameter 1 key/i)).toBeNull(); + expect(screen.getByText('No parameters yet.')).toBeTruthy(); + }); + + it('calls onChange with "{}" after the last row is removed', () => { + const { onChange } = setup({ value: '{"x":"1"}' }); + fireEvent.click(screen.getByRole('button', { name: /remove parameter 1/i })); + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0]; + expect(JSON.parse(lastCall)).toEqual({}); + }); + + // ── Parameter count badge ──────────────────────────────────────────────── + + it('shows the parameter count badge in form mode', () => { + setup({ value: '{"a":"1","b":"2"}' }); + // Two rows should give count badge of 2 + expect(screen.getByLabelText('2 parameters')).toBeTruthy(); + }); + + it('count badge shows singular for one parameter', () => { + setup({ value: '{"a":"1"}' }); + expect(screen.getByLabelText('1 parameter')).toBeTruthy(); + }); + + // ── Tab order (DOM order check) ───────────────────────────────────────── + + it('DOM order within a row is: key → type → value → remove', () => { + setup({ value: '{"foo":"bar"}' }); + const row = screen.getByRole('listitem', { name: /parameter 1/i }); + // Query all interactive elements in DOM order + const all = Array.from(row.querySelectorAll('input, select, button')); + const labels = all.map((el) => el.getAttribute('aria-label') ?? el.tagName.toLowerCase()); + expect(labels[0]).toMatch(/key/i); // first: key input + expect(labels[1]).toMatch(/type/i); // second: type select + expect(labels[2]).toMatch(/value/i); // third: value input/select + expect(labels[3]).toMatch(/remove/i); // fourth: remove button + }); + + // ── Mode toggle: form → raw ───────────────────────────────────────────── + + it('switches to Raw JSON mode when the Raw JSON button is clicked', () => { + setup({ value: '{}' }); + clickRaw(); + expect(screen.getByRole('textbox', { name: /raw json parameters/i })).toBeTruthy(); + }); + + it('serialises rows into the raw textarea when switching form → raw', () => { + setup({ value: '{"limit":10}' }); + clickRaw(); + const textarea = screen.getByRole('textbox', { + name: /raw json parameters/i, + }) as HTMLTextAreaElement; + expect(JSON.parse(textarea.value)).toEqual({ limit: 10 }); + }); + + it('Raw JSON button is aria-pressed="true" when in raw mode', () => { + setup({ value: '{}' }); + clickRaw(); + const rawBtn = screen.getByRole('button', { name: 'Raw JSON' }); + expect(rawBtn.getAttribute('aria-pressed')).toBe('true'); + }); + + it('Form button is aria-pressed="false" when in raw mode', () => { + setup({ value: '{}' }); + clickRaw(); + const formBtn = screen.getByRole('button', { name: 'Form' }); + expect(formBtn.getAttribute('aria-pressed')).toBe('false'); + }); + + // ── Mode toggle: raw → form ───────────────────────────────────────────── + + it('parses raw JSON back into form rows when switching raw → form', () => { + const { onChange } = setup({ value: '{}' }); + clickRaw(); + + const textarea = screen.getByRole('textbox', { + name: /raw json parameters/i, + }) as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: '{"page":2}' } }); + + clickForm(); + expect(screen.getByLabelText(/parameter 1 key/i)).toBeTruthy(); + expect((screen.getByLabelText(/parameter 1 key/i) as HTMLInputElement).value).toBe('page'); + }); + + // ── Invalid raw JSON ───────────────────────────────────────────────────── + + it('surfaces an inline error when raw JSON is syntactically invalid', async () => { + setup({ value: '{}' }); + clickRaw(); + + const textarea = screen.getByRole('textbox', { name: /raw json parameters/i }); + fireEvent.change(textarea, { target: { value: '{bad json}' } }); + + // The status region should show a JSON syntax error message + const status = screen.getByRole('status'); + expect(status.textContent).toMatch(/json syntax error/i); + }); + + it('stays in raw mode when switching to form with invalid JSON', () => { + setup({ value: '{}' }); + clickRaw(); + + const textarea = screen.getByRole('textbox', { name: /raw json parameters/i }); + fireEvent.change(textarea, { target: { value: '{bad json}' } }); + + clickForm(); + + // Still in raw mode + expect(screen.getByRole('textbox', { name: /raw json parameters/i })).toBeTruthy(); + }); + + it('shows a parse error alert when switching from invalid raw JSON to form', () => { + setup({ value: '{}' }); + clickRaw(); + + const textarea = screen.getByRole('textbox', { name: /raw json parameters/i }); + fireEvent.change(textarea, { target: { value: '{not valid json' } }); + + clickForm(); + + // The switch error alert should appear + expect(screen.getByRole('alert')).toBeTruthy(); + expect(screen.getByRole('alert').textContent).toMatch(/cannot switch to form/i); + }); + + it('surfaces an error when raw JSON is an array (not an object)', () => { + setup({ value: '{}' }); + clickRaw(); + + const textarea = screen.getByRole('textbox', { name: /raw json parameters/i }); + fireEvent.change(textarea, { target: { value: '[1,2,3]' } }); + + clickForm(); + + expect(screen.getByRole('alert').textContent).toMatch(/cannot switch to form/i); + }); + + // ── onChange propagation ───────────────────────────────────────────────── + + it('calls onChange when raw textarea content changes', () => { + const { onChange } = setup({ value: '{}' }); + clickRaw(); + const textarea = screen.getByRole('textbox', { name: /raw json parameters/i }); + fireEvent.change(textarea, { target: { value: '{"x":1}' } }); + expect(onChange).toHaveBeenCalledWith('{"x":1}'); + }); + + // ── Disabled state ─────────────────────────────────────────────────────── + + it('disables the Add parameter button when disabled=true', () => { + render(); + expect( + (screen.getByRole('button', { name: /add parameter/i }) as HTMLButtonElement).disabled, + ).toBe(true); + }); + + it('disables the mode toggle buttons when disabled=true', () => { + render(); + expect( + (screen.getByRole('button', { name: 'Form' }) as HTMLButtonElement).disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: 'Raw JSON' }) as HTMLButtonElement).disabled, + ).toBe(true); + }); + + it('disables key inputs when disabled=true and rows exist', () => { + render(); + expect( + (screen.getByLabelText(/parameter 1 key/i) as HTMLInputElement).disabled, + ).toBe(true); + }); + + it('disables remove buttons when disabled=true', () => { + render(); + expect( + (screen.getByRole('button', { name: /remove parameter 1/i }) as HTMLButtonElement).disabled, + ).toBe(true); + }); + + // ── Accessibility ──────────────────────────────────────────────────────── + + it('raw textarea has aria-invalid when JSON is invalid', () => { + setup({ value: '{}' }); + clickRaw(); + const textarea = screen.getByRole('textbox', { name: /raw json parameters/i }); + fireEvent.change(textarea, { target: { value: '{oops' } }); + expect(textarea.getAttribute('aria-invalid')).toBe('true'); + }); + + it('raw textarea does NOT have aria-invalid when JSON is valid', () => { + setup({ value: '{}' }); + clickRaw(); + const textarea = screen.getByRole('textbox', { name: /raw json parameters/i }); + fireEvent.change(textarea, { target: { value: '{"ok":1}' } }); + expect(textarea.getAttribute('aria-invalid')).toBeNull(); + }); + + it('raw status region has role="status" and aria-live="polite"', () => { + setup({ value: '{}' }); + clickRaw(); + const status = screen.getByRole('status'); + expect(status.getAttribute('aria-live')).toBe('polite'); + }); + + it('custom label is rendered', () => { + setup({ label: 'Query Params', value: '{}' }); + expect(screen.getByText('Query Params')).toBeTruthy(); + }); + + // ── Round-trip ─────────────────────────────────────────────────────────── + + it('round-trips form → raw → form without data loss', () => { + const initial = JSON.stringify({ city: 'London', count: 3, active: true }, null, 2); + setup({ value: initial }); + + // Switch to raw, should contain the same JSON + clickRaw(); + const textarea = screen.getByRole('textbox', { + name: /raw json parameters/i, + }) as HTMLTextAreaElement; + expect(JSON.parse(textarea.value)).toEqual({ city: 'London', count: 3, active: true }); + + // Switch back to form + clickForm(); + expect(screen.getByLabelText('3 parameters')).toBeTruthy(); + }); +}); diff --git a/src/components/ParamsBuilder.tsx b/src/components/ParamsBuilder.tsx new file mode 100644 index 0000000..1c1edf0 --- /dev/null +++ b/src/components/ParamsBuilder.tsx @@ -0,0 +1,831 @@ +/** + * ParamsBuilder + * + * A dual-mode parameters editor for API test calls. + * + * Modes + * ───── + * • Form — Add typed key/value rows (string / number / boolean). + * Each row exposes: key input, type selector, value input, remove + * button, with tab order: key → type → value → remove. + * • Raw — A plain JSON textarea (mirrors RequestBodyEditor conventions). + * Switching form→raw serialises rows; raw→form parses and shows + * inline errors if the JSON is malformed or not an object. + * + * Props + * ───── + * value — Controlled serialised JSON string (the "source of truth" the + * parent component stores). + * onChange — Called with the updated JSON string after every mutation. + * disabled — Disables all inputs. + * label — Heading for the parameters section. + * + * Acceptance criteria (issue #150) + * ───────────────────────────────── + * ✓ Form and raw modes round-trip without data loss. + * ✓ Invalid raw JSON surfaces a non-blocking inline error. + * ✓ Tab order flows key → type → value → remove per row. + * ✓ Empty state shows "No parameters yet" with an Add CTA. + */ + +import { useId, useState, useEffect, useCallback } from 'react'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** The three value types a row can carry. */ +export type ParamType = 'string' | 'number' | 'boolean'; + +/** One key/value/type row in form mode. */ +export interface ParamRow { + /** Stable client-side id so React reconciliation is correct. */ + id: string; + key: string; + type: ParamType; + value: string; +} + +export interface ParamsBuilderProps { + /** Controlled serialised JSON string (e.g. '{"limit":10}'). */ + value: string; + /** Fired with the updated JSON string after every mutation. */ + onChange: (json: string) => void; + /** Disables all inputs. */ + disabled?: boolean; + /** Section heading. Defaults to "Parameters". */ + label?: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +let _rowCounter = 0; +function nextId() { + return `pb-row-${++_rowCounter}`; +} + +/** Coerce a raw string into the appropriate JS value based on type. */ +function coerce(type: ParamType, raw: string): string | number | boolean { + if (type === 'boolean') return raw === 'true'; + if (type === 'number') { + const n = Number(raw); + return isNaN(n) ? 0 : n; + } + return raw; +} + +/** Serialise rows to a compact JSON object string. */ +function rowsToJson(rows: ParamRow[]): string { + const obj: Record = {}; + for (const row of rows) { + if (row.key.trim() === '') continue; // skip unnamed params + obj[row.key.trim()] = coerce(row.type, row.value); + } + return JSON.stringify(obj, null, 2); +} + +/** + * Parse a JSON string into ParamRow[]. + * Returns null and a message when parsing fails or the top-level is not an object. + */ +function jsonToRows(raw: string): { rows: ParamRow[]; error: string | null } { + const trimmed = raw.trim(); + if (trimmed === '' || trimmed === '{}') { + return { rows: [], error: null }; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + const msg = err instanceof SyntaxError ? err.message : String(err); + return { rows: [], error: `JSON syntax error: ${msg}` }; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return { rows: [], error: 'Parameters must be a JSON object ({ … }).' }; + } + const obj = parsed as Record; + const rows: ParamRow[] = Object.entries(obj).map(([key, val]) => { + let type: ParamType = 'string'; + let value = String(val); + if (typeof val === 'number') { type = 'number'; value = String(val); } + else if (typeof val === 'boolean') { type = 'boolean'; value = String(val); } + return { id: nextId(), key, type, value }; + }); + return { rows, error: null }; +} + +// --------------------------------------------------------------------------- +// Scoped styles +// --------------------------------------------------------------------------- + +const STYLES = ` +/* ── ParamsBuilder root ──────────────────────────────────────────────── */ +.pb-root { + display: flex; + flex-direction: column; + gap: 10px; +} + +/* ── Header bar (label + mode toggle + count badge) ──────────────────── */ +.pb-header { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.pb-heading { + font-size: 13px; + font-weight: 500; + color: var(--muted); + letter-spacing: 0.02em; + margin: 0; +} + +.pb-count { + font-size: 11px; + font-weight: 600; + background: var(--surface-soft); + color: var(--muted); + border: 1px solid var(--line); + border-radius: 20px; + padding: 1px 8px; + line-height: 1.6; +} + +.pb-mode-toggle { + margin-left: auto; + display: flex; + align-items: center; + gap: 0; + border: 1.5px solid var(--line); + border-radius: 8px; + overflow: hidden; +} + +.pb-mode-btn { + background: transparent; + border: none; + padding: 4px 12px; + font-size: 12px; + font-weight: 500; + color: var(--muted); + cursor: pointer; + transition: background var(--transition-speed, 240ms), color var(--transition-speed, 240ms); + line-height: 1.6; +} + +.pb-mode-btn:hover:not(:disabled) { + background: var(--surface-soft); + color: var(--text); +} + +.pb-mode-btn[aria-pressed="true"] { + background: var(--accent); + color: #fff; +} + +.pb-mode-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.pb-mode-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +/* ── Parse error banner (raw → form) ──────────────────────────────────── */ +.pb-parse-error { + display: flex; + align-items: flex-start; + gap: 6px; + padding: 8px 12px; + background: rgba(255, 125, 141, 0.08); + border: 1px solid var(--danger, #ff7d8d); + border-radius: 8px; + color: var(--danger, #ff7d8d); + font-size: 12px; + line-height: 1.5; +} + +/* ── Empty state ──────────────────────────────────────────────────────── */ +.pb-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 24px 16px; + border: 1.5px dashed var(--line); + border-radius: var(--radius-md, 12px); + background: var(--surface-soft); + text-align: center; +} + +.pb-empty-text { + font-size: 13px; + color: var(--muted); + margin: 0; +} + +/* ── Form rows ────────────────────────────────────────────────────────── */ +.pb-rows { + display: flex; + flex-direction: column; + gap: 6px; +} + +.params-builder__row { + display: grid; + grid-template-columns: 1fr auto 1fr auto; + gap: 6px; + align-items: center; + padding: 6px 8px; + border: 1.5px solid var(--line); + border-radius: 10px; + background: var(--surface-soft); + transition: border-color var(--transition-speed, 240ms); +} + +.params-builder__row:focus-within { + border-color: var(--accent); +} + +/* Inputs inside the row */ +.pb-key-input, +.pb-value-input, +.pb-type-select { + background: transparent; + border: 1px solid var(--line); + border-radius: 6px; + padding: 5px 8px; + font-size: 13px; + color: var(--text); + min-width: 0; + width: 100%; + box-sizing: border-box; + transition: border-color var(--transition-speed, 240ms); +} + +.pb-key-input::placeholder, +.pb-value-input::placeholder { + color: var(--muted); + opacity: 0.7; +} + +.pb-key-input:focus-visible, +.pb-value-input:focus-visible, +.pb-type-select:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; + border-color: var(--accent); +} + +.pb-key-input:disabled, +.pb-value-input:disabled, +.pb-type-select:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +/* Type selector auto-width */ +.pb-type-select { + width: auto; + min-width: 90px; + appearance: none; + -webkit-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%2393a0bf'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; + padding-right: 24px; + cursor: pointer; +} + +/* Remove button */ +.pb-remove-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + background: transparent; + border: 1.5px solid transparent; + border-radius: 6px; + color: var(--muted); + cursor: pointer; + flex-shrink: 0; + transition: color var(--transition-speed, 240ms), border-color var(--transition-speed, 240ms), background var(--transition-speed, 240ms); +} + +.pb-remove-btn:hover:not(:disabled) { + color: var(--danger, #ff7d8d); + border-color: var(--danger, #ff7d8d); + background: rgba(255, 125, 141, 0.08); +} + +.pb-remove-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.pb-remove-btn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +/* ── Add button ───────────────────────────────────────────────────────── */ +.pb-add-btn { + display: flex; + align-items: center; + gap: 6px; + align-self: flex-start; + padding: 6px 14px; + background: transparent; + border: 1.5px solid var(--accent); + border-radius: 8px; + color: var(--accent); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background var(--transition-speed, 240ms), color var(--transition-speed, 240ms); + line-height: 1.4; +} + +.pb-add-btn:hover:not(:disabled) { + background: var(--accent); + color: #fff; +} + +.pb-add-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} + +.pb-add-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +/* ── Raw textarea shell ───────────────────────────────────────────────── */ +.pb-raw-shell { + border: 1.5px solid var(--line); + border-radius: var(--radius-md, 12px); + background: var(--surface-soft); + overflow: hidden; + transition: border-color var(--transition-speed, 240ms); +} + +.pb-raw-shell:focus-within { + border-color: var(--accent); + box-shadow: var(--focus-ring); +} + +.pb-raw-shell[data-state="error"] { + border-color: var(--danger, #ff7d8d); +} + +.pb-raw-shell[data-state="ok"] { + border-color: var(--success, #73f2bb); +} + +.pb-raw-textarea { + display: block; + width: 100%; + box-sizing: border-box; + padding: 12px 14px; + background: transparent; + border: none; + outline: none; + color: var(--text); + font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", monospace; + font-size: 13px; + line-height: 1.65; + resize: vertical; +} + +.pb-raw-textarea::placeholder { + color: var(--muted); + opacity: 0.7; +} + +.pb-raw-textarea:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +/* ── Raw status bar ───────────────────────────────────────────────────── */ +.pb-raw-status { + display: flex; + flex-direction: column; + gap: 4px; + min-height: 20px; + font-size: 12px; +} + +.pb-raw-status-ok { + display: flex; + align-items: center; + gap: 5px; + color: var(--success, #73f2bb); + font-weight: 500; +} + +.pb-raw-error { + display: flex; + align-items: flex-start; + gap: 5px; + color: var(--danger, #ff7d8d); + line-height: 1.4; +} +`; + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +/** SVG cross ("✕") used on the remove button. */ +function IconRemove() { + return ( + + ); +} + +/** Small "+" icon for the Add button. */ +function IconPlus() { + return ( + + ); +} + +/** Checkmark icon for the raw status bar. */ +function IconCheck() { + return ( + + ); +} + +/** X-circle icon for raw errors. */ +function IconXCircle() { + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export default function ParamsBuilder({ + value, + onChange, + disabled = false, + label = 'Parameters', +}: ParamsBuilderProps) { + const headingId = useId(); + const rawStatusId = useId(); + + // ── Mode ──────────────────────────────────────────────────────────────── + const [mode, setMode] = useState<'form' | 'raw'>('form'); + + // ── Form state ────────────────────────────────────────────────────────── + const [rows, setRows] = useState(() => jsonToRows(value).rows); + + // ── Raw JSON state ─────────────────────────────────────────────────────── + // rawText is the textarea content when in raw mode. + const [rawText, setRawText] = useState(value); + + // ── Raw validation ─────────────────────────────────────────────────────── + const [rawValidation, setRawValidation] = useState< + { status: 'idle' } | { status: 'ok' } | { status: 'error'; message: string } + >({ status: 'idle' }); + + // ── Mode-switch parse error (raw → form) ────────────────────────────── + const [switchError, setSwitchError] = useState(null); + + // ── Keep rawText in sync when the external `value` prop changes ───────── + // (e.g. when the parent resets params on endpoint change) + useEffect(() => { + if (mode === 'raw') { + setRawText(value); + } else { + const { rows: parsed } = jsonToRows(value); + setRows(parsed); + } + // We intentionally only react to `value` prop changes here. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]); + + // ── Validate raw JSON live ──────────────────────────────────────────── + useEffect(() => { + if (mode !== 'raw') return; + const trimmed = rawText.trim(); + if (trimmed === '' || trimmed === '{}') { + setRawValidation({ status: 'idle' }); + return; + } + try { + JSON.parse(rawText); + setRawValidation({ status: 'ok' }); + } catch (err) { + const msg = err instanceof SyntaxError ? err.message : String(err); + setRawValidation({ status: 'error', message: msg }); + } + }, [rawText, mode]); + + // ── Row → JSON → parent ───────────────────────────────────────────── + const commitRows = useCallback( + (nextRows: ParamRow[]) => { + setRows(nextRows); + onChange(rowsToJson(nextRows)); + }, + [onChange], + ); + + // ── Raw text → parent ───────────────────────────────────────────────── + const handleRawChange = useCallback( + (next: string) => { + setRawText(next); + // Always propagate — parent decides whether to act on invalid JSON. + onChange(next); + }, + [onChange], + ); + + // ── Switch to raw mode ──────────────────────────────────────────────── + const switchToRaw = () => { + setSwitchError(null); + const serialised = rowsToJson(rows); + setRawText(serialised); + onChange(serialised); + setMode('raw'); + }; + + // ── Switch to form mode ─────────────────────────────────────────────── + const switchToForm = () => { + setSwitchError(null); + const { rows: parsed, error } = jsonToRows(rawText); + if (error) { + setSwitchError(error); + return; // Stay in raw mode, surface the error inline. + } + setRows(parsed); + onChange(rowsToJson(parsed)); + setMode('form'); + }; + + // ── Row mutations ───────────────────────────────────────────────────── + const addRow = () => { + commitRows([...rows, { id: nextId(), key: '', type: 'string', value: '' }]); + }; + + const updateRow = (id: string, field: keyof Omit, val: string) => { + const next = rows.map((r) => { + if (r.id !== id) return r; + const updated = { ...r, [field]: val }; + // When switching type to boolean, default the value to 'true' so it + // serialises correctly without the user having to touch the select. + if (field === 'type' && val === 'boolean' && r.value !== 'true' && r.value !== 'false') { + updated.value = 'true'; + } + return updated; + }); + commitRows(next); + }; + + const removeRow = (id: string) => { + commitRows(rows.filter((r) => r.id !== id)); + }; + + // ── Render ──────────────────────────────────────────────────────────── + const rawShellState = + rawValidation.status === 'ok' + ? 'ok' + : rawValidation.status === 'error' + ? 'error' + : 'default'; + + return ( + <> + + +
+ {/* ── Header ─────────────────────────────────────────────────── */} +
+ + {label} + + + {mode === 'form' && ( + + {rows.length} + + )} + +
+ + +
+
+ + {/* ── Switch error (raw → form failed) ──────────────────────── */} + {switchError && ( +
+ + + Cannot switch to Form: {switchError} + +
+ )} + + {/* ── Form mode ──────────────────────────────────────────────── */} + {mode === 'form' && ( + <> + {rows.length === 0 ? ( +
+

No parameters yet.

+ +
+ ) : ( + <> +
+ {rows.map((row, index) => ( +
+ {/* Key */} + updateRow(row.id, 'key', e.target.value)} + disabled={disabled} + aria-label={`Parameter ${index + 1} key`} + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck={false} + /> + + {/* Type */} + + + {/* Value */} + {row.type === 'boolean' ? ( + + ) : ( + updateRow(row.id, 'value', e.target.value)} + disabled={disabled} + aria-label={`Parameter ${index + 1} value`} + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck={false} + /> + )} + + {/* Remove */} + +
+ ))} +
+ + + + )} + + )} + + {/* ── Raw JSON mode ──────────────────────────────────────────── */} + {mode === 'raw' && ( +
+
+