@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.
@@ -7,7 +7,9 @@
7
7
  * This module is the single source of truth for that ordering.
8
8
  */
9
9
 
10
- import type { ApiEndpoint } from '../../types';
10
+ import { groupBy, orderBy, partition, sortBy } from 'lodash-es';
11
+
12
+ import type { ApiEndpoint, SchemaSource } from '../../types';
11
13
  import { longestCommonPrefix } from './sidebarLabel';
12
14
 
13
15
  export type EndpointGroup = {
@@ -19,6 +21,13 @@ export type EndpointGroup = {
19
21
  commonPrefix: string;
20
22
  };
21
23
 
24
+ /** A schema's worth of categorised endpoints. The outer level of the
25
+ * ``sections`` sidebar iterates over these. */
26
+ export type SchemaSection = {
27
+ source: SchemaSource;
28
+ groups: EndpointGroup[];
29
+ };
30
+
22
31
  const METHOD_ORDER: Record<string, number> = {
23
32
  GET: 0,
24
33
  POST: 1,
@@ -27,6 +36,8 @@ const METHOD_ORDER: Record<string, number> = {
27
36
  DELETE: 4,
28
37
  };
29
38
 
39
+ const methodRank = (ep: ApiEndpoint) => METHOD_ORDER[ep.method] ?? 99;
40
+
30
41
  /**
31
42
  * Stable, deterministic ordering so two different renders with the
32
43
  * same endpoint list always produce the same visual sequence.
@@ -38,29 +49,15 @@ const METHOD_ORDER: Record<string, number> = {
38
49
  * cluster), then by HTTP method (read → write → delete).
39
50
  */
40
51
  export function groupEndpoints(list: ApiEndpoint[]): EndpointGroup[] {
41
- const map = new Map<string, ApiEndpoint[]>();
42
- for (const ep of list) {
43
- const arr = map.get(ep.category) ?? [];
44
- arr.push(ep);
45
- map.set(ep.category, arr);
46
- }
47
-
48
- const groups: EndpointGroup[] = Array.from(map.entries()).map(([category, endpoints]) => ({
52
+ const byCategory = groupBy(list, 'category');
53
+ const all: EndpointGroup[] = Object.entries(byCategory).map(([category, endpoints]) => ({
49
54
  category,
50
- endpoints: [...endpoints].sort((a, b) => {
51
- const byPath = a.path.localeCompare(b.path);
52
- if (byPath !== 0) return byPath;
53
- return (METHOD_ORDER[a.method] ?? 99) - (METHOD_ORDER[b.method] ?? 99);
54
- }),
55
+ endpoints: orderBy(endpoints, ['path', methodRank], ['asc', 'asc']),
55
56
  commonPrefix: longestCommonPrefix(endpoints.map((e) => e.path)),
56
57
  }));
57
-
58
- groups.sort((a, b) => {
59
- if (a.category === 'Other') return 1;
60
- if (b.category === 'Other') return -1;
61
- return a.category.localeCompare(b.category);
62
- });
63
- return groups;
58
+ // "Other" sinks to the bottom regardless of alphabet.
59
+ const [other, named] = partition(all, (g) => g.category === 'Other');
60
+ return [...sortBy(named, (g) => g.category.toLowerCase()), ...other];
64
61
  }
65
62
 
66
63
  /** Flatten grouped endpoints back into a linear list that preserves
@@ -69,3 +66,23 @@ export function groupEndpoints(list: ApiEndpoint[]): EndpointGroup[] {
69
66
  export function flattenGrouped(groups: EndpointGroup[]): ApiEndpoint[] {
70
67
  return groups.flatMap((g) => g.endpoints);
71
68
  }
69
+
70
+ /** Build per-schema sections in the same order as the original
71
+ * ``schemas`` array. Schemas with zero endpoints are kept so users see
72
+ * an empty-state placeholder instead of "the section silently vanished". */
73
+ export function buildSchemaSections(
74
+ sources: SchemaSource[],
75
+ endpointsBySchema: Record<string, ApiEndpoint[]>,
76
+ ): SchemaSection[] {
77
+ return sources.map((source) => ({
78
+ source,
79
+ groups: groupEndpoints(endpointsBySchema[source.id] ?? []),
80
+ }));
81
+ }
82
+
83
+ /** Flatten schema-sections into a linear endpoint list. Used by scrollspy
84
+ * and by the docs longread to render endpoints in the exact same order
85
+ * as the sidebar. */
86
+ export function flattenSchemaSections(sections: SchemaSection[]): ApiEndpoint[] {
87
+ return sections.flatMap((s) => flattenGrouped(s.groups));
88
+ }
@@ -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((anchor: string) => {
65
- docsRef.current?.scrollToAnchor(anchor);
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
- // Esc handling lives inside SidePanel itself — no duplicate listener.
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
- className="grid grid-cols-[260px_1fr] min-h-0 overflow-hidden"
73
- style={{ height: 'calc(100dvh - var(--navbar-height, 64px))' }}
74
- >
75
- <div className="border-r p-3 space-y-1.5">
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/tablet: sidebar + docs only, playground opens in sheet.
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
- <DocsView
117
- ref={docsRef}
118
- info={schemaInfo}
119
- rawSchema={rawSchema}
120
- resolvedBaseUrl={resolvedBaseUrl}
121
- endpoints={endpoints}
122
- selectedVersion={state.selectedVersion}
123
- loadedEndpoint={state.selectedEndpoint}
124
- onTryEndpoint={handleTry}
125
- onActiveChange={setActiveAnchor}
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
- <DocsSidebar
140
- info={schemaInfo}
141
- endpoints={endpoints}
142
- schemas={schemas}
143
- currentSchemaId={currentSchema?.id ?? null}
144
- onSchemaChange={setCurrentSchema}
145
- activeEndpointId={activeAnchor}
146
- selectedVersion={state.selectedVersion}
147
- onNavigate={handleNavigate}
148
- />
149
- <DocsView
150
- ref={docsRef}
151
- info={schemaInfo}
152
- rawSchema={rawSchema}
153
- resolvedBaseUrl={resolvedBaseUrl}
154
- endpoints={endpoints}
155
- selectedVersion={state.selectedVersion}
156
- loadedEndpoint={state.selectedEndpoint}
157
- onTryEndpoint={handleTry}
158
- onActiveChange={setActiveAnchor}
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
+ }