@djangocfg/ui-tools 2.1.286 → 2.1.289
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-ERETJLLV.mjs → DocsLayout-TKJQ5W5E.mjs} +848 -266
- package/dist/DocsLayout-TKJQ5W5E.mjs.map +1 -0
- package/dist/{DocsLayout-BCVU6TTX.cjs → DocsLayout-YDR7DSMM.cjs} +843 -261
- package/dist/DocsLayout-YDR7DSMM.cjs.map +1 -0
- package/dist/index.cjs +2 -2
- package/dist/index.d.cts +16 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.mjs +2 -2
- package/package.json +9 -6
- package/src/tools/OpenapiViewer/.claude/.sidecar/activity.jsonl +2 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/history/2026-04-22.md +35 -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 +2 -2
- package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +27 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/DocsView.tsx +326 -54
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc.tsx +7 -2
- package/src/tools/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +32 -9
- package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar.tsx +348 -120
- package/src/tools/OpenapiViewer/components/DocsLayout/anchor.ts +19 -2
- package/src/tools/OpenapiViewer/components/DocsLayout/grouping.ts +38 -21
- package/src/tools/OpenapiViewer/components/DocsLayout/index.tsx +168 -50
- package/src/tools/OpenapiViewer/hooks/index.ts +3 -1
- package/src/tools/OpenapiViewer/hooks/useDocsUrlSync.ts +119 -0
- package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +127 -7
- package/src/tools/OpenapiViewer/types.ts +36 -1
- package/src/tools/OpenapiViewer/utils/scrollParent.ts +68 -0
- package/dist/DocsLayout-BCVU6TTX.cjs.map +0 -1
- package/dist/DocsLayout-ERETJLLV.mjs.map +0 -1
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { useCallback, useRef, useState } from 'react';
|
|
3
|
+
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { keyBy } from 'lodash-es';
|
|
4
5
|
|
|
5
6
|
import { Skeleton, TooltipProvider } from '@djangocfg/ui-core/components';
|
|
6
7
|
import { useMediaQuery } from '@djangocfg/ui-core/hooks';
|
|
7
8
|
|
|
8
9
|
import useOpenApiSchema from '../../hooks/useOpenApiSchema';
|
|
10
|
+
import { useDocsUrlSync, type ParsedHash } from '../../hooks/useDocsUrlSync';
|
|
9
11
|
import { usePlaygroundContext } from '../../context/PlaygroundContext';
|
|
10
12
|
import type { ApiEndpoint } from '../../types';
|
|
11
13
|
import { EndpointDraftSync } from '../shared/EndpointDraftSync';
|
|
14
|
+
import { slugifySchemaId } from './anchor';
|
|
12
15
|
import { DocsSidebar } from './Sidebar';
|
|
13
16
|
import { DocsView, type DocsViewHandle } from './DocsView';
|
|
14
17
|
import { SlideInPlayground } from './SlideInPlayground';
|
|
@@ -24,6 +27,12 @@ export const DocsLayout: React.FC = () => {
|
|
|
24
27
|
// mobile-style ``TryItSheet`` on those viewports.
|
|
25
28
|
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
|
26
29
|
const isMobile = !isDesktop;
|
|
30
|
+
|
|
31
|
+
const grouping = config.schemaGrouping ?? 'selector';
|
|
32
|
+
const preloadAll = grouping === 'sections';
|
|
33
|
+
const urlSyncEnabled =
|
|
34
|
+
typeof config.urlSync === 'boolean' ? config.urlSync : Boolean(config.urlSync?.enabled);
|
|
35
|
+
|
|
27
36
|
const {
|
|
28
37
|
endpoints,
|
|
29
38
|
schemaInfo,
|
|
@@ -34,13 +43,16 @@ export const DocsLayout: React.FC = () => {
|
|
|
34
43
|
schemas,
|
|
35
44
|
currentSchema,
|
|
36
45
|
setCurrentSchema,
|
|
46
|
+
schemasData,
|
|
37
47
|
} = useOpenApiSchema({
|
|
38
48
|
schemas: config.schemas,
|
|
39
49
|
defaultSchemaId: config.defaultSchemaId,
|
|
40
50
|
baseUrl: config.baseUrl,
|
|
51
|
+
preloadAll,
|
|
41
52
|
});
|
|
42
53
|
|
|
43
54
|
const [activeAnchor, setActiveAnchor] = useState<string | null>(null);
|
|
55
|
+
const [activeSchemaId, setActiveSchemaId] = useState<string | null>(null);
|
|
44
56
|
const [sheetOpen, setSheetOpen] = useState(false);
|
|
45
57
|
const docsRef = useRef<DocsViewHandle | null>(null);
|
|
46
58
|
|
|
@@ -49,6 +61,17 @@ export const DocsLayout: React.FC = () => {
|
|
|
49
61
|
// semantic — "which endpoint is loaded into the playground".
|
|
50
62
|
const slideOpen = !isMobile && state.selectedEndpoint !== null;
|
|
51
63
|
|
|
64
|
+
// Per-schema endpoint map for the sections sidebar. ``keyBy`` makes the
|
|
65
|
+
// lookup O(1) at render time instead of scanning schemasData in each
|
|
66
|
+
// CategoryBlock — a win for 10+ schemas.
|
|
67
|
+
const endpointsBySchema = useMemo<Record<string, ApiEndpoint[]>>(() => {
|
|
68
|
+
if (grouping !== 'sections') return {};
|
|
69
|
+
const byId = keyBy(schemasData, (e) => e.source.id);
|
|
70
|
+
const out: Record<string, ApiEndpoint[]> = {};
|
|
71
|
+
for (const src of schemas) out[src.id] = byId[src.id]?.endpoints ?? [];
|
|
72
|
+
return out;
|
|
73
|
+
}, [grouping, schemasData, schemas]);
|
|
74
|
+
|
|
52
75
|
const handleTry = useCallback(
|
|
53
76
|
(ep: ApiEndpoint) => {
|
|
54
77
|
setSelectedEndpoint(ep);
|
|
@@ -61,18 +84,83 @@ export const DocsLayout: React.FC = () => {
|
|
|
61
84
|
setSelectedEndpoint(null);
|
|
62
85
|
}, [setSelectedEndpoint]);
|
|
63
86
|
|
|
64
|
-
const handleNavigate = useCallback(
|
|
65
|
-
|
|
87
|
+
const handleNavigate = useCallback(
|
|
88
|
+
(anchor: string, schemaId?: string | null) => {
|
|
89
|
+
// In selector mode a schema switch may be required before the
|
|
90
|
+
// anchor exists in the DOM — defer the scroll until the next
|
|
91
|
+
// paint so ``useOpenApiSchema`` has a chance to swap endpoints.
|
|
92
|
+
if (schemaId && schemaId !== currentSchema?.id && grouping === 'selector') {
|
|
93
|
+
setCurrentSchema(schemaId);
|
|
94
|
+
requestAnimationFrame(() => {
|
|
95
|
+
docsRef.current?.scrollToAnchor(anchor);
|
|
96
|
+
});
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
docsRef.current?.scrollToAnchor(anchor);
|
|
100
|
+
},
|
|
101
|
+
[currentSchema?.id, grouping, setCurrentSchema],
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const handleActiveChange = useCallback((anchor: string | null, schemaId: string | null) => {
|
|
105
|
+
setActiveAnchor(anchor);
|
|
106
|
+
setActiveSchemaId(schemaId);
|
|
66
107
|
}, []);
|
|
67
|
-
|
|
108
|
+
|
|
109
|
+
// URL sync: read hash on mount / popstate → apply; write hash when
|
|
110
|
+
// scrollspy updates. Only the *effective* active schema goes into the
|
|
111
|
+
// hash — in ``selector`` mode it's the combobox value, in ``sections``
|
|
112
|
+
// mode it's whichever schema the scrollspy is currently inside.
|
|
113
|
+
const effectiveSchemaId = grouping === 'sections' ? activeSchemaId : currentSchema?.id ?? null;
|
|
114
|
+
|
|
115
|
+
const handleHashTarget = useCallback(
|
|
116
|
+
(target: ParsedHash) => {
|
|
117
|
+
if (!target.schemaId && !target.anchor) return;
|
|
118
|
+
|
|
119
|
+
// Schema-id segment may be either the raw id or a slug — match
|
|
120
|
+
// both so copy-pasted URLs survive id changes that don't affect
|
|
121
|
+
// the slug. First match wins.
|
|
122
|
+
const matched = target.schemaId
|
|
123
|
+
? schemas.find((s) => s.id === target.schemaId || slugifySchemaId(s.id) === target.schemaId)
|
|
124
|
+
: null;
|
|
125
|
+
|
|
126
|
+
const needsSchemaSwitch =
|
|
127
|
+
matched && grouping === 'selector' && matched.id !== currentSchema?.id;
|
|
128
|
+
|
|
129
|
+
if (needsSchemaSwitch) {
|
|
130
|
+
setCurrentSchema(matched.id);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (target.anchor) {
|
|
134
|
+
const anchor = target.anchor;
|
|
135
|
+
// Wait one frame when a switch happened so the new DOM exists.
|
|
136
|
+
if (needsSchemaSwitch) {
|
|
137
|
+
requestAnimationFrame(() => {
|
|
138
|
+
docsRef.current?.scrollToAnchor(anchor);
|
|
139
|
+
});
|
|
140
|
+
} else {
|
|
141
|
+
docsRef.current?.scrollToAnchor(anchor);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
[schemas, grouping, currentSchema?.id, setCurrentSchema],
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
useDocsUrlSync({
|
|
149
|
+
enabled: urlSyncEnabled,
|
|
150
|
+
currentSchemaId: effectiveSchemaId,
|
|
151
|
+
activeAnchor,
|
|
152
|
+
onHashTarget: handleHashTarget,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ─── Loading / error branches ─────────────────────────────────────────
|
|
68
156
|
|
|
69
157
|
if (loading) {
|
|
70
158
|
return (
|
|
71
|
-
<div
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
159
|
+
<div className="grid grid-cols-[260px_1fr] items-start">
|
|
160
|
+
<div
|
|
161
|
+
className="sticky top-[var(--navbar-height,64px)] border-r p-3 space-y-1.5 overflow-y-auto"
|
|
162
|
+
style={{ height: 'calc(100dvh - var(--navbar-height, 64px))' }}
|
|
163
|
+
>
|
|
76
164
|
{Array.from({ length: 12 }).map((_, i) => (
|
|
77
165
|
<Skeleton key={i} className="h-8 w-full rounded" />
|
|
78
166
|
))}
|
|
@@ -105,58 +193,88 @@ export const DocsLayout: React.FC = () => {
|
|
|
105
193
|
);
|
|
106
194
|
}
|
|
107
195
|
|
|
108
|
-
// Mobile
|
|
196
|
+
// ─── Mobile: sidebar + docs only, playground opens in sheet ───────────
|
|
197
|
+
|
|
109
198
|
if (isMobile) {
|
|
110
199
|
return (
|
|
111
|
-
<div
|
|
112
|
-
className="flex flex-col overflow-hidden"
|
|
113
|
-
style={{ height: 'calc(100dvh - var(--navbar-height, 64px))' }}
|
|
114
|
-
>
|
|
200
|
+
<div className="flex flex-col">
|
|
115
201
|
<EndpointDraftSync schemaId={currentSchema?.id ?? null} />
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
202
|
+
{grouping === 'sections' ? (
|
|
203
|
+
<DocsView
|
|
204
|
+
ref={docsRef}
|
|
205
|
+
grouping="sections"
|
|
206
|
+
schemasData={schemasData}
|
|
207
|
+
selectedVersion={state.selectedVersion}
|
|
208
|
+
loadedEndpoint={state.selectedEndpoint}
|
|
209
|
+
onTryEndpoint={handleTry}
|
|
210
|
+
onActiveChange={handleActiveChange}
|
|
211
|
+
/>
|
|
212
|
+
) : (
|
|
213
|
+
<DocsView
|
|
214
|
+
ref={docsRef}
|
|
215
|
+
info={schemaInfo}
|
|
216
|
+
rawSchema={rawSchema}
|
|
217
|
+
resolvedBaseUrl={resolvedBaseUrl}
|
|
218
|
+
endpoints={endpoints}
|
|
219
|
+
selectedVersion={state.selectedVersion}
|
|
220
|
+
loadedEndpoint={state.selectedEndpoint}
|
|
221
|
+
onTryEndpoint={handleTry}
|
|
222
|
+
onActiveChange={handleActiveChange}
|
|
223
|
+
/>
|
|
224
|
+
)}
|
|
127
225
|
<TryItSheet open={sheetOpen} onOpenChange={setSheetOpen} />
|
|
128
226
|
</div>
|
|
129
227
|
);
|
|
130
228
|
}
|
|
131
229
|
|
|
230
|
+
// ─── Desktop ──────────────────────────────────────────────────────────
|
|
231
|
+
|
|
132
232
|
return (
|
|
133
233
|
<TooltipProvider delayDuration={350}>
|
|
134
|
-
<div
|
|
135
|
-
className="grid grid-cols-[260px_minmax(0,1fr)] min-h-0 overflow-hidden"
|
|
136
|
-
style={{ height: 'calc(100dvh - var(--navbar-height, 64px))' }}
|
|
137
|
-
>
|
|
234
|
+
<div className="grid grid-cols-[260px_minmax(0,1fr)] items-start">
|
|
138
235
|
<EndpointDraftSync schemaId={currentSchema?.id ?? null} />
|
|
139
|
-
<
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
236
|
+
<div
|
|
237
|
+
className="sticky top-[var(--navbar-height,64px)]"
|
|
238
|
+
style={{ height: 'calc(100dvh - var(--navbar-height, 64px))' }}
|
|
239
|
+
>
|
|
240
|
+
<DocsSidebar
|
|
241
|
+
info={schemaInfo}
|
|
242
|
+
endpoints={endpoints}
|
|
243
|
+
schemas={schemas}
|
|
244
|
+
currentSchemaId={currentSchema?.id ?? null}
|
|
245
|
+
onSchemaChange={setCurrentSchema}
|
|
246
|
+
activeEndpointId={activeAnchor}
|
|
247
|
+
selectedVersion={state.selectedVersion}
|
|
248
|
+
onNavigate={handleNavigate}
|
|
249
|
+
grouping={grouping}
|
|
250
|
+
endpointsBySchema={endpointsBySchema}
|
|
251
|
+
rawSchema={rawSchema}
|
|
252
|
+
resolvedBaseUrl={resolvedBaseUrl}
|
|
253
|
+
/>
|
|
254
|
+
</div>
|
|
255
|
+
{grouping === 'sections' ? (
|
|
256
|
+
<DocsView
|
|
257
|
+
ref={docsRef}
|
|
258
|
+
grouping="sections"
|
|
259
|
+
schemasData={schemasData}
|
|
260
|
+
selectedVersion={state.selectedVersion}
|
|
261
|
+
loadedEndpoint={state.selectedEndpoint}
|
|
262
|
+
onTryEndpoint={handleTry}
|
|
263
|
+
onActiveChange={handleActiveChange}
|
|
264
|
+
/>
|
|
265
|
+
) : (
|
|
266
|
+
<DocsView
|
|
267
|
+
ref={docsRef}
|
|
268
|
+
info={schemaInfo}
|
|
269
|
+
rawSchema={rawSchema}
|
|
270
|
+
resolvedBaseUrl={resolvedBaseUrl}
|
|
271
|
+
endpoints={endpoints}
|
|
272
|
+
selectedVersion={state.selectedVersion}
|
|
273
|
+
loadedEndpoint={state.selectedEndpoint}
|
|
274
|
+
onTryEndpoint={handleTry}
|
|
275
|
+
onActiveChange={handleActiveChange}
|
|
276
|
+
/>
|
|
277
|
+
)}
|
|
160
278
|
{/* SidePanel renders into <body> via portal, so it floats
|
|
161
279
|
above the whole layout (sidebar + navbar included). */}
|
|
162
280
|
<SlideInPlayground open={slideOpen} onClose={handleCloseSlide} />
|
|
@@ -5,4 +5,6 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
export { default as useOpenApiSchema } from './useOpenApiSchema';
|
|
8
|
-
export { useMobile } from './useMobile';
|
|
8
|
+
export { useMobile } from './useMobile';
|
|
9
|
+
export { useDocsUrlSync, parseDocsHash, buildDocsHash } from './useDocsUrlSync';
|
|
10
|
+
export type { ParsedHash } from './useDocsUrlSync';
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
/** Hash format: ``#<schemaId>/<anchor>``.
|
|
6
|
+
* - ``#catalog/ep-get-users`` — specific endpoint in ``catalog`` schema
|
|
7
|
+
* - ``#catalog`` — open ``catalog`` schema at its top
|
|
8
|
+
* - empty — no initial target, leave viewer at its default
|
|
9
|
+
*
|
|
10
|
+
* We intentionally keep this opinionated and stringly-typed: the host app
|
|
11
|
+
* already controls which schemas exist, so there is no room for ambiguity
|
|
12
|
+
* beyond the two segments. */
|
|
13
|
+
export interface ParsedHash {
|
|
14
|
+
schemaId: string | null;
|
|
15
|
+
anchor: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseDocsHash(hash: string): ParsedHash {
|
|
19
|
+
const raw = hash.startsWith('#') ? hash.slice(1) : hash;
|
|
20
|
+
if (!raw) return { schemaId: null, anchor: null };
|
|
21
|
+
const [schemaId = null, ...rest] = raw.split('/');
|
|
22
|
+
const anchor = rest.length > 0 ? rest.join('/') : null;
|
|
23
|
+
return {
|
|
24
|
+
schemaId: schemaId || null,
|
|
25
|
+
anchor: anchor || null,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildDocsHash(schemaId: string | null, anchor: string | null): string {
|
|
30
|
+
if (!schemaId && !anchor) return '';
|
|
31
|
+
if (schemaId && anchor) return `#${schemaId}/${anchor}`;
|
|
32
|
+
if (schemaId) return `#${schemaId}`;
|
|
33
|
+
return anchor ? `#${anchor}` : '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface UseDocsUrlSyncProps {
|
|
37
|
+
enabled: boolean;
|
|
38
|
+
currentSchemaId: string | null;
|
|
39
|
+
activeAnchor: string | null;
|
|
40
|
+
/** Called on mount / ``popstate`` / ``hashchange`` with the hash state.
|
|
41
|
+
* The consumer is responsible for dispatching into its own handlers
|
|
42
|
+
* (switching schema, scrolling to endpoint) in the right order. */
|
|
43
|
+
onHashTarget: (target: ParsedHash) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Two-way sync between browser hash and docs viewer state.
|
|
47
|
+
*
|
|
48
|
+
* - Writes use ``history.replaceState`` so scrollspy-driven updates don't
|
|
49
|
+
* pollute the back/forward stack. User-initiated navigation (click on
|
|
50
|
+
* sidebar row, schema switch) still lands in history because the click
|
|
51
|
+
* itself already did ``pushState`` — or will, via plain anchor hrefs.
|
|
52
|
+
* - Reads happen on mount (initial target) and on ``popstate`` /
|
|
53
|
+
* ``hashchange`` (Back/Forward / external anchor clicks).
|
|
54
|
+
* - When ``enabled`` is false, the hook is a no-op — the viewer stays
|
|
55
|
+
* hash-free so you can embed it inside a larger page. */
|
|
56
|
+
export function useDocsUrlSync({
|
|
57
|
+
enabled,
|
|
58
|
+
currentSchemaId,
|
|
59
|
+
activeAnchor,
|
|
60
|
+
onHashTarget,
|
|
61
|
+
}: UseDocsUrlSyncProps) {
|
|
62
|
+
// Ignore the very first write — otherwise on mount we'd clobber the
|
|
63
|
+
// incoming hash with the viewer's empty defaults before ``onHashTarget``
|
|
64
|
+
// has a chance to apply it.
|
|
65
|
+
const primedRef = useRef(false);
|
|
66
|
+
const onHashTargetRef = useRef(onHashTarget);
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
onHashTargetRef.current = onHashTarget;
|
|
69
|
+
}, [onHashTarget]);
|
|
70
|
+
|
|
71
|
+
// Read: mount + hashchange/popstate
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!enabled || typeof window === 'undefined') return;
|
|
74
|
+
|
|
75
|
+
const apply = () => {
|
|
76
|
+
onHashTargetRef.current(parseDocsHash(window.location.hash));
|
|
77
|
+
};
|
|
78
|
+
apply();
|
|
79
|
+
primedRef.current = true;
|
|
80
|
+
|
|
81
|
+
window.addEventListener('hashchange', apply);
|
|
82
|
+
window.addEventListener('popstate', apply);
|
|
83
|
+
return () => {
|
|
84
|
+
window.removeEventListener('hashchange', apply);
|
|
85
|
+
window.removeEventListener('popstate', apply);
|
|
86
|
+
};
|
|
87
|
+
}, [enabled]);
|
|
88
|
+
|
|
89
|
+
// Write: whenever the viewer's state changes
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!enabled || typeof window === 'undefined') return;
|
|
92
|
+
if (!primedRef.current) return;
|
|
93
|
+
|
|
94
|
+
const next = buildDocsHash(currentSchemaId, activeAnchor);
|
|
95
|
+
const current = window.location.hash;
|
|
96
|
+
if (next === current) return;
|
|
97
|
+
|
|
98
|
+
// replaceState keeps Back/Forward meaningful — a single scroll-through
|
|
99
|
+
// the page shouldn't create 50 history entries.
|
|
100
|
+
const url = next
|
|
101
|
+
? `${window.location.pathname}${window.location.search}${next}`
|
|
102
|
+
: `${window.location.pathname}${window.location.search}`;
|
|
103
|
+
window.history.replaceState(window.history.state, '', url);
|
|
104
|
+
}, [enabled, currentSchemaId, activeAnchor]);
|
|
105
|
+
|
|
106
|
+
const pushTarget = useCallback(
|
|
107
|
+
(schemaId: string | null, anchor: string | null) => {
|
|
108
|
+
if (!enabled || typeof window === 'undefined') return;
|
|
109
|
+
const next = buildDocsHash(schemaId, anchor);
|
|
110
|
+
const url = next
|
|
111
|
+
? `${window.location.pathname}${window.location.search}${next}`
|
|
112
|
+
: `${window.location.pathname}${window.location.search}`;
|
|
113
|
+
window.history.pushState(window.history.state, '', url);
|
|
114
|
+
},
|
|
115
|
+
[enabled],
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
return { pushTarget };
|
|
119
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import consola from 'consola';
|
|
4
4
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
5
5
|
|
|
6
|
-
import { ApiEndpoint, OpenApiInfo, OpenApiSchema, SchemaSource, UseOpenApiSchemaReturn } from '../types';
|
|
6
|
+
import { ApiEndpoint, LoadedSchemaEntry, OpenApiInfo, OpenApiSchema, SchemaSource, UseOpenApiSchemaReturn } from '../types';
|
|
7
7
|
import { dereferenceSchema } from '../utils/schemaExport';
|
|
8
8
|
import { joinUrl, resolveBaseUrl } from '../utils/url';
|
|
9
9
|
|
|
@@ -73,8 +73,10 @@ const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete'] as const;
|
|
|
73
73
|
|
|
74
74
|
// Extract endpoints from OpenAPI schema (all methods). ``baseUrl`` is
|
|
75
75
|
// resolved by the caller via ``resolveBaseUrl`` — we just paste it onto
|
|
76
|
-
// the front of each path here.
|
|
77
|
-
|
|
76
|
+
// the front of each path here. ``schemaId`` is tagged onto every
|
|
77
|
+
// endpoint so downstream consumers (sections-mode sidebar, anchors, URL
|
|
78
|
+
// sync) can correlate endpoints back to their source schema.
|
|
79
|
+
const extractEndpoints = (schema: OpenApiSchema, baseUrl: string, schemaId?: string): ApiEndpoint[] => {
|
|
78
80
|
const endpoints: ApiEndpoint[] = [];
|
|
79
81
|
|
|
80
82
|
if (!schema.paths) return [];
|
|
@@ -150,6 +152,7 @@ const extractEndpoints = (schema: OpenApiSchema, baseUrl: string): ApiEndpoint[]
|
|
|
150
152
|
parameters: parameters.length > 0 ? parameters : undefined,
|
|
151
153
|
requestBody,
|
|
152
154
|
responses: responses.length > 0 ? responses : undefined,
|
|
155
|
+
schemaId,
|
|
153
156
|
};
|
|
154
157
|
|
|
155
158
|
endpoints.push(endpoint);
|
|
@@ -185,12 +188,24 @@ interface UseOpenApiSchemaProps {
|
|
|
185
188
|
/** Global base URL override from ``PlaygroundConfig.baseUrl``.
|
|
186
189
|
* Per-schema ``SchemaSource.baseUrl`` takes precedence over this. */
|
|
187
190
|
baseUrl?: string;
|
|
191
|
+
/** When ``true`` the hook fetches every schema in ``schemas`` (not just
|
|
192
|
+
* the active one) and exposes them via ``schemasData``. Used by the
|
|
193
|
+
* ``sections`` grouping mode — the docs column concatenates endpoints
|
|
194
|
+
* from every schema, so they all need to be on the client. Default
|
|
195
|
+
* is ``false`` to preserve the original lazy behaviour. */
|
|
196
|
+
preloadAll?: boolean;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
interface SchemaLoadState {
|
|
200
|
+
loading: boolean;
|
|
201
|
+
error: string | null;
|
|
188
202
|
}
|
|
189
203
|
|
|
190
204
|
export default function useOpenApiSchema({
|
|
191
205
|
schemas,
|
|
192
206
|
defaultSchemaId,
|
|
193
207
|
baseUrl: configBaseUrl,
|
|
208
|
+
preloadAll = false,
|
|
194
209
|
}: UseOpenApiSchemaProps): UseOpenApiSchemaReturn {
|
|
195
210
|
const [loading, setLoading] = useState(true);
|
|
196
211
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -200,6 +215,10 @@ export default function useOpenApiSchema({
|
|
|
200
215
|
const [loadedSchemas, setLoadedSchemas] = useState<Map<string, OpenApiSchema>>(
|
|
201
216
|
new Map()
|
|
202
217
|
);
|
|
218
|
+
// Per-schema loading/error state for ``preloadAll`` — each schema may
|
|
219
|
+
// succeed or fail independently, and the UI wants to render partial
|
|
220
|
+
// results while slow/broken ones are still resolving.
|
|
221
|
+
const [loadStates, setLoadStates] = useState<Map<string, SchemaLoadState>>(new Map());
|
|
203
222
|
|
|
204
223
|
const currentSchema = useMemo(
|
|
205
224
|
() => schemas.find((s) => s.id === currentSchemaId) || null,
|
|
@@ -233,8 +252,11 @@ export default function useOpenApiSchema({
|
|
|
233
252
|
);
|
|
234
253
|
|
|
235
254
|
const endpoints = useMemo(
|
|
236
|
-
() =>
|
|
237
|
-
|
|
255
|
+
() =>
|
|
256
|
+
dereferencedSchema
|
|
257
|
+
? extractEndpoints(dereferencedSchema, resolvedBaseUrl, currentSchemaId)
|
|
258
|
+
: [],
|
|
259
|
+
[dereferencedSchema, resolvedBaseUrl, currentSchemaId]
|
|
238
260
|
);
|
|
239
261
|
|
|
240
262
|
const categories = useMemo(() => getCategories(endpoints), [endpoints]);
|
|
@@ -250,8 +272,9 @@ export default function useOpenApiSchema({
|
|
|
250
272
|
};
|
|
251
273
|
}, [currentOpenApiSchema]);
|
|
252
274
|
|
|
253
|
-
// Load schema when current schema changes
|
|
275
|
+
// Load schema when current schema changes (single-schema mode)
|
|
254
276
|
useEffect(() => {
|
|
277
|
+
if (preloadAll) return;
|
|
255
278
|
if (!currentSchema) return;
|
|
256
279
|
|
|
257
280
|
// Skip if already loaded
|
|
@@ -274,7 +297,103 @@ export default function useOpenApiSchema({
|
|
|
274
297
|
setError(err instanceof Error ? err.message : 'Failed to load schema');
|
|
275
298
|
setLoading(false);
|
|
276
299
|
});
|
|
277
|
-
}, [currentSchema, loadedSchemas]);
|
|
300
|
+
}, [currentSchema, loadedSchemas, preloadAll]);
|
|
301
|
+
|
|
302
|
+
// Preload every schema (sections-grouping mode). Each schema is fetched
|
|
303
|
+
// independently — a slow or broken source doesn't block the rest.
|
|
304
|
+
useEffect(() => {
|
|
305
|
+
if (!preloadAll) return;
|
|
306
|
+
if (schemas.length === 0) {
|
|
307
|
+
setLoading(false);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
let cancelled = false;
|
|
312
|
+
const pending = schemas.filter((s) => !loadedSchemas.has(s.id));
|
|
313
|
+
if (pending.length === 0) {
|
|
314
|
+
setLoading(false);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
setLoading(true);
|
|
319
|
+
setLoadStates((prev) => {
|
|
320
|
+
const next = new Map(prev);
|
|
321
|
+
for (const s of pending) next.set(s.id, { loading: true, error: null });
|
|
322
|
+
return next;
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
Promise.allSettled(
|
|
326
|
+
pending.map((s) =>
|
|
327
|
+
fetchSchema(s.url).then((schema) => ({ id: s.id, name: s.name, schema })),
|
|
328
|
+
),
|
|
329
|
+
).then((results) => {
|
|
330
|
+
if (cancelled) return;
|
|
331
|
+
|
|
332
|
+
setLoadedSchemas((prev) => {
|
|
333
|
+
const next = new Map(prev);
|
|
334
|
+
for (const r of results) {
|
|
335
|
+
if (r.status === 'fulfilled') {
|
|
336
|
+
next.set(r.value.id, r.value.schema);
|
|
337
|
+
consola.success(`Schema loaded: ${r.value.name}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return next;
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
setLoadStates((prev) => {
|
|
344
|
+
const next = new Map(prev);
|
|
345
|
+
results.forEach((r, i) => {
|
|
346
|
+
const src = pending[i]!;
|
|
347
|
+
if (r.status === 'fulfilled') {
|
|
348
|
+
next.set(src.id, { loading: false, error: null });
|
|
349
|
+
} else {
|
|
350
|
+
const msg = r.reason instanceof Error ? r.reason.message : 'Failed to load schema';
|
|
351
|
+
consola.error(`Error loading schema from ${src.url}:`, r.reason);
|
|
352
|
+
next.set(src.id, { loading: false, error: msg });
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
return next;
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
setLoading(false);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
return () => {
|
|
362
|
+
cancelled = true;
|
|
363
|
+
};
|
|
364
|
+
}, [preloadAll, schemas, loadedSchemas]);
|
|
365
|
+
|
|
366
|
+
const schemasData = useMemo<LoadedSchemaEntry[]>(() => {
|
|
367
|
+
if (!preloadAll) return [];
|
|
368
|
+
return schemas.map((src) => {
|
|
369
|
+
const raw = loadedSchemas.get(src.id) ?? null;
|
|
370
|
+
const deref = raw ? dereferenceSchema(raw) : null;
|
|
371
|
+
const resolved = resolveBaseUrl({
|
|
372
|
+
schemaSource: src.baseUrl,
|
|
373
|
+
config: configBaseUrl,
|
|
374
|
+
fromServers: raw?.servers?.[0]?.url,
|
|
375
|
+
});
|
|
376
|
+
const info: OpenApiInfo | null = raw?.info
|
|
377
|
+
? {
|
|
378
|
+
title: raw.info.title,
|
|
379
|
+
version: raw.info.version,
|
|
380
|
+
description: raw.info.description,
|
|
381
|
+
servers: raw.servers,
|
|
382
|
+
}
|
|
383
|
+
: null;
|
|
384
|
+
const eps = deref ? extractEndpoints(deref, resolved, src.id) : [];
|
|
385
|
+
const state = loadStates.get(src.id) ?? { loading: !raw, error: null };
|
|
386
|
+
return {
|
|
387
|
+
source: src,
|
|
388
|
+
info,
|
|
389
|
+
rawSchema: raw,
|
|
390
|
+
endpoints: eps,
|
|
391
|
+
resolvedBaseUrl: resolved || undefined,
|
|
392
|
+
loading: state.loading,
|
|
393
|
+
error: state.error,
|
|
394
|
+
};
|
|
395
|
+
});
|
|
396
|
+
}, [preloadAll, schemas, loadedSchemas, loadStates, configBaseUrl]);
|
|
278
397
|
|
|
279
398
|
const setCurrentSchema = useCallback((schemaId: string) => {
|
|
280
399
|
setCurrentSchemaId(schemaId);
|
|
@@ -321,5 +440,6 @@ export default function useOpenApiSchema({
|
|
|
321
440
|
resolvedBaseUrl: resolvedBaseUrl || undefined,
|
|
322
441
|
setCurrentSchema,
|
|
323
442
|
refresh,
|
|
443
|
+
schemasData,
|
|
324
444
|
};
|
|
325
445
|
}
|
|
@@ -19,6 +19,10 @@ export interface ApiEndpoint {
|
|
|
19
19
|
name: string;
|
|
20
20
|
method: string;
|
|
21
21
|
path: string;
|
|
22
|
+
/** ID of the schema this endpoint was extracted from. Populated whenever
|
|
23
|
+
* endpoints from multiple schemas coexist (``sections`` grouping), so
|
|
24
|
+
* sidebar grouping, anchors and URL sync can all tell them apart. */
|
|
25
|
+
schemaId?: string;
|
|
22
26
|
/** Short human label from OpenAPI ``operation.summary``. Empty when
|
|
23
27
|
* the spec provides none. Prefer this for sidebar rows and breadcrumbs. */
|
|
24
28
|
summary: string;
|
|
@@ -92,6 +96,20 @@ export interface PlaygroundConfig {
|
|
|
92
96
|
* a spinner instead of "no keys yet". */
|
|
93
97
|
apiKeys?: ApiKey[];
|
|
94
98
|
apiKeysLoading?: boolean;
|
|
99
|
+
/** How multiple schemas are presented in the sidebar.
|
|
100
|
+
* - ``'selector'`` (default): a Combobox switches between schemas, the
|
|
101
|
+
* docs column shows endpoints of the active schema only.
|
|
102
|
+
* - ``'sections'``: the Combobox is hidden and every schema becomes a
|
|
103
|
+
* top-level heading in the sidebar, with endpoints of all schemas
|
|
104
|
+
* rendered back-to-back in the docs column. Scrollspy picks the
|
|
105
|
+
* active schema based on what's visible. */
|
|
106
|
+
schemaGrouping?: 'selector' | 'sections';
|
|
107
|
+
/** Optional URL-hash sync. When enabled, the viewer reads/writes
|
|
108
|
+
* ``#<schemaId>/<anchor>`` on the browser location. Falsy value (the
|
|
109
|
+
* default) keeps the viewer hash-free. Set to an object with
|
|
110
|
+
* ``{ enabled: true }`` to opt in; future fields (e.g. a custom
|
|
111
|
+
* adapter) stay backwards compatible. */
|
|
112
|
+
urlSync?: boolean | { enabled: boolean };
|
|
95
113
|
}
|
|
96
114
|
|
|
97
115
|
// Playground state types
|
|
@@ -190,6 +208,19 @@ export interface OpenApiInfo {
|
|
|
190
208
|
servers?: Array<{ url: string; description?: string }>;
|
|
191
209
|
}
|
|
192
210
|
|
|
211
|
+
/** Per-schema snapshot used by the ``sections`` grouping mode. Mirrors the
|
|
212
|
+
* fields that ``UseOpenApiSchemaReturn`` exposes for the active schema,
|
|
213
|
+
* but repeated for every loaded schema. */
|
|
214
|
+
export interface LoadedSchemaEntry {
|
|
215
|
+
source: SchemaSource;
|
|
216
|
+
info: OpenApiInfo | null;
|
|
217
|
+
rawSchema: OpenApiSchema | null;
|
|
218
|
+
endpoints: ApiEndpoint[];
|
|
219
|
+
resolvedBaseUrl: string | undefined;
|
|
220
|
+
loading: boolean;
|
|
221
|
+
error: string | null;
|
|
222
|
+
}
|
|
223
|
+
|
|
193
224
|
// Hook return types
|
|
194
225
|
export interface UseOpenApiSchemaReturn {
|
|
195
226
|
loading: boolean;
|
|
@@ -208,4 +239,8 @@ export interface UseOpenApiSchemaReturn {
|
|
|
208
239
|
resolvedBaseUrl: string | undefined;
|
|
209
240
|
setCurrentSchema: (schemaId: string) => void;
|
|
210
241
|
refresh: () => void;
|
|
211
|
-
|
|
242
|
+
/** Populated only when the hook was called with ``preloadAll: true``
|
|
243
|
+
* (``sections`` grouping mode). One entry per schema source, in the
|
|
244
|
+
* same order as ``schemas``. */
|
|
245
|
+
schemasData: LoadedSchemaEntry[];
|
|
246
|
+
}
|