@djangocfg/ui-tools 2.1.287 → 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.
@@ -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
- const extractEndpoints = (schema: OpenApiSchema, baseUrl: string): ApiEndpoint[] => {
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
- () => (dereferencedSchema ? extractEndpoints(dereferencedSchema, resolvedBaseUrl) : []),
237
- [dereferencedSchema, resolvedBaseUrl]
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
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Find the nearest ancestor that actually scrolls vertically, or
3
+ * ``window`` when none exists. Handles the common embed scenarios:
4
+ *
5
+ * - Standalone page: nothing in the chain scrolls → caller scrolls
6
+ * ``window`` and listens to ``window.scroll``.
7
+ * - Dev playground / modal shell: an intermediate ``overflow-auto``
8
+ * container scrolls → caller scrolls that element and listens to
9
+ * *its* ``scroll`` events (which do NOT bubble to window).
10
+ *
11
+ * A "scrollable" ancestor is one whose computed ``overflow-y`` is
12
+ * ``auto`` or ``scroll`` AND whose content actually overflows. We bail
13
+ * before ``document.body`` — ``documentElement`` is represented by
14
+ * ``window`` in the caller's hot path, so returning the body itself
15
+ * would double-count the scroll surface.
16
+ */
17
+ export type ScrollTarget = HTMLElement | Window;
18
+
19
+ export function getScrollParent(el: HTMLElement | null): ScrollTarget {
20
+ if (typeof window === 'undefined') return (null as unknown) as Window;
21
+ if (!el) return window;
22
+
23
+ let cur: HTMLElement | null = el.parentElement;
24
+ while (cur && cur !== document.body && cur !== document.documentElement) {
25
+ const style = getComputedStyle(cur);
26
+ const overflowY = style.overflowY;
27
+ const canScroll =
28
+ (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') &&
29
+ cur.scrollHeight > cur.clientHeight;
30
+ if (canScroll) return cur;
31
+ cur = cur.parentElement;
32
+ }
33
+ return window;
34
+ }
35
+
36
+ /** Top-relative scroll position of the target. */
37
+ export function getScrollTop(target: ScrollTarget): number {
38
+ return target === window ? window.scrollY : (target as HTMLElement).scrollTop;
39
+ }
40
+
41
+ /** Visible viewport height of the target. */
42
+ export function getViewportHeight(target: ScrollTarget): number {
43
+ return target === window ? window.innerHeight : (target as HTMLElement).clientHeight;
44
+ }
45
+
46
+ /** Y coordinate of the target's top edge, in viewport space. Used to
47
+ * translate ``getBoundingClientRect().top`` into target-relative
48
+ * coordinates. For ``window`` this is always ``0``. */
49
+ export function getTargetTop(target: ScrollTarget): number {
50
+ return target === window ? 0 : (target as HTMLElement).getBoundingClientRect().top;
51
+ }
52
+
53
+ /** Scroll the target so that the given absolute Y lands at its top.
54
+ *
55
+ * For ``window`` we ask the browser to animate smoothly — every engine
56
+ * honours that path. Nested ``overflow-auto`` elements are a different
57
+ * story: some layouts (e.g. dev-playground shells with flex parents)
58
+ * silently drop the animation, leaving the user stuck. Direct
59
+ * ``scrollTop`` writes always work, so we use them there — loss of
60
+ * animation beats broken navigation. Consumers who want animated
61
+ * scrolling inside a custom shell can wrap this function themselves. */
62
+ export function scrollTargetTo(target: ScrollTarget, top: number) {
63
+ if (target === window) {
64
+ window.scrollTo({ top, behavior: 'smooth' });
65
+ return;
66
+ }
67
+ (target as HTMLElement).scrollTop = top;
68
+ }