@uploadista/react-native-core 0.0.17-beta.9 → 0.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uploadista/react-native-core",
3
- "version": "0.0.17-beta.9",
3
+ "version": "0.0.17",
4
4
  "type": "module",
5
5
  "description": "Core React Native client for Uploadista",
6
6
  "license": "MIT",
@@ -15,8 +15,8 @@
15
15
  "dependencies": {
16
16
  "uuid": "^13.0.0",
17
17
  "js-base64": "^3.7.7",
18
- "@uploadista/core": "0.0.17-beta.9",
19
- "@uploadista/client-core": "0.0.17-beta.9"
18
+ "@uploadista/core": "0.0.17",
19
+ "@uploadista/client-core": "0.0.17"
20
20
  },
21
21
  "peerDependencies": {
22
22
  "react": ">=16.8.0",
@@ -31,7 +31,7 @@
31
31
  "devDependencies": {
32
32
  "@types/react": ">=18.0.0",
33
33
  "tsdown": "0.16.6",
34
- "@uploadista/typescript-config": "0.0.17-beta.9"
34
+ "@uploadista/typescript-config": "0.0.17"
35
35
  },
36
36
  "scripts": {
37
37
  "build": "tsdown",
@@ -5,6 +5,16 @@ export {
5
5
  } from "./uploadista-context";
6
6
  export { useCameraUpload } from "./use-camera-upload";
7
7
  export { useFileUpload } from "./use-file-upload";
8
+ // Flow hooks
9
+ export type {
10
+ FlowInputMetadata,
11
+ FlowUploadState,
12
+ FlowUploadStatus,
13
+ InputExecutionState,
14
+ UseFlowOptions,
15
+ UseFlowReturn,
16
+ } from "./use-flow";
17
+ export { useFlow } from "./use-flow";
8
18
  export { useFlowUpload } from "./use-flow-upload";
9
19
  export { useGalleryUpload } from "./use-gallery-upload";
10
20
  // Multi-upload hooks
@@ -0,0 +1,576 @@
1
+ import type {
2
+ FlowManager,
3
+ FlowUploadState,
4
+ FlowUploadStatus,
5
+ InputExecutionState,
6
+ } from "@uploadista/client-core";
7
+ import type { TypedOutput } from "@uploadista/core/flow";
8
+ import { useCallback, useEffect, useRef, useState } from "react";
9
+ import { useFlowManagerContext } from "../contexts/flow-manager-context";
10
+ import type { FilePickResult } from "../types";
11
+ import { createBlobFromBuffer } from "../types/platform-types";
12
+ import { useUploadistaContext } from "./use-uploadista-context";
13
+
14
+ // Re-export types from core for convenience
15
+ export type { FlowUploadState, FlowUploadStatus, InputExecutionState };
16
+
17
+ /**
18
+ * Input metadata discovered from the flow
19
+ */
20
+ export interface FlowInputMetadata {
21
+ /** Input node ID */
22
+ nodeId: string;
23
+ /** Human-readable node name */
24
+ nodeName: string;
25
+ /** Node description explaining what input is needed */
26
+ nodeDescription: string;
27
+ /** Input node type */
28
+ nodeTypeId: string;
29
+ /** Whether this input is required */
30
+ required: boolean;
31
+ }
32
+
33
+ /**
34
+ * Options for the useFlow hook
35
+ */
36
+ export interface UseFlowOptions {
37
+ /** Flow ID to execute */
38
+ flowId: string;
39
+ /** Storage ID for the upload */
40
+ storageId: string;
41
+ /** Output node ID for the flow */
42
+ outputNodeId?: string;
43
+ /** Metadata to pass to flow */
44
+ metadata?: Record<string, unknown>;
45
+ /** Called when upload succeeds (receives typed outputs from all output nodes) */
46
+ onSuccess?: (outputs: TypedOutput[]) => void;
47
+ /** Called when the flow completes successfully (receives full flow outputs) */
48
+ onFlowComplete?: (outputs: TypedOutput[]) => void;
49
+ /** Called when upload fails */
50
+ onError?: (error: Error) => void;
51
+ /** Called when upload progress updates */
52
+ onProgress?: (
53
+ progress: number,
54
+ bytesUploaded: number,
55
+ totalBytes: number | null,
56
+ ) => void;
57
+ /** Called when a chunk completes */
58
+ onChunkComplete?: (
59
+ chunkSize: number,
60
+ bytesAccepted: number,
61
+ bytesTotal: number | null,
62
+ ) => void;
63
+ }
64
+
65
+ /**
66
+ * Return value from the useFlow hook with upload control methods and state.
67
+ *
68
+ * @property state - Complete flow upload state with progress and outputs
69
+ * @property inputMetadata - Metadata about discovered input nodes (null until discovered)
70
+ * @property inputStates - Per-input execution state for multi-input flows
71
+ * @property inputs - Current input values set via setInput()
72
+ * @property setInput - Set an input value for a specific node (for progressive provision)
73
+ * @property execute - Execute the flow with current inputs (auto-detects types)
74
+ * @property upload - Convenience method for single-file upload (same as execute with one file input)
75
+ * @property abort - Cancel the current upload and flow execution
76
+ * @property reset - Reset state to idle (clears all data)
77
+ * @property retry - Retry the last failed upload
78
+ * @property isActive - True when upload or processing is active
79
+ * @property isUploadingFile - True only during file upload phase
80
+ * @property isProcessing - True only during flow processing phase
81
+ * @property isDiscoveringInputs - True while discovering flow inputs
82
+ * @property canRetry - True if a retry is possible
83
+ */
84
+ export interface UseFlowReturn {
85
+ /**
86
+ * Current upload state
87
+ */
88
+ state: FlowUploadState;
89
+
90
+ /**
91
+ * Discovered input nodes metadata (null until discovery completes)
92
+ */
93
+ inputMetadata: FlowInputMetadata[] | null;
94
+
95
+ /**
96
+ * Per-input execution state for multi-input flows
97
+ */
98
+ inputStates: ReadonlyMap<string, InputExecutionState>;
99
+
100
+ /**
101
+ * Current inputs set via setInput()
102
+ */
103
+ inputs: Record<string, unknown>;
104
+
105
+ /**
106
+ * Set an input value for a specific node.
107
+ * For progressive input provision before calling execute().
108
+ *
109
+ * @param nodeId - The input node ID
110
+ * @param value - The input value (FilePickResult, URL string, or structured data)
111
+ */
112
+ setInput: (nodeId: string, value: FilePickResult | string | unknown) => void;
113
+
114
+ /**
115
+ * Execute the flow with current inputs.
116
+ * Automatically detects input types and routes appropriately.
117
+ * For single input, uses standard upload path.
118
+ * For multiple inputs, requires multiInputUploadFn.
119
+ */
120
+ execute: () => Promise<void>;
121
+
122
+ /**
123
+ * Upload a single file through the flow (convenience method).
124
+ * Equivalent to setInput(firstNodeId, file) + execute().
125
+ *
126
+ * @param file - FilePickResult from a picker
127
+ */
128
+ upload: (file: FilePickResult) => Promise<void>;
129
+
130
+ /**
131
+ * Abort the current upload
132
+ */
133
+ abort: () => void;
134
+
135
+ /**
136
+ * Reset the upload state and clear all inputs
137
+ */
138
+ reset: () => void;
139
+
140
+ /**
141
+ * Retry the last failed upload
142
+ */
143
+ retry: () => void;
144
+
145
+ /**
146
+ * Whether an upload or flow execution is in progress (uploading OR processing)
147
+ */
148
+ isActive: boolean;
149
+
150
+ /**
151
+ * Whether the file is currently being uploaded (chunks being sent)
152
+ */
153
+ isUploadingFile: boolean;
154
+
155
+ /**
156
+ * Whether the flow is currently processing (after upload completes)
157
+ */
158
+ isProcessing: boolean;
159
+
160
+ /**
161
+ * Whether the hook is discovering flow inputs
162
+ */
163
+ isDiscoveringInputs: boolean;
164
+
165
+ /**
166
+ * Whether a retry is possible (after error or abort with stored inputs)
167
+ */
168
+ canRetry: boolean;
169
+ }
170
+
171
+ const initialState: FlowUploadState = {
172
+ status: "idle",
173
+ progress: 0,
174
+ bytesUploaded: 0,
175
+ totalBytes: null,
176
+ error: null,
177
+ jobId: null,
178
+ flowStarted: false,
179
+ currentNodeName: null,
180
+ currentNodeType: null,
181
+ flowOutputs: null,
182
+ };
183
+
184
+ /**
185
+ * React Native hook for executing flows with single or multiple inputs.
186
+ * Automatically discovers input nodes and detects input types (File, URL, structured data).
187
+ * Supports progressive input provision via setInput() and execute().
188
+ *
189
+ * This is the unified flow hook that replaces useFlowUpload for advanced use cases.
190
+ * It provides:
191
+ * - Auto-discovery of flow input nodes
192
+ * - Automatic input type detection (FilePickResult -> upload, string -> URL, object -> data)
193
+ * - Progressive input provision via setInput()
194
+ * - Multi-input support with parallel coordination
195
+ * - Per-input state tracking
196
+ *
197
+ * Must be used within FlowManagerProvider (which must be within UploadistaProvider).
198
+ * Flow events are automatically routed by the provider to the appropriate manager.
199
+ *
200
+ * @param options - Flow upload configuration including flow ID and event handlers
201
+ * @returns Flow upload state and control methods
202
+ *
203
+ * @example
204
+ * ```tsx
205
+ * function MyComponent() {
206
+ * const flow = useFlow({
207
+ * flowId: 'image-processing-flow',
208
+ * storageId: 'my-storage',
209
+ * onSuccess: (outputs) => console.log('Flow complete:', outputs),
210
+ * onError: (error) => console.error('Flow failed:', error),
211
+ * });
212
+ *
213
+ * const handlePickFile = async () => {
214
+ * const file = await fileSystemProvider.pickDocument();
215
+ * if (file) {
216
+ * await flow.upload(file);
217
+ * }
218
+ * };
219
+ *
220
+ * return (
221
+ * <View>
222
+ * <Button title="Pick File" onPress={handlePickFile} />
223
+ * {flow.isActive && <Text>Progress: {flow.state.progress}%</Text>}
224
+ * {flow.inputMetadata && (
225
+ * <Text>Found {flow.inputMetadata.length} input nodes</Text>
226
+ * )}
227
+ * <Button title="Abort" onPress={flow.abort} disabled={!flow.isActive} />
228
+ * </View>
229
+ * );
230
+ * }
231
+ * ```
232
+ *
233
+ * @example
234
+ * ```tsx
235
+ * // Multi-input flow
236
+ * function MultiInputComponent() {
237
+ * const flow = useFlow({
238
+ * flowId: 'multi-source-flow',
239
+ * storageId: 'my-storage',
240
+ * });
241
+ *
242
+ * const handlePickPrimary = async () => {
243
+ * const file = await fileSystemProvider.pickDocument();
244
+ * if (file.status === 'success') {
245
+ * flow.setInput('primary-input', file);
246
+ * }
247
+ * };
248
+ *
249
+ * const handleSetUrl = (url: string) => {
250
+ * flow.setInput('url-input', url);
251
+ * };
252
+ *
253
+ * return (
254
+ * <View>
255
+ * <Button title="Pick Primary" onPress={handlePickPrimary} />
256
+ * <TextInput onChangeText={handleSetUrl} placeholder="Enter URL" />
257
+ * <Button title="Execute" onPress={flow.execute} />
258
+ * </View>
259
+ * );
260
+ * }
261
+ * ```
262
+ *
263
+ * @see {@link useFlowUpload} for a simpler file-only upload hook
264
+ */
265
+ export function useFlow(options: UseFlowOptions): UseFlowReturn {
266
+ const { client, fileSystemProvider } = useUploadistaContext();
267
+ const { getManager, releaseManager } = useFlowManagerContext();
268
+ const [state, setState] = useState<FlowUploadState>(initialState);
269
+ const [inputMetadata, setInputMetadata] = useState<
270
+ FlowInputMetadata[] | null
271
+ >(null);
272
+ const [isDiscoveringInputs, setIsDiscoveringInputs] = useState(false);
273
+ const [inputs, setInputs] = useState<Record<string, unknown>>({});
274
+ const [inputStates, setInputStates] = useState<
275
+ ReadonlyMap<string, InputExecutionState>
276
+ >(new Map());
277
+ const managerRef = useRef<FlowManager<unknown> | null>(null);
278
+ const lastInputsRef = useRef<Record<string, unknown> | null>(null);
279
+
280
+ // Store callbacks in refs so they can be updated without recreating the manager
281
+ const callbacksRef = useRef(options);
282
+
283
+ // Update refs on every render to capture latest callbacks
284
+ useEffect(() => {
285
+ callbacksRef.current = options;
286
+ });
287
+
288
+ // Auto-discover flow inputs on mount
289
+ useEffect(() => {
290
+ const discoverInputs = async () => {
291
+ setIsDiscoveringInputs(true);
292
+ try {
293
+ const { flow } = await client.getFlow(options.flowId);
294
+
295
+ // Find all input nodes
296
+ const inputNodes = flow.nodes.filter((node) => node.type === "input");
297
+
298
+ const metadata: FlowInputMetadata[] = inputNodes.map((node) => ({
299
+ nodeId: node.id,
300
+ nodeName: node.name,
301
+ nodeDescription: node.description,
302
+ nodeTypeId: node.nodeTypeId,
303
+ required: true,
304
+ }));
305
+
306
+ setInputMetadata(metadata);
307
+ } catch (error) {
308
+ console.error("Failed to discover flow inputs:", error);
309
+ } finally {
310
+ setIsDiscoveringInputs(false);
311
+ }
312
+ };
313
+
314
+ discoverInputs();
315
+ }, [client, options.flowId]);
316
+
317
+ // Get or create manager from context when component mounts
318
+ // biome-ignore lint/correctness/useExhaustiveDependencies: we don't want to recreate the manager on every render
319
+ useEffect(() => {
320
+ const flowId = options.flowId;
321
+
322
+ // Create stable callback wrappers that call the latest callbacks via refs
323
+ const stableCallbacks = {
324
+ onStateChange: (newState: FlowUploadState) => {
325
+ setState(newState);
326
+ },
327
+ onProgress: (
328
+ _uploadId: string,
329
+ bytesUploaded: number,
330
+ totalBytes: number | null,
331
+ ) => {
332
+ if (callbacksRef.current.onProgress) {
333
+ const progress = totalBytes
334
+ ? Math.round((bytesUploaded / totalBytes) * 100)
335
+ : 0;
336
+ callbacksRef.current.onProgress(progress, bytesUploaded, totalBytes);
337
+ }
338
+ },
339
+ onChunkComplete: (
340
+ chunkSize: number,
341
+ bytesAccepted: number,
342
+ bytesTotal: number | null,
343
+ ) => {
344
+ callbacksRef.current.onChunkComplete?.(
345
+ chunkSize,
346
+ bytesAccepted,
347
+ bytesTotal,
348
+ );
349
+ },
350
+ onFlowComplete: (outputs: TypedOutput[]) => {
351
+ callbacksRef.current.onFlowComplete?.(outputs);
352
+ },
353
+ onSuccess: (outputs: TypedOutput[]) => {
354
+ callbacksRef.current.onSuccess?.(outputs);
355
+ },
356
+ onError: (error: Error) => {
357
+ callbacksRef.current.onError?.(error);
358
+ },
359
+ onAbort: () => {
360
+ // onAbort not exposed in public API
361
+ },
362
+ };
363
+
364
+ // Get manager from context (creates if doesn't exist, increments ref count)
365
+ managerRef.current = getManager(flowId, stableCallbacks, {
366
+ flowConfig: {
367
+ flowId: options.flowId,
368
+ storageId: options.storageId,
369
+ outputNodeId: options.outputNodeId,
370
+ metadata: options.metadata as Record<string, string> | undefined,
371
+ },
372
+ onChunkComplete: options.onChunkComplete,
373
+ onSuccess: options.onSuccess,
374
+ onError: options.onError,
375
+ });
376
+
377
+ // Set up interval to poll input states for multi-input flows
378
+ const pollInterval = setInterval(() => {
379
+ if (managerRef.current) {
380
+ const states = managerRef.current.getInputStates();
381
+ if (states.size > 0) {
382
+ setInputStates(new Map(states));
383
+ }
384
+ }
385
+ }, 100); // Poll every 100ms
386
+
387
+ // Release manager when component unmounts or flowId changes
388
+ return () => {
389
+ clearInterval(pollInterval);
390
+ releaseManager(flowId);
391
+ managerRef.current = null;
392
+ };
393
+ }, [
394
+ options.flowId,
395
+ options.storageId,
396
+ options.outputNodeId,
397
+ getManager,
398
+ releaseManager,
399
+ ]);
400
+
401
+ // Set an input value
402
+ const setInput = useCallback(
403
+ (nodeId: string, value: FilePickResult | string | unknown) => {
404
+ setInputs((prev) => ({ ...prev, [nodeId]: value }));
405
+ },
406
+ [],
407
+ );
408
+
409
+ // Helper to convert FilePickResult to Blob
410
+ const filePickToBlob = useCallback(
411
+ async (file: FilePickResult): Promise<Blob | null> => {
412
+ if (file.status === "cancelled") {
413
+ return null;
414
+ }
415
+ if (file.status === "error") {
416
+ throw file.error;
417
+ }
418
+
419
+ const fileContent = await fileSystemProvider.readFile(file.data.uri);
420
+ return createBlobFromBuffer(fileContent, {
421
+ type: file.data.mimeType || "application/octet-stream",
422
+ });
423
+ },
424
+ [fileSystemProvider],
425
+ );
426
+
427
+ // Execute flow with current inputs
428
+ const execute = useCallback(async () => {
429
+ if (!managerRef.current) {
430
+ throw new Error("FlowManager not initialized");
431
+ }
432
+
433
+ if (Object.keys(inputs).length === 0) {
434
+ throw new Error(
435
+ "No inputs provided. Use setInput() to provide inputs before calling execute()",
436
+ );
437
+ }
438
+
439
+ // Store inputs for retry
440
+ lastInputsRef.current = { ...inputs };
441
+
442
+ // Convert FilePickResults to Blobs
443
+ const processedInputs: Record<string, unknown> = {};
444
+
445
+ for (const [nodeId, value] of Object.entries(inputs)) {
446
+ // Check if value is a FilePickResult
447
+ if (
448
+ value &&
449
+ typeof value === "object" &&
450
+ "status" in value &&
451
+ (value.status === "success" ||
452
+ value.status === "cancelled" ||
453
+ value.status === "error")
454
+ ) {
455
+ const blob = await filePickToBlob(value as FilePickResult);
456
+ if (blob) {
457
+ processedInputs[nodeId] = blob;
458
+ }
459
+ // If blob is null (cancelled), skip this input
460
+ } else {
461
+ // Pass through strings (URLs) and other values as-is
462
+ processedInputs[nodeId] = value;
463
+ }
464
+ }
465
+
466
+ if (Object.keys(processedInputs).length === 0) {
467
+ throw new Error(
468
+ "No valid inputs after processing. All files may have been cancelled.",
469
+ );
470
+ }
471
+
472
+ await managerRef.current.executeFlow(processedInputs);
473
+ }, [inputs, filePickToBlob]);
474
+
475
+ // Convenience method for single file upload
476
+ const upload = useCallback(
477
+ async (file: FilePickResult) => {
478
+ // Handle cancelled picker
479
+ if (file.status === "cancelled") {
480
+ return;
481
+ }
482
+
483
+ // Handle picker error
484
+ if (file.status === "error") {
485
+ options.onError?.(file.error);
486
+ return;
487
+ }
488
+
489
+ if (!managerRef.current) {
490
+ throw new Error("FlowManager not initialized");
491
+ }
492
+
493
+ // Store for retry
494
+ if (inputMetadata && inputMetadata.length > 0) {
495
+ const firstInputNode = inputMetadata[0];
496
+ if (firstInputNode) {
497
+ lastInputsRef.current = { [firstInputNode.nodeId]: file };
498
+ }
499
+ }
500
+
501
+ try {
502
+ const fileContent = await fileSystemProvider.readFile(file.data.uri);
503
+ const blob = createBlobFromBuffer(fileContent, {
504
+ type: file.data.mimeType || "application/octet-stream",
505
+ });
506
+
507
+ // If we have input metadata, use the first input node
508
+ if (inputMetadata && inputMetadata.length > 0) {
509
+ const firstInputNode = inputMetadata[0];
510
+ if (!firstInputNode) {
511
+ throw new Error("No input nodes found");
512
+ }
513
+ setInputs({ [firstInputNode.nodeId]: file });
514
+ await managerRef.current.executeFlow({
515
+ [firstInputNode.nodeId]: blob,
516
+ });
517
+ } else {
518
+ // Fall back to direct upload (manager will handle discovery)
519
+ await managerRef.current.upload(blob);
520
+ }
521
+ } catch (error) {
522
+ options.onError?.(error as Error);
523
+ }
524
+ },
525
+ [inputMetadata, fileSystemProvider, options],
526
+ );
527
+
528
+ const abort = useCallback(() => {
529
+ managerRef.current?.abort();
530
+ }, []);
531
+
532
+ const reset = useCallback(() => {
533
+ managerRef.current?.reset();
534
+ setInputs({});
535
+ setInputStates(new Map());
536
+ lastInputsRef.current = null;
537
+ }, []);
538
+
539
+ const retry = useCallback(() => {
540
+ if (
541
+ lastInputsRef.current &&
542
+ (state.status === "error" || state.status === "aborted")
543
+ ) {
544
+ // Restore inputs and re-execute
545
+ setInputs(lastInputsRef.current);
546
+ execute();
547
+ }
548
+ }, [execute, state.status]);
549
+
550
+ // Derive computed values from state (reactive to state changes)
551
+ const isActive =
552
+ state.status === "uploading" || state.status === "processing";
553
+ const isUploadingFile = state.status === "uploading";
554
+ const isProcessing = state.status === "processing";
555
+ const canRetry =
556
+ (state.status === "error" || state.status === "aborted") &&
557
+ lastInputsRef.current !== null;
558
+
559
+ return {
560
+ state,
561
+ inputMetadata,
562
+ inputStates,
563
+ inputs,
564
+ setInput,
565
+ execute,
566
+ upload,
567
+ abort,
568
+ reset,
569
+ retry,
570
+ isActive,
571
+ isUploadingFile,
572
+ isProcessing,
573
+ isDiscoveringInputs,
574
+ canRetry,
575
+ };
576
+ }