@djangocfg/ui-tools 2.1.270 → 2.1.271

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
 
3
3
  import consola from 'consola';
4
4
  import React, {
5
- createContext, ReactNode, useCallback, useContext, useEffect, useState
5
+ createContext, ReactNode, useCallback, useContext, useEffect, useReducer, useRef
6
6
  } from 'react';
7
7
 
8
8
  import type {
@@ -12,18 +12,15 @@ import type {
12
12
  import { parseRequestHeaders, substituteUrlParameters } from '../utils';
13
13
  import { getDefaultVersion } from '../utils/versionManager';
14
14
 
15
+ // ─── Initial state ────────────────────────────────────────────────────────────
16
+
15
17
  const createInitialState = (): PlaygroundState => ({
16
- // Step management
17
18
  currentStep: 'endpoints',
18
19
  steps: ['endpoints', 'request', 'response'],
19
-
20
- // Endpoint selection
21
20
  selectedEndpoint: null,
22
21
  selectedCategory: 'All',
23
22
  searchTerm: '',
24
23
  selectedVersion: getDefaultVersion().id,
25
-
26
- // Request configuration
27
24
  requestUrl: '',
28
25
  requestMethod: 'GET',
29
26
  requestHeaders: '{\n "Content-Type": "application/json"\n}',
@@ -31,197 +28,197 @@ const createInitialState = (): PlaygroundState => ({
31
28
  selectedApiKey: null,
32
29
  manualApiToken: '',
33
30
  parameters: {},
34
-
35
- // Response
36
31
  response: null,
37
32
  loading: false,
38
-
39
- // UI state
40
- sidebarOpen: false, // kept for API compat
33
+ sidebarOpen: false,
41
34
  });
42
35
 
36
+ // ─── Actions ──────────────────────────────────────────────────────────────────
37
+
38
+ type Action =
39
+ | { type: 'SET_STEP'; step: PlaygroundStep }
40
+ | { type: 'NEXT_STEP' }
41
+ | { type: 'PREV_STEP' }
42
+ | { type: 'SELECT_ENDPOINT'; endpoint: ApiEndpoint | null }
43
+ | { type: 'SET_CATEGORY'; category: string }
44
+ | { type: 'SET_SEARCH'; term: string }
45
+ | { type: 'SET_VERSION'; version: string }
46
+ | { type: 'SET_REQUEST_URL'; url: string }
47
+ | { type: 'SET_REQUEST_METHOD'; method: string }
48
+ | { type: 'SET_REQUEST_HEADERS'; headers: string }
49
+ | { type: 'SET_REQUEST_BODY'; body: string }
50
+ | { type: 'SET_API_KEY'; apiKeyId: string | null }
51
+ | { type: 'SET_MANUAL_TOKEN'; token: string }
52
+ | { type: 'SET_PARAMETERS'; parameters: Record<string, string> }
53
+ | { type: 'SET_RESPONSE'; response: ApiResponse | null }
54
+ | { type: 'SET_LOADING'; loading: boolean }
55
+ // Batched: set loading + clear response atomically (avoids two renders on send)
56
+ | { type: 'REQUEST_START' }
57
+ // Batched: set response + loading=false + advance step atomically (avoids three renders)
58
+ | { type: 'REQUEST_SUCCESS'; response: ApiResponse }
59
+ // Batched: set error response + loading=false atomically
60
+ | { type: 'REQUEST_ERROR'; response: ApiResponse }
61
+ | { type: 'SET_SIDEBAR'; open: boolean }
62
+ | { type: 'SYNC_API_KEY_HEADER'; headers: string }
63
+ | { type: 'CLEAR_API_KEY_SELECTION' }
64
+ | { type: 'SYNC_URL'; url: string }
65
+ | { type: 'RESET' };
66
+
67
+ // ─── Reducer ──────────────────────────────────────────────────────────────────
68
+
69
+ function reducer(state: PlaygroundState, action: Action): PlaygroundState {
70
+ switch (action.type) {
71
+ case 'SET_STEP':
72
+ return { ...state, currentStep: action.step };
73
+
74
+ case 'NEXT_STEP': {
75
+ const i = state.steps.indexOf(state.currentStep);
76
+ return i < state.steps.length - 1
77
+ ? { ...state, currentStep: state.steps[i + 1]! }
78
+ : state;
79
+ }
80
+
81
+ case 'PREV_STEP': {
82
+ const i = state.steps.indexOf(state.currentStep);
83
+ return i > 0 ? { ...state, currentStep: state.steps[i - 1]! } : state;
84
+ }
85
+
86
+ case 'SELECT_ENDPOINT':
87
+ if (!action.endpoint) return { ...state, selectedEndpoint: null };
88
+ return {
89
+ ...state,
90
+ selectedEndpoint: action.endpoint,
91
+ requestMethod: action.endpoint.method,
92
+ requestUrl: action.endpoint.path,
93
+ parameters: {},
94
+ currentStep: 'request',
95
+ };
96
+
97
+ case 'SET_CATEGORY': return { ...state, selectedCategory: action.category };
98
+ case 'SET_SEARCH': return { ...state, searchTerm: action.term };
99
+ case 'SET_VERSION': return { ...state, selectedVersion: action.version };
100
+ case 'SET_REQUEST_URL': return { ...state, requestUrl: action.url };
101
+ case 'SET_REQUEST_METHOD': return { ...state, requestMethod: action.method };
102
+ case 'SET_REQUEST_HEADERS': return { ...state, requestHeaders: action.headers };
103
+ case 'SET_REQUEST_BODY': return { ...state, requestBody: action.body };
104
+ case 'SET_API_KEY': return { ...state, selectedApiKey: action.apiKeyId };
105
+ case 'SET_MANUAL_TOKEN': return { ...state, manualApiToken: action.token };
106
+ case 'SET_PARAMETERS': return { ...state, parameters: action.parameters };
107
+ case 'SET_RESPONSE': return { ...state, response: action.response };
108
+ case 'SET_LOADING': return { ...state, loading: action.loading };
109
+
110
+ case 'REQUEST_START':
111
+ return { ...state, loading: true, response: null };
112
+
113
+ case 'REQUEST_SUCCESS':
114
+ return { ...state, loading: false, response: action.response, currentStep: 'response' };
115
+
116
+ case 'REQUEST_ERROR':
117
+ return { ...state, loading: false, response: action.response };
118
+
119
+ case 'SET_SIDEBAR': return { ...state, sidebarOpen: action.open };
120
+ case 'SYNC_API_KEY_HEADER': return { ...state, requestHeaders: action.headers };
121
+ case 'CLEAR_API_KEY_SELECTION': return { ...state, selectedApiKey: null };
122
+ case 'SYNC_URL': return { ...state, requestUrl: action.url };
123
+ case 'RESET': return createInitialState();
124
+
125
+ default: return state;
126
+ }
127
+ }
128
+
129
+ // ─── Context ──────────────────────────────────────────────────────────────────
130
+
43
131
  const PlaygroundContext = createContext<PlaygroundContextType | undefined>(undefined);
44
132
 
45
133
  export const usePlaygroundContext = () => {
46
134
  const context = useContext(PlaygroundContext);
47
- if (!context) {
48
- throw new Error('usePlaygroundContext must be used within a PlaygroundProvider');
49
- }
135
+ if (!context) throw new Error('usePlaygroundContext must be used within a PlaygroundProvider');
50
136
  return context;
51
137
  };
52
138
 
139
+ // ─── Provider ─────────────────────────────────────────────────────────────────
140
+
53
141
  interface PlaygroundProviderProps {
54
142
  children: ReactNode;
55
143
  config: PlaygroundConfig;
56
144
  }
57
145
 
58
146
  export const PlaygroundProvider: React.FC<PlaygroundProviderProps> = ({ children, config }) => {
59
- const [state, setState] = useState<PlaygroundState>(() => createInitialState());
147
+ const [state, dispatch] = useReducer(reducer, undefined, createInitialState);
148
+ const abortControllerRef = useRef<AbortController | null>(null);
60
149
 
61
- // TODO: Get API keys from CFG context - temporarily disabled
62
- // const { apiKeys: apiKeysResponse, isLoadingApiKeys } = useApiKeysContext();
63
- // const apiKeys = (apiKeysResponse && apiKeysResponse.results) ? apiKeysResponse.results : [];
64
150
  const apiKeys = React.useMemo(() => [], []);
65
151
  const isLoadingApiKeys = false;
66
152
 
67
- const updateState = (updates: Partial<PlaygroundState>) => {
68
- setState((prev) => ({ ...prev, ...updates }));
69
- };
70
-
71
- // Auto-select first API key when available
153
+ // Auto-select first API key
72
154
  useEffect(() => {
73
155
  if (!isLoadingApiKeys && apiKeys.length > 0 && !state.selectedApiKey) {
74
- updateState({ selectedApiKey: apiKeys[0]?.id || null });
156
+ dispatch({ type: 'SET_API_KEY', apiKeyId: apiKeys[0]?.id || null });
75
157
  }
76
158
  }, [apiKeys, isLoadingApiKeys, state.selectedApiKey]);
77
159
 
78
- // Update headers when API key changes
160
+ // Sync X-API-Key header when selected key changes
79
161
  useEffect(() => {
80
162
  try {
81
- setState(prev => {
82
- const headers = parseRequestHeaders(prev.requestHeaders);
83
- let hasChanged = false;
84
-
85
- if (prev.selectedApiKey) {
86
- const apiKey = apiKeys.find(k => k.id === prev.selectedApiKey);
87
-
88
- if (apiKey) {
89
- // Add API key to headers only if it changed
90
- if (headers['X-API-Key'] !== apiKey.id) {
91
- headers['X-API-Key'] = apiKey.id;
92
- hasChanged = true;
93
- }
94
- } else {
95
- // Selected API key no longer exists, clear selection
96
- return { ...prev, selectedApiKey: null };
97
- }
98
- } else {
99
- // Remove API key from headers if no key is selected
100
- if (headers['X-API-Key']) {
101
- delete headers['X-API-Key'];
102
- hasChanged = true;
103
- }
104
- }
163
+ const headers = parseRequestHeaders(state.requestHeaders);
105
164
 
106
- // Only update if headers actually changed
107
- if (hasChanged) {
108
- const updatedHeaders = JSON.stringify(headers, null, 2);
109
- return { ...prev, requestHeaders: updatedHeaders };
165
+ if (state.selectedApiKey) {
166
+ const apiKey = apiKeys.find((k) => k.id === state.selectedApiKey);
167
+ if (!apiKey) {
168
+ dispatch({ type: 'CLEAR_API_KEY_SELECTION' });
169
+ return;
110
170
  }
111
-
112
- return prev;
113
- });
171
+ if (headers['X-API-Key'] !== apiKey.id) {
172
+ headers['X-API-Key'] = apiKey.id;
173
+ dispatch({ type: 'SYNC_API_KEY_HEADER', headers: JSON.stringify(headers, null, 2) });
174
+ }
175
+ } else if (headers['X-API-Key']) {
176
+ delete headers['X-API-Key'];
177
+ dispatch({ type: 'SYNC_API_KEY_HEADER', headers: JSON.stringify(headers, null, 2) });
178
+ }
114
179
  } catch (error) {
115
180
  consola.error('Error updating headers:', error);
116
181
  }
117
- }, [state.selectedApiKey, apiKeys]); // Removed state.requestHeaders dependency
182
+ }, [state.selectedApiKey, apiKeys]); // eslint-disable-line react-hooks/exhaustive-deps
118
183
 
119
- // Update URL when parameters change
184
+ // Sync URL when path parameters change
120
185
  useEffect(() => {
121
- if (state.selectedEndpoint && state.parameters) {
122
- // Path is already a full URL from the endpoint
123
- const updatedUrl = substituteUrlParameters(state.selectedEndpoint.path, state.parameters);
124
-
125
- // Only update if URL actually changed to avoid infinite loop
126
- if (updatedUrl !== state.requestUrl) {
127
- updateState({ requestUrl: updatedUrl });
128
- }
186
+ if (!state.selectedEndpoint) return;
187
+ const updated = substituteUrlParameters(state.selectedEndpoint.path, state.parameters);
188
+ if (updated !== state.requestUrl) {
189
+ dispatch({ type: 'SYNC_URL', url: updated });
129
190
  }
130
- }, [state.parameters, state.selectedEndpoint, state.requestUrl]);
131
-
132
- // Step management
133
- const setCurrentStep = (step: PlaygroundStep) => {
134
- updateState({ currentStep: step });
135
- };
136
-
137
- const goToNextStep = () => {
138
- const currentIndex = state.steps.indexOf(state.currentStep);
139
- if (currentIndex < state.steps.length - 1) {
140
- updateState({ currentStep: state.steps[currentIndex + 1] });
141
- }
142
- };
143
-
144
- const goToPreviousStep = () => {
145
- const currentIndex = state.steps.indexOf(state.currentStep);
146
- if (currentIndex > 0) {
147
- updateState({ currentStep: state.steps[currentIndex - 1] });
148
- }
149
- };
150
-
151
- // Endpoint management
152
- const setSelectedEndpoint = (endpoint: ApiEndpoint | null) => {
153
- if (endpoint) {
154
- updateState({
155
- selectedEndpoint: endpoint,
156
- requestMethod: endpoint.method,
157
- requestUrl: endpoint.path,
158
- parameters: {}, // Reset parameters when endpoint changes
159
- currentStep: 'request'
160
- });
161
- } else {
162
- updateState({ selectedEndpoint: endpoint });
163
- }
164
- };
165
-
166
- const setSelectedCategory = (category: string) => {
167
- updateState({ selectedCategory: category });
168
- };
169
-
170
- const setSearchTerm = (term: string) => {
171
- updateState({ searchTerm: term });
172
- };
173
-
174
- const setSelectedVersion = (version: string) => {
175
- updateState({ selectedVersion: version });
176
- };
177
-
178
- // Request management
179
- const setRequestUrl = (url: string) => {
180
- updateState({ requestUrl: url });
181
- };
182
-
183
- const setRequestMethod = (method: string) => {
184
- updateState({ requestMethod: method });
185
- };
186
-
187
- const setRequestHeaders = (headers: string) => {
188
- updateState({ requestHeaders: headers });
189
- };
190
-
191
- const setRequestBody = (body: string) => {
192
- updateState({ requestBody: body });
193
- };
194
-
195
- const setSelectedApiKey = (apiKeyId: string | null) => {
196
- updateState({ selectedApiKey: apiKeyId });
197
- };
198
-
199
- const setManualApiToken = (manualApiToken: string) => {
200
- updateState({ manualApiToken });
201
- };
202
-
203
- const setParameters = (parameters: Record<string, string>) => {
204
- updateState({ parameters });
205
- };
206
-
207
- // Response management
208
- const setResponse = (response: ApiResponse | null) => {
209
- updateState({ response });
210
- };
211
-
212
- const setLoading = (loading: boolean) => {
213
- updateState({ loading });
214
- };
215
-
216
- // UI management
217
- const setSidebarOpen = (sidebarOpen: boolean) => {
218
- updateState({ sidebarOpen });
219
- };
220
-
221
- // Actions
222
- const clearAll = useCallback(() => {
223
- setState(createInitialState());
224
- }, []);
191
+ }, [state.parameters, state.selectedEndpoint]); // eslint-disable-line react-hooks/exhaustive-deps
192
+
193
+ // ── Stable action dispatchers ─────────────────────────────────────────────
194
+
195
+ const setCurrentStep = useCallback((step: PlaygroundStep) => dispatch({ type: 'SET_STEP', step }), []);
196
+ const goToNextStep = useCallback(() => dispatch({ type: 'NEXT_STEP' }), []);
197
+ const goToPreviousStep = useCallback(() => dispatch({ type: 'PREV_STEP' }), []);
198
+
199
+ const setSelectedEndpoint = useCallback((endpoint: ApiEndpoint | null) =>
200
+ dispatch({ type: 'SELECT_ENDPOINT', endpoint }), []);
201
+
202
+ const setSelectedCategory = useCallback((category: string) =>
203
+ dispatch({ type: 'SET_CATEGORY', category }), []);
204
+
205
+ const setSearchTerm = useCallback((term: string) => dispatch({ type: 'SET_SEARCH', term }), []);
206
+ const setSelectedVersion = useCallback((version: string) => dispatch({ type: 'SET_VERSION', version }), []);
207
+ const setRequestUrl = useCallback((url: string) => dispatch({ type: 'SET_REQUEST_URL', url }), []);
208
+ const setRequestMethod = useCallback((method: string) => dispatch({ type: 'SET_REQUEST_METHOD', method }), []);
209
+ const setRequestHeaders = useCallback((headers: string) => dispatch({ type: 'SET_REQUEST_HEADERS', headers }), []);
210
+ const setRequestBody = useCallback((body: string) => dispatch({ type: 'SET_REQUEST_BODY', body }), []);
211
+ const setSelectedApiKey = useCallback((apiKeyId: string | null) => dispatch({ type: 'SET_API_KEY', apiKeyId }), []);
212
+ const setManualApiToken = useCallback((token: string) => dispatch({ type: 'SET_MANUAL_TOKEN', token }), []);
213
+ const setParameters = useCallback((parameters: Record<string, string>) =>
214
+ dispatch({ type: 'SET_PARAMETERS', parameters }), []);
215
+ const setResponse = useCallback((response: ApiResponse | null) =>
216
+ dispatch({ type: 'SET_RESPONSE', response }), []);
217
+ const setLoading = useCallback((loading: boolean) => dispatch({ type: 'SET_LOADING', loading }), []);
218
+ const setSidebarOpen = useCallback((open: boolean) => dispatch({ type: 'SET_SIDEBAR', open }), []);
219
+ const clearAll = useCallback(() => dispatch({ type: 'RESET' }), []);
220
+
221
+ // ── Send request ──────────────────────────────────────────────────────────
225
222
 
226
223
  const sendRequest = useCallback(async () => {
227
224
  if (!state.requestUrl) {
@@ -229,32 +226,31 @@ export const PlaygroundProvider: React.FC<PlaygroundProviderProps> = ({ children
229
226
  return;
230
227
  }
231
228
 
232
- setLoading(true);
233
- setResponse(null);
229
+ abortControllerRef.current?.abort();
230
+ const controller = new AbortController();
231
+ abortControllerRef.current = controller;
232
+
233
+ // Single dispatch: loading=true + clear response
234
+ dispatch({ type: 'REQUEST_START' });
235
+
236
+ const startTime = Date.now();
234
237
 
235
238
  try {
236
239
  const headers = parseRequestHeaders(state.requestHeaders);
237
240
 
238
- // Bearer token priority: manual token → JWT token from localStorage
239
241
  let bearerToken: string | null = null;
240
-
241
242
  if (state.manualApiToken) {
242
- // Use manual token if provided
243
243
  bearerToken = state.manualApiToken;
244
- } else {
245
- // Try to get JWT token from localStorage
246
- if (typeof window !== 'undefined') {
247
- bearerToken = window.localStorage.getItem('auth_token');
248
- }
244
+ } else if (typeof window !== 'undefined') {
245
+ bearerToken = window.localStorage.getItem('auth_token');
249
246
  }
250
247
 
251
- if (bearerToken) {
252
- headers['Authorization'] = `Bearer ${bearerToken}`;
253
- }
248
+ if (bearerToken) headers['Authorization'] = `Bearer ${bearerToken}`;
254
249
 
255
250
  const requestOptions: RequestInit = {
256
251
  method: state.requestMethod,
257
252
  headers,
253
+ signal: controller.signal,
258
254
  };
259
255
 
260
256
  if (state.requestBody && state.requestMethod !== 'GET') {
@@ -262,55 +258,55 @@ export const PlaygroundProvider: React.FC<PlaygroundProviderProps> = ({ children
262
258
  }
263
259
 
264
260
  const response = await fetch(state.requestUrl, requestOptions);
261
+ const duration = Date.now() - startTime;
265
262
  const responseText = await response.text();
266
263
 
267
- let responseData;
268
- try {
269
- responseData = JSON.parse(responseText);
270
- } catch {
271
- responseData = responseText;
272
- }
273
-
274
- setResponse({
275
- status: response.status,
276
- statusText: response.statusText,
277
- headers: Object.fromEntries(response.headers.entries()),
278
- data: responseData,
264
+ let responseData: unknown;
265
+ try { responseData = JSON.parse(responseText); }
266
+ catch { responseData = responseText; }
267
+
268
+ // Single dispatch: response + loading=false + step='response'
269
+ dispatch({
270
+ type: 'REQUEST_SUCCESS',
271
+ response: {
272
+ status: response.status,
273
+ statusText: response.statusText,
274
+ headers: Object.fromEntries(response.headers.entries()),
275
+ data: responseData,
276
+ duration,
277
+ },
279
278
  });
280
279
 
281
- consola.success(`Request successful: ${state.requestMethod} ${state.requestUrl}`);
282
-
283
- // Auto-advance to response step
284
- updateState({ currentStep: 'response' });
280
+ consola.success(`${state.requestMethod} ${state.requestUrl} → ${response.status} (${duration}ms)`);
285
281
  } catch (error) {
282
+ if (error instanceof DOMException && error.name === 'AbortError') return;
286
283
  consola.error('Request failed:', error);
287
- setResponse({
288
- error: error instanceof Error ? error.message : 'Request failed',
284
+
285
+ // Single dispatch: error response + loading=false
286
+ dispatch({
287
+ type: 'REQUEST_ERROR',
288
+ response: {
289
+ error: error instanceof Error ? error.message : 'Request failed',
290
+ duration: Date.now() - startTime,
291
+ },
289
292
  });
290
- } finally {
291
- setLoading(false);
292
293
  }
293
- }, [state, setLoading, setResponse]);
294
+ }, [state.requestUrl, state.requestHeaders, state.manualApiToken, state.requestMethod, state.requestBody]);
295
+
296
+ // ── Context value ─────────────────────────────────────────────────────────
294
297
 
295
298
  const contextValue: PlaygroundContextType = {
296
- // State
297
299
  state,
298
300
  config,
299
301
  apiKeys,
300
302
  apiKeysLoading: isLoadingApiKeys,
301
-
302
- // Step management
303
303
  setCurrentStep,
304
304
  goToNextStep,
305
305
  goToPreviousStep,
306
-
307
- // Endpoint management
308
306
  setSelectedEndpoint,
309
307
  setSelectedCategory,
310
308
  setSearchTerm,
311
309
  setSelectedVersion,
312
-
313
- // Request management
314
310
  setRequestUrl,
315
311
  setRequestMethod,
316
312
  setRequestHeaders,
@@ -318,18 +314,12 @@ export const PlaygroundProvider: React.FC<PlaygroundProviderProps> = ({ children
318
314
  setSelectedApiKey,
319
315
  setManualApiToken,
320
316
  setParameters,
321
-
322
- // Response management
323
317
  setResponse,
324
318
  setLoading,
325
-
326
- // UI management
327
319
  setSidebarOpen,
328
-
329
- // Actions
330
320
  clearAll,
331
321
  sendRequest,
332
322
  };
333
323
 
334
324
  return <PlaygroundContext.Provider value={contextValue}>{children}</PlaygroundContext.Provider>;
335
- };
325
+ };
@@ -96,6 +96,7 @@ export interface ApiResponse {
96
96
  headers?: any;
97
97
  data?: any;
98
98
  error?: string;
99
+ duration?: number; // ms
99
100
  }
100
101
 
101
102
  // Context types