@ryuzaki13/react-foundation-api 1.1.16 → 1.1.17
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 +32 -43
- package/dist/chunks/{odataFetchFn-vnAXC-c0.js → odataFetchFn-B9wSQpUS.js} +11 -11
- package/dist/chunks/{odataFetchFn-vnAXC-c0.js.map → odataFetchFn-B9wSQpUS.js.map} +1 -1
- package/dist/odata/fetchCollectionData.d.ts +1 -1
- package/dist/odata/fetchCollectionData.d.ts.map +1 -1
- package/dist/odata/index.js +111 -112
- package/dist/odata/index.js.map +1 -1
- package/dist/odata/projectODataCollectionSort.d.ts +1 -1
- package/dist/odata/projectODataCollectionSort.d.ts.map +1 -1
- package/dist/odata/types.d.ts +1 -2
- package/dist/odata/types.d.ts.map +1 -1
- package/dist/odata/useODataCollection.d.ts +1 -1
- package/dist/odata/useODataCollection.d.ts.map +1 -1
- package/dist/odata/useODataCollectionQuery.d.ts +1 -1
- package/dist/odata/useODataCollectionQuery.d.ts.map +1 -1
- package/dist/odata/useODataEntity.d.ts +1 -1
- package/dist/odata/useODataEntity.d.ts.map +1 -1
- package/dist/persisted/index.js +1 -1
- package/package.json +2 -2
- package/src/adt/README.mdx +164 -0
- package/src/async/README.mdx +253 -0
- package/src/error-report/README.mdx +148 -0
- package/src/foundationApi.mdx +123 -0
- package/src/http/README.mdx +221 -0
- package/src/odata/README.mdx +790 -0
- package/src/persisted/README.mdx +454 -0
- package/src/resource/README.mdx +358 -0
- package/src/server-fn/README.mdx +194 -0
- package/src/transport/README.mdx +183 -0
- package/src/README.md +0 -937
- package/src/async/README.md +0 -623
- package/src/async/async.mdx +0 -6
- package/src/odata/README.md +0 -761
- package/src/odata/odataFetchFn.mdx +0 -6
- package/src/persisted/README.md +0 -598
- package/src/persisted/persisted.mdx +0 -6
package/src/async/README.md
DELETED
|
@@ -1,623 +0,0 @@
|
|
|
1
|
-
# Async helpers
|
|
2
|
-
|
|
3
|
-
Модуль `src/shared/api/async` содержит инфраструктурный helper для пакетного запуска async-задач с ограничением по параллелизму.
|
|
4
|
-
|
|
5
|
-
Подходит для сценариев, где нужно:
|
|
6
|
-
|
|
7
|
-
- обработать массив элементов с лимитом одновременных запусков;
|
|
8
|
-
- собрать полный partial result без раннего падения на первой ошибке;
|
|
9
|
-
- показывать прогресс в UI;
|
|
10
|
-
- поддержать остановку очереди через `AbortSignal`.
|
|
11
|
-
|
|
12
|
-
## Публичный API
|
|
13
|
-
|
|
14
|
-
```ts
|
|
15
|
-
import {
|
|
16
|
-
ConcurrencyPartialError,
|
|
17
|
-
type ConcurrentTaskContext,
|
|
18
|
-
type ConcurrentTaskProgress,
|
|
19
|
-
type RunConcurrentTasksOptions,
|
|
20
|
-
type RunConcurrentTasksResult,
|
|
21
|
-
runConcurrentTasks
|
|
22
|
-
} from "@/shared/api";
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
Экспорты модуля:
|
|
26
|
-
|
|
27
|
-
- `runConcurrentTasks` — основной helper выполнения batch-задач;
|
|
28
|
-
- `ConcurrencyPartialError` — удобная error-обертка для strict-сценариев;
|
|
29
|
-
- `ConcurrentTaskContext` — контекст, который helper передает в `task`;
|
|
30
|
-
- `ConcurrentTaskProgress` — срез прогресса после каждого завершенного элемента;
|
|
31
|
-
- `RunConcurrentTasksOptions` — опции запуска;
|
|
32
|
-
- `RunConcurrentTasksResult` — итоговая структура результата.
|
|
33
|
-
|
|
34
|
-
## Базовый контракт
|
|
35
|
-
|
|
36
|
-
```ts
|
|
37
|
-
runConcurrentTasks<TInput, TResult>(
|
|
38
|
-
inputs: readonly TInput[],
|
|
39
|
-
task: (input: TInput, context: ConcurrentTaskContext) => Promise<TResult>,
|
|
40
|
-
options?: RunConcurrentTasksOptions<TInput, TResult>
|
|
41
|
-
): Promise<RunConcurrentTasksResult<TInput, TResult>>
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
Что гарантирует helper:
|
|
45
|
-
|
|
46
|
-
- не выбрасывает ошибки отдельных задач наружу;
|
|
47
|
-
- складывает их в `result.errors`;
|
|
48
|
-
- сохраняет порядок `settled`, `successes` и `errors` по исходному массиву `inputs`;
|
|
49
|
-
- вызывает progress callbacks после каждого завершенного элемента;
|
|
50
|
-
- ограничивает число одновременно выполняемых задач через `concurrency`;
|
|
51
|
-
- поддерживает остановку очереди через `AbortSignal`.
|
|
52
|
-
|
|
53
|
-
Что helper не делает:
|
|
54
|
-
|
|
55
|
-
- не отменяет уже начатую async-операцию сам по себе;
|
|
56
|
-
- не знает ничего про React Query, OData или конкретный transport;
|
|
57
|
-
- не превращает partial success в exception автоматически.
|
|
58
|
-
|
|
59
|
-
## Входы и выходы
|
|
60
|
-
|
|
61
|
-
### `ConcurrentTaskContext`
|
|
62
|
-
|
|
63
|
-
Контекст, который передается в `task`:
|
|
64
|
-
|
|
65
|
-
- `index` — индекс элемента во входном массиве;
|
|
66
|
-
- `signal` — внешний `AbortSignal`, если он был передан в options.
|
|
67
|
-
|
|
68
|
-
### `RunConcurrentTasksOptions`
|
|
69
|
-
|
|
70
|
-
Поддерживаемые опции:
|
|
71
|
-
|
|
72
|
-
- `concurrency?: number` — максимальное число одновременно выполняемых задач, по умолчанию `4`;
|
|
73
|
-
- `signal?: AbortSignal` — внешний сигнал остановки очереди;
|
|
74
|
-
- `onProgress?: (progress) => void` — вызывается после каждого завершенного элемента;
|
|
75
|
-
- `onItemSuccess?: (item) => void` — вызывается при успешной обработке элемента;
|
|
76
|
-
- `onItemError?: (item) => void` — вызывается при ошибке элемента.
|
|
77
|
-
|
|
78
|
-
Важно: эти callback-функции лучше считать вспомогательными уведомлениями о ходе выполнения, а не частью основной бизнес-логики.
|
|
79
|
-
|
|
80
|
-
Практически это означает следующее:
|
|
81
|
-
|
|
82
|
-
- callback может обновить локальный state UI, записать событие в лог, отправить метрику, показать прогресс;
|
|
83
|
-
- callback не должен менять внешний поток выполнения batch-а;
|
|
84
|
-
- callback не должен содержать критически важную логику, от которой зависит корректность результата `runConcurrentTasks`;
|
|
85
|
-
- callback не должен бросать исключения, потому что helper не рассчитан на обработку ошибок внутри `onProgress`, `onItemSuccess` и `onItemError`.
|
|
86
|
-
|
|
87
|
-
Хороший подход:
|
|
88
|
-
|
|
89
|
-
- обновить счетчики в интерфейсе;
|
|
90
|
-
- записать `console.log` или telemetry;
|
|
91
|
-
- сохранить служебную информацию о последнем обработанном элементе.
|
|
92
|
-
|
|
93
|
-
Плохой подход:
|
|
94
|
-
|
|
95
|
-
- внутри callback валидировать обязательные бизнес-условия через `throw`;
|
|
96
|
-
- запускать логику, без которой результат batch-а считается некорректным;
|
|
97
|
-
- использовать callback как единственное место, где сохраняются критичные данные.
|
|
98
|
-
|
|
99
|
-
Если нужна обязательная бизнес-проверка или ошибка должна остановить вызывающий код, это лучше делать после завершения `runConcurrentTasks`, анализируя `result.errors`, `result.aborted` и `result.summary`.
|
|
100
|
-
|
|
101
|
-
### `ConcurrentTaskProgress`
|
|
102
|
-
|
|
103
|
-
Структура прогресса:
|
|
104
|
-
|
|
105
|
-
- `total` — всего элементов во входном массиве;
|
|
106
|
-
- `completed` — уже завершено;
|
|
107
|
-
- `succeeded` — завершено успешно;
|
|
108
|
-
- `failed` — завершено с ошибкой;
|
|
109
|
-
- `running` — сейчас выполняется;
|
|
110
|
-
- `pending` — еще не завершено и не выполняется;
|
|
111
|
-
- `percentage` — округленный процент завершения;
|
|
112
|
-
- `aborted` — был ли активирован внешний `signal`;
|
|
113
|
-
- `lastSettled` — последний завершенный элемент.
|
|
114
|
-
|
|
115
|
-
### `RunConcurrentTasksResult`
|
|
116
|
-
|
|
117
|
-
Итог результата:
|
|
118
|
-
|
|
119
|
-
- `settled` — все завершенные элементы в порядке `inputs`;
|
|
120
|
-
- `successes` — только успешные элементы;
|
|
121
|
-
- `errors` — только ошибки отдельных элементов;
|
|
122
|
-
- `unprocessed` — элементы, которые не были запущены;
|
|
123
|
-
- `aborted` — batch был остановлен внешним `signal`;
|
|
124
|
-
- `summary` — компактная сводка для UI и логов.
|
|
125
|
-
|
|
126
|
-
## Partial success и порядок результатов
|
|
127
|
-
|
|
128
|
-
`runConcurrentTasks` рассчитан на сценарий, где успех отдельных элементов и общий успех batch-а определяются вызывающим кодом.
|
|
129
|
-
|
|
130
|
-
Например:
|
|
131
|
-
|
|
132
|
-
- если из `10` файлов загрузились `8`, helper вернет `8` элементов в `successes` и `2` в `errors`;
|
|
133
|
-
- если элементы завершились в порядке `2 -> 1 -> 3`, итоговый `settled` все равно будет отсортирован по порядку исходного массива.
|
|
134
|
-
|
|
135
|
-
Именно поэтому helper хорошо подходит для:
|
|
136
|
-
|
|
137
|
-
- массовой загрузки файлов;
|
|
138
|
-
- пакетных мутаций;
|
|
139
|
-
- многошаговых запросов по списку ID;
|
|
140
|
-
- UI, где нужен промежуточный прогресс и итоговая сводка.
|
|
141
|
-
|
|
142
|
-
## Пример 1. Загрузка N файлов
|
|
143
|
-
|
|
144
|
-
```ts
|
|
145
|
-
import { fetchJson } from "@/shared/api";
|
|
146
|
-
import { type ConcurrentTaskContext, runConcurrentTasks } from "@/shared/api/async";
|
|
147
|
-
|
|
148
|
-
type UploadFileResponse = {
|
|
149
|
-
id: string;
|
|
150
|
-
name: string;
|
|
151
|
-
url: string;
|
|
152
|
-
};
|
|
153
|
-
|
|
154
|
-
async function uploadSingleFile(file: File, { signal }: ConcurrentTaskContext): Promise<UploadFileResponse> {
|
|
155
|
-
const formData = new FormData();
|
|
156
|
-
formData.append("file", file);
|
|
157
|
-
|
|
158
|
-
return fetchJson<UploadFileResponse>(
|
|
159
|
-
"/api/files/upload",
|
|
160
|
-
{
|
|
161
|
-
method: "POST",
|
|
162
|
-
body: formData,
|
|
163
|
-
signal
|
|
164
|
-
},
|
|
165
|
-
""
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const result = await runConcurrentTasks(files, uploadSingleFile, {
|
|
170
|
-
concurrency: 4,
|
|
171
|
-
onProgress: (progress) => {
|
|
172
|
-
console.log(`[${progress.completed}/${progress.total}] ok=${progress.succeeded} fail=${progress.failed}`);
|
|
173
|
-
},
|
|
174
|
-
onItemError: (item) => {
|
|
175
|
-
console.error("Ошибка загрузки файла", item.input.name, item.error);
|
|
176
|
-
}
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
console.log(result.successes);
|
|
180
|
-
console.log(result.errors);
|
|
181
|
-
console.log(result.unprocessed);
|
|
182
|
-
```
|
|
183
|
-
|
|
184
|
-
Внутри `task` в проекте стоит использовать не голый `fetch`, а shared-обёртки из `@/shared/api`.
|
|
185
|
-
|
|
186
|
-
Для этого helper-а чаще всего подходят:
|
|
187
|
-
|
|
188
|
-
- `fetchJson` — если нужен одиночный JSON-запрос;
|
|
189
|
-
- `fetchJsonMutationFn` — если удобнее заранее собрать mutation-функцию;
|
|
190
|
-
- `odataQueryFn` — если задача работает через OData-слой.
|
|
191
|
-
|
|
192
|
-
## Strict-сценарий через `ConcurrencyPartialError`
|
|
193
|
-
|
|
194
|
-
Ниже более прикладной пример в стиле реального batch-upload слоя: каждый отдельный запрос идет через `fetchJsonMutationFn`, а общий orchestration и сбор partial result берет на себя `runConcurrentTasks`.
|
|
195
|
-
|
|
196
|
-
```ts
|
|
197
|
-
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
198
|
-
|
|
199
|
-
import { useViewScope } from "@/entities/view";
|
|
200
|
-
import { ConcurrencyPartialError, fetchJsonMutationFn, runConcurrentTasks } from "@/shared/api";
|
|
201
|
-
import { useNotify } from "@/shared/ui";
|
|
202
|
-
|
|
203
|
-
type UploadDocumentInput = readonly {
|
|
204
|
-
name: string;
|
|
205
|
-
value: string | ArrayBuffer;
|
|
206
|
-
}[];
|
|
207
|
-
|
|
208
|
-
type UploadResponse = {
|
|
209
|
-
EResponse: string;
|
|
210
|
-
};
|
|
211
|
-
|
|
212
|
-
type UploadRequestBody = {
|
|
213
|
-
ImCallFmJson: string;
|
|
214
|
-
ImUpdateBwJson: string;
|
|
215
|
-
ImCheckBeforeUpdate: string;
|
|
216
|
-
ImView: string;
|
|
217
|
-
};
|
|
218
|
-
|
|
219
|
-
const uploadDocumentMutation = fetchJsonMutationFn<UploadResponse, UploadRequestBody>(
|
|
220
|
-
"/TEXT_TEST_SRV;o=QC0CLNT700/TextEntitySet",
|
|
221
|
-
"POST"
|
|
222
|
-
);
|
|
223
|
-
|
|
224
|
-
function buildUploadDocumentRequest(file: UploadDocumentInput, viewId: string): UploadRequestBody {
|
|
225
|
-
return {
|
|
226
|
-
ImCallFmJson: JSON.stringify({
|
|
227
|
-
fm_name: "TEXT_FUNCTION",
|
|
228
|
-
params: JSON.stringify(file)
|
|
229
|
-
}),
|
|
230
|
-
ImUpdateBwJson: "",
|
|
231
|
-
ImCheckBeforeUpdate: "",
|
|
232
|
-
ImView: viewId
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
export function useUploadDocumentsMutation() {
|
|
237
|
-
const notify = useNotify();
|
|
238
|
-
const queryClient = useQueryClient();
|
|
239
|
-
const scope = useViewScope();
|
|
240
|
-
|
|
241
|
-
return useMutation({
|
|
242
|
-
mutationFn: async (files: readonly UploadDocumentInput[]) => {
|
|
243
|
-
const result = await runConcurrentTasks(
|
|
244
|
-
files,
|
|
245
|
-
(file, { signal }) => uploadDocumentMutation(buildUploadDocumentRequest(file, scope.viewId), signal),
|
|
246
|
-
{
|
|
247
|
-
concurrency: 4
|
|
248
|
-
}
|
|
249
|
-
);
|
|
250
|
-
|
|
251
|
-
// Если необходимо, чтобы мутация ушла в onError, тогда делаем throw.
|
|
252
|
-
// Иначе в onSuccess получаем тот же result и при необходимости обрабатываем.
|
|
253
|
-
if (result.errors.length > 0 || result.aborted) {
|
|
254
|
-
throw new ConcurrencyPartialError("Загрузка документов завершилась с ошибками.", result);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
return result;
|
|
258
|
-
},
|
|
259
|
-
|
|
260
|
-
onError: async (error) => {
|
|
261
|
-
if (error instanceof ConcurrencyPartialError && error.result.successes.length > 0) {
|
|
262
|
-
await queryClient.invalidateQueries({ queryKey: ["claims", "claimDocuments"] });
|
|
263
|
-
notify.warning(`Загружено файлов: ${error.result.successes.length} из ${error.result.summary.total}.`);
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
notify.error("Ошибка отправки данных");
|
|
268
|
-
},
|
|
269
|
-
|
|
270
|
-
onSuccess: async (result) => {
|
|
271
|
-
await queryClient.invalidateQueries({ queryKey: ["claims", "claimDocuments"] });
|
|
272
|
-
notify.success(`Файлы загружены успешно: ${result.successes.length}.`);
|
|
273
|
-
}
|
|
274
|
-
});
|
|
275
|
-
}
|
|
276
|
-
```
|
|
277
|
-
|
|
278
|
-
Что показывает этот паттерн:
|
|
279
|
-
|
|
280
|
-
- отдельный сетевой запрос инкапсулирован в `fetchJsonMutationFn`;
|
|
281
|
-
- batch-слой получает лимит параллелизма и общий `result` через `runConcurrentTasks`;
|
|
282
|
-
- partial success превращается в `ConcurrencyPartialError`, но при этом доступен `error.result`;
|
|
283
|
-
- UI может отдельно обработать полный успех, частичный успех и полный провал.
|
|
284
|
-
|
|
285
|
-
Если strict-семантика не нужна, можно просто вернуть `RunConcurrentTasksResult` как валидный partial success.
|
|
286
|
-
|
|
287
|
-
## Пример 2. Интеграция с useMutation без strict-ошибки
|
|
288
|
-
|
|
289
|
-
```ts
|
|
290
|
-
import { useMutation } from "@tanstack/react-query";
|
|
291
|
-
|
|
292
|
-
import { runConcurrentTasks } from "@/shared/api/async";
|
|
293
|
-
|
|
294
|
-
export function useUploadFilesMutation() {
|
|
295
|
-
return useMutation({
|
|
296
|
-
mutationFn: async (files: readonly File[]) => {
|
|
297
|
-
return runConcurrentTasks(files, uploadSingleFile, {
|
|
298
|
-
concurrency: 4
|
|
299
|
-
});
|
|
300
|
-
}
|
|
301
|
-
});
|
|
302
|
-
}
|
|
303
|
-
```
|
|
304
|
-
|
|
305
|
-
Использование:
|
|
306
|
-
|
|
307
|
-
```ts
|
|
308
|
-
const uploadMutation = useUploadFilesMutation();
|
|
309
|
-
|
|
310
|
-
const result = await uploadMutation.mutateAsync(files);
|
|
311
|
-
|
|
312
|
-
if (result.errors.length > 0) {
|
|
313
|
-
// показать пользователю частично успешный сценарий
|
|
314
|
-
// например: "7 файлов загружено, 2 завершились ошибкой"
|
|
315
|
-
}
|
|
316
|
-
```
|
|
317
|
-
|
|
318
|
-
## Пример 3. Прогресс в UI
|
|
319
|
-
|
|
320
|
-
```ts
|
|
321
|
-
import { runConcurrentTasks } from "@/shared/api/async";
|
|
322
|
-
|
|
323
|
-
const controller = new AbortController();
|
|
324
|
-
|
|
325
|
-
const result = await runConcurrentTasks(files, uploadSingleFile, {
|
|
326
|
-
concurrency: 3,
|
|
327
|
-
signal: controller.signal,
|
|
328
|
-
onProgress: (progress) => {
|
|
329
|
-
setUploadState({
|
|
330
|
-
total: progress.total,
|
|
331
|
-
completed: progress.completed,
|
|
332
|
-
succeeded: progress.succeeded,
|
|
333
|
-
failed: progress.failed,
|
|
334
|
-
percentage: progress.percentage,
|
|
335
|
-
aborted: progress.aborted
|
|
336
|
-
});
|
|
337
|
-
}
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
// где-то в UI:
|
|
341
|
-
controller.abort();
|
|
342
|
-
```
|
|
343
|
-
|
|
344
|
-
## Поведение `abort`
|
|
345
|
-
|
|
346
|
-
Это важная часть контракта.
|
|
347
|
-
|
|
348
|
-
Что делает helper при `signal.abort()`:
|
|
349
|
-
|
|
350
|
-
- перестает стартовать новые задачи;
|
|
351
|
-
- уже начатые задачи не останавливает сам;
|
|
352
|
-
- возвращает `aborted: true`;
|
|
353
|
-
- переносит неуспевшие стартовать элементы в `unprocessed`.
|
|
354
|
-
|
|
355
|
-
Что helper не гарантирует:
|
|
356
|
-
|
|
357
|
-
- отмену уже начатого HTTP-запроса;
|
|
358
|
-
- мгновенное завершение уже выполняющихся задач.
|
|
359
|
-
|
|
360
|
-
Для реальной отмены сетевых операций нужно пробрасывать `signal` в shared-обёртку, которая внутри выполняет сетевой запрос:
|
|
361
|
-
|
|
362
|
-
```ts
|
|
363
|
-
odataQueryFn({ ... })({ client, signal });
|
|
364
|
-
fetchJsonQueryFn(...)({ signal });
|
|
365
|
-
fetchJsonMutationFn(...)(body, signal);
|
|
366
|
-
fetchDeleteFn(...)(signal);
|
|
367
|
-
```
|
|
368
|
-
|
|
369
|
-
Иначе helper остановит только очередь постановки новых задач, а уже начатые запросы продолжат выполняться.
|
|
370
|
-
|
|
371
|
-
## Когда использовать этот helper
|
|
372
|
-
|
|
373
|
-
Подходит, если нужно:
|
|
374
|
-
|
|
375
|
-
- выполнять много однотипных async-задач с лимитом параллелизма;
|
|
376
|
-
- отделять per-item ошибки от общего статуса batch-а;
|
|
377
|
-
- показывать пользователю прогресс и итоговую статистику;
|
|
378
|
-
- поддержать cancel кнопкой без потери уже собранных результатов.
|
|
379
|
-
|
|
380
|
-
Не подходит, если нужен сценарий:
|
|
381
|
-
|
|
382
|
-
- fail-fast на первой ошибке;
|
|
383
|
-
- полная отмена уже начатых операций без участия task-реализации;
|
|
384
|
-
- сложная orchestration-логика между задачами, зависящая не только от лимита параллелизма.
|
|
385
|
-
|
|
386
|
-
---
|
|
387
|
-
|
|
388
|
-
## Пошаговый пример: загрузка N изображений с оптимистичным UI
|
|
389
|
-
|
|
390
|
-
### Шаг 1. Определяем типы состояния
|
|
391
|
-
|
|
392
|
-
Каждый загружаемый файл проходит три состояния: `uploading → success | error`.
|
|
393
|
-
|
|
394
|
-
```ts
|
|
395
|
-
export type ImageUploadStatus = "uploading" | "success" | "error";
|
|
396
|
-
|
|
397
|
-
export type ImageUploadItem = {
|
|
398
|
-
file: File;
|
|
399
|
-
previewUrl: string; // blob-URL для предпросмотра
|
|
400
|
-
status: ImageUploadStatus;
|
|
401
|
-
uploadedUrl?: string; // URL с сервера (только при success)
|
|
402
|
-
errorMessage?: string; // сообщение об ошибке (только при error)
|
|
403
|
-
};
|
|
404
|
-
```
|
|
405
|
-
|
|
406
|
-
### Шаг 2. Пишем task-функцию для одного файла
|
|
407
|
-
|
|
408
|
-
Task-функция получает `File` и `ConcurrentTaskContext` (с `signal`). Возвращает
|
|
409
|
-
Promise с данными от сервера.
|
|
410
|
-
|
|
411
|
-
```ts
|
|
412
|
-
import { type ConcurrentTaskContext } from "@/shared/api";
|
|
413
|
-
|
|
414
|
-
type UploadResult = { url: string; name: string };
|
|
415
|
-
|
|
416
|
-
async function uploadImage(file: File, { signal }: ConcurrentTaskContext): Promise<UploadResult> {
|
|
417
|
-
const formData = new FormData();
|
|
418
|
-
formData.append("file", file);
|
|
419
|
-
|
|
420
|
-
return odataCreateFn()({ client });
|
|
421
|
-
return fetchJson<UploadResult>("/api/images/upload", { method: "POST", body: formData, signal }, "");
|
|
422
|
-
}
|
|
423
|
-
```
|
|
424
|
-
|
|
425
|
-
### Шаг 3. Создаём хук `useImageUploadMutation`
|
|
426
|
-
|
|
427
|
-
Ключ паттерна — три точки обновления UI:
|
|
428
|
-
|
|
429
|
-
| Момент | Что делаем |
|
|
430
|
-
| --------------- | --------------------------------------------------------- |
|
|
431
|
-
| `onMutate` | Создаём заглушки со `status: "uploading"` — **мгновенно** |
|
|
432
|
-
| `onItemSuccess` | Обновляем конкретный индекс: `status: "success"` |
|
|
433
|
-
| `onItemError` | Обновляем конкретный индекс: `status: "error"` |
|
|
434
|
-
|
|
435
|
-
```ts
|
|
436
|
-
import { useState } from "react";
|
|
437
|
-
import { useMutation } from "@tanstack/react-query";
|
|
438
|
-
import { runConcurrentTasks } from "@/shared/api/async";
|
|
439
|
-
|
|
440
|
-
export function useImageUploadMutation() {
|
|
441
|
-
const [items, setItems] = useState<ImageUploadItem[]>([]);
|
|
442
|
-
|
|
443
|
-
const mutation = useMutation({
|
|
444
|
-
/**
|
|
445
|
-
* onMutate вызывается синхронно ДО mutationFn.
|
|
446
|
-
* Здесь создаём оптимистичные заглушки — UI реагирует мгновенно.
|
|
447
|
-
*/
|
|
448
|
-
onMutate(files: File[]) {
|
|
449
|
-
const stubs: ImageUploadItem[] = files.map((file) => ({
|
|
450
|
-
file,
|
|
451
|
-
previewUrl: URL.createObjectURL(file),
|
|
452
|
-
status: "uploading"
|
|
453
|
-
}));
|
|
454
|
-
setItems(stubs);
|
|
455
|
-
},
|
|
456
|
-
|
|
457
|
-
mutationFn: async (files: File[]) => {
|
|
458
|
-
return runConcurrentTasks(files, uploadImage, {
|
|
459
|
-
concurrency: 3,
|
|
460
|
-
|
|
461
|
-
// Вызывается сразу после успеха конкретного файла
|
|
462
|
-
onItemSuccess(item) {
|
|
463
|
-
setItems((prev) =>
|
|
464
|
-
prev.map((stub, i) => (i === item.index ? { ...stub, status: "success", uploadedUrl: item.data.url } : stub))
|
|
465
|
-
);
|
|
466
|
-
},
|
|
467
|
-
|
|
468
|
-
// Вызывается сразу после ошибки конкретного файла
|
|
469
|
-
onItemError(item) {
|
|
470
|
-
const message = item.error instanceof Error ? item.error.message : String(item.error);
|
|
471
|
-
setItems((prev) =>
|
|
472
|
-
prev.map((stub, i) => (i === item.index ? { ...stub, status: "error", errorMessage: message } : stub))
|
|
473
|
-
);
|
|
474
|
-
}
|
|
475
|
-
});
|
|
476
|
-
}
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
const reset = () => {
|
|
480
|
-
setItems([]);
|
|
481
|
-
mutation.reset();
|
|
482
|
-
};
|
|
483
|
-
|
|
484
|
-
return { items, mutation, reset };
|
|
485
|
-
}
|
|
486
|
-
```
|
|
487
|
-
|
|
488
|
-
> **Почему `onMutate`, а не начало `mutationFn`?**
|
|
489
|
-
> `onMutate` — синхронный lifecycle-хук TanStack Query. Он выполняется до
|
|
490
|
-
> промиса `mutationFn`, поэтому React батчит `setItems` в тот же render-цикл,
|
|
491
|
-
> что и переход `mutation.status` в `"pending"`. Если поставить `setItems`
|
|
492
|
-
> в начало `mutationFn` — React батчит его со следующим рендером, и заглушки
|
|
493
|
-
> появятся чуть позже.
|
|
494
|
-
|
|
495
|
-
### Шаг 4. Компонент одной карточки `ImageCard`
|
|
496
|
-
|
|
497
|
-
```tsx
|
|
498
|
-
import React from "react";
|
|
499
|
-
import styles from "./ImageCard.module.scss";
|
|
500
|
-
import { type ImageUploadItem } from "./useImageUploadMutation";
|
|
501
|
-
|
|
502
|
-
export const ImageCard: React.FC<{ item: ImageUploadItem }> = ({ item }) => {
|
|
503
|
-
const { status, previewUrl, uploadedUrl, file, errorMessage } = item;
|
|
504
|
-
const displayUrl = status === "success" && uploadedUrl ? uploadedUrl : previewUrl;
|
|
505
|
-
const cardClass = status === "success" ? styles.cardSuccess : status === "error" ? styles.cardError : styles.card;
|
|
506
|
-
|
|
507
|
-
return (
|
|
508
|
-
<div className={cardClass} title={file.name}>
|
|
509
|
-
{/* Изображение видно всегда — локальный blob при загрузке, серверный URL при успехе */}
|
|
510
|
-
<img src={displayUrl} alt={file.name} className={styles.image} />
|
|
511
|
-
|
|
512
|
-
{status === "uploading" && (
|
|
513
|
-
<div className={styles.overlayUploading}>
|
|
514
|
-
<div className={styles.spinner} />
|
|
515
|
-
<span className={styles.label}>Загружается...</span>
|
|
516
|
-
</div>
|
|
517
|
-
)}
|
|
518
|
-
|
|
519
|
-
{status === "success" && <div className={styles.iconSuccess}>✓</div>}
|
|
520
|
-
|
|
521
|
-
{status === "error" && (
|
|
522
|
-
<div className={styles.overlayError}>
|
|
523
|
-
<span className={styles.iconError}>✕</span>
|
|
524
|
-
<span className={styles.labelError}>{errorMessage ?? "Ошибка"}</span>
|
|
525
|
-
</div>
|
|
526
|
-
)}
|
|
527
|
-
|
|
528
|
-
<div className={styles.statusBar}>{file.name}</div>
|
|
529
|
-
</div>
|
|
530
|
-
);
|
|
531
|
-
};
|
|
532
|
-
```
|
|
533
|
-
|
|
534
|
-
### Шаг 5. Сборка в `ImageUploadDemo`
|
|
535
|
-
|
|
536
|
-
```tsx
|
|
537
|
-
import React, { useCallback, useRef, useState } from "react";
|
|
538
|
-
import { Button } from "@/shared/ui";
|
|
539
|
-
import { ImageCard } from "./ImageCard";
|
|
540
|
-
import { useImageUploadMutation } from "./useImageUploadMutation";
|
|
541
|
-
|
|
542
|
-
export const ImageUploadDemo: React.FC = () => {
|
|
543
|
-
const inputRef = useRef<HTMLInputElement>(null);
|
|
544
|
-
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
|
545
|
-
const { items, mutation, reset } = useImageUploadMutation();
|
|
546
|
-
|
|
547
|
-
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
548
|
-
const files = e.target.files;
|
|
549
|
-
if (!files || files.length === 0) return;
|
|
550
|
-
setPendingFiles(Array.from(files));
|
|
551
|
-
e.target.value = "";
|
|
552
|
-
}, []);
|
|
553
|
-
|
|
554
|
-
const handleUpload = useCallback(() => {
|
|
555
|
-
if (pendingFiles.length === 0) return;
|
|
556
|
-
// mutation.mutate() → синхронно вызывает onMutate (заглушки появляются)
|
|
557
|
-
// → асинхронно запускает mutationFn (runConcurrentTasks)
|
|
558
|
-
mutation.mutate(pendingFiles);
|
|
559
|
-
setPendingFiles([]);
|
|
560
|
-
}, [mutation, pendingFiles]);
|
|
561
|
-
|
|
562
|
-
const isUploading = mutation.status === "pending";
|
|
563
|
-
const isDone = mutation.status === "success" || mutation.status === "error";
|
|
564
|
-
|
|
565
|
-
return (
|
|
566
|
-
<div>
|
|
567
|
-
<input ref={inputRef} type="file" accept="image/*" multiple style={{ display: "none" }} onChange={handleFileChange} />
|
|
568
|
-
|
|
569
|
-
<Button onClick={() => inputRef.current?.click()} disabled={isUploading}>
|
|
570
|
-
{pendingFiles.length > 0 ? `Выбрано: ${pendingFiles.length} файл(а)` : "Выбрать изображения"}
|
|
571
|
-
</Button>
|
|
572
|
-
|
|
573
|
-
<Button onClick={handleUpload} disabled={pendingFiles.length === 0 || isUploading}>
|
|
574
|
-
{isUploading ? "Загружается..." : "Загрузить"}
|
|
575
|
-
</Button>
|
|
576
|
-
|
|
577
|
-
{isDone && mutation.data && (
|
|
578
|
-
<p>
|
|
579
|
-
Загружено: {mutation.data.summary.succeeded} из {mutation.data.summary.total}
|
|
580
|
-
</p>
|
|
581
|
-
)}
|
|
582
|
-
|
|
583
|
-
<div style={{ display: "flex", flexWrap: "wrap", gap: "16px" }}>
|
|
584
|
-
{items.map((item, i) => (
|
|
585
|
-
<ImageCard key={`${item.file.name}-${i}`} item={item} />
|
|
586
|
-
))}
|
|
587
|
-
</div>
|
|
588
|
-
</div>
|
|
589
|
-
);
|
|
590
|
-
};
|
|
591
|
-
```
|
|
592
|
-
|
|
593
|
-
### Итоговый поток данных
|
|
594
|
-
|
|
595
|
-
```
|
|
596
|
-
Пользователь нажимает «Загрузить»
|
|
597
|
-
│
|
|
598
|
-
▼
|
|
599
|
-
mutation.mutate(files)
|
|
600
|
-
│
|
|
601
|
-
├─► onMutate() → setItems(stubs) → render: N заглушек (uploading)
|
|
602
|
-
│
|
|
603
|
-
└─► mutationFn(files)
|
|
604
|
-
│
|
|
605
|
-
└─► runConcurrentTasks(files, uploadImage, { concurrency: 3, ... })
|
|
606
|
-
│
|
|
607
|
-
├─► [файл 0] uploadImage() ──успех──► onItemSuccess → setItems → render: карточка 0 (success)
|
|
608
|
-
├─► [файл 1] uploadImage() ──ошибка─► onItemError → setItems → render: карточка 1 (error)
|
|
609
|
-
├─► [файл 2] uploadImage() ──успех──► onItemSuccess → setItems → render: карточка 2 (success)
|
|
610
|
-
│ ... (следующие стартуют по мере освобождения слотов)
|
|
611
|
-
│
|
|
612
|
-
└─► Promise<RunConcurrentTasksResult>
|
|
613
|
-
│
|
|
614
|
-
└─► onSuccess / onError (опционально: queryClient.invalidate, уведомление)
|
|
615
|
-
```
|
|
616
|
-
|
|
617
|
-
### Почему `index` надёжен для обновления заглушек
|
|
618
|
-
|
|
619
|
-
`runConcurrentTasks` гарантирует, что `item.index` в `onItemSuccess` и
|
|
620
|
-
`onItemError` всегда соответствует позиции элемента в исходном массиве `files`.
|
|
621
|
-
`onMutate` создаёт заглушки в той же последовательности. Поэтому `prev[item.index]`
|
|
622
|
-
в `setItems` всегда указывает на правильную карточку — независимо от того,
|
|
623
|
-
в каком порядке завершились задачи.
|