@ibdop/platform-kit 1.0.0
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/README.md +63 -0
- package/dist/components/ErrorBoundary.d.ts +68 -0
- package/dist/components/ErrorBoundary.d.ts.map +1 -0
- package/dist/components/Notification.d.ts +46 -0
- package/dist/components/Notification.d.ts.map +1 -0
- package/dist/components/VersionInfo.d.ts +18 -0
- package/dist/components/VersionInfo.d.ts.map +1 -0
- package/dist/components/index.d.ts +8 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/hooks/index.d.ts +9 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/useApi.d.ts +65 -0
- package/dist/hooks/useApi.d.ts.map +1 -0
- package/dist/hooks/useInfoData.d.ts +35 -0
- package/dist/hooks/useInfoData.d.ts.map +1 -0
- package/dist/hooks/usePermissions.d.ts +23 -0
- package/dist/hooks/usePermissions.d.ts.map +1 -0
- package/dist/hooks/useShellAuth.d.ts +23 -0
- package/dist/hooks/useShellAuth.d.ts.map +1 -0
- package/dist/hooks/useV1Config.d.ts +27 -0
- package/dist/hooks/useV1Config.d.ts.map +1 -0
- package/dist/index.cjs.js +18 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.es.js +1500 -0
- package/dist/index.umd.js +18 -0
- package/dist/services/api.d.ts +59 -0
- package/dist/services/api.d.ts.map +1 -0
- package/dist/services/index.d.ts +8 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/services/logger.d.ts +41 -0
- package/dist/services/logger.d.ts.map +1 -0
- package/dist/types/index.d.ts +178 -0
- package/dist/types/index.d.ts.map +1 -0
- package/package.json +90 -0
- package/src/components/ErrorBoundary.tsx +292 -0
- package/src/components/Notification.tsx +297 -0
- package/src/components/VersionInfo.tsx +271 -0
- package/src/components/index.ts +14 -0
- package/src/global.d.ts +40 -0
- package/src/hooks/index.ts +13 -0
- package/src/hooks/useApi.ts +314 -0
- package/src/hooks/useInfoData.ts +124 -0
- package/src/hooks/usePermissions.ts +88 -0
- package/src/hooks/useShellAuth.ts +145 -0
- package/src/hooks/useV1Config.ts +112 -0
- package/src/index.ts +17 -0
- package/src/services/api.ts +290 -0
- package/src/services/index.ts +9 -0
- package/src/services/logger.ts +71 -0
- package/src/types/index.ts +215 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VersionInfo - Компонент для отображения информации о версии MF
|
|
3
|
+
*
|
|
4
|
+
* Показывает popover с детальной информацией о версии, Git, CI/CD
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React, { useState } from 'react'
|
|
8
|
+
import { useInfoData } from '../hooks/useInfoData'
|
|
9
|
+
import type { InfoData } from '../types'
|
|
10
|
+
|
|
11
|
+
// Icons (mock - in real implementation use @coreui/icons)
|
|
12
|
+
const icons = {
|
|
13
|
+
info: 'ℹ️',
|
|
14
|
+
code: '💻',
|
|
15
|
+
link: '🔗',
|
|
16
|
+
user: '👤',
|
|
17
|
+
clock: '🕐',
|
|
18
|
+
apps: '📦',
|
|
19
|
+
storage: '💾',
|
|
20
|
+
tags: '🏷️',
|
|
21
|
+
spreadsheet: '📊',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// MF Name for logging
|
|
25
|
+
const MF_NAME = 'platform-kit'
|
|
26
|
+
|
|
27
|
+
// Development mode flag
|
|
28
|
+
const isDev = import.meta.env?.DEV === true || import.meta.env?.MODE === 'development'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Logger for VersionInfo
|
|
32
|
+
*/
|
|
33
|
+
const logger = {
|
|
34
|
+
log: (...args: unknown[]) => {
|
|
35
|
+
if (isDev) console.log(`[${MF_NAME}]`, ...args)
|
|
36
|
+
},
|
|
37
|
+
warn: (...args: unknown[]) => {
|
|
38
|
+
if (isDev) console.warn(`[${MF_NAME}]`, ...args)
|
|
39
|
+
},
|
|
40
|
+
error: (...args: unknown[]) => {
|
|
41
|
+
console.error(`[${MF_NAME}]`, ...args)
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Props для VersionInfo
|
|
47
|
+
*/
|
|
48
|
+
interface VersionInfoProps {
|
|
49
|
+
mfeName: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* VersionInfo - компонент для отображения информации о версии
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```tsx
|
|
57
|
+
* <VersionInfo mfeName="@ib-dop/mf-home" />
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export default function VersionInfo({ mfeName }: VersionInfoProps): React.ReactElement {
|
|
61
|
+
const { data: infoData, isLoading, error } = useInfoData({ mfeName })
|
|
62
|
+
const [visible, setVisible] = useState(false)
|
|
63
|
+
|
|
64
|
+
logger.log('VersionInfo rendered:', { mfeName, isLoading, error })
|
|
65
|
+
|
|
66
|
+
if (isLoading) {
|
|
67
|
+
return (
|
|
68
|
+
<span className="text-muted small me-2">
|
|
69
|
+
<span className="me-1">⏳</span>
|
|
70
|
+
Загрузка...
|
|
71
|
+
</span>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (error || !infoData) {
|
|
76
|
+
logger.warn('Failed to load version info:', error)
|
|
77
|
+
return (
|
|
78
|
+
<span className="text-muted small me-2" title={`Ошибка: ${error || 'нет данных'}`}>
|
|
79
|
+
<span className="me-1">ℹ️</span>
|
|
80
|
+
N/A
|
|
81
|
+
</span>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const info = infoData as Record<string, string>
|
|
86
|
+
const displayVersion = info.BUILD_VERSION || info.IMAGE_VERSION || info.APP_NAME || 'N/A'
|
|
87
|
+
|
|
88
|
+
// Collect available fields
|
|
89
|
+
const availableFields: { key: string; value: string; label: string; icon: string }[] = []
|
|
90
|
+
|
|
91
|
+
if (info.APP_NAME) {
|
|
92
|
+
availableFields.push({ key: 'APP_NAME', value: info.APP_NAME, label: 'Приложение', icon: icons.apps })
|
|
93
|
+
}
|
|
94
|
+
if (info.BUILD_VERSION) {
|
|
95
|
+
availableFields.push({ key: 'BUILD_VERSION', value: info.BUILD_VERSION, label: 'Версия', icon: icons.tags })
|
|
96
|
+
}
|
|
97
|
+
if (info.IMAGE_VERSION) {
|
|
98
|
+
availableFields.push({ key: 'IMAGE_VERSION', value: info.IMAGE_VERSION, label: 'Образ', icon: icons.storage })
|
|
99
|
+
}
|
|
100
|
+
if (info.GIT_COMMIT) {
|
|
101
|
+
availableFields.push({ key: 'GIT_COMMIT', value: info.GIT_COMMIT, label: 'Commit', icon: icons.spreadsheet })
|
|
102
|
+
}
|
|
103
|
+
if (info.GIT_BRANCH) {
|
|
104
|
+
availableFields.push({ key: 'GIT_BRANCH', value: info.GIT_BRANCH, label: 'Ветка', icon: icons.spreadsheet })
|
|
105
|
+
}
|
|
106
|
+
if (info.CI_COMMIT_AUTHOR) {
|
|
107
|
+
availableFields.push({ key: 'CI_COMMIT_AUTHOR', value: info.CI_COMMIT_AUTHOR, label: 'Автор', icon: icons.user })
|
|
108
|
+
}
|
|
109
|
+
if (info.CI_COMMIT_TIMESTAMP) {
|
|
110
|
+
availableFields.push({ key: 'CI_COMMIT_TIMESTAMP', value: info.CI_COMMIT_TIMESTAMP, label: 'Дата', icon: icons.clock })
|
|
111
|
+
}
|
|
112
|
+
if (info.CI_JOB_URL) {
|
|
113
|
+
availableFields.push({ key: 'CI_JOB_URL', value: info.CI_JOB_URL, label: 'CI Job', icon: icons.link })
|
|
114
|
+
}
|
|
115
|
+
if (info.CI_PIPELINE_URL) {
|
|
116
|
+
availableFields.push({ key: 'CI_PIPELINE_URL', value: info.CI_PIPELINE_URL, label: 'Pipeline', icon: icons.link })
|
|
117
|
+
}
|
|
118
|
+
if (info.CI_COMMIT_MESSAGE) {
|
|
119
|
+
const msg = info.CI_COMMIT_MESSAGE.length > 60
|
|
120
|
+
? info.CI_COMMIT_MESSAGE.substring(0, 57) + '...'
|
|
121
|
+
: info.CI_COMMIT_MESSAGE
|
|
122
|
+
availableFields.push({ key: 'CI_COMMIT_MESSAGE', value: info.CI_COMMIT_MESSAGE, label: 'Сообщение', icon: icons.code })
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div style={{ display: 'inline-block', position: 'relative' }}>
|
|
127
|
+
{/* Button */}
|
|
128
|
+
<button
|
|
129
|
+
onClick={() => setVisible(!visible)}
|
|
130
|
+
style={{
|
|
131
|
+
background: 'transparent',
|
|
132
|
+
border: 'none',
|
|
133
|
+
cursor: 'pointer',
|
|
134
|
+
padding: '4px 8px',
|
|
135
|
+
fontSize: '14px',
|
|
136
|
+
display: 'inline-flex',
|
|
137
|
+
alignItems: 'center',
|
|
138
|
+
}}
|
|
139
|
+
title="Информация о версии"
|
|
140
|
+
>
|
|
141
|
+
<span className="me-1">{icons.info}</span>
|
|
142
|
+
<span className="text-dark">{displayVersion}</span>
|
|
143
|
+
</button>
|
|
144
|
+
|
|
145
|
+
{/* Popover */}
|
|
146
|
+
{visible && (
|
|
147
|
+
<div
|
|
148
|
+
style={{
|
|
149
|
+
position: 'absolute',
|
|
150
|
+
top: '100%',
|
|
151
|
+
right: 0,
|
|
152
|
+
zIndex: 1000,
|
|
153
|
+
minWidth: '300px',
|
|
154
|
+
background: 'white',
|
|
155
|
+
border: '1px solid #dee2e6',
|
|
156
|
+
borderRadius: '8px',
|
|
157
|
+
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
|
158
|
+
padding: '16px',
|
|
159
|
+
marginTop: '4px',
|
|
160
|
+
}}
|
|
161
|
+
>
|
|
162
|
+
{/* Header */}
|
|
163
|
+
<div style={{
|
|
164
|
+
marginBottom: '12px',
|
|
165
|
+
paddingBottom: '8px',
|
|
166
|
+
borderBottom: '1px solid #dee2e6',
|
|
167
|
+
display: 'flex',
|
|
168
|
+
alignItems: 'center',
|
|
169
|
+
}}>
|
|
170
|
+
<span className="me-2">{icons.apps}</span>
|
|
171
|
+
<strong>{info.APP_NAME || mfeName}</strong>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
{/* Version */}
|
|
175
|
+
<div style={{ marginBottom: '12px' }}>
|
|
176
|
+
<span className="text-muted">Версия: </span>
|
|
177
|
+
<strong>{displayVersion}</strong>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{/* Fields */}
|
|
181
|
+
{availableFields.length > 0 && (
|
|
182
|
+
<div style={{ fontSize: '13px' }}>
|
|
183
|
+
{availableFields.map(({ key, value, label, icon }) => {
|
|
184
|
+
const displayValue = value.length > 40 && (key.includes('URL') || key.includes('MESSAGE'))
|
|
185
|
+
? `${value.substring(0, 37)}...`
|
|
186
|
+
: value
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<div key={key} style={{ marginBottom: '8px', display: 'flex', alignItems: 'flex-start' }}>
|
|
190
|
+
<span className="me-2" style={{ flexShrink: 0 }}>{icon}</span>
|
|
191
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
192
|
+
<div className="small text-muted">{label}</div>
|
|
193
|
+
<div
|
|
194
|
+
className="font-monospace small text-truncate"
|
|
195
|
+
style={{ maxWidth: '100%' }}
|
|
196
|
+
title={value}
|
|
197
|
+
>
|
|
198
|
+
{key.includes('URL') ? (
|
|
199
|
+
<a
|
|
200
|
+
href={value}
|
|
201
|
+
target="_blank"
|
|
202
|
+
rel="noopener noreferrer"
|
|
203
|
+
style={{ color: '#007bff' }}
|
|
204
|
+
>
|
|
205
|
+
{displayValue}
|
|
206
|
+
</a>
|
|
207
|
+
) : (
|
|
208
|
+
displayValue
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
)
|
|
214
|
+
})}
|
|
215
|
+
</div>
|
|
216
|
+
)}
|
|
217
|
+
|
|
218
|
+
{availableFields.length === 0 && (
|
|
219
|
+
<div className="text-center text-muted py-2 small">
|
|
220
|
+
Нет информации о версии
|
|
221
|
+
</div>
|
|
222
|
+
)}
|
|
223
|
+
|
|
224
|
+
{/* Footer */}
|
|
225
|
+
<div style={{
|
|
226
|
+
marginTop: '12px',
|
|
227
|
+
paddingTop: '8px',
|
|
228
|
+
borderTop: '1px solid #dee2e6',
|
|
229
|
+
textAlign: 'center',
|
|
230
|
+
fontSize: '12px',
|
|
231
|
+
color: '#6c757d',
|
|
232
|
+
}}>
|
|
233
|
+
IngoBank DevOps Platform
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
{/* Close button */}
|
|
237
|
+
<button
|
|
238
|
+
onClick={() => setVisible(false)}
|
|
239
|
+
style={{
|
|
240
|
+
position: 'absolute',
|
|
241
|
+
top: '8px',
|
|
242
|
+
right: '8px',
|
|
243
|
+
background: 'transparent',
|
|
244
|
+
border: 'none',
|
|
245
|
+
cursor: 'pointer',
|
|
246
|
+
fontSize: '16px',
|
|
247
|
+
color: '#6c757d',
|
|
248
|
+
}}
|
|
249
|
+
>
|
|
250
|
+
×
|
|
251
|
+
</button>
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
254
|
+
|
|
255
|
+
{/* Click outside to close */}
|
|
256
|
+
{visible && (
|
|
257
|
+
<div
|
|
258
|
+
onClick={() => setVisible(false)}
|
|
259
|
+
style={{
|
|
260
|
+
position: 'fixed',
|
|
261
|
+
top: 0,
|
|
262
|
+
left: 0,
|
|
263
|
+
right: 0,
|
|
264
|
+
bottom: 0,
|
|
265
|
+
zIndex: 999,
|
|
266
|
+
}}
|
|
267
|
+
/>
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
)
|
|
271
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Components barrel export
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { default as ErrorBoundary } from './ErrorBoundary'
|
|
6
|
+
|
|
7
|
+
export { default as VersionInfo } from './VersionInfo'
|
|
8
|
+
|
|
9
|
+
export { NotificationProvider, useNotification, dispatchNotification } from './Notification'
|
|
10
|
+
|
|
11
|
+
export type {
|
|
12
|
+
NotificationPayload,
|
|
13
|
+
NotificationType,
|
|
14
|
+
} from '../types'
|
package/src/global.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global type declarations for platform-kit
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
interface ImportMetaEnv {
|
|
6
|
+
readonly DEV: boolean
|
|
7
|
+
readonly MODE: string
|
|
8
|
+
readonly BASE_URL: string
|
|
9
|
+
readonly PROD: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ImportMeta {
|
|
13
|
+
readonly env: ImportMetaEnv
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Window extensions for shell integration
|
|
17
|
+
interface Window {
|
|
18
|
+
__SHELL_AUTH_INSTANCE__?: {
|
|
19
|
+
isAuthenticated: boolean
|
|
20
|
+
user?: {
|
|
21
|
+
profile?: {
|
|
22
|
+
access_token?: string
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
signinRedirect?: () => Promise<void>
|
|
26
|
+
removeUser?: () => Promise<void>
|
|
27
|
+
[key: string]: unknown
|
|
28
|
+
}
|
|
29
|
+
__SHELL_AUTH__?: {
|
|
30
|
+
authInstance?: {
|
|
31
|
+
isAuthenticated: boolean
|
|
32
|
+
user?: {
|
|
33
|
+
profile?: {
|
|
34
|
+
access_token?: string
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
__MF_NAME__?: string
|
|
40
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hooks barrel export
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { useShellAuth, getAuth } from './useShellAuth'
|
|
6
|
+
|
|
7
|
+
export { useInfoData } from './useInfoData'
|
|
8
|
+
|
|
9
|
+
export { useV1Config } from './useV1Config'
|
|
10
|
+
|
|
11
|
+
export { useApi } from './useApi'
|
|
12
|
+
|
|
13
|
+
export { usePermissions } from './usePermissions'
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useApi - Унифицированный хук для API запросов
|
|
3
|
+
*
|
|
4
|
+
* Предоставляет удобный интерфейс для работы с API с автоматической
|
|
5
|
+
* обработкой ошибок и уведомлениями.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState, useCallback } from 'react'
|
|
9
|
+
import type { ApiError, ApiResponse, NotificationPayload } from '../types'
|
|
10
|
+
|
|
11
|
+
// Development mode flag
|
|
12
|
+
const isDev = import.meta.env?.DEV === true || import.meta.env?.MODE === 'development'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Logger for useApi
|
|
16
|
+
*/
|
|
17
|
+
const logger = {
|
|
18
|
+
log: (...args: unknown[]) => {
|
|
19
|
+
if (isDev) console.log('[useApi]', ...args)
|
|
20
|
+
},
|
|
21
|
+
warn: (...args: unknown[]) => {
|
|
22
|
+
if (isDev) console.warn('[useApi]', ...args)
|
|
23
|
+
},
|
|
24
|
+
error: (...args: unknown[]) => {
|
|
25
|
+
console.error('[useApi]', ...args)
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Dispatch notification to shell
|
|
33
|
+
*/
|
|
34
|
+
export function dispatchNotification(notification: Omit<NotificationPayload, 'timestamp' | 'mfeName'>): void {
|
|
35
|
+
if (typeof window === 'undefined') return
|
|
36
|
+
|
|
37
|
+
const mfeName = (window as unknown as { __MF_NAME__?: string }).__MF_NAME__ || 'unknown'
|
|
38
|
+
|
|
39
|
+
const payload: NotificationPayload = {
|
|
40
|
+
...notification,
|
|
41
|
+
mfeName,
|
|
42
|
+
timestamp: Date.now(),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
window.dispatchEvent(new CustomEvent('mfe-notification', {
|
|
46
|
+
detail: payload,
|
|
47
|
+
bubbles: true,
|
|
48
|
+
}))
|
|
49
|
+
|
|
50
|
+
logger.log('Notification dispatched:', payload)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Dispatch error notification to shell
|
|
55
|
+
*/
|
|
56
|
+
export function dispatchErrorNotification(error: ApiError, context?: string): void {
|
|
57
|
+
const messages: Record<ApiError['type'], { title: string; message: string }> = {
|
|
58
|
+
network: {
|
|
59
|
+
title: 'Нет подключения',
|
|
60
|
+
message: 'Сервер недоступен. Проверьте подключение к интернету.',
|
|
61
|
+
},
|
|
62
|
+
unauthorized: {
|
|
63
|
+
title: 'Требуется вход',
|
|
64
|
+
message: 'Ваша сессия истекла. Войдите в систему снова.',
|
|
65
|
+
},
|
|
66
|
+
forbidden: {
|
|
67
|
+
title: 'Доступ запрещён',
|
|
68
|
+
message: 'У вас нет прав для выполнения этого действия.',
|
|
69
|
+
},
|
|
70
|
+
not_found: {
|
|
71
|
+
title: 'Не найдено',
|
|
72
|
+
message: 'Запрошенный ресурс не найден.',
|
|
73
|
+
},
|
|
74
|
+
server: {
|
|
75
|
+
title: 'Ошибка сервера',
|
|
76
|
+
message: 'Произошла ошибка на сервере. Попробуйте позже.',
|
|
77
|
+
},
|
|
78
|
+
client: {
|
|
79
|
+
title: 'Ошибка',
|
|
80
|
+
message: error.message || 'Произошла ошибка при выполнении запроса.',
|
|
81
|
+
},
|
|
82
|
+
unknown: {
|
|
83
|
+
title: 'Неизвестная ошибка',
|
|
84
|
+
message: error.message || 'Произошла неизвестная ошибка.',
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const msg = messages[error.type] || messages.unknown
|
|
89
|
+
|
|
90
|
+
dispatchNotification({
|
|
91
|
+
type: error.type === 'network' ? 'warning' : 'error',
|
|
92
|
+
title: msg.title,
|
|
93
|
+
message: context ? `${msg.message} (${context})` : msg.message,
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Interface для конфигурации useApi
|
|
99
|
+
*/
|
|
100
|
+
export interface UseApiConfig<T = never> {
|
|
101
|
+
notifyOnError?: boolean
|
|
102
|
+
notifyOnSuccess?: boolean
|
|
103
|
+
successMessage?: string
|
|
104
|
+
errorContext?: string
|
|
105
|
+
onSuccess?: (data: T) => void
|
|
106
|
+
onError?: (error: ApiError) => void
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Interface для результата useApi
|
|
111
|
+
*/
|
|
112
|
+
export interface UseApiResult<T> {
|
|
113
|
+
data: T | null
|
|
114
|
+
error: ApiError | null
|
|
115
|
+
isLoading: boolean
|
|
116
|
+
isError: boolean
|
|
117
|
+
isSuccess: boolean
|
|
118
|
+
execute: () => Promise<T | null>
|
|
119
|
+
reset: () => void
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Централизованный хук для API запросов
|
|
124
|
+
*
|
|
125
|
+
* @param request - функция, возвращающая Promise<ApiResponse<T>>
|
|
126
|
+
* @param config - конфигурация
|
|
127
|
+
* @returns UseApiResult<T>
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* ```tsx
|
|
131
|
+
* const { data, isLoading, isError, execute } = useApi<User[]>(
|
|
132
|
+
* () => fetch('/api/users').then(r => r.json()),
|
|
133
|
+
* { notifyOnError: true, errorContext: 'загрузка пользователей' }
|
|
134
|
+
* )
|
|
135
|
+
* ```
|
|
136
|
+
*/
|
|
137
|
+
export function useApi<T>(
|
|
138
|
+
request: () => Promise<ApiResponse<T>>,
|
|
139
|
+
config: UseApiConfig<T> = {}
|
|
140
|
+
): UseApiResult<T> {
|
|
141
|
+
const {
|
|
142
|
+
notifyOnError = true,
|
|
143
|
+
notifyOnSuccess = false,
|
|
144
|
+
successMessage,
|
|
145
|
+
errorContext,
|
|
146
|
+
onSuccess,
|
|
147
|
+
onError,
|
|
148
|
+
} = config
|
|
149
|
+
|
|
150
|
+
const [data, setData] = useState<T | null>(null)
|
|
151
|
+
const [error, setError] = useState<ApiError | null>(null)
|
|
152
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
153
|
+
|
|
154
|
+
const isError = error !== null
|
|
155
|
+
const isSuccess = data !== null && !isLoading && !isError
|
|
156
|
+
|
|
157
|
+
const execute = useCallback(async () => {
|
|
158
|
+
setIsLoading(true)
|
|
159
|
+
setError(null)
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const response = await request()
|
|
163
|
+
|
|
164
|
+
if (response.ok) {
|
|
165
|
+
setData(response.data)
|
|
166
|
+
|
|
167
|
+
if (notifyOnSuccess && successMessage) {
|
|
168
|
+
dispatchNotification({
|
|
169
|
+
type: 'success',
|
|
170
|
+
title: 'Успешно',
|
|
171
|
+
message: successMessage,
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
onSuccess?.(response.data)
|
|
176
|
+
return response.data
|
|
177
|
+
} else {
|
|
178
|
+
// Handle API error
|
|
179
|
+
const apiError: ApiError = {
|
|
180
|
+
message: (response.data as unknown as string) || 'Request failed',
|
|
181
|
+
status: response.status,
|
|
182
|
+
type: 'client',
|
|
183
|
+
timestamp: Date.now(),
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
setError(apiError)
|
|
187
|
+
|
|
188
|
+
if (notifyOnError) {
|
|
189
|
+
dispatchErrorNotification(apiError, errorContext)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
onError?.(apiError)
|
|
193
|
+
return null
|
|
194
|
+
}
|
|
195
|
+
} catch (err) {
|
|
196
|
+
// Handle network/server errors
|
|
197
|
+
const apiError = err as ApiError
|
|
198
|
+
setError(apiError)
|
|
199
|
+
|
|
200
|
+
if (notifyOnError) {
|
|
201
|
+
dispatchErrorNotification(apiError, errorContext)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
onError?.(apiError)
|
|
205
|
+
return null
|
|
206
|
+
} finally {
|
|
207
|
+
setIsLoading(false)
|
|
208
|
+
}
|
|
209
|
+
}, [request, notifyOnError, notifyOnSuccess, successMessage, errorContext, onSuccess, onError])
|
|
210
|
+
|
|
211
|
+
const reset = useCallback(() => {
|
|
212
|
+
setData(null)
|
|
213
|
+
setError(null)
|
|
214
|
+
setIsLoading(false)
|
|
215
|
+
}, [])
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
data,
|
|
219
|
+
error,
|
|
220
|
+
isLoading,
|
|
221
|
+
isError,
|
|
222
|
+
isSuccess,
|
|
223
|
+
execute,
|
|
224
|
+
reset,
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Convenience hooks for common HTTP methods
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* GET запрос
|
|
232
|
+
*/
|
|
233
|
+
export function useGet<T>(
|
|
234
|
+
url: string,
|
|
235
|
+
params?: Record<string, unknown>,
|
|
236
|
+
config: UseApiConfig<T> = {}
|
|
237
|
+
): UseApiResult<T> {
|
|
238
|
+
const request = () =>
|
|
239
|
+
fetch(url, params ? {
|
|
240
|
+
method: 'GET',
|
|
241
|
+
headers: { 'Content-Type': 'application/json' }
|
|
242
|
+
} : {})
|
|
243
|
+
.then(async (response) => {
|
|
244
|
+
const data = await response.json()
|
|
245
|
+
return { data, status: response.status, ok: response.ok } as ApiResponse<T>
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
return useApi(request, config)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* POST запрос
|
|
253
|
+
*/
|
|
254
|
+
export function usePost<T>(
|
|
255
|
+
url: string,
|
|
256
|
+
data?: unknown,
|
|
257
|
+
config: UseApiConfig<T> = {}
|
|
258
|
+
): UseApiResult<T> {
|
|
259
|
+
const request = () =>
|
|
260
|
+
fetch(url, {
|
|
261
|
+
method: 'POST',
|
|
262
|
+
headers: { 'Content-Type': 'application/json' },
|
|
263
|
+
body: data ? JSON.stringify(data) : undefined,
|
|
264
|
+
})
|
|
265
|
+
.then(async (response) => {
|
|
266
|
+
const responseData = await response.json()
|
|
267
|
+
return { data: responseData, status: response.status, ok: response.ok } as ApiResponse<T>
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
return useApi(request, config)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* PUT запрос
|
|
275
|
+
*/
|
|
276
|
+
export function usePut<T>(
|
|
277
|
+
url: string,
|
|
278
|
+
data?: unknown,
|
|
279
|
+
config: UseApiConfig<T> = {}
|
|
280
|
+
): UseApiResult<T> {
|
|
281
|
+
const request = () =>
|
|
282
|
+
fetch(url, {
|
|
283
|
+
method: 'PUT',
|
|
284
|
+
headers: { 'Content-Type': 'application/json' },
|
|
285
|
+
body: data ? JSON.stringify(data) : undefined,
|
|
286
|
+
})
|
|
287
|
+
.then(async (response) => {
|
|
288
|
+
const responseData = await response.json()
|
|
289
|
+
return { data: responseData, status: response.status, ok: response.ok } as ApiResponse<T>
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
return useApi(request, config)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* DELETE запрос
|
|
297
|
+
*/
|
|
298
|
+
export function useDel<T>(
|
|
299
|
+
url: string,
|
|
300
|
+
config: UseApiConfig<T> = {}
|
|
301
|
+
): UseApiResult<T> {
|
|
302
|
+
const request = () =>
|
|
303
|
+
fetch(url, {
|
|
304
|
+
method: 'DELETE',
|
|
305
|
+
headers: { 'Content-Type': 'application/json' },
|
|
306
|
+
})
|
|
307
|
+
.then(async (response) => {
|
|
308
|
+
const responseData = await response.json()
|
|
309
|
+
return { data: responseData, status: response.status, ok: response.ok } as ApiResponse<T>
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
return useApi(request, config)
|
|
313
|
+
}
|
|
314
|
+
|