Skip to content

Commit 5f198aa

Browse files
Copilothotlong
andcommitted
feat(studio): add form-based query parameter editor to API Console
Replace static query params cheatsheet with interactive form table: - Add/remove query parameter rows with key/value inputs - Toggle individual params enabled/disabled (checkbox) - Quick-add preset buttons for common OData params ($top, $skip, $sort, $select, $count, $filter) - URL preview shows full path with appended query string - Replay from history restores query params into form - Add test for query param URL building logic Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 6ef0da7 commit 5f198aa

2 files changed

Lines changed: 160 additions & 14 deletions

File tree

apps/studio/src/components/ApiConsolePage.tsx

Lines changed: 128 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { useClient } from '@objectstack/client-react';
55
import { Badge } from '@/components/ui/badge';
66
import {
77
Globe, Play, Copy, Check, ChevronDown, ChevronRight, Clock,
8-
Loader2, Search, RefreshCw, Trash2,
8+
Loader2, Search, RefreshCw, Trash2, Plus, X,
99
} from 'lucide-react';
1010
import { useApiDiscovery, type EndpointDef, type HttpMethod } from '@/hooks/use-api-discovery';
1111
import {
@@ -31,6 +31,23 @@ const STATUS_COLORS: Record<string, string> = {
3131
'5': 'text-red-600 dark:text-red-400',
3232
};
3333

34+
/** Common OData-style query parameter presets for quick-add */
35+
const QUERY_PARAM_PRESETS = [
36+
{ key: '$top', placeholder: '10', hint: 'limit' },
37+
{ key: '$skip', placeholder: '0', hint: 'offset' },
38+
{ key: '$sort', placeholder: 'name', hint: 'sort field' },
39+
{ key: '$select', placeholder: 'name,email', hint: 'fields' },
40+
{ key: '$count', placeholder: 'true', hint: 'total count' },
41+
{ key: '$filter', placeholder: "status eq 'active'", hint: 'OData filter' },
42+
];
43+
44+
interface QueryParam {
45+
id: number;
46+
key: string;
47+
value: string;
48+
enabled: boolean;
49+
}
50+
3451
interface RequestHistoryEntry {
3552
id: number;
3653
method: HttpMethod;
@@ -54,6 +71,7 @@ export function ApiConsolePage() {
5471
const [methodOverride, setMethodOverride] = useState<HttpMethod | ''>('');
5572
const [urlOverride, setUrlOverride] = useState('');
5673
const [requestBody, setRequestBody] = useState('');
74+
const [queryParams, setQueryParams] = useState<QueryParam[]>([]);
5775
const [loading, setLoading] = useState(false);
5876
const [response, setResponse] = useState<{ status: number; body: string; duration: number } | null>(null);
5977
const [history, setHistory] = useState<RequestHistoryEntry[]>([]);
@@ -89,11 +107,38 @@ export function ApiConsolePage() {
89107
setMethodOverride(ep.method);
90108
setUrlOverride(ep.path);
91109
setRequestBody(ep.bodyTemplate ? JSON.stringify(ep.bodyTemplate, null, 2) : '');
110+
setQueryParams([]);
92111
setResponse(null);
93112
}, []);
94113

95114
const effectiveMethod = (methodOverride || selectedEndpoint?.method || 'GET') as HttpMethod;
96-
const effectiveUrl = urlOverride || selectedEndpoint?.path || '';
115+
const basePath = urlOverride || selectedEndpoint?.path || '';
116+
117+
// Build full URL with query params
118+
const effectiveUrl = useMemo(() => {
119+
const enabledParams = queryParams.filter(p => p.enabled && p.key.trim());
120+
if (enabledParams.length === 0) return basePath;
121+
const qs = enabledParams.map(p => `${encodeURIComponent(p.key)}=${encodeURIComponent(p.value)}`).join('&');
122+
return `${basePath}?${qs}`;
123+
}, [basePath, queryParams]);
124+
125+
// ─── Query Param Helpers ────────────────────────────────────────────
126+
127+
const addQueryParam = useCallback((key = '', value = '') => {
128+
setQueryParams(prev => [...prev, { id: Date.now(), key, value, enabled: true }]);
129+
}, []);
130+
131+
const removeQueryParam = useCallback((id: number) => {
132+
setQueryParams(prev => prev.filter(p => p.id !== id));
133+
}, []);
134+
135+
const updateQueryParam = useCallback((id: number, field: 'key' | 'value', val: string) => {
136+
setQueryParams(prev => prev.map(p => p.id === id ? { ...p, [field]: val } : p));
137+
}, []);
138+
139+
const toggleQueryParam = useCallback((id: number) => {
140+
setQueryParams(prev => prev.map(p => p.id === id ? { ...p, enabled: !p.enabled } : p));
141+
}, []);
97142

98143
const sendRequest = useCallback(async () => {
99144
if (loading || !effectiveUrl) return;
@@ -153,8 +198,20 @@ export function ApiConsolePage() {
153198

154199
const replayHistoryEntry = useCallback((entry: RequestHistoryEntry) => {
155200
setMethodOverride(entry.method);
156-
setUrlOverride(entry.url);
201+
// Separate path and query params from history URL
202+
const [path, qs] = entry.url.split('?');
203+
setUrlOverride(path);
157204
setRequestBody(entry.body || '');
205+
if (qs) {
206+
const params = new URLSearchParams(qs);
207+
const restored: QueryParam[] = [];
208+
params.forEach((value, key) => {
209+
restored.push({ id: Date.now() + Math.random(), key, value, enabled: true });
210+
});
211+
setQueryParams(restored);
212+
} else {
213+
setQueryParams([]);
214+
}
158215
setResponse(null);
159216
setSelectedEndpoint(null);
160217
}, []);
@@ -252,23 +309,75 @@ export function ApiConsolePage() {
252309
)}
253310
</div>
254311

255-
{/* Query params cheatsheet */}
256-
<div className="p-3 border-t">
257-
<h4 className="text-[10px] font-medium text-muted-foreground mb-1.5 uppercase tracking-wider">Query Parameters</h4>
258-
<div className="space-y-0.5 text-[10px] text-muted-foreground">
259-
<p><code className="text-foreground">?$top=10</code> — limit</p>
260-
<p><code className="text-foreground">?$skip=20</code> — offset</p>
261-
<p><code className="text-foreground">?$sort=name</code> — sort</p>
262-
<p><code className="text-foreground">?$select=name,email</code> — fields</p>
263-
<p><code className="text-foreground">?$count=true</code> — total count</p>
312+
{/* Query params form */}
313+
<div className="p-3 border-t space-y-2">
314+
<div className="flex items-center justify-between">
315+
<h4 className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Query Parameters</h4>
316+
<button
317+
onClick={() => addQueryParam()}
318+
className="inline-flex items-center gap-0.5 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
319+
>
320+
<Plus className="h-3 w-3" />
321+
Add
322+
</button>
264323
</div>
324+
325+
{/* Preset quick-add buttons */}
326+
<div className="flex flex-wrap gap-1">
327+
{QUERY_PARAM_PRESETS.map(preset => (
328+
<button
329+
key={preset.key}
330+
onClick={() => addQueryParam(preset.key, '')}
331+
className="text-[10px] px-1.5 py-0.5 rounded border bg-muted/30 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors font-mono"
332+
title={preset.hint}
333+
>
334+
{preset.key}
335+
</button>
336+
))}
337+
</div>
338+
339+
{/* Param rows */}
340+
{queryParams.length > 0 && (
341+
<div className="space-y-1">
342+
{queryParams.map(param => (
343+
<div key={param.id} className="flex items-center gap-1">
344+
<input
345+
type="checkbox"
346+
checked={param.enabled}
347+
onChange={() => toggleQueryParam(param.id)}
348+
className="h-3 w-3 shrink-0 rounded border accent-primary"
349+
/>
350+
<input
351+
type="text"
352+
value={param.key}
353+
onChange={e => updateQueryParam(param.id, 'key', e.target.value)}
354+
placeholder="key"
355+
className="flex-1 min-w-0 rounded border bg-background px-1.5 py-0.5 text-[10px] font-mono focus:outline-none focus:ring-1 focus:ring-ring"
356+
/>
357+
<input
358+
type="text"
359+
value={param.value}
360+
onChange={e => updateQueryParam(param.id, 'value', e.target.value)}
361+
placeholder="value"
362+
className="flex-1 min-w-0 rounded border bg-background px-1.5 py-0.5 text-[10px] font-mono focus:outline-none focus:ring-1 focus:ring-ring"
363+
/>
364+
<button
365+
onClick={() => removeQueryParam(param.id)}
366+
className="shrink-0 p-0.5 rounded text-muted-foreground hover:text-destructive transition-colors"
367+
>
368+
<X className="h-3 w-3" />
369+
</button>
370+
</div>
371+
))}
372+
</div>
373+
)}
265374
</div>
266375
</div>
267376

268377
{/* ── Right: Request / Response ──────────────────────── */}
269378
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
270379
{/* URL bar */}
271-
<div className="p-3 border-b">
380+
<div className="p-3 border-b space-y-1">
272381
<div className="flex items-center gap-2">
273382
<select
274383
value={effectiveMethod}
@@ -281,7 +390,7 @@ export function ApiConsolePage() {
281390
</select>
282391
<input
283392
type="text"
284-
value={effectiveUrl}
393+
value={basePath}
285394
onChange={e => setUrlOverride(e.target.value)}
286395
className="flex-1 rounded-md border bg-background px-3 py-1.5 font-mono text-sm focus:outline-none focus:ring-1 focus:ring-ring"
287396
placeholder="/api/v1/..."
@@ -296,6 +405,11 @@ export function ApiConsolePage() {
296405
Send
297406
</button>
298407
</div>
408+
{queryParams.some(p => p.enabled && p.key.trim()) && (
409+
<div className="text-[10px] font-mono text-muted-foreground truncate pl-1">
410+
{effectiveUrl}
411+
</div>
412+
)}
299413
</div>
300414

301415
{/* Body + Response split */}

apps/studio/test/api-discovery.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,4 +110,36 @@ describe('API Discovery for Global Console', () => {
110110

111111
expect(items2.length).toBe(count1);
112112
});
113+
114+
it('should build effective URL with query params appended', () => {
115+
const basePath = '/api/v1/data/task';
116+
const params = [
117+
{ id: 1, key: '$top', value: '10', enabled: true },
118+
{ id: 2, key: '$skip', value: '20', enabled: true },
119+
{ id: 3, key: '$sort', value: 'name', enabled: false }, // disabled
120+
{ id: 4, key: '$select', value: 'name,email', enabled: true },
121+
{ id: 5, key: '', value: 'ignored', enabled: true }, // empty key
122+
];
123+
124+
// Replicate the effective URL building logic from ApiConsolePage
125+
const enabledParams = params.filter(p => p.enabled && p.key.trim());
126+
let effectiveUrl: string;
127+
if (enabledParams.length === 0) {
128+
effectiveUrl = basePath;
129+
} else {
130+
const qs = enabledParams.map(p => `${encodeURIComponent(p.key)}=${encodeURIComponent(p.value)}`).join('&');
131+
effectiveUrl = `${basePath}?${qs}`;
132+
}
133+
134+
// Should include enabled params with non-empty keys only
135+
expect(effectiveUrl).toContain('%24top=10');
136+
expect(effectiveUrl).toContain('%24skip=20');
137+
expect(effectiveUrl).toContain('%24select=name%2Cemail');
138+
// Disabled param should not appear
139+
expect(effectiveUrl).not.toContain('sort');
140+
// Empty key param should not appear
141+
expect(effectiveUrl).not.toContain('ignored');
142+
// Should start with basePath
143+
expect(effectiveUrl.startsWith(basePath)).toBe(true);
144+
});
113145
});

0 commit comments

Comments
 (0)