@djangocfg/ui-tools 2.1.285 → 2.1.287
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/DocsLayout-BCVU6TTX.cjs +2027 -0
- package/dist/DocsLayout-BCVU6TTX.cjs.map +1 -0
- package/dist/DocsLayout-ERETJLLV.mjs +2020 -0
- package/dist/DocsLayout-ERETJLLV.mjs.map +1 -0
- package/dist/{PlaygroundLayout-O52C6HK5.css → DocsLayout-MBFIB4NO.css} +1 -1
- package/dist/{PrettyCode.client-SGDGQTYT.cjs → PrettyCode.client-5GABIN2I.cjs} +57 -35
- package/dist/PrettyCode.client-5GABIN2I.cjs.map +1 -0
- package/dist/{PrettyCode.client-DW5LTG47.mjs → PrettyCode.client-IZTXXYHG.mjs} +57 -35
- package/dist/PrettyCode.client-IZTXXYHG.mjs.map +1 -0
- package/dist/chunk-IULI4XII.cjs +1129 -0
- package/dist/chunk-IULI4XII.cjs.map +1 -0
- package/dist/chunk-VZGQC3NG.mjs +1100 -0
- package/dist/chunk-VZGQC3NG.mjs.map +1 -0
- package/dist/index.cjs +88 -552
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +18 -6
- package/dist/index.d.ts +18 -6
- package/dist/index.mjs +25 -496
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -6
- package/src/tools/OpenapiViewer/.claude/.sidecar/activity.jsonl +6 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/history/2026-04-22.md +35 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/map_cache.json +30 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/review.md +35 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/scan.log +3 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-001.md +18 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-002.md +18 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-003.md +18 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-004.md +18 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-005.md +18 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/usage.json +5 -0
- package/src/tools/OpenapiViewer/.claude/project-map.md +23 -0
- package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +28 -2
- package/src/tools/OpenapiViewer/README.md +104 -51
- package/src/tools/OpenapiViewer/components/DocsLayout/ApiIntroSection.tsx +64 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/DocsView.tsx +137 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc.tsx +268 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +139 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar.tsx +211 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/SlideInPlayground.tsx +101 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/TryItSheet.tsx +57 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/anchor.ts +11 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/grouping.ts +71 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/index.tsx +166 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/schemaFields.ts +121 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/sidebarLabel.ts +60 -0
- package/src/tools/OpenapiViewer/components/index.ts +5 -2
- package/src/tools/OpenapiViewer/components/shared/BodyFormEditor.tsx +422 -0
- package/src/tools/OpenapiViewer/components/shared/EndpointDraftSync.tsx +108 -0
- package/src/tools/OpenapiViewer/components/shared/EndpointResetButton.tsx +50 -0
- package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/RequestPanel.tsx +174 -87
- package/src/tools/OpenapiViewer/components/shared/SendButton.tsx +91 -0
- package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/ui.tsx +5 -4
- package/src/tools/OpenapiViewer/context/PlaygroundContext.tsx +82 -8
- package/src/tools/OpenapiViewer/hooks/useEndpointDraft.ts +142 -0
- package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +126 -13
- package/src/tools/OpenapiViewer/index.tsx +3 -7
- package/src/tools/OpenapiViewer/lazy.tsx +6 -27
- package/src/tools/OpenapiViewer/types.ts +44 -0
- package/src/tools/OpenapiViewer/utils/formatters.ts +2 -23
- package/src/tools/OpenapiViewer/utils/index.ts +3 -1
- package/src/tools/OpenapiViewer/utils/schemaExport.ts +206 -0
- package/src/tools/OpenapiViewer/utils/url.ts +202 -0
- package/src/tools/PrettyCode/PrettyCode.client.tsx +42 -8
- package/src/tools/PrettyCode/index.tsx +6 -0
- package/dist/PlaygroundLayout-DHUATCHB.cjs +0 -798
- package/dist/PlaygroundLayout-DHUATCHB.cjs.map +0 -1
- package/dist/PlaygroundLayout-NONWOVQR.mjs +0 -791
- package/dist/PlaygroundLayout-NONWOVQR.mjs.map +0 -1
- package/dist/PrettyCode.client-DW5LTG47.mjs.map +0 -1
- package/dist/PrettyCode.client-SGDGQTYT.cjs.map +0 -1
- package/dist/chunk-5FKE7OME.cjs +0 -369
- package/dist/chunk-5FKE7OME.cjs.map +0 -1
- package/dist/chunk-BKWDHJKF.mjs +0 -356
- package/dist/chunk-BKWDHJKF.mjs.map +0 -1
- package/src/tools/OpenapiViewer/components/PlaygroundLayout/EndpointList.tsx +0 -228
- package/src/tools/OpenapiViewer/components/PlaygroundLayout/index.tsx +0 -107
- /package/dist/{PlaygroundLayout-O52C6HK5.css.map → DocsLayout-MBFIB4NO.css.map} +0 -0
- /package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/ResponsePanel.tsx +0 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
import { usePlaygroundContext } from '../../context/PlaygroundContext';
|
|
6
|
+
import { useEndpointDraft } from '../../hooks/useEndpointDraft';
|
|
7
|
+
|
|
8
|
+
interface EndpointDraftSyncProps {
|
|
9
|
+
/** Active schema id (so drafts don't bleed between different APIs).
|
|
10
|
+
* When null — drafts are disabled (hook returns empty draft, writes
|
|
11
|
+
* are no-ops). Pass the current schema's ``id`` from useOpenApiSchema. */
|
|
12
|
+
schemaId: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Headless component: keeps ``RequestPanel`` state mirrored with a
|
|
17
|
+
* per-endpoint draft in localStorage.
|
|
18
|
+
*
|
|
19
|
+
* Flow:
|
|
20
|
+
* 1. On endpoint change ⇒ read draft ⇒ push into context state.
|
|
21
|
+
* 2. On context state change (user edits params/body) ⇒ write draft.
|
|
22
|
+
*
|
|
23
|
+
* Step 1 is gated by a ref so "just-loaded" drafts don't immediately
|
|
24
|
+
* trigger step 2. Step 2 additionally compares against a serialised
|
|
25
|
+
* snapshot of the last persisted value so a re-render without a real
|
|
26
|
+
* value change doesn't touch storage.
|
|
27
|
+
*
|
|
28
|
+
* Had an infinite-render-loop bug here earlier: the persist callbacks
|
|
29
|
+
* were driven by ``useLocalStorage`` whose setter identity changed on
|
|
30
|
+
* every internal state update, so depending on them from an effect
|
|
31
|
+
* created a cycle. Fixed by making ``useEndpointDraft``'s writers
|
|
32
|
+
* stable (ref-based), and by gating writes with value comparison below.
|
|
33
|
+
*/
|
|
34
|
+
export function EndpointDraftSync({ schemaId }: EndpointDraftSyncProps) {
|
|
35
|
+
const { state, setParameters, setRequestBody, setActiveSchemaId } = usePlaygroundContext();
|
|
36
|
+
const ep = state.selectedEndpoint;
|
|
37
|
+
|
|
38
|
+
// Mirror schemaId into context so other components (e.g. the Reset
|
|
39
|
+
// button in RequestPanel) can read it without receiving props from
|
|
40
|
+
// a parent they don't own.
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
setActiveSchemaId(schemaId);
|
|
43
|
+
}, [schemaId, setActiveSchemaId]);
|
|
44
|
+
|
|
45
|
+
const { draft, setParameters: persistParams, setRequestBody: persistBody } =
|
|
46
|
+
useEndpointDraft(schemaId, ep);
|
|
47
|
+
|
|
48
|
+
const lastLoadedKeyRef = useRef<string | null>(null);
|
|
49
|
+
const lastPersistedParamsRef = useRef<string>('');
|
|
50
|
+
const lastPersistedBodyRef = useRef<string>('');
|
|
51
|
+
const currentKey = ep ? `${ep.method}|${ep.path}` : null;
|
|
52
|
+
|
|
53
|
+
// Step 1 — hydrate context from draft on endpoint switch.
|
|
54
|
+
//
|
|
55
|
+
// IMPORTANT: only apply fields that actually exist in the saved draft.
|
|
56
|
+
// SELECT_ENDPOINT in the reducer pre-fills ``requestBody`` with an
|
|
57
|
+
// auto-generated schema example, and ``parameters`` with ``{}``.
|
|
58
|
+
// If we blindly overwrite those with an empty draft we wipe the
|
|
59
|
+
// example before the user ever sees it. Tracked via a bug: opening
|
|
60
|
+
// POST /pet showed ``{"key":"value"}`` instead of the Pet example.
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!ep || !currentKey) {
|
|
63
|
+
lastLoadedKeyRef.current = null;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (lastLoadedKeyRef.current === currentKey) return;
|
|
67
|
+
lastLoadedKeyRef.current = currentKey;
|
|
68
|
+
|
|
69
|
+
const hasStoredParams =
|
|
70
|
+
draft.parameters && Object.keys(draft.parameters).length > 0;
|
|
71
|
+
const hasStoredBody = typeof draft.requestBody === 'string' && draft.requestBody !== '';
|
|
72
|
+
|
|
73
|
+
if (hasStoredParams) {
|
|
74
|
+
setParameters(draft.parameters);
|
|
75
|
+
lastPersistedParamsRef.current = JSON.stringify(draft.parameters);
|
|
76
|
+
} else {
|
|
77
|
+
// Keep whatever the reducer put there; mirror it into the
|
|
78
|
+
// "last persisted" ref so step-2 treats it as a baseline.
|
|
79
|
+
lastPersistedParamsRef.current = JSON.stringify(state.parameters);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (hasStoredBody) {
|
|
83
|
+
setRequestBody(draft.requestBody);
|
|
84
|
+
lastPersistedBodyRef.current = draft.requestBody;
|
|
85
|
+
} else {
|
|
86
|
+
lastPersistedBodyRef.current = state.requestBody;
|
|
87
|
+
}
|
|
88
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
89
|
+
}, [currentKey]);
|
|
90
|
+
|
|
91
|
+
// Step 2 — persist user edits.
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!ep || lastLoadedKeyRef.current !== currentKey) return;
|
|
94
|
+
const serialised = JSON.stringify(state.parameters);
|
|
95
|
+
if (serialised === lastPersistedParamsRef.current) return;
|
|
96
|
+
lastPersistedParamsRef.current = serialised;
|
|
97
|
+
persistParams(state.parameters);
|
|
98
|
+
}, [state.parameters, ep, currentKey, persistParams]);
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (!ep || lastLoadedKeyRef.current !== currentKey) return;
|
|
102
|
+
if (state.requestBody === lastPersistedBodyRef.current) return;
|
|
103
|
+
lastPersistedBodyRef.current = state.requestBody;
|
|
104
|
+
persistBody(state.requestBody);
|
|
105
|
+
}, [state.requestBody, ep, currentKey, persistBody]);
|
|
106
|
+
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { RotateCcw } from 'lucide-react';
|
|
4
|
+
import React, { useCallback } from 'react';
|
|
5
|
+
|
|
6
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
7
|
+
|
|
8
|
+
import { usePlaygroundContext } from '../../context/PlaygroundContext';
|
|
9
|
+
import { useEndpointDraft } from '../../hooks/useEndpointDraft';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* "Reset" ghost button for the current endpoint's draft.
|
|
13
|
+
*
|
|
14
|
+
* Wipes both the in-memory context state (parameters + body) and the
|
|
15
|
+
* persisted localStorage entry. We call reset() *after* clearing the
|
|
16
|
+
* context so the EndpointDraftSync effect that mirrors state → storage
|
|
17
|
+
* doesn't immediately re-create a now-empty draft with an empty object
|
|
18
|
+
* (benign, but uglier in storage).
|
|
19
|
+
*/
|
|
20
|
+
export function EndpointResetButton() {
|
|
21
|
+
const { state, setParameters, setRequestBody } = usePlaygroundContext();
|
|
22
|
+
const ep = state.selectedEndpoint;
|
|
23
|
+
const { reset } = useEndpointDraft(state.activeSchemaId, ep);
|
|
24
|
+
|
|
25
|
+
const hasDraft =
|
|
26
|
+
Object.keys(state.parameters).length > 0 || state.requestBody.length > 0;
|
|
27
|
+
|
|
28
|
+
const onClick = useCallback(() => {
|
|
29
|
+
setParameters({});
|
|
30
|
+
setRequestBody('');
|
|
31
|
+
reset();
|
|
32
|
+
}, [setParameters, setRequestBody, reset]);
|
|
33
|
+
|
|
34
|
+
if (!ep || !hasDraft) return null;
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
onClick={onClick}
|
|
40
|
+
title="Reset parameters & body (keeps auth)"
|
|
41
|
+
className={cn(
|
|
42
|
+
'inline-flex items-center gap-1 text-[10px] text-muted-foreground',
|
|
43
|
+
'hover:text-foreground transition-colors',
|
|
44
|
+
)}
|
|
45
|
+
>
|
|
46
|
+
<RotateCcw className="h-2.5 w-2.5" />
|
|
47
|
+
Reset
|
|
48
|
+
</button>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { Key,
|
|
4
|
-
import React, { useMemo } from 'react';
|
|
3
|
+
import { Key, Send, Sparkles, Terminal } from 'lucide-react';
|
|
4
|
+
import React, { useCallback, useMemo } from 'react';
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
|
-
Button,
|
|
8
7
|
Combobox,
|
|
9
8
|
type ComboboxOption,
|
|
10
|
-
CopyButton,
|
|
11
9
|
Input,
|
|
12
10
|
Textarea,
|
|
13
11
|
} from '@djangocfg/ui-core/components';
|
|
@@ -16,13 +14,14 @@ import { cn } from '@djangocfg/ui-core/lib';
|
|
|
16
14
|
import PrettyCode from '../../../PrettyCode';
|
|
17
15
|
import { usePlaygroundContext } from '../../context/PlaygroundContext';
|
|
18
16
|
import { findApiKeyById, isValidJson, parseRequestHeaders } from '../../utils';
|
|
17
|
+
import { resolveAbsolute } from '../../utils/url';
|
|
18
|
+
import { BodyFormEditor } from './BodyFormEditor';
|
|
19
|
+
import { EndpointResetButton } from './EndpointResetButton';
|
|
19
20
|
import {
|
|
20
21
|
CollapsibleSection,
|
|
21
22
|
EmptyState,
|
|
22
|
-
MethodBadge,
|
|
23
23
|
ScrollArea,
|
|
24
24
|
SectionLabel,
|
|
25
|
-
StatusBadge,
|
|
26
25
|
relativePath,
|
|
27
26
|
} from './ui';
|
|
28
27
|
|
|
@@ -107,13 +106,15 @@ export function RequestPanel() {
|
|
|
107
106
|
|
|
108
107
|
const curlCommand = useMemo(() => {
|
|
109
108
|
if (!state.requestUrl) return '';
|
|
109
|
+
// Resolve to an absolute URL so the snippet is runnable from a
|
|
110
|
+
// shell. The live ``fetch`` inside the playground resolves the
|
|
111
|
+
// relative form itself via the browser; copy-pasting to curl
|
|
112
|
+
// doesn't get that treatment.
|
|
113
|
+
const absoluteUrl = resolveAbsolute(state.requestUrl);
|
|
110
114
|
const apiKey = state.selectedApiKey ? findApiKeyById(apiKeys, state.selectedApiKey) : null;
|
|
111
115
|
const hdrs = parseRequestHeaders(state.requestHeaders);
|
|
112
|
-
// Use ``secret`` (the raw key) for the cURL header — matches
|
|
113
|
-
// what the playground sends over the wire. Fall back to
|
|
114
|
-
// ``id`` for legacy callers still on the old ApiKey shape.
|
|
115
116
|
if (apiKey) hdrs['X-API-Key'] = apiKey.secret || apiKey.id;
|
|
116
|
-
let cmd = `curl -X ${state.requestMethod} "${
|
|
117
|
+
let cmd = `curl -X ${state.requestMethod} "${absoluteUrl}"`;
|
|
117
118
|
Object.entries(hdrs).forEach(([k, v]) => { cmd += ` \\\n -H "${k}: ${v}"`; });
|
|
118
119
|
if (state.requestBody && state.requestMethod !== 'GET' && isJsonValid) {
|
|
119
120
|
cmd += ` \\\n -d '${state.requestBody}'`;
|
|
@@ -132,14 +133,19 @@ export function RequestPanel() {
|
|
|
132
133
|
|
|
133
134
|
// ── Derived ───────────────────────────────────────────────────────────────
|
|
134
135
|
const isSendDisabled = state.loading || !state.requestUrl || !isJsonValid;
|
|
135
|
-
|
|
136
|
+
// Show the absolute URL in the meta row so the user sees exactly
|
|
137
|
+
// what will go over the wire — same rewrite we do for cURL.
|
|
138
|
+
const displayUrl = resolveAbsolute(state.requestUrl || ep?.path || '');
|
|
136
139
|
const hasBody = ep?.method !== 'GET';
|
|
137
140
|
const bodyType = ep?.requestBody?.type ?? '';
|
|
138
141
|
const hasPathParams = pathParams.length > 0;
|
|
139
142
|
const hasQueryParams = queryParams.length > 0;
|
|
140
143
|
const hasCurl = Boolean(curlCommand);
|
|
141
144
|
const epPath = ep ? relativePath(ep.path) : '';
|
|
142
|
-
|
|
145
|
+
// Show the URL meta row only when it differs from the endpoint's
|
|
146
|
+
// template shape — i.e. the user has substituted path params or the
|
|
147
|
+
// URL host is worth showing explicitly.
|
|
148
|
+
const urlChanged = displayUrl !== '' && displayUrl !== epPath;
|
|
143
149
|
|
|
144
150
|
// ── Early return ──────────────────────────────────────────────────────────
|
|
145
151
|
if (!ep) {
|
|
@@ -149,29 +155,25 @@ export function RequestPanel() {
|
|
|
149
155
|
// ── Render ────────────────────────────────────────────────────────────────
|
|
150
156
|
return (
|
|
151
157
|
<>
|
|
152
|
-
{/*
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
158
|
+
{/* Inline meta row — shows the effective URL when it differs
|
|
159
|
+
from the endpoint's path template (e.g. after substituting
|
|
160
|
+
path parameters), plus the Reset action. We dropped the
|
|
161
|
+
full endpoint header because the containing surface (SidePanel
|
|
162
|
+
in docs layout, column header in classic layout) already
|
|
163
|
+
shows the method + path, and duplicating the Send button
|
|
164
|
+
next to a full-width Send at the bottom read as confusing. */}
|
|
165
|
+
{(urlChanged || ep) && (
|
|
166
|
+
<div className="shrink-0 border-b px-4 py-2 bg-muted/10 flex items-center gap-2 min-h-[28px]">
|
|
167
|
+
{urlChanged ? (
|
|
168
|
+
<span className="font-mono text-[10px] text-muted-foreground/60 break-all leading-snug truncate min-w-0 flex-1">
|
|
169
|
+
{displayUrl}
|
|
170
|
+
</span>
|
|
171
|
+
) : (
|
|
172
|
+
<span className="flex-1" />
|
|
173
|
+
)}
|
|
174
|
+
<EndpointResetButton />
|
|
168
175
|
</div>
|
|
169
|
-
|
|
170
|
-
<div className="font-mono text-[10px] text-muted-foreground/50 break-all leading-snug pl-0.5">
|
|
171
|
-
{displayUrl}
|
|
172
|
-
</div>
|
|
173
|
-
)}
|
|
174
|
-
</div>
|
|
176
|
+
)}
|
|
175
177
|
|
|
176
178
|
{/* Scrollable fields */}
|
|
177
179
|
<ScrollArea className="px-4 py-3 space-y-3">
|
|
@@ -181,41 +183,14 @@ export function RequestPanel() {
|
|
|
181
183
|
|
|
182
184
|
{/* Body */}
|
|
183
185
|
{hasBody && (
|
|
184
|
-
<
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
{isJsonValid && state.requestBody && (
|
|
193
|
-
<button
|
|
194
|
-
type="button"
|
|
195
|
-
onClick={() => {
|
|
196
|
-
try {
|
|
197
|
-
setRequestBody(JSON.stringify(JSON.parse(state.requestBody), null, 2));
|
|
198
|
-
} catch {}
|
|
199
|
-
}}
|
|
200
|
-
className="inline-flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
|
201
|
-
>
|
|
202
|
-
<Sparkles className="h-2.5 w-2.5" />
|
|
203
|
-
Format
|
|
204
|
-
</button>
|
|
205
|
-
)}
|
|
206
|
-
</div>
|
|
207
|
-
<Textarea
|
|
208
|
-
placeholder={'{\n "key": "value"\n}'}
|
|
209
|
-
value={state.requestBody}
|
|
210
|
-
onChange={(e) => setRequestBody(e.target.value)}
|
|
211
|
-
className={cn(
|
|
212
|
-
'font-mono text-[11px] min-h-[90px] resize-y',
|
|
213
|
-
!isJsonValid && 'border-destructive focus-visible:ring-destructive/30',
|
|
214
|
-
)}
|
|
215
|
-
rows={4}
|
|
216
|
-
/>
|
|
217
|
-
{!isJsonValid && <p className="text-[10px] text-destructive">Invalid JSON</p>}
|
|
218
|
-
</div>
|
|
186
|
+
<BodySection
|
|
187
|
+
schema={ep.requestBody?.schema}
|
|
188
|
+
bodyType={bodyType}
|
|
189
|
+
bodyDescription={ep.requestBody?.description}
|
|
190
|
+
value={state.requestBody}
|
|
191
|
+
onChange={setRequestBody}
|
|
192
|
+
isJsonValid={isJsonValid}
|
|
193
|
+
/>
|
|
219
194
|
)}
|
|
220
195
|
|
|
221
196
|
{/* Auth & Headers — collapsed by default */}
|
|
@@ -273,7 +248,8 @@ export function RequestPanel() {
|
|
|
273
248
|
</div>
|
|
274
249
|
</CollapsibleSection>
|
|
275
250
|
|
|
276
|
-
{/* cURL — collapsed by default
|
|
251
|
+
{/* cURL — collapsed by default. PrettyCode has its own
|
|
252
|
+
hover-toolbar with Copy, so no duplicate action here. */}
|
|
277
253
|
{hasCurl && (
|
|
278
254
|
<CollapsibleSection
|
|
279
255
|
label={
|
|
@@ -282,30 +258,141 @@ export function RequestPanel() {
|
|
|
282
258
|
cURL
|
|
283
259
|
</span>
|
|
284
260
|
}
|
|
285
|
-
action={
|
|
286
|
-
<CopyButton value={curlCommand} variant="ghost" size="sm" className="h-5 px-2 text-[10px] text-muted-foreground">
|
|
287
|
-
Copy
|
|
288
|
-
</CopyButton>
|
|
289
|
-
}
|
|
290
261
|
>
|
|
291
|
-
<div className="
|
|
292
|
-
<PrettyCode
|
|
262
|
+
<div className="mt-2">
|
|
263
|
+
<PrettyCode
|
|
264
|
+
data={curlCommand}
|
|
265
|
+
language="bash"
|
|
266
|
+
isCompact
|
|
267
|
+
maxLines={50}
|
|
268
|
+
/>
|
|
293
269
|
</div>
|
|
294
270
|
</CollapsibleSection>
|
|
295
271
|
)}
|
|
296
272
|
|
|
297
|
-
|
|
273
|
+
{/* Bottom breathing room — the Send footer lives outside
|
|
274
|
+
this component (in SlideInPlayground / TryItSheet),
|
|
275
|
+
so we just leave a little space so the last section
|
|
276
|
+
doesn't crash against the container edge. */}
|
|
277
|
+
<div className="h-4" />
|
|
298
278
|
</ScrollArea>
|
|
279
|
+
</>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
299
282
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
283
|
+
// ─── Body section ─────────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
interface BodySectionProps {
|
|
286
|
+
schema: Record<string, unknown> | undefined;
|
|
287
|
+
bodyType: string;
|
|
288
|
+
bodyDescription?: string;
|
|
289
|
+
/** JSON-serialised body kept in context. Form edits are re-serialised
|
|
290
|
+
* before being written back so one source of truth survives across
|
|
291
|
+
* the two view modes. */
|
|
292
|
+
value: string;
|
|
293
|
+
onChange: (raw: string) => void;
|
|
294
|
+
isJsonValid: boolean;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
type BodyViewMode = 'form' | 'json';
|
|
298
|
+
|
|
299
|
+
function BodySection({ schema, bodyType, bodyDescription, value, onChange, isJsonValid }: BodySectionProps) {
|
|
300
|
+
// Default to form view when we have a schema to drive it. Fall back
|
|
301
|
+
// to raw JSON for schemaless endpoints (or binary bodies, etc.).
|
|
302
|
+
const hasSchema = !!schema;
|
|
303
|
+
const [mode, setMode] = React.useState<BodyViewMode>(hasSchema ? 'form' : 'json');
|
|
304
|
+
|
|
305
|
+
// Parse the context's JSON string once per value change so the form
|
|
306
|
+
// sees a structured object. Invalid JSON is tolerated — the form
|
|
307
|
+
// simply shows empty fields until the user fixes it.
|
|
308
|
+
const parsed = React.useMemo(() => {
|
|
309
|
+
if (!value) return null;
|
|
310
|
+
try { return JSON.parse(value); } catch { return null; }
|
|
311
|
+
}, [value]);
|
|
312
|
+
|
|
313
|
+
const handleFormChange = useCallback(
|
|
314
|
+
(next: unknown) => {
|
|
315
|
+
onChange(JSON.stringify(next, null, 2));
|
|
316
|
+
},
|
|
317
|
+
[onChange],
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
return (
|
|
321
|
+
<div className="space-y-2">
|
|
322
|
+
<div className="flex items-center justify-between gap-2 flex-wrap">
|
|
323
|
+
<div className="flex items-baseline gap-2 min-w-0">
|
|
324
|
+
<SectionLabel>Body</SectionLabel>
|
|
325
|
+
{bodyType && (
|
|
326
|
+
<span className="text-[10px] text-muted-foreground/40 font-mono">{bodyType}</span>
|
|
327
|
+
)}
|
|
328
|
+
{bodyDescription && (
|
|
329
|
+
<span className="text-[10px] text-muted-foreground/60 truncate">{bodyDescription}</span>
|
|
330
|
+
)}
|
|
331
|
+
</div>
|
|
332
|
+
{hasSchema && (
|
|
333
|
+
<div className="inline-flex rounded-md border overflow-hidden text-[10px]">
|
|
334
|
+
<ModeButton active={mode === 'form'} onClick={() => setMode('form')}>Form</ModeButton>
|
|
335
|
+
<ModeButton active={mode === 'json'} onClick={() => setMode('json')}>JSON</ModeButton>
|
|
336
|
+
</div>
|
|
337
|
+
)}
|
|
338
|
+
{mode === 'json' && isJsonValid && value && (
|
|
339
|
+
<button
|
|
340
|
+
type="button"
|
|
341
|
+
onClick={() => {
|
|
342
|
+
try { onChange(JSON.stringify(JSON.parse(value), null, 2)); } catch { /* noop */ }
|
|
343
|
+
}}
|
|
344
|
+
className="inline-flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
|
345
|
+
>
|
|
346
|
+
<Sparkles className="h-2.5 w-2.5" />
|
|
347
|
+
Format
|
|
348
|
+
</button>
|
|
349
|
+
)}
|
|
308
350
|
</div>
|
|
309
|
-
|
|
351
|
+
|
|
352
|
+
{mode === 'form' && hasSchema ? (
|
|
353
|
+
<BodyFormEditor
|
|
354
|
+
schema={schema as Record<string, unknown>}
|
|
355
|
+
value={parsed}
|
|
356
|
+
onChange={handleFormChange}
|
|
357
|
+
/>
|
|
358
|
+
) : (
|
|
359
|
+
<>
|
|
360
|
+
<Textarea
|
|
361
|
+
placeholder={'{\n "key": "value"\n}'}
|
|
362
|
+
value={value}
|
|
363
|
+
onChange={(e) => onChange(e.target.value)}
|
|
364
|
+
className={cn(
|
|
365
|
+
'font-mono text-[11px] min-h-[90px] resize-y',
|
|
366
|
+
!isJsonValid && 'border-destructive focus-visible:ring-destructive/30',
|
|
367
|
+
)}
|
|
368
|
+
rows={4}
|
|
369
|
+
/>
|
|
370
|
+
{!isJsonValid && <p className="text-[10px] text-destructive">Invalid JSON</p>}
|
|
371
|
+
</>
|
|
372
|
+
)}
|
|
373
|
+
</div>
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function ModeButton({
|
|
378
|
+
active, onClick, children,
|
|
379
|
+
}: {
|
|
380
|
+
active: boolean;
|
|
381
|
+
onClick: () => void;
|
|
382
|
+
children: React.ReactNode;
|
|
383
|
+
}) {
|
|
384
|
+
return (
|
|
385
|
+
<button
|
|
386
|
+
type="button"
|
|
387
|
+
onClick={onClick}
|
|
388
|
+
className={cn(
|
|
389
|
+
'px-2 py-0.5 font-medium transition-colors',
|
|
390
|
+
active
|
|
391
|
+
? 'bg-primary/10 text-foreground'
|
|
392
|
+
: 'text-muted-foreground hover:text-foreground',
|
|
393
|
+
)}
|
|
394
|
+
>
|
|
395
|
+
{children}
|
|
396
|
+
</button>
|
|
310
397
|
);
|
|
311
398
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { AlertCircle, Loader2, Send } from 'lucide-react';
|
|
4
|
+
import React, { useMemo } from 'react';
|
|
5
|
+
|
|
6
|
+
import { Button } from '@djangocfg/ui-core/components';
|
|
7
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
8
|
+
|
|
9
|
+
import { usePlaygroundContext } from '../../context/PlaygroundContext';
|
|
10
|
+
import { isValidJson } from '../../utils';
|
|
11
|
+
import { UrlBuilder } from '../../utils/url';
|
|
12
|
+
|
|
13
|
+
interface SendButtonProps {
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Standalone Send button that reads from and acts on the playground
|
|
19
|
+
* context. Disabled + explainer-banner when required inputs are missing.
|
|
20
|
+
*
|
|
21
|
+
* Validation rules (in order — first failure wins the banner text):
|
|
22
|
+
* 1. Required path/query parameters must be non-empty.
|
|
23
|
+
* 2. Path template must be fully substituted (no leftover ``{name}``).
|
|
24
|
+
* 3. Request body, when present, must be valid JSON.
|
|
25
|
+
*
|
|
26
|
+
* We validate on the client so the user isn't punished with a 404 from
|
|
27
|
+
* the server for a trivially detectable mistake (e.g. empty ``{petId}``).
|
|
28
|
+
*/
|
|
29
|
+
export function SendButton({ className }: SendButtonProps) {
|
|
30
|
+
const { state, sendRequest } = usePlaygroundContext();
|
|
31
|
+
|
|
32
|
+
const ep = state.selectedEndpoint;
|
|
33
|
+
|
|
34
|
+
// Single source of truth for URL-level validation lives in UrlBuilder.
|
|
35
|
+
// Recomputing every render is cheap (pure string work).
|
|
36
|
+
const builder = useMemo(
|
|
37
|
+
() => (ep ? new UrlBuilder(ep, state.parameters) : null),
|
|
38
|
+
[ep, state.parameters],
|
|
39
|
+
);
|
|
40
|
+
const missingRequired = builder?.missingRequired() ?? [];
|
|
41
|
+
const unsubstituted = builder?.unfilledPlaceholders() ?? [];
|
|
42
|
+
|
|
43
|
+
const isJsonValid = state.requestBody ? isValidJson(state.requestBody) : true;
|
|
44
|
+
|
|
45
|
+
const blockers: string[] = [];
|
|
46
|
+
if (missingRequired.length > 0) {
|
|
47
|
+
blockers.push(
|
|
48
|
+
`Fill required parameter${missingRequired.length > 1 ? 's' : ''}: ${missingRequired.join(', ')}`,
|
|
49
|
+
);
|
|
50
|
+
} else if (unsubstituted.length > 0) {
|
|
51
|
+
blockers.push(`URL still has unfilled placeholder${unsubstituted.length > 1 ? 's' : ''}: ${unsubstituted.map((n) => `{${n}}`).join(', ')}`);
|
|
52
|
+
}
|
|
53
|
+
if (!isJsonValid) blockers.push('Request body is not valid JSON');
|
|
54
|
+
|
|
55
|
+
const disabled =
|
|
56
|
+
state.loading ||
|
|
57
|
+
!state.requestUrl ||
|
|
58
|
+
blockers.length > 0;
|
|
59
|
+
|
|
60
|
+
const tooltip = blockers.length > 0 ? blockers.join('\n') : undefined;
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className={cn('space-y-2', className)}>
|
|
64
|
+
{blockers.length > 0 && (
|
|
65
|
+
<div className="flex items-start gap-2 rounded-md border border-amber-500/25 bg-amber-500/[0.06] px-3 py-2 text-[11px] text-amber-600 dark:text-amber-400">
|
|
66
|
+
<AlertCircle className="h-3.5 w-3.5 shrink-0 mt-px" />
|
|
67
|
+
<span className="leading-snug">{blockers[0]}</span>
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
<Button
|
|
71
|
+
onClick={sendRequest}
|
|
72
|
+
disabled={disabled}
|
|
73
|
+
size="sm"
|
|
74
|
+
title={tooltip}
|
|
75
|
+
className="w-full gap-2 h-9"
|
|
76
|
+
>
|
|
77
|
+
{state.loading ? (
|
|
78
|
+
<>
|
|
79
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
80
|
+
Sending…
|
|
81
|
+
</>
|
|
82
|
+
) : (
|
|
83
|
+
<>
|
|
84
|
+
<Send className="h-3.5 w-3.5" />
|
|
85
|
+
Send Request
|
|
86
|
+
</>
|
|
87
|
+
)}
|
|
88
|
+
</Button>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Shared primitive UI components used
|
|
4
|
+
* Shared primitive UI components used by the playground panels
|
|
5
|
+
* (Request, Response) in both the slide-in and mobile-sheet surfaces.
|
|
5
6
|
* Keep this file free of any business logic or context reads.
|
|
6
7
|
*/
|
|
7
8
|
|
|
@@ -33,9 +34,9 @@ export function getStatusStyle(status: number): string {
|
|
|
33
34
|
return 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25';
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
37
|
+
// ``relativePath`` lives in utils/url.ts now — re-exported here so every
|
|
38
|
+
// component that used to import it from ``./ui`` keeps working.
|
|
39
|
+
export { relativePath } from '../../utils/url';
|
|
39
40
|
|
|
40
41
|
// ─── Atoms ────────────────────────────────────────────────────────────────────
|
|
41
42
|
|