@ibdop/platform-kit 1.0.10 → 1.0.12

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.
@@ -5,7 +5,7 @@
5
5
  * обработкой ошибок и уведомлениями.
6
6
  */
7
7
 
8
- import { useState, useCallback } from 'react'
8
+ import { useState, useCallback, useRef, useEffect } from 'react'
9
9
  import type { ApiError, ApiResponse, NotificationPayload } from '../types'
10
10
 
11
11
  // Development mode flag
@@ -104,6 +104,8 @@ export interface UseApiConfig<T = never> {
104
104
  errorContext?: string
105
105
  onSuccess?: (data: T) => void
106
106
  onError?: (error: ApiError) => void
107
+ /** Auto-execute on mount (default: false) */
108
+ immediate?: boolean
107
109
  }
108
110
 
109
111
  /**
@@ -117,6 +119,14 @@ export interface UseApiResult<T> {
117
119
  isSuccess: boolean
118
120
  execute: () => Promise<T | null>
119
121
  reset: () => void
122
+ abort: () => void
123
+ }
124
+
125
+ /**
126
+ * Check if error is an abort error
127
+ */
128
+ function isAbortError(error: unknown): boolean {
129
+ return error instanceof Error && error.name === 'AbortError'
120
130
  }
121
131
 
122
132
  /**
@@ -128,14 +138,14 @@ export interface UseApiResult<T> {
128
138
  *
129
139
  * @example
130
140
  * ```tsx
131
- * const { data, isLoading, isError, execute } = useApi<User[]>(
132
- * () => fetch('/api/users').then(r => r.json()),
141
+ * const { data, isLoading, isError, execute, abort } = useApi<User[]>(
142
+ * (signal) => fetch('/api/users', { signal }).then(r => r.json()),
133
143
  * { notifyOnError: true, errorContext: 'загрузка пользователей' }
134
144
  * )
135
145
  * ```
136
146
  */
