@djangocfg/ui-tools 2.1.268 → 2.1.271
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/PlaygroundLayout-G325I6HM.mjs +736 -0
- package/dist/PlaygroundLayout-G325I6HM.mjs.map +1 -0
- package/dist/PlaygroundLayout-ZO2LO7M5.cjs +743 -0
- package/dist/PlaygroundLayout-ZO2LO7M5.cjs.map +1 -0
- package/dist/{PrettyCode.client-OO3KAJSM.mjs → PrettyCode.client-DW5LTG47.mjs} +5 -5
- package/dist/PrettyCode.client-DW5LTG47.mjs.map +1 -0
- package/dist/{PrettyCode.client-V2ZN5DTH.cjs → PrettyCode.client-SGDGQTYT.cjs} +5 -5
- package/dist/PrettyCode.client-SGDGQTYT.cjs.map +1 -0
- package/dist/{chunk-SZ2CZEQZ.mjs → chunk-QZ55LYK2.mjs} +141 -169
- package/dist/chunk-QZ55LYK2.mjs.map +1 -0
- package/dist/{chunk-CRHHUOVJ.cjs → chunk-WM4RT5KX.cjs} +139 -169
- package/dist/chunk-WM4RT5KX.cjs.map +1 -0
- package/dist/index.cjs +8 -8
- package/dist/index.mjs +5 -5
- package/package.json +6 -6
- package/src/tools/OpenapiViewer/README.md +121 -0
- package/src/tools/OpenapiViewer/components/PlaygroundLayout/EndpointList.tsx +228 -0
- package/src/tools/OpenapiViewer/components/PlaygroundLayout/RequestPanel.tsx +258 -0
- package/src/tools/OpenapiViewer/components/PlaygroundLayout/ResponsePanel.tsx +127 -0
- package/src/tools/OpenapiViewer/components/PlaygroundLayout/index.tsx +107 -0
- package/src/tools/OpenapiViewer/components/PlaygroundLayout/ui.tsx +137 -0
- package/src/tools/OpenapiViewer/components/index.ts +0 -9
- package/src/tools/OpenapiViewer/context/PlaygroundContext.tsx +198 -208
- package/src/tools/OpenapiViewer/types.ts +1 -0
- package/src/tools/PrettyCode/PrettyCode.client.tsx +17 -12
- package/dist/PlaygroundLayout-FKXSULJ3.cjs +0 -971
- package/dist/PlaygroundLayout-FKXSULJ3.cjs.map +0 -1
- package/dist/PlaygroundLayout-XMMHPZYP.mjs +0 -964
- package/dist/PlaygroundLayout-XMMHPZYP.mjs.map +0 -1
- package/dist/PrettyCode.client-OO3KAJSM.mjs.map +0 -1
- package/dist/PrettyCode.client-V2ZN5DTH.cjs.map +0 -1
- package/dist/chunk-CRHHUOVJ.cjs.map +0 -1
- package/dist/chunk-SZ2CZEQZ.mjs.map +0 -1
- package/src/tools/OpenapiViewer/components/EndpointInfo.tsx +0 -149
- package/src/tools/OpenapiViewer/components/EndpointsLibrary.tsx +0 -278
- package/src/tools/OpenapiViewer/components/PlaygroundLayout.tsx +0 -91
- package/src/tools/OpenapiViewer/components/PlaygroundStepper.tsx +0 -100
- package/src/tools/OpenapiViewer/components/RequestBuilder.tsx +0 -157
- package/src/tools/OpenapiViewer/components/RequestParametersForm.tsx +0 -253
- package/src/tools/OpenapiViewer/components/ResponseViewer.tsx +0 -173
- package/src/tools/OpenapiViewer/components/VersionSelector.tsx +0 -68
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-tools",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.271",
|
|
4
4
|
"description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-tools",
|
|
@@ -90,8 +90,8 @@
|
|
|
90
90
|
"check": "tsc --noEmit"
|
|
91
91
|
},
|
|
92
92
|
"peerDependencies": {
|
|
93
|
-
"@djangocfg/i18n": "^2.1.
|
|
94
|
-
"@djangocfg/ui-core": "^2.1.
|
|
93
|
+
"@djangocfg/i18n": "^2.1.271",
|
|
94
|
+
"@djangocfg/ui-core": "^2.1.271",
|
|
95
95
|
"consola": "^3.4.2",
|
|
96
96
|
"lucide-react": "^0.545.0",
|
|
97
97
|
"react": "^19.1.0",
|
|
@@ -133,10 +133,10 @@
|
|
|
133
133
|
"@maplibre/maplibre-gl-geocoder": "^1.7.0"
|
|
134
134
|
},
|
|
135
135
|
"devDependencies": {
|
|
136
|
-
"@djangocfg/i18n": "^2.1.
|
|
136
|
+
"@djangocfg/i18n": "^2.1.271",
|
|
137
137
|
"@djangocfg/playground": "workspace:*",
|
|
138
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
139
|
-
"@djangocfg/ui-core": "^2.1.
|
|
138
|
+
"@djangocfg/typescript-config": "^2.1.271",
|
|
139
|
+
"@djangocfg/ui-core": "^2.1.271",
|
|
140
140
|
"@types/mapbox__mapbox-gl-draw": "^1.4.8",
|
|
141
141
|
"@types/node": "^24.7.2",
|
|
142
142
|
"@types/react": "^19.1.0",
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# OpenapiViewer
|
|
2
|
+
|
|
3
|
+
An interactive OpenAPI 3.x playground — browse endpoints, build requests, and inspect responses. Designed as a minimal, three-column developer tool (similar in spirit to Swagger UI / Scalar, but embedded in the app shell).
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
import { Playground } from '@djangocfg/ui-tools/openapi';
|
|
9
|
+
|
|
10
|
+
<Playground
|
|
11
|
+
config={{
|
|
12
|
+
schemas: [
|
|
13
|
+
{ id: 'petstore', name: 'Petstore API', url: 'https://petstore3.swagger.io/api/v3/openapi.json' },
|
|
14
|
+
],
|
|
15
|
+
defaultSchemaId: 'petstore',
|
|
16
|
+
}}
|
|
17
|
+
/>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
For lazy-loading (recommended in production):
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { LazyOpenapiViewer } from '@djangocfg/ui-tools/openapi/lazy';
|
|
24
|
+
|
|
25
|
+
<LazyOpenapiViewer config={config} />
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Layout
|
|
29
|
+
|
|
30
|
+
### Desktop (≥ 768 px) — three columns
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
┌──────────────────┬─────────────────────┬─────────────────────┐
|
|
34
|
+
│ ENDPOINTS │ REQUEST │ RESPONSE │
|
|
35
|
+
│ │ │ │
|
|
36
|
+
│ 🔍 Search [⊞] │ POST /api/v3/user │ 200 OK 0.4 KB │
|
|
37
|
+
│ │ ───────────────── │ ───────────────── │
|
|
38
|
+
│ POST /pet │ Path Parameters │ { "id": 1, ... } │
|
|
39
|
+
│ GET /pet/… │ Query Parameters │ │
|
|
40
|
+
│ PUT /pet/… │ Body │ │
|
|
41
|
+
│ … │ ▶ Auth & Headers │ │
|
|
42
|
+
│ │ ▶ cURL │ │
|
|
43
|
+
│ │ ───────────────── │ │
|
|
44
|
+
│ │ [Send Request] │ │
|
|
45
|
+
└──────────────────┴─────────────────────┴─────────────────────┘
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Mobile (< 768 px) — three tabs
|
|
49
|
+
|
|
50
|
+
Tab bar at the top: **Endpoints → Request → Response**.
|
|
51
|
+
Tabs switch automatically when an endpoint is selected and after a successful request.
|
|
52
|
+
|
|
53
|
+
## File Structure
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
OpenapiViewer/
|
|
57
|
+
├── index.tsx # Main export: <Playground config={…} />
|
|
58
|
+
├── lazy.tsx # Lazy-loaded variant: <LazyOpenapiViewer />
|
|
59
|
+
├── types.ts # All TypeScript types
|
|
60
|
+
├── constants.ts # HTTP method/status colour maps
|
|
61
|
+
├── README.md # ← you are here
|
|
62
|
+
│
|
|
63
|
+
├── context/
|
|
64
|
+
│ └── PlaygroundContext.tsx # Global state (endpoint, request, response, auth)
|
|
65
|
+
│
|
|
66
|
+
├── hooks/
|
|
67
|
+
│ ├── useOpenApiSchema.ts # Fetches & parses OpenAPI schema; caches per URL
|
|
68
|
+
│ └── useMobile.ts # Thin wrapper around useIsMobile from ui-core
|
|
69
|
+
│
|
|
70
|
+
├── utils/
|
|
71
|
+
│ ├── formatters.ts # getMethodColor, getStatusColor, isValidJson, …
|
|
72
|
+
│ ├── versionManager.ts # Endpoint version detection & deduplication
|
|
73
|
+
│ ├── apiKeyManager.ts # X-API-Key header helpers
|
|
74
|
+
│ └── index.ts
|
|
75
|
+
│
|
|
76
|
+
└── components/
|
|
77
|
+
├── index.ts # Re-exports PlaygroundLayout
|
|
78
|
+
└── PlaygroundLayout/ # Three-panel layout (all panels live here)
|
|
79
|
+
├── index.tsx # Root: DesktopView / MobileView router
|
|
80
|
+
├── EndpointList.tsx # Left panel: search, filter, schema switcher, list
|
|
81
|
+
├── RequestPanel.tsx # Middle panel: params, body, auth, cURL, send
|
|
82
|
+
├── ResponsePanel.tsx # Right panel: status, JsonTree / raw fallback
|
|
83
|
+
└── ui.tsx # Shared atoms: MethodBadge, StatusBadge, Panel, …
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Key Design Decisions
|
|
87
|
+
|
|
88
|
+
| Topic | Decision |
|
|
89
|
+
|---|---|
|
|
90
|
+
| **No stepper** | All three panels visible simultaneously on desktop; tabs on mobile |
|
|
91
|
+
| **Collapsed sections** | Auth & Headers and cURL start collapsed to reduce visual noise |
|
|
92
|
+
| **JSON normalisation** | `ResponsePanel` always tries `JSON.parse` before rendering; falls back to `<pre>` for non-JSON bodies |
|
|
93
|
+
| **Hover-only toolbar** | `PrettyCode` language badge + copy button appear only on hover |
|
|
94
|
+
| **Data before JSX** | All derived values (`isFiltered`, `hasCurl`, `epPath`, …) computed before `return` — no logic in JSX |
|
|
95
|
+
| **Static configs** | `JSON_TREE_CONFIG`, `MOBILE_TABS`, style maps are module-level constants |
|
|
96
|
+
| **Lazy loading** | `PlaygroundLayout` is lazy-loaded via `Suspense` to keep the initial bundle small |
|
|
97
|
+
|
|
98
|
+
## Config Reference
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
interface PlaygroundConfig {
|
|
102
|
+
/** Array of OpenAPI 3.x schema URLs */
|
|
103
|
+
schemas: SchemaSource[];
|
|
104
|
+
/** Schema to select on first render */
|
|
105
|
+
defaultSchemaId?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface SchemaSource {
|
|
109
|
+
id: string; // unique key
|
|
110
|
+
name: string; // display name in the switcher combobox
|
|
111
|
+
url: string; // full URL to fetch the JSON schema from
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Auth
|
|
116
|
+
|
|
117
|
+
Bearer token priority (highest wins):
|
|
118
|
+
|
|
119
|
+
1. Manual token entered in the **Auth & Headers** section
|
|
120
|
+
2. JWT from `localStorage` key `auth_token`
|
|
121
|
+
3. X-API-Key header (when an API key is selected — currently disabled pending CFG context)
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { ChevronRight, Filter, Search } from 'lucide-react';
|
|
4
|
+
import React, { useEffect, useMemo, useState } from 'react';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
Combobox,
|
|
8
|
+
DownloadButton,
|
|
9
|
+
DropdownMenu,
|
|
10
|
+
DropdownMenuContent,
|
|
11
|
+
DropdownMenuItem,
|
|
12
|
+
DropdownMenuTrigger,
|
|
13
|
+
Input,
|
|
14
|
+
Skeleton,
|
|
15
|
+
} from '@djangocfg/ui-core/components';
|
|
16
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
17
|
+
|
|
18
|
+
import { usePlaygroundContext } from '../../context/PlaygroundContext';
|
|
19
|
+
import useOpenApiSchema from '../../hooks/useOpenApiSchema';
|
|
20
|
+
import { deduplicateEndpoints } from '../../utils/versionManager';
|
|
21
|
+
import { MethodBadge, ScrollArea, relativePath } from './ui';
|
|
22
|
+
|
|
23
|
+
// ─── Endpoint row ─────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
function EndpointRow({
|
|
26
|
+
method,
|
|
27
|
+
path,
|
|
28
|
+
description,
|
|
29
|
+
isActive,
|
|
30
|
+
onClick,
|
|
31
|
+
}: {
|
|
32
|
+
method: string;
|
|
33
|
+
path: string;
|
|
34
|
+
description: string;
|
|
35
|
+
isActive: boolean;
|
|
36
|
+
onClick: () => void;
|
|
37
|
+
}) {
|
|
38
|
+
const displayPath = relativePath(path);
|
|
39
|
+
const rowCls = cn(
|
|
40
|
+
'group w-full text-left flex items-start gap-2.5 px-3 py-2.5 transition-colors hover:bg-muted/40',
|
|
41
|
+
isActive && 'bg-primary/[0.06] hover:bg-primary/[0.09]',
|
|
42
|
+
);
|
|
43
|
+
const arrowCls = cn(
|
|
44
|
+
'h-3.5 w-3.5 shrink-0 mt-px transition-opacity',
|
|
45
|
+
isActive ? 'text-primary opacity-100' : 'opacity-0 group-hover:opacity-30',
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<button className={rowCls} onClick={onClick}>
|
|
50
|
+
<MethodBadge method={method} />
|
|
51
|
+
<div className="flex-1 min-w-0">
|
|
52
|
+
<p className="font-mono text-[11px] text-foreground/75 truncate leading-tight">
|
|
53
|
+
{displayPath}
|
|
54
|
+
</p>
|
|
55
|
+
{description && (
|
|
56
|
+
<p className="text-[10px] text-muted-foreground/60 truncate leading-tight mt-0.5">
|
|
57
|
+
{description}
|
|
58
|
+
</p>
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
61
|
+
<ChevronRight className={arrowCls} />
|
|
62
|
+
</button>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── EndpointList ─────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
export function EndpointList() {
|
|
69
|
+
const { state, config, setSelectedEndpoint, setSelectedCategory, setSearchTerm } =
|
|
70
|
+
usePlaygroundContext();
|
|
71
|
+
const { endpoints, categories, loading, error, schemas, currentSchema, setCurrentSchema } =
|
|
72
|
+
useOpenApiSchema({ schemas: config.schemas, defaultSchemaId: config.defaultSchemaId });
|
|
73
|
+
|
|
74
|
+
// ── Debounced search ──────────────────────────────────────────────────────
|
|
75
|
+
const [debouncedSearch, setDebouncedSearch] = useState(state.searchTerm);
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const id = setTimeout(() => setDebouncedSearch(state.searchTerm), 150);
|
|
78
|
+
return () => clearTimeout(id);
|
|
79
|
+
}, [state.searchTerm]);
|
|
80
|
+
|
|
81
|
+
// ── Data ──────────────────────────────────────────────────────────────────
|
|
82
|
+
const schemaOptions = useMemo(
|
|
83
|
+
() => schemas.map((s) => ({ value: s.id, label: s.name })),
|
|
84
|
+
[schemas],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const filtered = useMemo(() => {
|
|
88
|
+
let list = deduplicateEndpoints(endpoints, state.selectedVersion);
|
|
89
|
+
if (state.selectedCategory !== 'All') {
|
|
90
|
+
list = list.filter((e) => e.category === state.selectedCategory);
|
|
91
|
+
}
|
|
92
|
+
if (debouncedSearch) {
|
|
93
|
+
const q = debouncedSearch.toLowerCase();
|
|
94
|
+
list = list.filter((e) =>
|
|
95
|
+
e.name.toLowerCase().includes(q) ||
|
|
96
|
+
e.description.toLowerCase().includes(q) ||
|
|
97
|
+
e.path.toLowerCase().includes(q),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return list;
|
|
101
|
+
}, [endpoints, state.selectedCategory, debouncedSearch, state.selectedVersion]);
|
|
102
|
+
|
|
103
|
+
// ── Derived ───────────────────────────────────────────────────────────────
|
|
104
|
+
const isFiltered = state.selectedCategory !== 'All';
|
|
105
|
+
const hasCategories = categories.length > 0;
|
|
106
|
+
const hasMultipleSchemas = schemas.length > 1;
|
|
107
|
+
const endpointLabel = `${filtered.length} endpoint${filtered.length !== 1 ? 's' : ''}`;
|
|
108
|
+
const downloadFilename = currentSchema ? `${currentSchema.id}-openapi.json` : 'openapi.json';
|
|
109
|
+
|
|
110
|
+
// ── Early returns ─────────────────────────────────────────────────────────
|
|
111
|
+
if (loading) {
|
|
112
|
+
return (
|
|
113
|
+
<div className="p-3 space-y-1.5">
|
|
114
|
+
{Array.from({ length: 12 }).map((_, i) => (
|
|
115
|
+
<Skeleton key={i} className="h-10 w-full rounded" />
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (error) {
|
|
122
|
+
return (
|
|
123
|
+
<div className="p-4">
|
|
124
|
+
<p className="text-xs text-destructive">Failed to load schema: {error}</p>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Render ────────────────────────────────────────────────────────────────
|
|
130
|
+
return (
|
|
131
|
+
<>
|
|
132
|
+
{/* Toolbar */}
|
|
133
|
+
<div className="shrink-0 border-b px-2.5 py-2 space-y-2">
|
|
134
|
+
{hasMultipleSchemas && (
|
|
135
|
+
<Combobox
|
|
136
|
+
options={schemaOptions}
|
|
137
|
+
value={currentSchema?.id ?? ''}
|
|
138
|
+
onValueChange={(id) => id && setCurrentSchema(id)}
|
|
139
|
+
placeholder="Select API"
|
|
140
|
+
searchPlaceholder="Search APIs…"
|
|
141
|
+
emptyText="No APIs found"
|
|
142
|
+
className="w-full h-8 text-xs"
|
|
143
|
+
/>
|
|
144
|
+
)}
|
|
145
|
+
|
|
146
|
+
<div className="flex gap-1.5">
|
|
147
|
+
<div className="relative flex-1 min-w-0">
|
|
148
|
+
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/50 pointer-events-none" />
|
|
149
|
+
<Input
|
|
150
|
+
placeholder="Search endpoints…"
|
|
151
|
+
value={state.searchTerm}
|
|
152
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchTerm(e.target.value)}
|
|
153
|
+
className="pl-8 h-8 text-xs"
|
|
154
|
+
/>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
{hasCategories && (
|
|
158
|
+
<DropdownMenu>
|
|
159
|
+
<DropdownMenuTrigger asChild>
|
|
160
|
+
<button className={cn(
|
|
161
|
+
'relative shrink-0 flex items-center justify-center h-8 w-8 rounded-md border transition-colors',
|
|
162
|
+
isFiltered
|
|
163
|
+
? 'border-primary bg-primary/10 text-primary'
|
|
164
|
+
: 'border-input bg-background text-muted-foreground hover:text-foreground hover:bg-muted/50',
|
|
165
|
+
)}>
|
|
166
|
+
<Filter className="h-3.5 w-3.5" />
|
|
167
|
+
{isFiltered && (
|
|
168
|
+
<span className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-primary" />
|
|
169
|
+
)}
|
|
170
|
+
</button>
|
|
171
|
+
</DropdownMenuTrigger>
|
|
172
|
+
<DropdownMenuContent align="end" className="min-w-[160px] max-h-72 overflow-y-auto">
|
|
173
|
+
{['All', ...categories].map((c) => (
|
|
174
|
+
<DropdownMenuItem
|
|
175
|
+
key={c}
|
|
176
|
+
onClick={() => setSelectedCategory(c)}
|
|
177
|
+
className={cn('text-xs', state.selectedCategory === c && 'bg-accent font-medium')}
|
|
178
|
+
>
|
|
179
|
+
{c}
|
|
180
|
+
</DropdownMenuItem>
|
|
181
|
+
))}
|
|
182
|
+
</DropdownMenuContent>
|
|
183
|
+
</DropdownMenu>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
{/* Meta row */}
|
|
189
|
+
<div className="shrink-0 flex items-center justify-between px-3 py-1 border-b bg-muted/20">
|
|
190
|
+
<span className="text-[10px] text-muted-foreground/50 tabular-nums">{endpointLabel}</span>
|
|
191
|
+
{currentSchema && (
|
|
192
|
+
<DownloadButton
|
|
193
|
+
url={currentSchema.url}
|
|
194
|
+
filename={downloadFilename}
|
|
195
|
+
variant="ghost"
|
|
196
|
+
size="sm"
|
|
197
|
+
className="h-6 px-2 text-[10px] text-muted-foreground/50 hover:text-foreground"
|
|
198
|
+
>
|
|
199
|
+
JSON
|
|
200
|
+
</DownloadButton>
|
|
201
|
+
)}
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
{/* List */}
|
|
205
|
+
<ScrollArea>
|
|
206
|
+
{filtered.length === 0 ? (
|
|
207
|
+
<div className="py-10 text-center text-xs text-muted-foreground">No endpoints found</div>
|
|
208
|
+
) : (
|
|
209
|
+
<div className="divide-y divide-border/40">
|
|
210
|
+
{filtered.map((ep) => (
|
|
211
|
+
<EndpointRow
|
|
212
|
+
key={`${ep.method}-${ep.path}`}
|
|
213
|
+
method={ep.method}
|
|
214
|
+
path={ep.path}
|
|
215
|
+
description={ep.description}
|
|
216
|
+
isActive={
|
|
217
|
+
state.selectedEndpoint?.path === ep.path &&
|
|
218
|
+
state.selectedEndpoint?.method === ep.method
|
|
219
|
+
}
|
|
220
|
+
onClick={() => setSelectedEndpoint(ep)}
|
|
221
|
+
/>
|
|
222
|
+
))}
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
</ScrollArea>
|
|
226
|
+
</>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Key, Loader2, Send, Sparkles, Terminal } from 'lucide-react';
|
|
4
|
+
import React, { useMemo } from 'react';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
Button,
|
|
8
|
+
CopyButton,
|
|
9
|
+
Input,
|
|
10
|
+
Textarea,
|
|
11
|
+
} from '@djangocfg/ui-core/components';
|
|
12
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
13
|
+
|
|
14
|
+
import PrettyCode from '../../../PrettyCode';
|
|
15
|
+
import { usePlaygroundContext } from '../../context/PlaygroundContext';
|
|
16
|
+
import { findApiKeyById, isValidJson, parseRequestHeaders } from '../../utils';
|
|
17
|
+
import {
|
|
18
|
+
CollapsibleSection,
|
|
19
|
+
EmptyState,
|
|
20
|
+
MethodBadge,
|
|
21
|
+
ScrollArea,
|
|
22
|
+
SectionLabel,
|
|
23
|
+
StatusBadge,
|
|
24
|
+
relativePath,
|
|
25
|
+
} from './ui';
|
|
26
|
+
|
|
27
|
+
// ─── Param fields ─────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
type Param = { name: string; type: string; required: boolean; description?: string };
|
|
30
|
+
|
|
31
|
+
function ParamFields({ label, params }: { label: string; params: Param[] }) {
|
|
32
|
+
const { state, setParameters } = usePlaygroundContext();
|
|
33
|
+
|
|
34
|
+
function handleChange(name: string, value: string) {
|
|
35
|
+
setParameters({ ...state.parameters, [name]: value });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="space-y-2">
|
|
40
|
+
<SectionLabel>{label}</SectionLabel>
|
|
41
|
+
<div className="space-y-2">
|
|
42
|
+
{params.map((p) => {
|
|
43
|
+
const value = state.parameters[p.name] ?? '';
|
|
44
|
+
const placeholder = p.description || p.name;
|
|
45
|
+
return (
|
|
46
|
+
<div key={p.name} className="space-y-1">
|
|
47
|
+
<div className="flex items-center gap-1.5">
|
|
48
|
+
<span className="font-mono text-[11px] text-foreground/80">{p.name}</span>
|
|
49
|
+
{p.required && (
|
|
50
|
+
<span className="text-[9px] text-destructive font-bold leading-none">*</span>
|
|
51
|
+
)}
|
|
52
|
+
<span className="font-mono text-[10px] text-muted-foreground/50">{p.type}</span>
|
|
53
|
+
</div>
|
|
54
|
+
<Input
|
|
55
|
+
value={value}
|
|
56
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
57
|
+
handleChange(p.name, e.target.value)
|
|
58
|
+
}
|
|
59
|
+
placeholder={placeholder}
|
|
60
|
+
className="h-8 text-xs font-mono"
|
|
61
|
+
/>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
})}
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─── RequestPanel ─────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
export function RequestPanel() {
|
|
73
|
+
const { state, apiKeys, setRequestBody, setRequestHeaders, setManualApiToken, sendRequest } =
|
|
74
|
+
usePlaygroundContext();
|
|
75
|
+
|
|
76
|
+
const ep = state.selectedEndpoint;
|
|
77
|
+
|
|
78
|
+
// ── Data (hooks must not be conditional) ─────────────────────────────────
|
|
79
|
+
const isJsonValid = state.requestBody ? isValidJson(state.requestBody) : true;
|
|
80
|
+
|
|
81
|
+
const curlCommand = useMemo(() => {
|
|
82
|
+
if (!state.requestUrl) return '';
|
|
83
|
+
const apiKey = state.selectedApiKey ? findApiKeyById(apiKeys, state.selectedApiKey) : null;
|
|
84
|
+
const hdrs = parseRequestHeaders(state.requestHeaders);
|
|
85
|
+
if (apiKey) hdrs['X-API-Key'] = apiKey.id;
|
|
86
|
+
let cmd = `curl -X ${state.requestMethod} "${state.requestUrl}"`;
|
|
87
|
+
Object.entries(hdrs).forEach(([k, v]) => { cmd += ` \\\n -H "${k}: ${v}"`; });
|
|
88
|
+
if (state.requestBody && state.requestMethod !== 'GET' && isJsonValid) {
|
|
89
|
+
cmd += ` \\\n -d '${state.requestBody}'`;
|
|
90
|
+
}
|
|
91
|
+
return cmd;
|
|
92
|
+
}, [state, apiKeys, isJsonValid]);
|
|
93
|
+
|
|
94
|
+
const pathParams = useMemo(
|
|
95
|
+
() => ep?.parameters?.filter((p) => ep.path.includes(`{${p.name}}`)) ?? [],
|
|
96
|
+
[ep],
|
|
97
|
+
);
|
|
98
|
+
const queryParams = useMemo(
|
|
99
|
+
() => ep?.parameters?.filter((p) => !ep.path.includes(`{${p.name}}`)) ?? [],
|
|
100
|
+
[ep],
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// ── Derived ───────────────────────────────────────────────────────────────
|
|
104
|
+
const isSendDisabled = state.loading || !state.requestUrl || !isJsonValid;
|
|
105
|
+
const displayUrl = state.requestUrl || ep?.path || '';
|
|
106
|
+
const hasBody = ep?.method !== 'GET';
|
|
107
|
+
const bodyType = ep?.requestBody?.type ?? '';
|
|
108
|
+
const hasPathParams = pathParams.length > 0;
|
|
109
|
+
const hasQueryParams = queryParams.length > 0;
|
|
110
|
+
const hasCurl = Boolean(curlCommand);
|
|
111
|
+
const epPath = ep ? relativePath(ep.path) : '';
|
|
112
|
+
const urlChanged = displayUrl !== epPath;
|
|
113
|
+
|
|
114
|
+
// ── Early return ──────────────────────────────────────────────────────────
|
|
115
|
+
if (!ep) {
|
|
116
|
+
return <EmptyState icon={Send} text="Select an endpoint to build a request" />;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Render ────────────────────────────────────────────────────────────────
|
|
120
|
+
return (
|
|
121
|
+
<>
|
|
122
|
+
{/* Endpoint header */}
|
|
123
|
+
<div className="shrink-0 border-b px-4 py-3 bg-muted/20 space-y-1.5">
|
|
124
|
+
<div className="flex items-center gap-2">
|
|
125
|
+
<MethodBadge method={ep.method} />
|
|
126
|
+
<span className="font-mono text-xs text-foreground/70 truncate min-w-0 flex-1">{epPath}</span>
|
|
127
|
+
<Button
|
|
128
|
+
onClick={sendRequest}
|
|
129
|
+
disabled={isSendDisabled}
|
|
130
|
+
size="sm"
|
|
131
|
+
className="shrink-0 gap-1.5 h-7 text-xs px-3"
|
|
132
|
+
>
|
|
133
|
+
{state.loading
|
|
134
|
+
? <><Loader2 className="h-3 w-3 animate-spin" /> Sending…</>
|
|
135
|
+
: <><Send className="h-3 w-3" /> Send</>
|
|
136
|
+
}
|
|
137
|
+
</Button>
|
|
138
|
+
</div>
|
|
139
|
+
{urlChanged && (
|
|
140
|
+
<div className="font-mono text-[10px] text-muted-foreground/50 break-all leading-snug pl-0.5">
|
|
141
|
+
{displayUrl}
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
{/* Scrollable fields */}
|
|
147
|
+
<ScrollArea className="px-4 py-3 space-y-3">
|
|
148
|
+
|
|
149
|
+
{hasPathParams && <ParamFields label="Path Parameters" params={pathParams} />}
|
|
150
|
+
{hasQueryParams && <ParamFields label="Query Parameters" params={queryParams} />}
|
|
151
|
+
|
|
152
|
+
{/* Body */}
|
|
153
|
+
{hasBody && (
|
|
154
|
+
<div className="space-y-1.5">
|
|
155
|
+
<div className="flex items-center justify-between gap-2">
|
|
156
|
+
<div className="flex items-baseline gap-2">
|
|
157
|
+
<SectionLabel>Body</SectionLabel>
|
|
158
|
+
{bodyType && (
|
|
159
|
+
<span className="text-[10px] text-muted-foreground/40 font-mono">{bodyType}</span>
|
|
160
|
+
)}
|
|
161
|
+
</div>
|
|
162
|
+
{isJsonValid && state.requestBody && (
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
onClick={() => {
|
|
166
|
+
try {
|
|
167
|
+
setRequestBody(JSON.stringify(JSON.parse(state.requestBody), null, 2));
|
|
168
|
+
} catch {}
|
|
169
|
+
}}
|
|
170
|
+
className="inline-flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
|
171
|
+
>
|
|
172
|
+
<Sparkles className="h-2.5 w-2.5" />
|
|
173
|
+
Format
|
|
174
|
+
</button>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
<Textarea
|
|
178
|
+
placeholder={'{\n "key": "value"\n}'}
|
|
179
|
+
value={state.requestBody}
|
|
180
|
+
onChange={(e) => setRequestBody(e.target.value)}
|
|
181
|
+
className={cn(
|
|
182
|
+
'font-mono text-[11px] min-h-[90px] resize-y',
|
|
183
|
+
!isJsonValid && 'border-destructive focus-visible:ring-destructive/30',
|
|
184
|
+
)}
|
|
185
|
+
rows={4}
|
|
186
|
+
/>
|
|
187
|
+
{!isJsonValid && <p className="text-[10px] text-destructive">Invalid JSON</p>}
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
|
|
191
|
+
{/* Auth & Headers — collapsed by default */}
|
|
192
|
+
<CollapsibleSection
|
|
193
|
+
label={
|
|
194
|
+
<span className="inline-flex items-center gap-1">
|
|
195
|
+
<Key className="h-2.5 w-2.5" />
|
|
196
|
+
Auth & Headers
|
|
197
|
+
</span>
|
|
198
|
+
}
|
|
199
|
+
>
|
|
200
|
+
<div className="space-y-3 pt-2">
|
|
201
|
+
<div className="space-y-1.5">
|
|
202
|
+
<SectionLabel>Bearer Token</SectionLabel>
|
|
203
|
+
<Input
|
|
204
|
+
type="password"
|
|
205
|
+
placeholder="Leave empty to use JWT from localStorage"
|
|
206
|
+
value={state.manualApiToken}
|
|
207
|
+
onChange={(e) => setManualApiToken(e.target.value)}
|
|
208
|
+
className="font-mono text-xs h-8"
|
|
209
|
+
/>
|
|
210
|
+
</div>
|
|
211
|
+
<div className="space-y-1.5">
|
|
212
|
+
<SectionLabel>Headers</SectionLabel>
|
|
213
|
+
<Textarea
|
|
214
|
+
value={state.requestHeaders}
|
|
215
|
+
onChange={(e) => setRequestHeaders(e.target.value)}
|
|
216
|
+
className="font-mono text-[11px] min-h-[60px] resize-y"
|
|
217
|
+
rows={3}
|
|
218
|
+
/>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
</CollapsibleSection>
|
|
222
|
+
|
|
223
|
+
{/* cURL — collapsed by default */}
|
|
224
|
+
{hasCurl && (
|
|
225
|
+
<CollapsibleSection
|
|
226
|
+
label={
|
|
227
|
+
<span className="inline-flex items-center gap-1">
|
|
228
|
+
<Terminal className="h-2.5 w-2.5" />
|
|
229
|
+
cURL
|
|
230
|
+
</span>
|
|
231
|
+
}
|
|
232
|
+
action={
|
|
233
|
+
<CopyButton value={curlCommand} variant="ghost" size="sm" className="h-5 px-2 text-[10px] text-muted-foreground">
|
|
234
|
+
Copy
|
|
235
|
+
</CopyButton>
|
|
236
|
+
}
|
|
237
|
+
>
|
|
238
|
+
<div className="rounded-md overflow-hidden mt-2">
|
|
239
|
+
<PrettyCode data={curlCommand} language="bash" isCompact />
|
|
240
|
+
</div>
|
|
241
|
+
</CollapsibleSection>
|
|
242
|
+
)}
|
|
243
|
+
|
|
244
|
+
<div className="h-1" />
|
|
245
|
+
</ScrollArea>
|
|
246
|
+
|
|
247
|
+
{/* Send footer */}
|
|
248
|
+
<div className="shrink-0 border-t px-4 py-3 bg-background/95 backdrop-blur-sm">
|
|
249
|
+
<Button onClick={sendRequest} disabled={isSendDisabled} size="sm" className="w-full gap-2 h-9">
|
|
250
|
+
{state.loading
|
|
251
|
+
? <><Loader2 className="h-3.5 w-3.5 animate-spin" /> Sending…</>
|
|
252
|
+
: <><Send className="h-3.5 w-3.5" /> Send Request</>
|
|
253
|
+
}
|
|
254
|
+
</Button>
|
|
255
|
+
</div>
|
|
256
|
+
</>
|
|
257
|
+
);
|
|
258
|
+
}
|