@@ -5,7 +5,7 @@ import { useClient } from '@objectstack/client-react';
55import { Badge } from '@/components/ui/badge' ;
66import {
77 Globe , Play , Copy , Check , ChevronDown , ChevronRight , Clock ,
8- Loader2 , Search , RefreshCw , Trash2 ,
8+ Loader2 , Search , RefreshCw , Trash2 , Plus , X ,
99} from 'lucide-react' ;
1010import { useApiDiscovery , type EndpointDef , type HttpMethod } from '@/hooks/use-api-discovery' ;
1111import {
@@ -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+
3451interface 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 */ }
0 commit comments