137
147
  export function useApi<T>(
138
- request: () => Promise<ApiResponse<T>>,
148
+ request: (signal?: AbortSignal) => Promise<ApiResponse<T>>,
139
149
  config: UseApiConfig<T> = {}
140
150
  ): UseApiResult<T> {
141
151
  const {
@@ -151,15 +161,50 @@ export function useApi<T>(
151
161
  const [error, setError] = useState<ApiError | null>(null)
152
162
  const [isLoading, setIsLoading] = useState(false)
153
163
 
164
+ // AbortController ref for request cancellation
165
+ const abortControllerRef = useRef<AbortController | null>(null)
166
+
154
167
  const isError = error !== null
155
168
  const isSuccess = data !== null && !isLoading && !isError
156
169
 
170
+ // Cleanup on unmount
171
+ useEffect(() => {
172
+ return () => {
173
+ if (abortControllerRef.current) {
174
+ abortControllerRef.current.abort()
175
+ }
176
+ }
177
+ }, [])
178
+
179
+ const abort = useCallback(() => {
180
+ if (abortControllerRef.current) {
181
+ abortControllerRef.current.abort()
182
+ abortControllerRef.current = null
183
+ setIsLoading(false)
184
+ logger.log('Request aborted')
185
+ }
186
+ }, [])
187
+
157
188
  const execute = useCallback(async () => {
189
+ // Abort any pending request
190
+ if (abortControllerRef.current) {
191
+ abortControllerRef.current.abort()
192
+ }
193
+
194
+ // Create new AbortController
195
+ abortControllerRef.current = new AbortController()
196
+ const signal = abortControllerRef.current.signal
197
+
158
198
  setIsLoading(true)
159
199
  setError(null)
160
200
 
161
201
  try {
162
- const response = await request()
202
+ const response = await request(signal)
203
+
204
+ // Check if aborted during request
205
+ if (signal.aborted) {
206
+ return null
207
+ }
163
208
 
164
209
  if (response.ok) {
165
210
  setData(response.data)
@@ -193,6 +238,12 @@ export function useApi<T>(
193
238
  return null
194
239
  }
195
240
  } catch (err) {
241
+ // Ignore abort errors
242
+ if (isAbortError(err)) {
243
+ logger.log('Request was aborted')
244
+ return null
245
+ }
246
+
196
247
  // Handle network/server errors
197
248
  const apiError = err as ApiError
198
249
  setError(apiError)
@@ -204,7 +255,10 @@ export function useApi<T>(
204
255
  onError?.(apiError)
205
256
  return null
206
257
  } finally {
207
- setIsLoading(false)
258
+ if (!signal.aborted) {
259
+ setIsLoading(false)
260
+ }
261
+ abortControllerRef.current = null
208
262
  }
209
263
  }, [request, notifyOnError, notifyOnSuccess, successMessage, errorContext, onSuccess, onError])
210
264
 
@@ -222,24 +276,26 @@ export function useApi<T>(
222
276
  isSuccess,
223
277
  execute,
224
278
  reset,
279
+ abort,
225
280
  }
226
281
  }
227
282
 
228
283
  // Convenience hooks for common HTTP methods
229
284
 
230
285
  /**
231
- * GET запрос
286
+ * GET запрос с AbortController
232
287
  */
233
288
  export function useGet<T>(
234
289
  url: string,
235
290
  params?: Record<string, unknown>,
236
291
  config: UseApiConfig<T> = {}
237
292
  ): UseApiResult<T> {
238
- const request = () =>
239
- fetch(url, params ? {
293
+ const request = (signal?: AbortSignal) =>
294
+ fetch(url, {
240
295
  method: 'GET',
241
- headers: { 'Content-Type': 'application/json' }
242
- } : {})
296
+ headers: { 'Content-Type': 'application/json' },
297
+ signal,
298
+ })
243
299
  .then(async (response) => {
244
300
  const data = await response.json()
245
301
  return { data, status: response.status, ok: response.ok } as ApiResponse<T>
@@ -249,18 +305,19 @@ export function useGet<T>(
249
305
  }
250
306
 
251
307
  /**
252
- * POST запрос
308
+ * POST запрос с AbortController
253
309
  */
254
310
  export function usePost<T>(
255
311
  url: string,
256
312
  data?: unknown,
257
313
  config: UseApiConfig<T> = {}
258
314
  ): UseApiResult<T> {
259
- const request = () =>
315
+ const request = (signal?: AbortSignal) =>
260
316
  fetch(url, {
261
317
  method: 'POST',
262
318
  headers: { 'Content-Type': 'application/json' },
263
319
  body: data ? JSON.stringify(data) : undefined,
320
+ signal,
264
321
  })
265
322
  .then(async (response) => {
266
323
  const responseData = await response.json()
@@ -271,18 +328,19 @@ export function usePost<T>(
271
328
  }
272
329
 
273
330
  /**
274
- * PUT запрос
331
+ * PUT запрос с AbortController
275
332
  */
276
333
  export function usePut<T>(
277
334
  url: string,
278
335
  data?: unknown,
279
336
  config: UseApiConfig<T> = {}
280
337
  ): UseApiResult<T> {
281
- const request = () =>
338
+ const request = (signal?: AbortSignal) =>
282
339
  fetch(url, {
283
340
  method: 'PUT',
284
341
  headers: { 'Content-Type': 'application/json' },
285
342
  body: data ? JSON.stringify(data) : undefined,
343
+ signal,
286
344
  })
287
345
  .then(async (response) => {
288
346
  const responseData = await response.json()
@@ -293,16 +351,17 @@ export function usePut<T>(
293
351
  }
294
352
 
295
353
  /**
296
- * DELETE запрос
354
+ * DELETE запрос с AbortController
297
355
  */
298
356
  export function useDel<T>(
299
357
  url: string,
300
358
  config: UseApiConfig<T> = {}
301
359
  ): UseApiResult<T> {
302
- const request = () =>
360
+ const request = (signal?: AbortSignal) =>
303
361
  fetch(url, {
304
362
  method: 'DELETE',
305
363
  headers: { 'Content-Type': 'application/json' },
364
+ signal,
306
365
  })
307
366
  .then(async (response) => {
308
367
  const responseData = await response.json()
@@ -0,0 +1,445 @@
1
+ /**
2
+ * Hook for working with feature toggles
3
+ */
4
+
5
+ import { useState, useEffect, useCallback, useMemo } from 'react'
6
+ import { useShellAuth } from './useShellAuth'
7
+ import type {
8
+ FeatureToggleInfo,
9
+ FeaturesResponse,
10
+ FeatureToggleAdmin,
11
+ FeatureAdminResponse,
12
+ FeatureCreateRequest,
13
+ FeatureUpdateRequest,
14
+ MicrofrontendWithFeatures,
15
+ MicrofrontendsFeaturesResponse,
16
+ } from '../types'
17
+
18
+ /**
19
+ * Result of useFeatures hook
20
+ */
21
+ export interface UseFeaturesResult {
22
+ features: FeatureToggleInfo[]
23
+ totalCount: number
24
+ userRoles: string[]
25
+ isLoading: boolean
26
+ error: string | null
27
+ refetch: () => Promise<void>
28
+ isFeatureEnabled: (featureName: string) => boolean
29
+ getFeaturesByMf: (mfName: string) => FeatureToggleInfo[]
30
+ }
31
+
32
+ /**
33
+ * Hook to get feature toggles for the current user
34
+ *
35
+ * @example
36
+ * ```typescript
37
+ * import { useFeatures } from '@ib-dop/platform-kit'
38
+ *
39
+ * function FeatureFlagComponent() {
40
+ * const { features, isFeatureEnabled, isLoading } = useFeatures()
41
+ *
42
+ * if (isLoading) return <Spinner />
43
+ *
44
+ * return (
45
+ * <div>
46
+ * {isFeatureEnabled('new-dashboard') && <NewDashboard />}
47
+ * {!isFeatureEnabled('new-dashboard') && <OldDashboard />}
48
+ * </div>
49
+ * )
50
+ * }
51
+ * ```
52
+ */
53
+ export function useFeatures(): UseFeaturesResult {
54
+ const auth = useShellAuth()
55
+ const [features, setFeatures] = useState<FeatureToggleInfo[]>([])
56
+ const [totalCount, setTotalCount] = useState(0)
57
+ const [userRoles, setUserRoles] = useState<string[]>([])
58
+ const [isLoading, setIsLoading] = useState(true)
59
+ const [error, setError] = useState<string | null>(null)
60
+
61
+ const fetchFeatures = useCallback(async () => {
62
+ if (!auth.isAuthenticated) {
63
+ console.debug('[useFeatures] Not authenticated')
64
+ setIsLoading(false)
65
+ return
66
+ }
67
+
68
+ setIsLoading(true)
69
+ setError(null)
70
+
71
+ try {
72
+ const headers: Record<string, string> = {
73
+ 'Content-Type': 'application/json',
74
+ }
75
+
76
+ const token = auth.user?.access_token
77
+ if (token) {
78
+ headers['Authorization'] = `Bearer ${token}`
79
+ }
80
+
81
+ const response = await fetch('/api/features', { headers })
82
+
83
+ if (!response.ok) {
84
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
85
+ }
86
+
87
+ const result: FeaturesResponse = await response.json()
88
+ setFeatures(result.features || [])
89
+ setTotalCount(result.totalCount || 0)
90
+ setUserRoles(result.userRoles || [])
91
+ } catch (err) {
92
+ console.debug('Features fetch error:', err)
93
+ setError(err instanceof Error ? err.message : String(err))
94
+ setFeatures([])
95
+ setUserRoles([])
96
+ } finally {
97
+ setIsLoading(false)
98
+ }
99
+ }, [auth.isAuthenticated, auth.user?.access_token])
100
+
101
+ useEffect(() => {
102
+ fetchFeatures()
103
+ }, [fetchFeatures])
104
+
105
+ const isFeatureEnabled = useCallback(
106
+ (featureName: string): boolean => {
107
+ const feature = features.find((f) => f.name === featureName)
108
+ return feature?.userEnabled ?? false
109
+ },
110
+ [features]
111
+ )
112
+
113
+ const getFeaturesByMf = useCallback(
114
+ (mfName: string): FeatureToggleInfo[] => {
115
+ return features.filter((f) => f.mfDependencies?.includes(mfName))
116
+ },
117
+ [features]
118
+ )
119
+
120
+ return {
121
+ features,
122
+ totalCount,
123
+ userRoles,
124
+ isLoading,
125
+ error,
126
+ refetch: fetchFeatures,
127
+ isFeatureEnabled,
128
+ getFeaturesByMf,
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Result of useFeatureAdmin hook
134
+ */
135
+ export interface UseFeatureAdminResult {
136
+ features: FeatureToggleAdmin[]
137
+ microfrontends: string[]
138
+ isAdmin: boolean
139
+ isLoading: boolean
140
+ error: string | null
141
+ refetch: () => Promise<void>
142
+ createFeature: (feature: FeatureCreateRequest) => Promise<boolean>
143
+ updateFeature: (name: string, feature: FeatureUpdateRequest) => Promise<boolean>
144
+ toggleFeature: (name: string, enabled: boolean) => Promise<boolean>
145
+ deleteFeature: (name: string) => Promise<boolean>
146
+ }
147
+
148
+ /**
149
+ * Hook for admin operations with feature toggles
150
+ *
151
+ * @example
152
+ * ```typescript
153
+ * import { useFeatureAdmin } from '@ib-dop/platform-kit'
154
+ *
155
+ * function AdminPanel() {
156
+ * const {
157
+ * features,
158
+ * isAdmin,
159
+ * isLoading,
160
+ * toggleFeature
161
+ * } = useFeatureAdmin()
162
+ *
163
+ * if (isLoading) return <Spinner />
164
+ * if (!isAdmin) return <AccessDenied />
165
+ *
166
+ * return (
167
+ * <table>
168
+ * {features.map(f => (
169
+ * <tr key={f.name}>
170
+ * <td>{f.name}</td>
171
+ * <td>{f.enabled ? 'ON' : 'OFF'}</td>
172
+ * <button onClick={() => toggleFeature(f.name, !f.enabled)}>
173
+ * Toggle
174
+ * </button>
175
+ * </tr>
176
+ * ))}
177
+ * </table>
178
+ * )
179
+ * }
180
+ * ```
181
+ */
182
+ export function useFeatureAdmin(): UseFeatureAdminResult {
183
+ const auth = useShellAuth()
184
+ const [features, setFeatures] = useState<FeatureToggleAdmin[]>([])
185
+ const [microfrontends, setMicrofrontends] = useState<string[]>([])
186
+ const [isAdmin, setIsAdmin] = useState(false)
187
+ const [isLoading, setIsLoading] = useState(true)
188
+ const [error, setError] = useState<string | null>(null)
189
+
190
+ const fetchFeatures = useCallback(async () => {
191
+ if (!auth.isAuthenticated) {
192
+ console.debug('[useFeatureAdmin] Not authenticated')
193
+ setIsLoading(false)
194
+ return
195
+ }
196
+
197
+ setIsLoading(true)
198
+ setError(null)
199
+
200
+ try {
201
+ const headers: Record<string, string> = {
202
+ 'Content-Type': 'application/json',
203
+ }
204
+
205
+ const token = auth.user?.access_token
206
+ if (token) {
207
+ headers['Authorization'] = `Bearer ${token}`
208
+ }
209
+
210
+ const response = await fetch('/api/features/admin', { headers })
211
+
212
+ if (!response.ok) {
213
+ if (response.status === 403) {
214
+ console.warn('[useFeatureAdmin] 403 Forbidden - checking if token has admin role')
215
+ setIsAdmin(false)
216
+ setFeatures([])
217
+ setMicrofrontends([])
218
+ setIsLoading(false)
219
+ return
220
+ }
221
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
222
+ }
223
+
224
+ const result: FeatureAdminResponse = await response.json()
225
+ setFeatures(result.featureToggles || [])
226
+ setMicrofrontends(result.microfrontends || [])
227
+ setIsAdmin(result.isAdmin || false)
228
+ } catch (err) {
229
+ console.debug('FeatureAdmin fetch error:', err)
230
+ setFeatures([])
231
+ setMicrofrontends([])
232
+ setIsAdmin(false)
233
+ } finally {
234
+ setIsLoading(false)
235
+ }
236
+ }, [auth.isAuthenticated, auth.user?.access_token])
237
+
238
+ const createFeature = useCallback(
239
+ async (feature: FeatureCreateRequest): Promise<boolean> => {
240
+ try {
241
+ const headers: Record<string, string> = {
242
+ 'Content-Type': 'application/json',
243
+ }
244
+ const token = auth.user?.access_token
245
+ if (token) {
246
+ headers['Authorization'] = `Bearer ${token}`
247
+ }
248
+
249
+ const response = await fetch('/api/features/admin', {
250
+ method: 'POST',
251
+ headers,
252
+ body: JSON.stringify(feature),
253
+ })
254
+
255
+ if (!response.ok) {
256
+ const errorData = await response.json().catch(() => ({}))
257
+ throw new Error(errorData.error || `HTTP ${response.status}`)
258
+ }
259
+
260
+ await fetchFeatures()
261
+ return true
262
+ } catch (err) {
263
+ setError(err instanceof Error ? err.message : String(err))
264
+ return false
265
+ }
266
+ },
267
+ [fetchFeatures]
268
+ )
269
+
270
+ const updateFeature = useCallback(
271
+ async (name: string, feature: FeatureUpdateRequest): Promise<boolean> => {
272
+ try {
273
+ const headers: Record<string, string> = {
274
+ 'Content-Type': 'application/json',
275
+ }
276
+ const token = auth.user?.access_token
277
+ if (token) {
278
+ headers['Authorization'] = `Bearer ${token}`
279
+ }
280
+
281
+ const response = await fetch(`/api/features/admin/${encodeURIComponent(name)}`, {
282
+ method: 'PUT',
283
+ headers,
284
+ body: JSON.stringify(feature),
285
+ })
286
+
287
+ if (!response.ok) {
288
+ const errorData = await response.json().catch(() => ({}))
289
+ throw new Error(errorData.error || `HTTP ${response.status}`)
290
+ }
291
+
292
+ await fetchFeatures()
293
+ return true
294
+ } catch (err) {
295
+ setError(err instanceof Error ? err.message : String(err))
296
+ return false
297
+ }
298
+ },
299
+ [fetchFeatures]
300
+ )
301
+
302
+ const toggleFeature = useCallback(
303
+ async (name: string, enabled: boolean): Promise<boolean> => {
304
+ try {
305
+ const headers: Record<string, string> = {}
306
+ const token = auth.user?.access_token
307
+ if (token) {
308
+ headers['Authorization'] = `Bearer ${token}`
309
+ }
310
+
311
+ const response = await fetch(
312
+ `/api/features/admin/${encodeURIComponent(name)}/toggle?enabled=${enabled}`,
313
+ {
314
+ method: 'POST',
315
+ headers,
316
+ }
317
+ )
318
+
319
+ if (!response.ok) {
320
+ const errorData = await response.json().catch(() => ({}))
321
+ throw new Error(errorData.error || `HTTP ${response.status}`)
322
+ }
323
+
324
+ await fetchFeatures()
325
+ return true
326
+ } catch (err) {
327
+ setError(err instanceof Error ? err.message : String(err))
328
+ return false
329
+ }
330
+ },
331
+ [fetchFeatures]
332
+ )
333
+
334
+ const deleteFeature = useCallback(
335
+ async (name: string): Promise<boolean> => {
336
+ try {
337
+ const headers: Record<string, string> = {}
338
+ const token = auth.user?.access_token
339
+ if (token) {
340
+ headers['Authorization'] = `Bearer ${token}`
341
+ }
342
+
343
+ const response = await fetch(`/api/features/admin/${encodeURIComponent(name)}`, {
344
+ method: 'DELETE',
345
+ headers,
346
+ })
347
+
348
+ if (!response.ok) {
349
+ const errorData = await response.json().catch(() => ({}))
350
+ throw new Error(errorData.error || `HTTP ${response.status}`)
351
+ }
352
+
353
+ await fetchFeatures()
354
+ return true
355
+ } catch (err) {
356
+ setError(err instanceof Error ? err.message : String(err))
357
+ return false
358
+ }
359
+ },
360
+ [fetchFeatures]
361
+ )
362
+
363
+ useEffect(() => {
364
+ fetchFeatures()
365
+ }, [fetchFeatures])
366
+
367
+ return {
368
+ features,
369
+ microfrontends,
370
+ isAdmin,
371
+ isLoading,
372
+ error,
373
+ refetch: fetchFeatures,
374
+ createFeature,
375
+ updateFeature,
376
+ toggleFeature,
377
+ deleteFeature,
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Hook to get microfrontends with their features
383
+ */
384
+ export interface UseMicrofrontendsFeaturesResult {
385
+ microfrontends: MicrofrontendWithFeatures[]
386
+ totalCount: number
387
+ isLoading: boolean
388
+ error: string | null
389
+ refetch: () => Promise<void>
390
+ }
391
+
392
+ export function useMicrofrontendsFeatures(): UseMicrofrontendsFeaturesResult {
393
+ const auth = useShellAuth()
394
+ const [microfrontends, setMicrofrontends] = useState<MicrofrontendWithFeatures[]>([])
395
+ const [totalCount, setTotalCount] = useState(0)
396
+ const [isLoading, setIsLoading] = useState(true)
397
+ const [error, setError] = useState<string | null>(null)
398
+
399
+ const fetchData = useCallback(async () => {
400
+ if (!auth.isAuthenticated) {
401
+ setIsLoading(false)
402
+ return
403
+ }
404
+
405
+ setIsLoading(true)
406
+ setError(null)
407
+
408
+ try {
409
+ const headers: Record<string, string> = {
410
+ 'Content-Type': 'application/json',
411
+ }
412
+
413
+ const token = auth.user?.access_token
414
+ if (token) {
415
+ headers['Authorization'] = `Bearer ${token}`
416
+ }
417
+
418
+ const response = await fetch('/api/features/microfrontends', { headers })
419
+
420
+ if (!response.ok) {
421
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
422
+ }
423
+
424
+ const result: MicrofrontendsFeaturesResponse = await response.json()
425
+ setMicrofrontends(result.microfrontends || [])
426
+ setTotalCount(result.totalCount || 0)
427
+ } catch (err) {
428
+ setError(err instanceof Error ? err.message : String(err))
429
+ } finally {
430
+ setIsLoading(false)
431
+ }
432
+ }, [auth.isAuthenticated, auth.user?.access_token])
433
+
434
+ useEffect(() => {
435
+ fetchData()
436
+ }, [fetchData])
437
+
438
+ return {
439
+ microfrontends,
440
+ totalCount,
441
+ isLoading,
442
+ error,
443
+ refetch: fetchData,
444
+ }
445
+ }